the pvp duel system: server-authoritative state

Building the biggest single system in the game: dueling a friend asynchronously, backed by a server-authoritative engine and a compare-and-set turn model.

When we set out to build the PvP duel system—where you can challenge a friend asynchronously—we knew it would be the biggest single system in the game. It required a fundamentally different architecture than the local AI duels. The design phase was so extensive that the architecture specification was drafted in June and finalized in July, serving as the blueprint for the entire implementation arc.

We ended up writing seven architectural decision records to capture the model before writing any UI code. We moved the game logic authority to the server side: the engine runs in the API route, and the game state lives in PostgreSQL. Because we needed to notify players when it was their turn, we used Server-Sent Events (SSE). But instead of broadcasting full state objects, we kept the SSE stream unauthenticated and payload-free—it just serves as a thin wake-up signal telling the client to fetch the latest state via a token-guarded GET request.

sequenceDiagram
  accTitle: PvP turn submission and notification
  accDescr: The guest submits a turn to the API route, which validates it against a compare-and-set turn index before writing it to PostgreSQL. On success the server publishes a thin Server-Sent Events wake-up, carrying only the event type and turn index, never state. The host's client receives the wake-up and fetches the latest state through a token-guarded endpoint, which returns the canonical host-perspective state.
  participant Guest
  participant Server as API route
  participant DB as PostgreSQL
  participant Host
  Guest->>Server: submit turn
  Server->>DB: compare-and-set write
  DB-->>Server: turn accepted
  Server-->>Host: SSE wake-up (type, turn index)
  Host->>Server: GET state (token-guarded)
  Server-->>Host: canonical host-perspective state
A guest's turn reaches the host only as a thin wake-up signal; the host's client re-fetches the canonical state through a token-guarded endpoint.

Managing state for two different players looking at the same board presented a classic perspective problem. We decided to store exactly one canonical state in the database: the host’s perspective. When the guest requests the state, a pure engine module transforms the canonical state into a viewer perspective, swapping the sides cleanly.

To prevent race conditions during a fast-paced game, every write goes through a single compare-and-set guard. The game service validates the current turn index and phase before appending a turn, replaying duplicate submissions idempotently and rejecting out-of-order moves. We polished the experience with a live opponent typing indicator, a fold-safe mobile layout, and a script to record gameplay for both the host and the guest, closing out the arc.