Four roles, one game: a WebSocket protocol for TV, phones, and a human referee¶
You're designing a multiplayer board game. The TV in the living room shows the board. Each player has a phone showing their hand. A referee phone (pick a kid, give it to them, they're the dungeon master tonight) wants to see everything. An aunt watching the stream wants to follow along but obviously shouldn't see anyone's hand. After the game, you replay it and want every move and every state visible.
That's five different views of the same game state, and they have to stay coherent in real time as a game with hidden information progresses. This post is about how I structured the WebSocket protocol so that this stays simple and stays correct.
The TL;DR: server-authoritative state, role-filtered broadcast, three view methods on every game engine. After that the rest of the protocol is mostly bookkeeping.
The five roles¶
Every connected client has exactly one role. The role determines two things: what the client can see, and what the client can do.
| Role | Device | View | Auth |
|---|---|---|---|
TABLE |
TV / shared screen | public state only | room code |
PLAYER |
phone | public state + own private state | per-player token via QR |
REFEREE |
tablet / phone | full state (public + everyone's private) | room-creator token (strongest) |
OBSERVER |
any | public state only | none, or session-locked |
REVIEW |
any (post-game) | full state on a frozen game record | game-completion token |
The five roles look like a lot, but they collapse into three view shapes -- public_view, player_view, full_view -- which is what the engine actually needs to implement. The five roles are what the server knows. The three views are what the engine implements.
Four of these roles -- TABLE, PLAYER, REFEREE, and OBSERVER -- are fully live. REVIEW has its token issuance path and broadcast filter in place but the attach codepath is not yet live; see "What this gives you" below for the current status.
Three view methods, every engine¶
Every game engine in this project subclasses a base class that requires three methods:
```python class GameEngine(ABC): @abstractmethod def public_view(self, state: GameState) -> dict: """For TABLE / OBSERVER. Public board only."""
@abstractmethod
def player_view(self, state: GameState, player_id: Any) -> dict:
"""For PLAYER. Public board + this player's private state. No other player's private state."""
@abstractmethod
def full_view(self, state: GameState) -> dict:
"""For REFEREE / REVIEW. Everything."""
GameState itself is split into two fields:
```python @dataclass(frozen=True) class GameState: public: dict private: dict[player_id, dict]
So public_view returns state.public. player_view(state, p) returns state.public | state.private[p]. full_view returns the whole thing. For perfect-information games like Connect Four or Memory Match (well, Hidden-Flips Memory Match has private cells, but you get the idea), all three methods return the same shape -- the structure is just there for uniformity.
For games with hidden information -- Bluff with hidden hands, Hearts with hidden tricks, Werewolf with hidden roles, Battleland with hidden legion contents on the masterboard -- the three methods diverge, and the engine itself is the place where "what is hidden from whom" gets decided.
This is the load-bearing rule. Privacy is an engine-level concern, not a server-level concern. The server doesn't know that Bluff hands are hidden. The server doesn't know that Werewolf roles are hidden. The server only knows: "give me the player's view," and Bluff's player_view is the thing that knows to filter. The protocol layer never has to learn game rules.
This sounds obvious. Most multiplayer game frameworks I've seen get this wrong. They have a global "hidden info filter" that the protocol layer applies after fetching state, and every new game has to register its hidden-info schema with that filter. This works until you have a game where what's hidden depends on game state -- like a Werewolf night phase where the seer reveals one role, and now that role isn't hidden for that one player. The global filter can't express that. The engine method can.
Server-authoritative, full stop¶
The server holds the authoritative GameState. Clients never report what the state is. Clients only report what action they want to take. The server applies the action, computes new state, applies the per-recipient view filter, and broadcasts.
The flow:
client server game engine | | | | --- WS action ---> | | | | -- apply_action ---> | | | <-- new state ------ | | | | | | -- compute view ---> | | | (public/player/full)| | | <-- filtered view -- | | | | | <-- WS state --- | |
A client that ever computes "I think the state is now X" and reports X to the server -- that client is a vulnerability. The protocol does not have a way to express "client says state is X." The protocol has actions in (typed, validated) and filtered views out. State flows one direction.
This rule alone eliminates an entire class of multiplayer cheats. A client that wants to cheat would need to make the server believe a different state, which requires either a server bug or a privilege escalation. Client modifications can't lie about state; they don't get asked.
The dispatch pattern¶
Every authenticated WebSocket message type routes through a single method on the room object:
```python class Room: async def handle_action(self, ws, msg: dict) -> None: try: action = parse_action_message(msg, self.engine) ctx = self.connections[ws] if ctx.role == "OBSERVER": raise ProtocolError("observers cannot act") # PLAYER, TABLE, and REFEREE can all submit actions self.state = self.engine.apply_action(self.state, action) await self.broadcast_state() except ProtocolError as exc: log.warning("action.reject", reason=str(exc)) await ws.send_json({"type": MSG_ERROR, "message": str(exc)})
Today Room has 30 handle_* methods, all of them on that one class -- action, referee_action, set_coaching_level, set_api_key, set_commentator_personality, and more. That handler surface has roughly doubled since the protocol settled, and not one of those new handlers touched the three-view broadcast filter below. That is the orthogonality this design is built to give you, stated as a real number: the message-type count grows as games and features land, and the role/view filtering stays exactly where it was. Token verification via hmac.compare_digest happens at WebSocket attach time: PLAYER and REFEREE roles are each issued tokens, verified on connect, and stored in the connection context. Handlers that carry a per-message token (like set_coaching_level and set_api_key) re-verify with hmac.compare_digest at the call site before acting. handle_action relies on the role established at connect time and checks ctx.role rather than re-verifying per message. If a check fails, the handler logs auth.fail with structured context (stage, attempted_token_prefix) and sends an MSG_ERROR reply.
The reason this lives in Room and not in the WebSocket receive loop is that the receive loop is shared infrastructure -- it should not contain game logic. New protocol message types add a method on Room. Adding it inline to the receive loop is the regression case I watch for; it's the easy mistake and it puts auth logic in the wrong place.
Role-filtered broadcast¶
When state changes, every connected client to the room gets a state update. But each client gets a different update -- filtered by their role. Pseudocode:
```python async def broadcast_state(self, new_state: GameState) -> None: public = self.engine.public_view(new_state) for client in self.clients: if client.role in ("TABLE", "OBSERVER"): await client.send({"type": "state_update", "view": public}) elif client.role == "PLAYER": view = self.engine.player_view(new_state, client.player_id) await client.send({"type": "state_update", "view": view}) elif client.role in ("REFEREE", "REVIEW"): # REVIEW attach not yet live view = self.engine.full_view(new_state) await client.send({"type": "state_update", "view": view})
This is the entire trick. The TV gets public_view. Each player gets their own player_view. The referee gets full_view. The aunt watching gets public_view. After the game, the review client gets full_view on a frozen GameRecord.
Three views, five roles, one broadcast. The complexity of "who can see what" lives in three pure methods on the engine, and the protocol just dispatches. Adding a new role is changing three lines in the broadcast function. Adding a new game means writing a new engine that implements three methods. The two axes of change are orthogonal -- you change one without touching the other.
What I avoided¶
A few patterns I tried and rolled back:
Single mega-view with field-level visibility tags. I had a brief flirtation with a system where the engine returned one big state object with each field tagged {visibility: "public"} or {visibility: "private:player_3"}, and the broadcast layer filtered the tags per recipient. This is more "elegant" in some abstract sense and was the wrong choice in every concrete sense. The engine ends up tagging fields it shouldn't have to think about, the broadcast layer ends up doing string-matching on visibility tags, and any bug in the tagging is a privacy bug. The three-method pattern is harder to mess up -- the engine is the only thing that knows what private means in this game, so make the engine output it directly.
Client-side state reconciliation. Tempting because of latency. "What if the client predicts the next state and the server confirms?" I built this for two days and threw it away. Optimistic clients are great for FPS games where the rendering speed matters. For a turn-based board game where moves take 5+ seconds and the TV is the social focus, the round-trip latency to the server is invisible compared to the time the human takes to think about their move. Optimistic prediction doubles the client complexity and prevents the next thing.
Letting clients hold move history in memory. TV browsers (Tizen, webOS, Android TV) are underpowered. Holding a 200-move history in client memory makes them swap. The protocol broadcasts state, the client renders state, and history queries go back to the server (which has DuckDB on disk and doesn't care). The TV client only ever knows the current state plus, if it wants, the last 1-3 moves for animation purposes.
What this gives you¶
When the protocol layer is this thin, you get a few specific things:
- New game = new engine class. The protocol is unchanged. The broadcast is unchanged. The auth is unchanged. You implement four methods (
apply_action, plus the three views) and the game works for all five roles automatically. - New role = a branch in the broadcast function. Adding
REVIEW-- a post-game "frozen record" client that receivesfull_viewon a completed game -- illustrates the pattern: REVIEW token issuance, thegame_review_tokensstore, and the broadcast filter that routes REVIEW tofull_viewalongside REFEREE are all in place in the auth layer and the broadcast layer. The attach codepath raises aProtocolErrorpending a later phase; the protocol shape is settled before the attach logic ships. - Privacy bugs become impossible to write accidentally. A client cannot ask for "another player's hand." There is no protocol message that returns it. The only way data leaves the server is through
public_view/player_view(self) / full_view, and each is dispatched against the verified role. - The engine is testable in isolation. Engines have no network code, no logging, no framework imports. Pure Python, pure functions on
GameState. They run as fast as the tests can call them. A game engine and its three views can have hundreds of unit tests that complete in under a second.
When this doesn't work¶
A few cases where the pattern would need adjustment:
- Massive multiplayer (100+ concurrent clients). The broadcast loop above is O(n) per state change. Fine for board games (3-8 players, 1 TV, 1 referee, maybe an observer or two). Not fine for an MMO. For MMO scale, you'd shard rooms and probably move filtering to a separate service. This product doesn't need that.
- Soft real-time (sub-100ms move loops). Twitch-style games where input latency matters. The server-authoritative pattern adds a round trip. For board games, that round trip is invisible. For action games, it's the bottleneck.
- Provably-fair gameplay over a hostile network. If the server itself is not trusted (e.g., player-vs-player gambling), you need cryptographic move commitments and verifiable randomness, which is a much harder protocol. Server-authoritative assumes the server is the trusted party.
For everything in between -- turn-based, real-time-but-not-twitch, single-server -- the four-role + three-view pattern is one of the cleanest separations I've found between "what the game does" and "how the network works."