Lesson 21: Lobby Server & Client
Before a single shot is fired, players need somewhere to meet: a lobby that lists open rooms, lets them join by code or quick match, and waits until everyone is ready. In this lesson you build a threaded lobby server on the framing layer from the Framing & Concurrency lesson, and a pygame-ce client that stays smooth while messages arrive.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Design a lobby protocol of requests and server pushes in which the server holds the only true copy of every room.
- Build a room model that merges settings over defaults, validates them, refuses joins to full or started rooms, migrates the host, and keeps passwords only as salted hashes.
- Build a thread-per-client lobby server that makes every change under one lock and sends replies in order through per-client outbox queues.
- Connect a pygame-ce client through a reader thread and a
queue.Queue, so the game loop never waits on the network. - Debug lobby race conditions by testing many players who quick-match at the same moment.
Project: a Lobby Client that finds a room of two bots, joins it, readies up and shows the host's start countdown.
In This Lesson
๐๏ธ What a Lobby Does
Think of a board-game cafรฉ. You walk in, look at which tables have a free seat, and either sit down at one or start a new table. Nobody deals the cards until everyone at the table says they're ready. A multiplayer lobby is that cafรฉ. It lists the rooms that have space, lets a player create a room or join one (by picking it, by typing its short code, or with a quick match that picks for them), tracks who is ready, and lets the room's host start the game once the room is ready.
The most important design decision is who is in charge. In this lesson the server is authoritative: it holds the only real copy of every room, checks every request against the rules, and tells clients what the room now looks like. A client never says "I am in room KX7P now"; it asks to join, and it believes it has joined only when the server sends back the room. You met the same idea for movement in the Client Prediction & Reconciliation lesson. Here it keeps two players from both grabbing the last seat.
๐ก Why this matters
Players spend real time in lobbies, and lobby bugs are very visible: a crash when someone creates a room, a room that shows 5/4 players, or a game that starts without the last player. Every one of those came from the lobby code this lesson replaces, and every one comes down to a rule the server forgot to check or a change it made without holding a lock.
๐จ The Lobby Protocol
Every message is a JSON object with a "type", sent with send_msg() and read with recv_msg() from the framing.py module you wrote in the Framing & Concurrency lesson. Framing matters here: a lobby sends small messages in quick bursts (three players ready up at once), which is exactly when one recv() returns two messages glued together.
| Client sends | Fields | Server answers |
|---|---|---|
set_name | name | name to the sender |
list_rooms | none | room_list: public rooms that are still waiting |
create_room | settings (any subset), optional password | room to the creator, who is now host |
join_room | code, optional password | room and a note to everyone in the room |
quick_match | none | same as a join (into the fullest open room, or a new one) |
set_ready | ready (true or false) | room to everyone in the room |
start_game | none (host only) | game_starting with a countdown to everyone in the room |
leave_room | none | left to the leaver, room and a note to the rest |
Two more messages come from the server on its own: welcome (your player ID, as soon as you connect) and error (a request was refused, with a reason a player can read). Notice that the server always sends the whole room in a room message, not "Bo is now ready". A client that missed or misread one update is fixed by the next one, and the client code only ever does one thing with it: replace its copy.
๐ช The Room Model
Keep the rules of a room in a class with no sockets in it at all. Then you can test every rule in a plain Python script, and the server only has to call the right method and pass on the result or the error. A refused request raises RoomError, whose message goes straight back to the player.
Settings: merge over the defaults, then check every value
The old lobby code crashed with a KeyError when a client created a room and sent only some settings: the room list later read room.settings['is_private'], which that room simply didn't have. Its quick-match rooms were built the same way, so pressing Refresh after a quick match crashed too. The fix is one line: start from a full set of defaults and lay the client's choices on top.
DEFAULT_SETTINGS = {
"name": "New room",
"max_players": 4,
"min_players": 2,
"mode": "classic",
"private": False,
}
settings = {**DEFAULT_SETTINGS, **requested} # every key exists, whatever the client sent
Merging isn't enough on its own, because now a client can send {"max_players": 999} or {"max_players": "lots"}. Check every value, and reject keys you don't know rather than storing them. One Python trap: True is an int (isinstance(True, int) is True), so a type check for "whole number" has to rule out bool first.
for key in ("max_players", "min_players"):
value = settings[key]
if isinstance(value, bool) or not isinstance(value, int):
raise RoomError(f"{key} must be a whole number")
if not 2 <= settings["max_players"] <= PLAYER_LIMIT:
raise RoomError(f"max_players must be between 2 and {PLAYER_LIMIT}")
if not 2 <= settings["min_players"] <= settings["max_players"]:
raise RoomError("min_players must be between 2 and max_players")
Joining, leaving and the host
A join has four ways to fail, and the one that is easiest to forget is the room's state. A room that has started still has "free seats" once someone leaves mid-game, and the old code happily let a stranger walk into a match in progress.
def join(self, player_id, name, password=None):
if self.state is not RoomState.WAITING:
raise RoomError("that game has already started")
if player_id in self.players:
raise RoomError("you are already in this room")
if len(self.players) >= self.settings["max_players"]:
raise RoomError("room is full")
if not self.check_password(password):
raise RoomError("wrong room password")
self.players[player_id] = {"name": name, "ready": False}
if self.host_id is None:
self.host_id = player_id # the first player in is the host
def leave(self, player_id):
"""Remove a player. Returns True when the room is now empty."""
if player_id not in self.players:
raise RoomError("you are not in this room")
del self.players[player_id]
if player_id == self.host_id:
# host migration: the longest-waiting player takes over (dicts keep join order)
self.host_id = next(iter(self.players), None)
return not self.players
Host migration keeps a room alive when its host leaves. Python dictionaries remember insertion order, so next(iter(self.players), None) is the player who joined earliest, or None when the room is empty (the server then deletes it).
When can the game start?
def can_start(self):
if self.state is not RoomState.WAITING:
return False
if len(self.players) < self.settings["min_players"]:
return False
return all(info["ready"] for pid, info in self.players.items() if pid != self.host_id)
Everyone except the host must be ready. That is a design choice, not a technical necessity: pressing Start is the host's way of saying "I'm ready", so asking them to tick a box as well would just be an extra click. Some games do make the host ready up too, and then Start becomes automatic once everyone is ready. Pick one rule and make the UI match it.
Room passwords: store a hash, compare in constant time
A private room can have a password. Even though it only lives in the server's memory, keep a salted, deliberately slow hash of it instead of the text, so a debug print, a log line or a crash dump of the room never shows it. hashlib.pbkdf2_hmac is in the standard library. hmac.compare_digest is designed so its running time does not depend on where the two values first differ, so timing a wrong guess reveals nothing useful (a plain == can stop at the first mismatch).
def hash_password(password, salt):
"""A slow, salted hash (PBKDF2). The room keeps this, never the password."""
return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, HASH_ITERATIONS)
def check_password(self, password):
if self._password_hash is None:
return True
if not isinstance(password, str):
return False
return hmac.compare_digest(hash_password(password, self._salt), self._password_hash)
Slow is the point: it makes guessing expensive. With HASH_ITERATIONS = 100_000, one hash took about 19 ms on the author's machine (an Intel i7-12700K, measured with timeit). The server hashes while holding its lock, so the whole lobby pauses for that long whenever someone creates or joins a locked room. For a classroom lobby that's fine; a busy server would hash outside the lock.
๐งช Warm-up lab: Room Rules (about 20 minutes)
Your instructor has room_rules_starter.py. Run it first: its walkthrough prints lines such as Accepted {'max_players': 99} (it should be rejected). Work through its six numbered comments (merge, validate, join guards, host migration, can_start(), password hashing) until the output matches the finished version below, then save the file as room.py: the server imports it.
โ The finished room.py
"""room.py: the rules of one lobby room, with no networking at all.
Advanced Lesson 21 (Lobby Server & Client). The lobby server imports this
module and calls these methods while it holds its lock; each method either
changes the room or raises RoomError with a message the player can read.
Run it directly for a short walkthrough.
"""
import hashlib
import hmac
import os
from enum import Enum
class RoomState(Enum):
WAITING = "waiting" # open: players can join, leave and ready up
IN_PROGRESS = "in_progress" # the match has started: nobody new can join
class RoomError(Exception):
"""A request the room refuses. str(error) is shown to the player."""
DEFAULT_SETTINGS = {
"name": "New room",
"max_players": 4,
"min_players": 2,
"mode": "classic",
"private": False,
}
MODES = ("classic", "ranked", "casual")
PLAYER_LIMIT = 8
HASH_ITERATIONS = 100_000
def clean_settings(requested):
"""Merge a client's requested settings over the defaults, checking every value.
A client may send only some keys (or none), so every room ends up with
every key: code that reads room.settings["private"] can never KeyError.
"""
if not isinstance(requested, dict):
raise RoomError("settings must be a JSON object")
unknown = set(requested) - set(DEFAULT_SETTINGS)
if unknown:
raise RoomError(f"unknown setting: {sorted(unknown)[0]}")
settings = {**DEFAULT_SETTINGS, **requested}
name = settings["name"]
if not isinstance(name, str) or not 1 <= len(name.strip()) <= 24:
raise RoomError("room name must be 1 to 24 characters")
settings["name"] = name.strip()
for key in ("max_players", "min_players"):
value = settings[key]
# bool is a subclass of int in Python, so True would sneak through without this check
if isinstance(value, bool) or not isinstance(value, int):
raise RoomError(f"{key} must be a whole number")
if not 2 <= settings["max_players"] <= PLAYER_LIMIT:
raise RoomError(f"max_players must be between 2 and {PLAYER_LIMIT}")
if not 2 <= settings["min_players"] <= settings["max_players"]:
raise RoomError("min_players must be between 2 and max_players")
if settings["mode"] not in MODES:
raise RoomError(f"mode must be one of {', '.join(MODES)}")
if not isinstance(settings["private"], bool):
raise RoomError("private must be true or false")
return settings
def hash_password(password, salt):
"""A slow, salted hash (PBKDF2). The room keeps this, never the password."""
return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, HASH_ITERATIONS)
class Room:
def __init__(self, code, settings=None, password=None):
self.code = code
self.settings = clean_settings(settings or {})
self.state = RoomState.WAITING
self.players = {} # player id -> {"name": str, "ready": bool}, in join order
self.host_id = None
self._salt = None
self._password_hash = None
if password:
if not isinstance(password, str) or len(password) > 64:
raise RoomError("password must be text of at most 64 characters")
self._salt = os.urandom(16)
self._password_hash = hash_password(password, self._salt)
@property
def has_password(self):
return self._password_hash is not None
def check_password(self, password):
if self._password_hash is None:
return True
if not isinstance(password, str):
return False
# compare_digest takes the same time wherever the bytes differ
return hmac.compare_digest(hash_password(password, self._salt), self._password_hash)
def join(self, player_id, name, password=None):
if self.state is not RoomState.WAITING:
raise RoomError("that game has already started")
if player_id in self.players:
raise RoomError("you are already in this room")
if len(self.players) >= self.settings["max_players"]:
raise RoomError("room is full")
if not self.check_password(password):
raise RoomError("wrong room password")
self.players[player_id] = {"name": name, "ready": False}
if self.host_id is None:
self.host_id = player_id # the first player in is the host
def leave(self, player_id):
"""Remove a player. Returns True when the room is now empty."""
if player_id not in self.players:
raise RoomError("you are not in this room")
del self.players[player_id]
if player_id == self.host_id:
# host migration: the longest-waiting player takes over (dicts keep join order)
self.host_id = next(iter(self.players), None)
return not self.players
def set_ready(self, player_id, ready):
if player_id not in self.players:
raise RoomError("you are not in this room")
if self.state is not RoomState.WAITING:
raise RoomError("the game has already started")
if not isinstance(ready, bool):
raise RoomError("ready must be true or false")
self.players[player_id]["ready"] = ready
def can_start(self):
"""Enough players, and everyone except the host has readied up.
Design choice: pressing Start IS the host's ready signal, so the host
never has to tick a separate box.
"""
if self.state is not RoomState.WAITING:
return False
if len(self.players) < self.settings["min_players"]:
return False
return all(info["ready"] for pid, info in self.players.items() if pid != self.host_id)
def start(self, player_id):
if player_id != self.host_id:
raise RoomError("only the host can start the game")
if not self.can_start():
raise RoomError("need more players, or someone is not ready")
self.state = RoomState.IN_PROGRESS
def summary(self):
"""One row of the public room list."""
return {"code": self.code, "name": self.settings["name"], "mode": self.settings["mode"],
"players": len(self.players), "max_players": self.settings["max_players"],
"locked": self.has_password}
def to_dict(self):
"""Everything a player inside the room sees. The password hash never leaves."""
return {
"code": self.code,
"state": self.state.value,
"settings": dict(self.settings),
"host_id": self.host_id,
"can_start": self.can_start(),
"players": [{"id": pid, "name": info["name"], "ready": info["ready"],
"host": pid == self.host_id} for pid, info in self.players.items()],
}
def walkthrough():
room = Room("KX7P", {"name": "Friday night", "max_players": 3})
print("Settings after merge:", room.settings)
room.join(1, "Ada")
room.join(2, "Bo")
print("Can start before Bo is ready?", room.can_start())
room.set_ready(2, True)
print("Can start after Bo is ready?", room.can_start())
for bad in ({"max_players": 99}, {"max_players": "4"}, {"map": "desert"}):
try:
Room("TEST", bad)
print(f"Accepted {bad} (it should be rejected)")
except RoomError as exc:
print(f"Rejected {bad}: {exc}")
room.leave(1)
print("Ada left; the new host is player", room.host_id)
room.join(3, "Cy")
room.set_ready(3, True)
try:
room.start(2)
print("Bo started the game; the room is now", room.state.value)
except RoomError as exc:
print("Bo could not start the game:", exc)
try:
room.join(4, "Dee")
print("Dee joined mid-game (that should be refused)")
except RoomError as exc:
print("Dee tried to join mid-game:", exc)
locked = Room("LOCK", {"private": True}, password="hunter22")
print("Password stored as text?", "hunter22" in repr(vars(locked)))
print("Right password accepted?", locked.check_password("hunter22"))
print("Wrong password accepted?", locked.check_password("hunter2"))
if __name__ == "__main__":
walkthrough()
๐ฅ๏ธ The Lobby Server
The server is a restaurant kitchen with one head chef. Many waiters (one reader thread per client) bring in orders, but only the chef (whoever holds the lock) changes the kitchen, one order at a time. Finished plates go onto each table's tray (its outbox queue), and each table's own runner (a writer thread) carries them out in order.
Each tray is a queue.Queue from the standard library: a first-in, first-out queue built for threads. Any thread can put() an item, another thread can get() it (waiting until one is there), and the queue does its own locking, so no extra lock is needed around it. get_nowait() is the version that never waits: it raises queue.Empty when there is nothing to take. A threading.Event, which the demo bots use, is the simplest signal between threads: one thread calls set(), and others check is_set() or wait() for it.
Save this as lobby_server.py in the same folder as framing.py and room.py. Run it with no arguments and three bots (Ada, Bo and Cy) quick-match into one room, ready up and start a game, then the server shuts down by itself. Run python3 lobby_server.py 5555 to keep a server running on port 5555 for the client you build in the exercise.
"""lobby_server.py: a threaded lobby server built on framing.py and room.py.
Advanced Lesson 21 (Lobby Server & Client). Keep framing.py (from the Framing
& Concurrency lesson) and room.py in the same folder.
python3 lobby_server.py # a 3-bot demo on 127.0.0.1 that ends by itself
python3 lobby_server.py 5555 # serve on 127.0.0.1:5555 until Ctrl+C
Each client gets two threads: a reader that turns the byte stream into
messages, and a writer that sends whatever lands in the client's outbox queue.
Every handler runs while holding ONE lock and returns a list of
(player id, message) pairs, which are put into the outboxes before the lock
is released. Putting is instant, so no one waits on a slow socket while
holding the lock, and every client sees updates in the order they happened.
"""
import itertools
import queue
import secrets
import socket
import sys
import threading
from framing import FramingError, recv_msg, send_msg
from room import Room, RoomError, RoomState
HOST = "127.0.0.1"
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O or 1/I to misread
class Client:
def __init__(self, sock):
self.sock = sock
self.outbox = queue.Queue() # messages waiting for the writer thread
self.name = "Player"
self.room_code = None
def writer(self):
"""Send outbox messages in order until None arrives or the socket fails."""
while True:
message = self.outbox.get()
if message is None:
return
try:
send_msg(self.sock, message)
except OSError:
try:
self.sock.shutdown(socket.SHUT_RDWR) # the reader notices and cleans up
except OSError:
pass
return
class LobbyServer:
def __init__(self, host=HOST, port=0):
self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.listener.bind((host, port))
self.listener.listen()
self.port = self.listener.getsockname()[1]
self.lock = threading.Lock() # guards clients, rooms and the ID counter
self.clients = {} # player id -> Client
self.rooms = {} # room code -> Room
self.ids = itertools.count(1)
self.handlers = {
"set_name": self.on_set_name, "list_rooms": self.on_list_rooms,
"create_room": self.on_create_room, "join_room": self.on_join_room,
"quick_match": self.on_quick_match, "leave_room": self.on_leave_room,
"set_ready": self.on_set_ready, "start_game": self.on_start_game,
}
# ------------------------------------------------------------ threads
def start(self):
threading.Thread(target=self.accept_loop, daemon=True).start()
return self
def accept_loop(self):
while True:
try:
conn, _ = self.listener.accept()
except OSError: # the listener was closed: shut down
return
threading.Thread(target=self.serve_client, args=(conn,), daemon=True).start()
def serve_client(self, conn):
client = Client(conn)
threading.Thread(target=client.writer, daemon=True).start()
with self.lock:
pid = next(self.ids)
self.clients[pid] = client
client.outbox.put({"type": "welcome", "id": pid})
try:
while True:
message = recv_msg(conn) # a whole message, however TCP split it
if message is None: # the client hung up
break
self.dispatch(pid, message)
except (OSError, FramingError): # ConnectionError is a kind of OSError
pass
finally:
self.disconnect(pid)
conn.close()
def dispatch(self, pid, message):
with self.lock: # one handler at a time sees the lobby
if not isinstance(message, dict):
outbox = [(pid, {"type": "error", "message": "messages must be JSON objects"})]
elif message.get("type") not in self.handlers:
outbox = [(pid, {"type": "error", "message": "unknown message type"})]
else:
try:
outbox = self.handlers[message["type"]](pid, self.clients[pid], message)
except RoomError as exc:
outbox = [(pid, {"type": "error", "message": str(exc)})]
self.post(outbox)
def post(self, outbox):
"""Queue each (player id, message) for its writer. Called with self.lock held."""
for pid, message in outbox:
client = self.clients.get(pid)
if client is not None: # it may have left in the meantime
client.outbox.put(message)
def stop(self):
self.listener.close()
with self.lock:
socks = [c.sock for c in self.clients.values()]
for sock in socks:
try:
sock.shutdown(socket.SHUT_RDWR) # wakes the reader threads
except OSError:
pass
# ------------------------------------------------------------ helpers (lock held)
def room_update(self, room, note=None):
state = room.to_dict()
out = [(member, {"type": "room", "room": state}) for member in room.players]
if note:
out += [(member, {"type": "note", "text": note}) for member in room.players]
return out
def new_code(self):
while True:
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(4))
if code not in self.rooms:
return code
def enter(self, pid, client, room, password=None):
room.join(pid, client.name, password) # raises RoomError if refused
client.room_code = room.code
return self.room_update(room, note=f"{client.name} joined")
def remove_from_room(self, pid, client):
room = self.rooms.get(client.room_code)
client.room_code = None
if room is None:
return []
if room.leave(pid): # True: nobody left
del self.rooms[room.code]
return []
return self.room_update(room, note=f"{client.name} left")
def disconnect(self, pid):
with self.lock:
client = self.clients.pop(pid, None)
if client is None:
return
client.outbox.put(None) # stops its writer thread
if client.room_code is not None:
self.post(self.remove_from_room(pid, client))
# ------------------------------------------------------------ handlers (lock held)
def on_set_name(self, pid, client, msg):
name = msg.get("name")
if not isinstance(name, str) or not 1 <= len(name.strip()) <= 16 or not name.isprintable():
raise RoomError("name must be 1 to 16 printable characters")
if client.room_code is not None:
raise RoomError("change your name before joining a room")
client.name = name.strip()
return [(pid, {"type": "name", "name": client.name})]
def on_list_rooms(self, pid, client, msg):
rows = [room.summary() for room in self.rooms.values()
if room.state is RoomState.WAITING and not room.settings["private"]]
return [(pid, {"type": "room_list", "rooms": rows})]
def on_create_room(self, pid, client, msg):
if client.room_code is not None:
raise RoomError("you are already in a room")
room = Room(self.new_code(), msg.get("settings", {}), msg.get("password"))
self.rooms[room.code] = room
return self.enter(pid, client, room, msg.get("password"))
def on_join_room(self, pid, client, msg):
if client.room_code is not None:
raise RoomError("you are already in a room")
code = msg.get("code")
room = self.rooms.get(code.upper()) if isinstance(code, str) else None
if room is None:
raise RoomError("no room with that code")
return self.enter(pid, client, room, msg.get("password"))
def on_quick_match(self, pid, client, msg):
"""Join the fullest open public room, or open a new one."""
if client.room_code is not None:
raise RoomError("you are already in a room")
open_rooms = [room for room in self.rooms.values()
if room.state is RoomState.WAITING and not room.settings["private"]
and not room.has_password
and len(room.players) < room.settings["max_players"]]
if open_rooms:
room = max(open_rooms, key=lambda r: len(r.players)) # ties: the oldest room
else:
room = Room(self.new_code(), {"name": "Quick match"})
self.rooms[room.code] = room
return self.enter(pid, client, room)
def on_leave_room(self, pid, client, msg):
if client.room_code is None:
raise RoomError("you are not in a room")
return [(pid, {"type": "left"})] + self.remove_from_room(pid, client)
def on_set_ready(self, pid, client, msg):
room = self.rooms.get(client.room_code)
if room is None:
raise RoomError("you are not in a room")
room.set_ready(pid, msg.get("ready"))
return self.room_update(room)
def on_start_game(self, pid, client, msg):
room = self.rooms.get(client.room_code)
if room is None:
raise RoomError("you are not in a room")
room.start(pid) # host only, and only when can_start()
state = room.to_dict()
return [(member, {"type": "game_starting", "countdown": 3, "room": state})
for member in room.players]
# ------------------------------------------------------------------ bots
def run_bot(port, name, seated=None, start_when=None, log=None):
"""A scripted player: set a name, quick-match, ready up; a host bot starts
the game once start_when players are in and everyone else is ready.
seated (a threading.Event) is set once the bot is in a room; log (a
queue.Queue) receives one line when the game starts."""
try:
with socket.create_connection((HOST, port), timeout=5) as sock:
sock.settimeout(None)
send_msg(sock, {"type": "set_name", "name": name})
send_msg(sock, {"type": "quick_match"})
my_id = None
while True:
msg = recv_msg(sock)
if msg is None:
return
kind = msg["type"]
if kind == "welcome":
my_id = msg["id"]
elif kind == "room":
room = msg["room"]
if seated is not None:
seated.set()
me = next((p for p in room["players"] if p["id"] == my_id), None)
if me is None:
continue
if not me["host"] and not me["ready"]:
send_msg(sock, {"type": "set_ready", "ready": True})
elif (me["host"] and start_when and room["can_start"]
and len(room["players"]) >= start_when):
send_msg(sock, {"type": "start_game"})
elif kind == "game_starting" and log is not None:
names = ", ".join(p["name"] for p in msg["room"]["players"])
log.put(f"{name} got game_starting in room {msg['room']['code']}: {names}")
except (OSError, FramingError):
return
def demo():
server = LobbyServer().start()
print(f"Lobby server listening on {HOST}:{server.port}")
log = queue.Queue()
for name in ("Ada", "Bo", "Cy"):
seated = threading.Event()
threading.Thread(target=run_bot, args=(server.port, name, seated, 3, log), daemon=True).start()
seated.wait(5) # join one at a time, so Ada is the host
lines = [log.get(timeout=5) for _ in range(3)] # wait for all three game_starting messages
with server.lock:
rooms = list(server.rooms.values())
for room in rooms:
names = ", ".join(info["name"] for info in room.players.values())
print(f"Room {room.code} is {room.state.value} with {names}")
for line in sorted(lines):
print(line)
server.stop()
print("Server stopped.")
if __name__ == "__main__":
if len(sys.argv) > 1:
server = LobbyServer(HOST, int(sys.argv[1])).start()
print(f"Lobby server listening on {HOST}:{server.port}. Press Ctrl+C to stop.")
try:
threading.Event().wait()
except KeyboardInterrupt:
server.stop()
else:
demo()
With no arguments it prints something like this (your room code will differ, because codes are random):
Lobby server listening on 127.0.0.1:45449
Room VY3B is in_progress with Ada, Bo, Cy
Ada got game_starting in room VY3B: Ada, Bo, Cy
Bo got game_starting in room VY3B: Ada, Bo, Cy
Cy got game_starting in room VY3B: Ada, Bo, Cy
Server stopped.
The parts worth reading twice:
- One lock around every handler.
dispatch()holdsself.lockwhile a handler reads and changesself.roomsandself.clients. The old lobby kept its quick-match queue in a plain list shared by several threads with no lock at all; here, twenty players pressing Quick Match at the same instant still fill exactly five rooms of four (the lab's tests check this). - Handlers return messages instead of sending them.
post()puts each message in the right client'soutboxwhile the lock is still held.queue.Queue.put()on a queue with no size limit returns immediately, so the lock is never held while a slow socket drains. And because messages are queued in the same order the changes happened, every client sees room snapshots in order. If each handler sent its own messages after releasing the lock, two threads could race and a client could receive an older snapshot after a newer one, then show the wrong room until the next change. - Refusals are values, not crashes. A
RoomErrorbecomes anerrormessage to that player only. A message that isn't a JSON object, or has an unknowntype, gets an error too, instead of killing the connection. - Every exit path cleans up. Whether a client sends
leave_room, closes its window or loses its network,serve_client()ends indisconnect(), which removes the player from their room (migrating the host, deleting an empty room) and tells everyone left. - Codes come from
secrets. Four characters from an alphabet without0/Oand1/Iare easy to read out loud, andnew_code()loops until the code is unused. 127.0.0.1by default. Only programs on your own computer can connect. The FAQ explains how to open it to your home network.
โ Growth Mindset: Race Conditions Hide, So Go Looking
A lobby with a missing lock usually works perfectly when you test it by clicking around on your own. Race conditions appear only when two things happen at nearly the same moment, which is rare for one tester and constant for a thousand players. If your server passes every manual test, you haven't proved it correct yet. Write a test that starts twenty threads behind a threading.Barrier and fires them all at once, then check an invariant ("nobody is in two rooms", "no room is over capacity"). That habit finds bugs that would otherwise wait for launch day.
๐ฎ A Client That Never Freezes
recv_msg() blocks until a whole message arrives. Call it inside the game loop and the window freezes whenever the server is quiet, which in a lobby is most of the time. The fix is the same kitchen idea in reverse: a background reader thread waits on the socket and drops every message into a queue.Queue, and the game loop empties that queue once per frame with get_nowait(), which never waits.
class NetClient:
def __init__(self, host, port):
self.sock = socket.create_connection((host, port), timeout=5)
self.sock.settimeout(None) # blocking reads in the reader thread
self.inbox = queue.Queue()
threading.Thread(target=self.reader, daemon=True).start()
def reader(self):
reason = "server closed the connection"
try:
while True:
message = recv_msg(self.sock)
if message is None:
break
self.inbox.put(message)
except (OSError, FramingError) as exc:
reason = f"connection lost ({exc.__class__.__name__})"
self.inbox.put({"type": "disconnected", "reason": reason})
def poll(self):
"""Every message that has arrived so far. Never waits."""
messages = []
while True:
try:
messages.append(self.inbox.get_nowait())
except queue.Empty:
return messages
Two rules keep this safe. First, the reader thread never touches pygame or the client's state; it only puts messages in the queue, and queue.Queue does its own locking. All drawing and every change to what the client knows happen on the main thread, in order. Second, even a lost connection arrives as an ordinary message ("disconnected"), so the game loop handles it like everything else instead of the thread printing a traceback and quietly dying.
Each frame of the client then runs in this order:
while running:
dt = clock.tick(60) / 1000
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1 and view.connected:
action = button_clicked(view, event.pos)
message = action_message(view, action) if action else None
if message is not None:
net.send(message)
for message in net.poll(): # everything that arrived since last frame
for reply in view.apply(message):
net.send(reply)
view.update(dt) # the start countdown, in seconds
draw(screen, fonts, view)
pygame.display.flip()
Clicking a button does not change the screen by itself; it only sends a request. The screen changes when the server's answer arrives and view.apply() stores it. That feels backwards at first, but it is what keeps the client honest: if the server refuses ("room is full"), the client never showed you inside the room. apply() also returns any messages to send back (after welcome it asks for your name to be set and for the room list), which lets the lab test it without any sockets at all.
Status is drawn as colored circles and words such as [READY]. The old client drew emoji with pygame.font.Font(None, ...), and pygame's default font has no emoji, so players saw empty boxes.
๐ Trust Nothing the Client Sends
Anyone can write a program that speaks your protocol, so every field in every message is untrusted until the server has checked it. This table lists what this lesson's server does, and what a public server would add on top.
| Risk | This lesson's server | A public server would alsoโฆ |
|---|---|---|
| Wrong types or huge values | Checks every setting's type and range; names are 1โ16 printable characters | Limit chat and message rates per player |
| Giant or garbage frames | framing.py rejects bodies over 1 MiB and non-JSON bodies | Use a much smaller limit for lobby traffic |
| Acting for someone else | Uses the ID the server assigned to this connection, never an ID from the message | Log players in with real accounts |
| Host-only actions | start() checks player_id == self.host_id | The same, for kick and settings changes |
| Room passwords | Salted PBKDF2 hash and hmac.compare_digest | Limit wrong guesses per player |
| Room spam | Nothing yet (one room per player at a time) | Rate-limit create_room; drop idle connections |
| Eavesdropping | Plain TCP on 127.0.0.1 | Encrypt with TLS (Python's ssl module); never show players each other's IP addresses |
โ Growth Mindset: Think Like the Troublemaker
It is natural to test your server the way a friendly player would use it. Security thinking means also asking "what if someone sends this?": a number where you expected a string, a room code that doesn't exist, start_game from a player who isn't the host. You won't think of every trick on day one, and that's fine. Each time you find one, add a test for it, and your server gets harder to break with every bug you fix.
๐๏ธ Practice Exercise: Lobby Client
Objective: finish a pygame-ce lobby client that shows the open rooms, joins one with a click, readies up, and counts down when the host starts the game, without ever freezing the window.
Time: about 40 minutes. Starter file: lobby_client_starter.py (your instructor has it), plus framing.py, room.py and lobby_server.py in the same folder. Run with no arguments, it starts a local server and two bots, Ada and Bo, who quick-match into a room and wait for a third player. The drawing and buttons are done; the numbered comments match the steps below.
- Run the starter. The window opens, but the lobby stays empty and says
player None: no messages are getting through yet. (โ 2 min) - Write the reader thread (comment 1): loop on
recv_msg(), put each message inself.inbox, and put a"disconnected"message when the loop ends for any reason. (โ 8 min) - Write
poll()(comment 2) withget_nowait()andqueue.Empty. Run it: your player number appears, and the bots' room (named Quick match) shows up in the list. (โ 5 min) - Handle
room,leftandgame_startinginClientView.apply()(comment 3). (โ 10 min) - Turn the join, ready, start and leave clicks into messages in
action_message()(comment 4). Click Ada's room, then Ready. (โ 8 min) - Count the countdown down by
dtinupdate()(comment 5), stopping at zero. (โ 3 min) - Try to break it: leave and rejoin, or start a second server with
python3 lobby_server.py 5555and run two clients against it withpython3 lobby_client_starter.py 127.0.0.1:5555. (โ 4 min)
You are done when:
- the lobby lists the bots'
Quick matchroom as2/4, and clicking it puts you in the room with Ada marked[HOST]; - pressing Ready makes Ada start the game, and
Game starts in 3.0counts down smoothly toGAME ON!; - the terminal shows
Joined room ...,Game starting in 3 sand, when you close the window,Room at exit: Ada, Bo, You (in_progress); - you can drag the window around at any time without it freezing.
๐ก Hint
If the window freezes, something on the main thread is waiting on the network: check that poll() uses get_nowait() and that nothing in the loop calls recv_msg(). If nothing ever arrives, make sure the reader thread's loop puts every message, and that poll() returns the list it built. For Ready, send the opposite of what the server last told you: not view.me()["ready"].
โ Example Solution
If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top and and frame_budget() in the loop. They let the instructor's checker run the program automatically; when you run it yourself they do nothing.
"""Lobby Client: Advanced Lesson 21 practice exercise (solution).
A pygame-ce lobby client. A background thread reads framed messages from the
server and puts them in a queue; the game loop drains that queue once per
frame, so the window never freezes waiting for the network.
python3 lobby_client_solution.py # local server + two bots
python3 lobby_client_solution.py 127.0.0.1:5555 # a server you started yourself
Keep framing.py, room.py and lobby_server.py in the same folder.
"""
import queue
import socket
import sys
import threading
import pygame
from framing import FramingError, recv_msg, send_msg
import lobby_server
WIDTH, HEIGHT = 800, 520
MY_NAME = "You"
BG = (17, 24, 39)
PANEL = (31, 41, 55)
TEXT = (229, 231, 235)
DIM = (148, 163, 184)
GREEN = (74, 222, 128)
AMBER = (251, 191, 36)
GOLD = (250, 204, 21)
PINK = (248, 113, 113)
BLUE = (59, 130, 246)
RED = (220, 38, 38)
GRAY = (75, 85, 99)
BUTTONS = { # name -> rectangle; which ones show depends on the screen
"create": pygame.Rect(20, 60, 170, 40),
"quick": pygame.Rect(200, 60, 170, 40),
"refresh": pygame.Rect(380, 60, 170, 40),
"ready": pygame.Rect(20, 330, 170, 44),
"start": pygame.Rect(200, 330, 170, 44),
"leave": pygame.Rect(380, 330, 170, 44),
}
ROW_TOP, ROW_HEIGHT = 150, 34
class NetClient:
"""One TCP connection. A reader thread fills self.inbox; nothing here ever
touches pygame, so the game loop stays the only code that draws."""
def __init__(self, host, port):
self.sock = socket.create_connection((host, port), timeout=5)
self.sock.settimeout(None) # blocking reads in the reader thread
self.inbox = queue.Queue()
threading.Thread(target=self.reader, daemon=True).start()
def reader(self):
reason = "server closed the connection"
try:
while True:
message = recv_msg(self.sock)
if message is None:
break
self.inbox.put(message)
except (OSError, FramingError) as exc:
reason = f"connection lost ({exc.__class__.__name__})"
self.inbox.put({"type": "disconnected", "reason": reason})
def poll(self):
"""Every message that has arrived so far. Never waits."""
messages = []
while True:
try:
messages.append(self.inbox.get_nowait())
except queue.Empty:
return messages
def send(self, message):
try:
send_msg(self.sock, message)
except OSError:
self.inbox.put({"type": "disconnected", "reason": "send failed"})
def close(self):
try:
self.sock.shutdown(socket.SHUT_RDWR) # wakes the reader thread
except OSError:
pass
self.sock.close()
class ClientView:
"""What the client knows, built only from server messages.
apply() returns the messages to send back, so it can be tested without sockets."""
def __init__(self):
self.my_id = None
self.rooms = []
self.room = None
self.countdown = None
self.connected = True
self.log = []
def note(self, text):
self.log = (self.log + [text])[-6:]
print(text)
def me(self):
if self.room is None:
return None
return next((p for p in self.room["players"] if p["id"] == self.my_id), None)
def apply(self, msg):
kind = msg.get("type")
if kind == "welcome":
self.my_id = msg["id"]
return [{"type": "set_name", "name": MY_NAME}, {"type": "list_rooms"}]
if kind == "room_list":
self.rooms = msg["rooms"]
elif kind == "room":
if self.room is None:
self.note(f"Joined room {msg['room']['code']}")
self.room = msg["room"]
elif kind == "note":
self.note(msg["text"])
elif kind == "left":
self.room = None
self.countdown = None
self.note("You left the room")
return [{"type": "list_rooms"}]
elif kind == "game_starting":
self.room = msg["room"]
self.countdown = float(msg["countdown"])
self.note(f"Game starting in {msg['countdown']} s")
elif kind == "error":
self.note(f"Server says: {msg['message']}")
elif kind == "disconnected":
self.connected = False
self.note(f"Disconnected: {msg['reason']}")
return []
def update(self, dt):
if self.countdown is not None:
self.countdown = max(0.0, self.countdown - dt)
def start_local_lobby():
"""A server on 127.0.0.1 plus two bots who quick-match into one room."""
server = lobby_server.LobbyServer().start()
for name in ("Ada", "Bo"):
seated = threading.Event()
threading.Thread(target=lobby_server.run_bot, args=(server.port, name, seated, 3),
daemon=True).start()
seated.wait(5)
return server
def button_clicked(view, pos):
"""Which visible button (or room row) is under pos, as an action tuple."""
me = view.me()
if view.room is None:
names = ("create", "quick", "refresh")
elif view.room["state"] != "waiting" or me is None:
names = ()
else:
names = ("start", "leave") if me["host"] else ("ready", "leave")
for name in names:
if BUTTONS[name].collidepoint(pos):
return (name,)
if view.room is None:
for i, row in enumerate(view.rooms):
if pygame.Rect(20, ROW_TOP + i * ROW_HEIGHT, WIDTH - 40, ROW_HEIGHT - 4).collidepoint(pos):
return ("join", row["code"])
return None
def action_message(view, action):
"""Turn a click into the message for the server (or None)."""
kind = action[0]
if kind == "create":
return {"type": "create_room", "settings": {"name": f"{MY_NAME}'s room"}}
if kind == "quick":
return {"type": "quick_match"}
if kind == "refresh":
return {"type": "list_rooms"}
if kind == "join":
return {"type": "join_room", "code": action[1]}
if kind == "ready" and view.me() is not None:
return {"type": "set_ready", "ready": not view.me()["ready"]}
if kind == "start":
return {"type": "start_game"}
if kind == "leave":
return {"type": "leave_room"}
return None
def draw_button(screen, font, rect, label, color):
pygame.draw.rect(screen, color, rect, border_radius=8)
text = font.render(label, True, TEXT)
screen.blit(text, text.get_rect(center=rect.center))
def draw(screen, fonts, view):
font, small, big = fonts
screen.fill(BG)
if view.room is None:
screen.blit(font.render(f"Lobby: {MY_NAME} (player {view.my_id})", True, TEXT), (20, 20))
draw_button(screen, font, BUTTONS["create"], "Create room", BLUE)
draw_button(screen, font, BUTTONS["quick"], "Quick match", BLUE)
draw_button(screen, font, BUTTONS["refresh"], "Refresh", GRAY)
screen.blit(small.render("Open rooms (click one to join):", True, DIM), (20, 122))
for i, row in enumerate(view.rooms[:6]):
rect = pygame.Rect(20, ROW_TOP + i * ROW_HEIGHT, WIDTH - 40, ROW_HEIGHT - 4)
pygame.draw.rect(screen, PANEL, rect, border_radius=6)
lock = " [locked]" if row["locked"] else ""
label = f"{row['code']} {row['name']} {row['players']}/{row['max_players']} {row['mode']}{lock}"
screen.blit(small.render(label, True, TEXT), (rect.x + 10, rect.y + 7))
if not view.rooms:
screen.blit(small.render("No open rooms yet. Create one!", True, DIM), (20, ROW_TOP))
else:
room = view.room
title = f"Room {room['code']}: {room['settings']['name']} ({room['settings']['mode']})"
screen.blit(font.render(title, True, TEXT), (20, 20))
for i, p in enumerate(room["players"]):
y = 70 + i * 34
color = GOLD if p["host"] else (GREEN if p["ready"] else PINK)
pygame.draw.circle(screen, color, (32, y + 10), 8)
tag = "HOST" if p["host"] else ("READY" if p["ready"] else "not ready")
you = " (you)" if p["id"] == view.my_id else ""
screen.blit(font.render(f"{p['name']} [{tag}]{you}", True, TEXT), (50, y))
me = view.me()
if room["state"] == "waiting":
screen.blit(small.render("Everyone but the host readies up; then the host starts the game.",
True, DIM), (20, 300))
if room["state"] == "waiting" and me is not None:
if not me["host"]:
draw_button(screen, font, BUTTONS["ready"], "Not ready" if me["ready"] else "Ready",
AMBER if me["ready"] else GREEN)
else:
draw_button(screen, font, BUTTONS["start"], "Start game",
GREEN if room["can_start"] else GRAY)
draw_button(screen, font, BUTTONS["leave"], "Leave", RED)
if view.countdown is not None:
msg = f"Game starts in {view.countdown:.1f}" if view.countdown > 0 else "GAME ON!"
screen.blit(big.render(msg, True, GOLD), (20, 330))
pygame.draw.rect(screen, PANEL, (0, 400, WIDTH, HEIGHT - 400))
for i, line in enumerate(view.log):
screen.blit(small.render(line, True, DIM), (20, 408 + i * 18))
if not view.connected:
screen.blit(font.render("Not connected", True, RED), (WIDTH - 170, 20))
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Lobby Client")
clock = pygame.time.Clock()
fonts = (pygame.font.Font(None, 30), pygame.font.Font(None, 24), pygame.font.Font(None, 56))
server = None
if len(sys.argv) > 1: # connect to host:port
host, port = sys.argv[1].rsplit(":", 1)
net = NetClient(host, int(port))
else:
server = start_local_lobby()
net = NetClient(lobby_server.HOST, server.port)
view = ClientView()
running = True
while running:
dt = clock.tick(60) / 1000
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1 and view.connected:
action = button_clicked(view, event.pos)
message = action_message(view, action) if action else None
if message is not None:
net.send(message)
for message in net.poll(): # everything that arrived since last frame
for reply in view.apply(message):
net.send(reply)
view.update(dt)
draw(screen, fonts, view)
pygame.display.flip()
if view.room is not None:
names = ", ".join(p["name"] for p in view.room["players"])
print(f"Room at exit: {names} ({view.room['state']})")
net.close()
if server is not None:
server.stop()
pygame.quit()
if __name__ == "__main__":
main()
๐ Learning Journal
Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:
- Key concepts you learned today
- Techniques that clicked (and the ones that haven't, yet)
- Questions or confusion to bring to the next session
- Ideas to try in your own game
- Progress and feelings: how did this lesson go for you?
โ๏ธ This lesson's prompts:
- In your own words, why does the client wait for the server's
roommessage instead of switching screens the moment you click Join? - Pick one rule from the Room model (the state check, host migration or the ready rule). Describe a real game where you have seen that rule, or seen it missing.
- If your capstone had online play, which lobby feature would your players care about most, and which one would you skip?
๐ Summary
A lobby is a small program with big consequences: it decides who plays with whom, and every mistake is visible to players before the game even starts. You kept the rules in a socket-free Room class that merges settings over defaults, validates them, refuses joins to started rooms, migrates the host and stores only a password hash. The server wraps those rules with one lock and per-client outbox queues, so changes happen one at a time and every client sees them in order. The client reads the network on a background thread and drains a queue once per frame, so the window never waits.
๐ Key Takeaways
- The server is authoritative: clients ask, and believe only what the server sends back.
- Build settings with
{**DEFAULTS, **requested}, then validate every value; remember thatTrueis anint. - Check the room's state on every join, not just its seat count.
- Change shared state under one lock, and queue outgoing messages while you hold it, so updates stay in order and no one waits on a slow socket.
- Keep blocking network reads off the game loop: a reader thread plus
queue.Queueandget_nowait(). - Store room passwords as salted PBKDF2 hashes and compare them with
hmac.compare_digest.
๐ญ Looking Ahead
Quick match here just fills the fullest open room. In Matchmaking & Ratings you give every player an Elo rating, widen each player's search the longer they wait, measure how fair a match is, and split players into balanced teams.
โ Common Questions
Can I play with a friend on another computer?
On a home network, yes. Change HOST in lobby_server.py to your computer's local IP address (or "0.0.0.0" for all of them) and start the server with a port: python3 lobby_server.py 5555. Your friend's client connects with python3 lobby_client_solution.py 192.168.1.23:5555 (your address and port), and your own client with 127.0.0.1:5555. Don't run the server or the client without an address while HOST is changed: the built-in demo and bots connect to HOST, and connecting to 0.0.0.0 fails on Windows. Change it back to 127.0.0.1 when you're done. Your operating system will probably ask whether to allow Python through the firewall. Only do this on a network you trust: this server has no accounts and no encryption. The lab keeps 127.0.0.1 so it never opens anything to the network.
Why threads instead of asyncio or the selectors loop?
All three work. Thread-per-client is the easiest to read when you already know threading, and a lobby has few connections and little traffic. The selectors loop from the Framing & Concurrency lesson handles more connections on one thread, and asyncio does the same with async/await. Whichever you pick, the Room rules and the "change state in one place, in order" idea stay the same.
Why does the host not have to press Ready?
Because pressing Start already says "I'm ready". It is a design choice, not a rule of networking. If you prefer everyone to ready up, change can_start() to check every player and make the room start automatically when it returns True. Either way, make the buttons match the rule so nobody waits for a host who doesn't know it's their turn.
What happens when a player's connection drops in a room?
recv_msg() returns None (a clean close) or raises an OSError (a reset). Either way serve_client() reaches its finally block and calls disconnect(), which removes the player, hands the host role on if needed, deletes the room if it is now empty, and sends everyone else the updated room.
Why use a queue instead of letting the reader thread update the view directly?
The main thread reads the view many times per frame while drawing. If another thread replaced view.room in the middle of a frame, you could draw half of one snapshot and half of the next, or read a player that just vanished. The queue hands each message across in one safe step, and the main thread applies it between frames. It also keeps every pygame call on the main thread, which is where pygame expects them.
Why does the server send the whole room every time?
Rooms are tiny (a few players), so a full snapshot costs almost nothing, and it makes the client simple and self-healing: it replaces its copy with each room message. Big game worlds can't afford that, which is why the optional State Sync & Bandwidth reading sends only what changed.
๐ฏ Quick Quiz
Question 1: The old lobby crashed with a KeyError when a client created a room with only some settings. What is the fix used in this lesson?
Question 2: Why does the client read the socket on a background thread and hand messages over through a queue.Queue?
Question 3: Suppose each handler released the lock and then sent its own room snapshot. What could go wrong?
Question 4: A room has max_players 4 and three players. Which join must still be refused?
Question 5: How does this lesson's Room store and check a room password?
๐ Going Further
- Room chat. Add a
chatmessage type: the server checks the text (a string, at most 120 characters, trimmed) and sends it to everyone in the room; the client shows the last few lines and a text box. - Host tools. Let the host change settings (
update_settings, reusingclean_settings(), and never lettingmax_playersdrop below the players already in the room) and kick a player. - Spectators. Keep a second dictionary of watchers in each room. They receive room updates but don't count toward
max_playersorcan_start(). - Idle timeout. Record when each client last sent anything and disconnect anyone silent for two minutes. Write a test for it with a short timeout.
- A private-room client. Add a password text box to the client and a "Join by code" field.
- Docs: queue, threading, hashlib.pbkdf2_hmac, hmac.compare_digest, secrets, and the OWASP Password Storage Cheat Sheet for storing real account passwords.