Multithreaded Watch Party App in Python
Build a synchronized watch party server using Python threads, locks, and events.
Introduction
30 Second Summary
Think about watching a show with friends who are all in different places. Everyone needs to stay in sync. If one person pauses, everyone should pause too.
In this project, you will build a multithreaded watch party application in Python where multiple users connect to a central server and control a shared playback timer together. You'll use threads, locks, and events to keep everyone perfectly synchronized.
What You'll Build
A terminal-based watch party system with a multithreaded server handling multiple simultaneous clients, and a dual-threaded client that sends and receives messages concurrently.
By the end of this project, you'll have:
- A multithreaded TCP server that accepts multiple viewer connections, each handled in its own dedicated thread.
- A dual-threaded client with separate send and receive threads running concurrently.
- Hands-on experience with Lock, Event, and Thread to manage shared state and synchronize playback across all connected viewers.
- Secret Mission: A host role system so only the room creator can control playback, while other viewers are read-only spectators.
Are there any prerequisites?
You need basic Python knowledge (functions, classes, dictionaries). No threading or socket experience is required. This project uses only Python standard library modules, so there are no external packages to install.
Before We Start
To build a watch party app, you need multiple users interacting with the same server at the same time. Python's threading module lets you handle all of these simultaneous connections without any external packages.
In this step, you will set up your Python environment and create the two files that will power your watch party. Everything you need ships with Python's standard library.
In this step, get ready to:
- Verify Python 3.10 or higher is installed.
- Create your project directory and files.
- Open the project in your code editor.
Verify Python is installed
This project requires Python 3.10 or higher. Python's threading, socket, json, and time modules are all part of the standard library, so no pip install is needed.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Check your Python version by running this command:
python3 --version
✔️ I see version 3.10 or higher
If your output shows Python 3.10.x or higher (for example Python 3.14.5), you are good to go.
ⓧ I see a version lower than 3.10
If your output shows a version below 3.10 (for example Python 3.9.7), you need to install a newer version. On macOS, the system Python can be outdated even after installing a newer version.
- Download the latest Python installer from python.org/downloads.
- Run the macOS installer package (.pkg) and follow the on-screen prompts.
- Close and reopen Terminal, then run python3 --version again to confirm the new version appears.
macOS may still show the old version
If python3 still shows the old version after installing, try running python3.14 --version (substituting the version you installed). The new installer sometimes installs alongside the system Python rather than replacing it.
ⓧ Command not found
If you see an error like "command not found", Python is not installed on your machine yet.
- Go to python.org/downloads.
- Download the macOS installer package for the latest stable release.
- Run the installer and follow the on-screen prompts.
- Close and reopen Terminal, then run python3 --version to confirm it prints a version number of 3.10 or higher.
Why no pip install?
Every module this project uses (threading, socket, json, time, sys) ships with Python. No virtual environment or package manager is required.
Create your project directory and files
You will create a watch-party folder on your Desktop containing two files: server.py (the multithreaded server) and client.py (the viewer client).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir watch-party
cd watch-party
- Create the two project files by running these commands:
touch server.py client.py
- Confirm the files exist by running:
ls
You should see client.py and server.py in the output.
Don't see both files?
Make sure you ran touch server.py client.py as a single command with both filenames separated by a space. If you only see one file, run touch again with the missing filename.
help me figure out why my project files weren't created
Open the project in your code editor
You can use any code editor you prefer. VS Code is a common choice if you do not already have a preference.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type your code editor's name (for example VS Code) and press Enter to open it.
- Open the watch-party folder from your Desktop using your editor's File > Open Folder menu.
- Verify you can see both server.py and client.py in the editor's file sidebar.
What will these files do?
server.py will run the watch party server that accepts connections and synchronizes playback across all viewers.
client.py will connect to the server and let each viewer send commands and receive live updates.
Your environment is ready and both files are waiting for code. Next up, you will build the server's socket foundation and accept your first client connections.
Set Up Your Project Environment
Before writing any code, you need a project folder with the right tooling in place.
You'll use Poetry to manage your project's dependencies and virtual environment. Right now the app only uses standard library modules, but Poetry gives you a clean structure to add video playback packages later without refactoring.
In this step, get ready to:
- Verify Python 3.11+ and Poetry are installed.
- Create and initialize the project folder with Poetry.
- Create the starter files and open them in your editor.
Check your Python version
This project requires Python 3.11 or higher. The threading and socket modules are part of the standard library, so no extra packages are needed yet.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
- Check your Python version by running:
python3 --version
✔️ I see version 3.11 or higher
Nice, Python is ready. Move on to the next check.
ⓧ I see a version lower than 3.11
Your system has an older Python that won't work for this project. On macOS, the default python3 often points to an outdated version.
- Download and install Python 3.11+ from python.org/downloads.
- After installing, close and reopen your terminal.
- Run python3 --version again to confirm the new version shows 3.11 or higher.
ⓧ Command not found
Python isn't installed on your system yet.
- Download and install Python 3.11+ from python.org/downloads.
- Follow the installer's prompts. Make sure to check the box that says Add Python to PATH if it appears.
- Close and reopen your terminal, then run python3 --version to confirm.
Check Poetry is installed
Poetry manages your project's dependencies and virtual environment. It keeps your packages isolated so different projects don't interfere with each other.
- Check if Poetry is already installed by running:
poetry --version
✔️ I see a version number
Poetry is ready. Move on to creating your project.
ⓧ Command not found
Install Poetry using pipx, which is the officially recommended method.
- Install pipx if you don't have it by running:
python3 -m pip install --user pipx
python3 -m pipx ensurepath
- Close and reopen your terminal so the PATH update takes effect.
- Install Poetry by running:
pipx install poetry
- Confirm the install by running poetry --version. You should see a version number.
What is Poetry?
Poetry is a dependency management and packaging tool for Python. It creates an isolated virtual environment for each project and tracks all your packages in a pyproject.toml file.
When you later add video playback libraries, Poetry ensures they install cleanly without conflicting with other projects on your machine.
Create the project folder and initialize Poetry
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir watch-party
cd watch-party
- Initialize the Poetry project by running:
poetry init -n --name watch-party --python ">=3.11"
What does this command do?
- -n skips all interactive prompts and uses defaults.
- --name watch-party sets the project name.
- --python ">=3.11" tells Poetry that this project requires Python 3.11 or higher.
- Confirm the files exist by running:
ls
You should see pyproject.toml in the output. This is the file Poetry uses to track your project's metadata and dependencies.
Don't see pyproject.toml?
Make sure you ran the poetry init command from inside the watch-party folder. Run pwd to check your current directory. It should end with /watch-party.
help me fix my Poetry initialization
Create the project files and open your editor
- Create the two Python files you'll build throughout this project by running:
touch server.py client.py
- Confirm both files exist by running:
ls
You should see client.py, pyproject.toml, and server.py in the output.
- Open your code editor by pressing Cmd+Space (macOS) or the Windows key (Windows), typing VS Code, and pressing Enter.
- Open the watch-party folder in your editor by clicking File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the watch-party folder, and click Open.
Can't find VS Code?
If you don't have VS Code installed, download it from code.visualstudio.com. Any code editor works for this project (Cursor, Sublime Text, etc.).
help me set up my code editor for this project
Your project environment is ready with Poetry managing the structure and both files in place. Next up, you'll build a server that can handle multiple users connecting at the same time.
Build the Server Socket Foundation
Your project is set up with Poetry managing the environment and both files in place. Now comes the exciting part: building a server that can handle multiple users connecting at the same time.
A basic server can only talk to one user at a time, which means everyone else waits in line. You'll solve this by spawning a dedicated thread for each connection, using Python's socket and threading modules.
In this step, get ready to:
- Create a TCP server socket that binds and listens on port 9999.
- Spawn a dedicated thread for each connecting client.
- Read and echo JSON messages in the client handler.
Set up the server socket and accept loop
A TCP server follows a standard lifecycle: create a socket, bind it to an address, listen for incoming connections, and accept them in a loop. Each time a client connects, your server will hand that connection off to a new thread so it can immediately go back to waiting for the next client.
- Open server.py in your code editor and add the following code:
import socket
import threading
import json
# Server configuration
HOST = "localhost"
PORT = 9999
def main():
# Create a TCP socket
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Allow reuse of the port if the server restarts quickly
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind((HOST, PORT))
server_sock.listen(5)
print(f"Watch Party server running on {HOST}:{PORT}")
try:
while True:
# Block until a new client connects
conn, addr = server_sock.accept()
# Spawn a dedicated thread for this client
client_thread = threading.Thread(
target=handle_client,
args=(conn, addr),
daemon=True
)
client_thread.start()
except KeyboardInterrupt:
print("\nServer shutting down.")
finally:
server_sock.close()
if __name__ == "__main__":
main()
What does this code do?
This sets up the server's main loop. Here's what each section handles:
- socket.AF_INET, socket.SOCK_STREAM creates a TCP socket (reliable, ordered byte stream over IPv4).
- SO_REUSEADDR lets you restart the server immediately without waiting for the OS to release the port.
- server_sock.accept() blocks (pauses) the main thread until a new client connects. It returns the client's socket and address.
- daemon=True means the thread dies automatically when the main program exits. Without this, leftover threads would keep the process alive.
💡 What is a daemon thread?
A daemon thread runs in the background and gets terminated when the main program shuts down. Non-daemon threads would keep Python running even after you press Ctrl+C.
For a watch party server, daemon threads are ideal because you want all client threads to stop when you shut down the server.
You won't see a change yet. The server references a handle_client function that doesn't exist. The next chunk adds it.
Implement the client handler
Each thread needs to know what to do with its client connection. The handle_client function reads JSON messages line by line and echoes them back. This is the thread-per-client architecture in action.
- In server.py, add the following function between the PORT = 9999 line and the def main(): line:
def handle_client(conn, addr):
print(f"[+] Connection from {addr} on {threading.current_thread().name}")
try:
# Create a file-like object for line-by-line reading
with conn.makefile("r", encoding="utf-8", newline="\n") as reader:
while True:
line = reader.readline()
# Empty string means the client disconnected
if not line:
break
try:
msg = json.loads(line.strip())
except json.JSONDecodeError:
continue
print(f"Received: {msg}")
# Echo the message back to the sender
conn.sendall((json.dumps(msg) + "\n").encode("utf-8"))
except (OSError, ConnectionResetError):
pass
finally:
conn.close()
print(f"[-] {addr} disconnected")
What does this code do?
This is the function that runs inside each client's dedicated thread:
- conn.makefile() wraps the raw socket in a file-like object so you can call readline() to read one message at a time. TCP is a byte stream with no built-in message boundaries, so the newline character acts as the delimiter.
- json.loads() parses each line from raw text into a Python dictionary.
- conn.sendall() sends the echoed message back. The \n at the end ensures the client can also read line by line.
- The finally block always closes the connection, even if an error occurs mid-read.
💡 Why newline-delimited JSON?
TCP delivers data as a continuous stream of bytes with no concept of individual messages. You need a way to know where one message ends and the next begins.
Adding a newline after each JSON object is the simplest reliable framing protocol. The receiver reads until it hits a newline, then parses that chunk as one complete message.
- Save server.py.
Seeing a red underline on handle_client in main()?
Make sure the handle_client function is defined above def main(): in your file. Python reads top to bottom, so handle_client must exist before main() tries to reference it.
help me fix my handle_client function placement
Run the server and test connections
Your server is ready to accept connections. Each client that connects gets its own thread, and you can verify this by checking the thread name printed in the server output.
- Start the server by running this command in your terminal:
python3 server.py
You should see:
Watch Party server running on localhost:9999
- Open a second terminal tab by pressing Cmd+T (macOS).
- Connect to your server using nc (netcat) by running:
nc localhost 9999
- Type the following JSON message and press Enter:
{"test": "hello"}
In your netcat terminal, you should see the message echoed back as JSON. Switch to the server terminal. You should see output like this:
[+] Connection from ('127.0.0.1', 52431) on Thread-1 (handle_client)
Received: {'test': 'hello'}
What is the thread name telling you?
The Thread-1 (handle_client) label confirms that this client is being handled in its own separate thread. If you open a third terminal and connect again, you'll see Thread-2 (handle_client) for that connection.
This is the thread-per-client pattern. The main thread only accepts connections and delegates work. Each client gets isolated processing without blocking anyone else.
- Press Ctrl+C in the netcat terminal to disconnect the test client.
- Check the server terminal. You should see the disconnect message:
[-] ('127.0.0.1', 52431) disconnected
- Press Ctrl+C in the server terminal to shut down the server.
Seeing "Address already in use"?
This means a previous server process is still running on port 9999. Make sure you stopped any earlier instance with Ctrl+C before starting again.
If the error persists, the OS may be holding the port for a few seconds after a crash. Wait 10 seconds and try again. The SO_REUSEADDR option in your code helps prevent this in most cases.
help me debug the Address already in use error on port 9999
✔️ Awesome, I've got everything!
Great. Make sure server.py is saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete server.py at this point:
import socket
import threading
import json
HOST = "localhost"
PORT = 9999
def handle_client(conn, addr):
print(f"[+] Connection from {addr} on {threading.current_thread().name}")
try:
with conn.makefile("r", encoding="utf-8", newline="\n") as reader:
while True:
line = reader.readline()
if not line:
break
try:
msg = json.loads(line.strip())
except json.JSONDecodeError:
continue
print(f"Received: {msg}")
conn.sendall((json.dumps(msg) + "\n").encode("utf-8"))
except (OSError, ConnectionResetError):
pass
finally:
conn.close()
print(f"[-] {addr} disconnected")
def main():
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind((HOST, PORT))
server_sock.listen(5)
print(f"Watch Party server running on {HOST}:{PORT}")
try:
while True:
conn, addr = server_sock.accept()
client_thread = threading.Thread(
target=handle_client,
args=(conn, addr),
daemon=True
)
client_thread.start()
except KeyboardInterrupt:
print("\nServer shutting down.")
finally:
server_sock.close()
if __name__ == "__main__":
main()
Your server foundation is solid. It accepts connections, handles each one in its own thread, and echoes messages back. Next up, you'll add shared room state so that all connected viewers can see the same playback position.
Add Thread-Safe Room State
Your server can accept multiple connections, but each thread operates in isolation. In a watch party, every viewer needs to see the same playback state. That means threads need to read and write shared data.
When multiple threads access the same data simultaneously, they can overwrite each other's changes. A Lock prevents this by ensuring only one thread touches the data at a time. In this step, you'll protect your shared room state with a Lock and broadcast changes to all connected viewers.
In this step, get ready to:
- Define shared room state protected by a threading Lock.
- Build a broadcast function that sends messages to all connected clients.
- Update the client handler to register and deregister viewers safely.
Define the shared room state
Every viewer in the watch party needs to see the same playback position and the same list of who's watching. This shared data lives in a dictionary that every client thread can read and update.
- In server.py, find the line that says HOST = "localhost".
- Add the following code between PORT = 9999 and the handle_client function:
state_lock = threading.Lock()
room_state = {
"position": 0.0,
"is_playing": False,
"viewers": []
}
clients = {}
What does this code do?
state_lock = threading.Lock() creates a mutex (mutual exclusion lock). Only one thread can hold this lock at a time. Any other thread that tries to acquire it will wait until the first thread releases it.
room_state holds the shared watch party data: the current playback position in seconds, whether the show is_playing, and a list of viewers by username.
clients maps each client's socket object to their username. This lets the server send messages back to specific connections.
- Save server.py.
You won't see a change yet. The next chunk uses these variables to broadcast messages to all connected clients.
Build the broadcast functions
When something changes in the room (someone joins, leaves, or presses play), the server needs to tell every connected viewer. A broadcast function iterates over all client sockets and sends the same message to each.
- In server.py, add the following broadcast function below clients = {} and above the handle_client function:
def broadcast(message):
# Convert the message dict to a JSON string with a newline delimiter
data = json.dumps(message) + "\n"
with state_lock:
for client_sock in list(clients.keys()):
try:
client_sock.sendall(data.encode("utf-8"))
except OSError:
# Client already disconnected, skip it
pass
What does this code do?
with state_lock: acquires the lock before iterating over clients. This prevents another thread from modifying the clients dictionary while we're looping through it.
list(clients.keys()) creates a snapshot of the keys so the loop doesn't break if a client disconnects mid-iteration. The try/except OSError silently skips clients whose connections have already dropped.
- Directly below broadcast, add a helper function called send_state_to_all that packages the current room state and broadcasts it:
def send_state_to_all():
# Read current state under the lock, then broadcast to everyone
with state_lock:
state_msg = {
"type": "state",
"position": room_state["position"],
"is_playing": room_state["is_playing"],
"viewers": room_state["viewers"]
}
broadcast(state_msg)
Why a separate helper?
send_state_to_all reads room_state under the lock to get a consistent snapshot. Then it calls broadcast to push that snapshot to every viewer. You'll call this helper every time the room state changes.
- Save server.py.
You won't see a change yet. The updated handle_client in the next substep calls these functions when viewers join and leave.
Update the client handler with registration
The current handle_client function just echoes messages. You need it to register each viewer when they connect and clean up when they disconnect. Both operations touch shared data, so they need the lock.
- In server.py, find the existing handle_client function.
- Replace the entire function with the following code:
def handle_client(conn, addr):
username = "anonymous"
try:
with conn.makefile("r", encoding="utf-8", newline="\n") as reader:
# First message from the client contains their username
first_line = reader.readline().strip()
if first_line:
msg = json.loads(first_line)
username = msg.get("username", "anonymous")
# Register this client under the lock
with state_lock:
clients[conn] = username
room_state["viewers"].append(username)
# Tell everyone about the new viewer
broadcast({"type": "join", "username": username, "viewers": room_state["viewers"]})
send_state_to_all()
print(f"[+] {username} connected from {addr}")
# Keep the connection open, reading messages
while True:
line = reader.readline()
if not line:
break
except (OSError, ConnectionResetError):
pass
finally:
# Deregister this client under the lock
with state_lock:
clients.pop(conn, None)
if username in room_state["viewers"]:
room_state["viewers"].remove(username)
conn.close()
broadcast({"type": "leave", "username": username, "viewers": room_state["viewers"]})
print(f"[-] {username} disconnected")
What does this code do?
On connect, the function reads the first JSON line to extract a username. It acquires state_lock before adding the client to clients and viewers. Then it broadcasts a join message to all viewers.
The finally block guarantees cleanup even if the connection drops unexpectedly. It acquires the lock again, removes the client from shared state, closes the socket, and broadcasts a leave message.
This pattern (register under lock, broadcast, read loop, deregister under lock in finally) is the standard structure for thread-safe client handling.
- Save server.py.
- Stop the running server by pressing Ctrl+C in your terminal.
- Restart the server by running:
python3 server.py
- Open a second terminal window and test a connection by running:
echo '{"username": "alice"}' | nc localhost 9999
In the server terminal, you should see [+] alice connected from followed almost immediately by [-] alice disconnected. The connection opens, registers, and then closes because the echo command ends.
Don't see the join and leave messages?
- Make sure you stopped the old server before restarting. If port 9999 is still in use, you'll see an Address already in use error.
- Check that the JSON in your echo command uses double quotes around keys and values. Single quotes inside the JSON will cause a parse error.
- Confirm that nc is available in your terminal by running which nc. It comes preinstalled on macOS.
Help me debug my watch party server registration
✔️ Awesome, I've got everything!
Great. Double check you've saved server.py before moving on.
ⓧ I'd like to double check the full code
import socket
import threading
import json
HOST = "localhost"
PORT = 9999
state_lock = threading.Lock()
room_state = {
"position": 0.0,
"is_playing": False,
"viewers": []
}
clients = {}
def broadcast(message):
data = json.dumps(message) + "\n"
with state_lock:
for client_sock in list(clients.keys()):
try:
client_sock.sendall(data.encode("utf-8"))
except OSError:
pass
def send_state_to_all():
with state_lock:
state_msg = {
"type": "state",
"position": room_state["position"],
"is_playing": room_state["is_playing"],
"viewers": room_state["viewers"]
}
broadcast(state_msg)
def handle_client(conn, addr):
username = "anonymous"
try:
with conn.makefile("r", encoding="utf-8", newline="\n") as reader:
first_line = reader.readline().strip()
if first_line:
msg = json.loads(first_line)
username = msg.get("username", "anonymous")
with state_lock:
clients[conn] = username
room_state["viewers"].append(username)
broadcast({"type": "join", "username": username, "viewers": room_state["viewers"]})
send_state_to_all()
print(f"[+] {username} connected from {addr}")
while True:
line = reader.readline()
if not line:
break
except (OSError, ConnectionResetError):
pass
finally:
with state_lock:
clients.pop(conn, None)
if username in room_state["viewers"]:
room_state["viewers"].remove(username)
conn.close()
broadcast({"type": "leave", "username": username, "viewers": room_state["viewers"]})
print(f"[-] {username} disconnected")
def main():
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind((HOST, PORT))
server_sock.listen(5)
print(f"Watch Party server running on {HOST}:{PORT}")
try:
while True:
conn, addr = server_sock.accept()
client_thread = threading.Thread(
target=handle_client,
args=(conn, addr),
daemon=True
)
client_thread.start()
except KeyboardInterrupt:
print("\nServer shutting down.")
finally:
server_sock.close()
if __name__ == "__main__":
main()
Your server now tracks who's in the room and broadcasts join and leave events to all viewers. Next up, you'll build the client with dual threads so viewers can send commands and receive updates at the same time.
Build the Client with Dual Threads
Your server handles multiple clients concurrently, each in its own thread. Now you need a client that can do two things at once: listen for incoming messages from the server and accept commands from the user.
A single-threaded client would get stuck. It would block waiting for user input and miss incoming messages, or block waiting for server messages and ignore the keyboard. You'll solve this by giving the client two threads that share a threading.Event to coordinate a clean shutdown.
In this step, get ready to:
- Connect to the server and send your username.
- Write separate receive and send functions that each run in their own thread.
- Coordinate graceful shutdown with a threading Event.
Set up the client foundation
The client needs the same networking and threading modules as the server, plus a small helper to format seconds into a readable timestamp.
- Open client.py in your code editor and add the imports, connection constants, and time formatter:
import socket
import threading
import json
import sys
# Connect to the same address the server is listening on
HOST = "localhost"
PORT = 9999
def format_time(seconds):
# Convert raw seconds into MM:SS display format
minutes = int(seconds) // 60
secs = int(seconds) % 60
return f"{minutes:02d}:{secs:02d}"
What does this code do?
- The four imports cover networking (socket), concurrency (threading), message formatting (json), and clean exits (sys).
- HOST and PORT match the server's address so the client knows where to connect.
- format_time turns a float like 125.0 into 02:05 for the playback display.
- Save client.py.
- You won't see a change yet. The next chunk builds the functions that use this foundation.
Write the receive and send functions
Each thread needs its own function. The receive function listens for server messages in the background. The send function reads keyboard input in the foreground.
- Below the format_time function, add the receive_messages function:
def receive_messages(sock, stop_event):
# This function runs in a background thread, constantly listening for server messages
try:
with sock.makefile("r", encoding="utf-8", newline="\n") as reader:
while not stop_event.is_set():
line = reader.readline()
if not line:
# Empty string means server closed the connection
print("\nDisconnected from server.")
stop_event.set()
break
try:
msg = json.loads(line.strip())
except json.JSONDecodeError:
continue
msg_type = msg.get("type")
if msg_type == "state":
# Display current playback position and viewer list
position = msg.get("position", 0.0)
is_playing = msg.get("is_playing", False)
icon = "\u25b6" if is_playing else "\u23f8"
print(f"\r [{icon} {format_time(position)}] Viewers: {', '.join(msg.get('viewers', []))}")
print("> ", end="", flush=True)
elif msg_type == "join":
print(f"\r {msg.get('username')} joined the watch party!")
print("> ", end="", flush=True)
elif msg_type == "leave":
print(f"\r {msg.get('username')} left the watch party.")
print("> ", end="", flush=True)
elif msg_type == "error":
print(f"\r Error: {msg.get('message')}")
print("> ", end="", flush=True)
except OSError:
# Socket error means connection is lost
if not stop_event.is_set():
stop_event.set()
What does this code do?
- stop_event.is_set() checks the shared flag on every loop iteration. When another thread sets it, this loop exits.
- readline() blocks until the server sends a newline-terminated message. An empty return value means the server disconnected.
- The \r (carriage return) at the start of each print overwrites the current input prompt, keeping the display clean while messages arrive.
- If an OSError occurs (broken connection), the function sets stop_event so the send thread knows to stop too.
- Below receive_messages, add the send_commands function:
def send_commands(sock, stop_event):
# This function runs in the main thread, reading user input
try:
while not stop_event.is_set():
try:
user_input = input("> ")
except EOFError:
stop_event.set()
break
if not user_input:
continue
# /quit signals both threads to stop
if user_input.lower() == "/quit":
stop_event.set()
break
parts = user_input.strip().split()
command = parts[0].lower()
# Convert text commands into JSON messages for the server
if command == "play":
msg = {"action": "play"}
elif command == "pause":
msg = {"action": "pause"}
elif command == "seek" and len(parts) > 1:
try:
position = float(parts[1])
msg = {"action": "seek", "position": position}
except ValueError:
print(" Usage: seek <seconds>")
continue
else:
print(" Commands: play | pause | seek <seconds> | /quit")
continue
# Send as newline-delimited JSON to match server's expected format
data = json.dumps(msg) + "\n"
sock.sendall(data.encode("utf-8"))
except OSError:
if not stop_event.is_set():
stop_event.set()
What does this code do?
- input("> ") blocks the thread until the user types something and presses Enter. This is why it needs its own thread.
- The function parses text commands (play, pause, seek 30) into JSON messages the server expects.
- Typing /quit sets stop_event, which the receive thread checks on its next loop iteration.
- Each message ends with \n so the server's readline() knows where one message ends and the next begins.
💡 How does Event coordinate two threads?
A threading.Event is a simple shared flag. Any thread can call set() to flip it to True, and any thread can call is_set() to check it.
When the receive thread detects a disconnect, it sets the event. The send thread checks it on the next loop and exits. The same works in reverse if the user types /quit. One flag, two threads, no polling.
- Save client.py.
- You won't see a change yet. The next chunk wires these functions into threads and lets you test the full client.
Wire up threads and test the connection
The main() function ties everything together. It connects to the server, sends the username, starts the receive thread in the background, then hands control to send_commands in the main thread.
- Below send_commands, add the main() function and entry point:
def main():
# Prompt for a display name before connecting
username = input("Enter your username: ").strip() or "anonymous"
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.connect((HOST, PORT))
except ConnectionRefusedError:
print("Could not connect to server. Is it running?")
sys.exit(1)
# Send username as the first message so the server knows who we are
hello_msg = json.dumps({"username": username}) + "\n"
sock.sendall(hello_msg.encode("utf-8"))
# Event flag shared between threads to coordinate shutdown
stop_event = threading.Event()
# Background thread listens for incoming server messages
recv_thread = threading.Thread(
target=receive_messages,
args=(sock, stop_event),
daemon=True
)
recv_thread.start()
print(f"Connected as '{username}'. Commands: play | pause | seek <seconds> | /quit")
# Main thread handles user input (blocks until /quit or disconnect)
send_commands(sock, stop_event)
sock.close()
print("Goodbye!")
if __name__ == "__main__":
main()
What does this code do?
- The client catches ConnectionRefusedError immediately. If the server isn't running, the user sees a helpful message instead of a stack trace.
- The first JSON message sent is the username. The server's handle_client reads this as first_line and registers the viewer.
- recv_thread is set to daemon=True so it doesn't prevent the program from exiting. When the main thread finishes, daemon threads are terminated automatically.
- send_commands runs directly in the main thread. It blocks on input() until the user types /quit or the Event is set by the receive thread.
- Save client.py.
- Make sure your server is still running from the previous step. If not, start it again by running:
python3 server.py
- Open a second terminal window in the same watch-party/ directory. Run the client:
python3 client.py
- Enter a username when prompted.
- In the server terminal, you should see a message like [+] yourname connected from ('127.0.0.1', ...).
- Open a third terminal window and run python3 client.py with a different username. Both clients should see each other's join messages.
- Type /quit in one client. The server should print a disconnect message, and the other client should see a leave notification.
Client won't connect?
- If you see "Could not connect to server. Is it running?" make sure python3 server.py is active in another terminal. The server must be running first.
- If the client connects but you see no join message, check that your server code still has the broadcast call inside handle_client after registering the viewer.
- If the client hangs after entering your username, confirm the server's readline() is receiving the newline. The client must append \n to the JSON message.
Help me debug my client connection issue
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
import socket
import threading
import json
import sys
HOST = "localhost"
PORT = 9999
def format_time(seconds):
minutes = int(seconds) // 60
secs = int(seconds) % 60
return f"{minutes:02d}:{secs:02d}"
def receive_messages(sock, stop_event):
try:
with sock.makefile("r", encoding="utf-8", newline="\n") as reader:
while not stop_event.is_set():
line = reader.readline()
if not line:
print("\nDisconnected from server.")
stop_event.set()
break
try:
msg = json.loads(line.strip())
except json.JSONDecodeError:
continue
msg_type = msg.get("type")
if msg_type == "state":
position = msg.get("position", 0.0)
is_playing = msg.get("is_playing", False)
icon = "\u25b6" if is_playing else "\u23f8"
print(f"\r [{icon} {format_time(position)}] Viewers: {', '.join(msg.get('viewers', []))}")
print("> ", end="", flush=True)
elif msg_type == "join":
print(f"\r {msg.get('username')} joined the watch party!")
print("> ", end="", flush=True)
elif msg_type == "leave":
print(f"\r {msg.get('username')} left the watch party.")
print("> ", end="", flush=True)
elif msg_type == "error":
print(f"\r Error: {msg.get('message')}")
print("> ", end="", flush=True)
except OSError:
if not stop_event.is_set():
stop_event.set()
def send_commands(sock, stop_event):
try:
while not stop_event.is_set():
try:
user_input = input("> ")
except EOFError:
stop_event.set()
break
if not user_input:
continue
if user_input.lower() == "/quit":
stop_event.set()
break
parts = user_input.strip().split()
command = parts[0].lower()
if command == "play":
msg = {"action": "play"}
elif command == "pause":
msg = {"action": "pause"}
elif command == "seek" and len(parts) > 1:
try:
position = float(parts[1])
msg = {"action": "seek", "position": position}
except ValueError:
print(" Usage: seek <seconds>")
continue
else:
print(" Commands: play | pause | seek <seconds> | /quit")
continue
data = json.dumps(msg) + "\n"
sock.sendall(data.encode("utf-8"))
except OSError:
if not stop_event.is_set():
stop_event.set()
def main():
username = input("Enter your username: ").strip() or "anonymous"
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.connect((HOST, PORT))
except ConnectionRefusedError:
print("Could not connect to server. Is it running?")
sys.exit(1)
hello_msg = json.dumps({"username": username}) + "\n"
sock.sendall(hello_msg.encode("utf-8"))
stop_event = threading.Event()
recv_thread = threading.Thread(
target=receive_messages,
args=(sock, stop_event),
daemon=True
)
recv_thread.start()
print(f"Connected as '{username}'. Commands: play | pause | seek <seconds> | /quit")
send_commands(sock, stop_event)
sock.close()
print("Goodbye!")
if __name__ == "__main__":
main()
Your client connects, receives messages, and shuts down cleanly. Next up, you'll add playback synchronization so all connected viewers share the same timer position.
Implement Playback Synchronization
Your server accepts connections and tracks who is in the room. Your client can send and receive messages. The missing piece is the actual watch party experience: a shared timer that ticks forward and responds to play, pause, and seek commands from any viewer.
In this step, you will add a background playback_ticker thread that advances the shared position every second, and you will wire up command handling so any connected client can control playback for everyone.
In this step, get ready to:
- Add a playback ticker thread that advances the shared timer every second.
- Handle play, pause, and seek commands from connected clients.
- Test synchronized playback across multiple terminal windows.
Add the playback ticker thread
A watch party needs a clock that everyone shares. The playback_ticker is a dedicated background thread that runs an infinite loop: sleep one second, then if playback is active, increment the position and broadcast the new state to every connected viewer.
- In server.py, find the import lines at the top of the file.
- Find this line:
import json
- Add import time on a new line directly below it, so your imports look like this:
import json
import time
Now add the ticker function. This is the heart of the synchronized playback experience.
- Scroll down to just above the def main(): function.
- Add the following playback_ticker function above main():
def playback_ticker():
while True:
time.sleep(1.0)
with state_lock:
if room_state["is_playing"]:
room_state["position"] += 1.0
state_msg = {
"type": "state",
"position": room_state["position"],
"is_playing": room_state["is_playing"],
"viewers": room_state["viewers"][:]
}
data = json.dumps(state_msg) + "\n"
for client_sock in list(clients.keys()):
try:
client_sock.sendall(data.encode("utf-8"))
except OSError:
pass
What does this code do?
This function runs forever in its own thread. Every second it wakes up, acquires the lock, and checks if playback is active.
- time.sleep(1.0) pauses the thread for one second between ticks.
- with state_lock: acquires the Lock before touching shared data. This prevents race conditions if a client sends a command at the same instant.
- room_state["viewers"][:] creates a copy of the list so the broadcast doesn't hold the lock while sending network data.
Now you need to actually start this thread. The ticker should launch as a daemon thread before the server begins accepting connections.
- In the main() function, find this line:
print(f"Watch Party server running on {HOST}:{PORT}")
- Add the following two lines directly below it, before the try: block:
ticker_thread = threading.Thread(target=playback_ticker, daemon=True)
ticker_thread.start()
Why a daemon thread?
Setting daemon=True means this thread will terminate automatically when the main program exits. Without it, the infinite while True loop would keep the server alive even after you press Ctrl+C.
- Save server.py.
You will not see a change yet. The ticker is running, but no one is sending play commands to start it ticking. The next substep adds command handling.
Handle playback commands from clients
The server receives JSON messages from clients, but right now it ignores them. You need to parse each message, check the action field, and update the shared room state accordingly.
- In server.py, find the while True loop inside handle_client. Locate these lines:
while True:
line = reader.readline()
if not line:
break
- Replace the entire while True block with the following code that parses incoming JSON and handles each action:
while True:
line = reader.readline()
if not line:
break
try:
msg = json.loads(line.strip())
except json.JSONDecodeError:
continue
action = msg.get("action")
if action == "play":
with state_lock:
room_state["is_playing"] = True
send_state_to_all()
print(f" {username} pressed play")
elif action == "pause":
with state_lock:
room_state["is_playing"] = False
send_state_to_all()
print(f" {username} pressed pause")
elif action == "seek":
position = msg.get("position", 0.0)
with state_lock:
room_state["position"] = float(position)
send_state_to_all()
print(f" {username} seeked to {position}")
What does this code do?
Each iteration of the loop reads one line from the client, parses it as JSON, and routes it based on the action field.
- "play" sets is_playing to True, which causes the ticker thread to start counting up.
- "pause" sets is_playing to False, which freezes the timer.
- "seek" jumps the position to a specific second.
- Every action acquires the lock, makes the change, then calls send_state_to_all() to broadcast the update to all viewers instantly.
💡 Why does the lock prevent race conditions here?
Imagine two clients send "seek" at the exact same moment. Without the lock, both threads could read room_state simultaneously and write conflicting values. The state_lock ensures only one thread modifies the state at a time, so the last command always wins cleanly.
- Save server.py.
Seeing a syntax error when you save?
- Check indentation carefully. The while True: block sits inside the with conn.makefile(...) as reader: context manager (three levels of indent: function, try, with).
- Make sure the elif lines align with the if action == "play": line above them.
Help me fix a syntax error in my handle_client function
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
import socket
import threading
import json
import time
HOST = "localhost"
PORT = 9999
state_lock = threading.Lock()
room_state = {
"position": 0.0,
"is_playing": False,
"viewers": []
}
clients = {}
def broadcast(message):
data = json.dumps(message) + "\n"
with state_lock:
for client_sock in list(clients.keys()):
try:
client_sock.sendall(data.encode("utf-8"))
except OSError:
pass
def send_state_to_all():
with state_lock:
state_msg = {
"type": "state",
"position": room_state["position"],
"is_playing": room_state["is_playing"],
"viewers": room_state["viewers"]
}
broadcast(state_msg)
def handle_client(conn, addr):
username = "anonymous"
try:
with conn.makefile("r", encoding="utf-8", newline="\n") as reader:
first_line = reader.readline().strip()
if first_line:
msg = json.loads(first_line)
username = msg.get("username", "anonymous")
with state_lock:
clients[conn] = username
room_state["viewers"].append(username)
broadcast({"type": "join", "username": username, "viewers": room_state["viewers"]})
send_state_to_all()
print(f"[+] {username} connected from {addr}")
while True:
line = reader.readline()
if not line:
break
try:
msg = json.loads(line.strip())
except json.JSONDecodeError:
continue
action = msg.get("action")
if action == "play":
with state_lock:
room_state["is_playing"] = True
send_state_to_all()
print(f" {username} pressed play")
elif action == "pause":
with state_lock:
room_state["is_playing"] = False
send_state_to_all()
print(f" {username} pressed pause")
elif action == "seek":
position = msg.get("position", 0.0)
with state_lock:
room_state["position"] = float(position)
send_state_to_all()
print(f" {username} seeked to {position}")
except (OSError, ConnectionResetError):
pass
finally:
with state_lock:
clients.pop(conn, None)
if username in room_state["viewers"]:
room_state["viewers"].remove(username)
conn.close()
broadcast({"type": "leave", "username": username, "viewers": room_state["viewers"]})
print(f"[-] {username} disconnected")
def playback_ticker():
while True:
time.sleep(1.0)
with state_lock:
if room_state["is_playing"]:
room_state["position"] += 1.0
state_msg = {
"type": "state",
"position": room_state["position"],
"is_playing": room_state["is_playing"],
"viewers": room_state["viewers"][:]
}
data = json.dumps(state_msg) + "\n"
for client_sock in list(clients.keys()):
try:
client_sock.sendall(data.encode("utf-8"))
except OSError:
pass
def main():
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind((HOST, PORT))
server_sock.listen(5)
print(f"Watch Party server running on {HOST}:{PORT}")
ticker_thread = threading.Thread(target=playback_ticker, daemon=True)
ticker_thread.start()
try:
while True:
conn, addr = server_sock.accept()
client_thread = threading.Thread(
target=handle_client,
args=(conn, addr),
daemon=True
)
client_thread.start()
except KeyboardInterrupt:
print("\nServer shutting down.")
finally:
server_sock.close()
if __name__ == "__main__":
main()
Test synchronized playback across multiple terminals
Your client already knows how to display playback state and send commands. It was built in the previous step with play, pause, and seek parsing built in. Now that the server handles those commands, everything connects together.
- If your server is still running from earlier, stop it by pressing Ctrl+C in its terminal window.
- Restart the server by running:
python3 server.py
- Open a second terminal window and connect a client by running:
cd ~/Desktop/watch-party
python3 client.py
- Enter a username when prompted (for example, alice).
- Open a third terminal window and connect another client with a different username (for example, bob).
- In alice's terminal, type play and press Enter.
Both terminals should now show a ticking timer that updates every second, formatted as [▶ 00:01], [▶ 00:02], and so on.
- In bob's terminal, type pause and press Enter.
Both terminals should instantly show the paused icon: [⏸ 00:XX] (where XX is whatever second the timer reached). The timer stops ticking for everyone.
- In either terminal, type seek 30 and press Enter.
Both terminals should jump to [⏸ 00:30]. Type play again to resume ticking from 30.
What should I see?
In the server terminal, you should see logs like:
Watch Party server running on localhost:9999
[+] alice connected from ('127.0.0.1', 52341)
[+] bob connected from ('127.0.0.1', 52342)
alice pressed play
bob pressed pause
bob seeked to 30.0
The key test: when one person controls playback, everyone else sees the change instantly. That is synchronized state in action.
🙋♀️ Timer not ticking after typing play?
- Make sure you restarted the server after adding the new code. The old server process does not have playback_ticker or the command handlers.
- Check that import time is at the top of server.py. Without it, the ticker thread crashes silently because daemon threads do not print tracebacks by default.
- Verify that ticker_thread.start() is called before the accept loop in main(). If it is inside the try: block after server_sock.accept(), it will never run.
Help me debug why my playback ticker is not working
You have a fully synchronized watch party. Multiple viewers share a single playback timer, and any one of them can control it for everyone. Next up, you will extend this with a host role system in the Secret Mission so only the room creator can control playback.
Secret mission
Add Host Role System
Right now, any connected viewer can play, pause, or seek. That's fine for friends, but what about a real watch party where one person controls the show? Your mission: add a host role so only the room creator can control playback, while everyone else watches along as spectators.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using only Python standard library modules, so there are no ongoing costs or cloud resources to worry about.
Decide whether to keep your project files for future experimentation or delete them entirely.
Resources you used:
- The watch-party/ project directory containing server.py and client.py.
- Any running server or client processes in your terminal.
Keep everything running
No action needed. Choose this if you want to keep experimenting with threading patterns, adding new features, or connecting more clients.
- Your watch-party/ folder stays on your Desktop for future use.
- If your server is still running, you can stop it anytime with Ctrl+C in its terminal window.
Pause - I'll come back to this later
Stop any running processes to free up your terminal, but keep the project files for later.
- Switch to any terminal window running the server and press Ctrl+C to shut it down.
- Close any client terminal windows. The clients exit automatically when the server stops.
- Your watch-party/ folder remains on your Desktop. You can restart anytime by running python3 server.py again.
Delete - I don't want to use this again
Remove all project files and start fresh.
- Stop any running server or client processes with Ctrl+C.
- Delete the project folder by running this command on macOS/Linux:
rm -rf ~/Desktop/watch-party
- On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\watch-party
- Confirm the folder is gone by checking your Desktop. You should no longer see a watch-party folder.
Nice Work!
Nice Work!
You just built a real-time multithreaded application from scratch using nothing but Python's standard library.
You've learned how to:
- Build a multithreaded TCP server using threading.Thread that handles multiple simultaneous clients, each in its own dedicated thread.
- Protect shared state with threading.Lock to prevent race conditions when multiple threads read and write the same data.
- Coordinate graceful shutdown across threads using threading.Event, and build a dual-threaded client that sends and receives messages simultaneously.
- Secret Mission: Implemented a role-based permission system with host/viewer roles and automatic host transfer on disconnect.
Ready to quiz yourself?