The design with no second copy

Four candidate shapes for pulling PvP sync logic out of an 898-line Svelte store. Two of them carried a duplicate copy of the game state; the winner needed none.

The same architecture review that deepened the duel word panel had a second pick waiting: the PvP store, 898 lines mixing reactive UI state with the transport logic that resyncs a game, reacts to server wake-ups, and reconciles a player’s own draft against an opponent’s. None of that sync half had ever been tested on its own, only indirectly through whatever the store happened to expose.

Four candidate shapes for the extraction got drawn up before any of the store’s code moved, covering the same responsibilities from different angles. One handed the new engine its own EventSource and ticker, driving the whole session lifecycle as a single object with one clean stop(). Another stayed closer to a request/response protocol. The one that won scaled up a pattern the codebase already trusted for the local AI duel: no events, no subscriber, just per-operation methods a caller awaits and applies, one at a time.

What actually decided it wasn’t ergonomics, it was state. Two of the four designs gave the engine its own internal copy of the canonical game state, deliberately allowed to diverge from the store’s own reactive state while an animation was mid-flight. That divergence was intentional and documented, but it was also exactly the kind of thing a future reader with no memory of why it was written that way could mistake for a bug and “fix.” The winning design never copies the state at all: it reads whatever the store’s current state actually is at the moment it’s asked, so there’s nothing to keep in sync with anything else. That same discipline shows up at the type level too, continuing the idea the word panel had already proven: the outcome the engine hands back after a server wake-up omits the collision result on purpose, so a second method has to compute it before that turn’s animation is allowed to run:

/** Second phase of 'animate-turn': synchronous, computed against the
 *  store's CURRENT canonical state so the collision heuristic sees exactly
 *  what the enqueued animation will run over. Returns null when the turn
 *  was already presented (an interleaved wake-up or claim/submit race won). */
presentTurn(
  turn: PvpTurnRow,
  prevCanonical: DuelGameState,
  nextCanonical: DuelGameState
): TurnPresentation | null;

Skipping that second call can’t compile into something that merely runs wrong, since nothing produces a collision result without it, which closes a real gap the old code had between fetching a turn and rechecking it a tick later. The raw EventSource and its polling ticker stayed behind in the store rather than moving into the engine, since a pull-only design has no room for a channel that pushes on its own; draft throttling and the timeout-claim check moved in instead, since both are ordinary operations a caller can just await.

The store came down from 898 lines to 777 behind one switch that applies whatever outcome the engine hands back, with forty-seven new tests exercising the engine directly rather than through the store’s reactive surface. Nothing about how a PvP game plays changed, verified against the existing two-browser Playwright suites that actually create, join, and finish a game, so no recording needed a re-shoot for this one either.