Challenges & Solutions

Four problems that didn't have an obvious answer, and why I chose the approach I did over the alternatives I considered.

1. Adding a game without touching the lobby

The problem: My first iteration of Imposter was extremely coupled with the Durable Object itself. It worked for one game, but it would cause growing pains when adding more. Imposter and Wavelength don't just look different, they have completely different round structures, different numbers of phases, different scoring, different secret information. If the lobby (seating, host handoff, timers, connection tracking) knows anything specific about either game, every new game means editing code that every other game also depends on.

What I considered and rejected:

  • One big handler with a switch on the active game. The obvious first draft, but the lobby's core files grow by one branch per game forever, and a bug in Wavelength's branch ships inside the same file, and same deploy, as Imposter's working code.
  • A separate Durable Object class per game. Isolates games completely, but throws away everything they actually share (seating, host handoff, connection tracking, the reconnect story) and duplicates all of it per game instead of once.

What I built: The strategy pattern. A fixed GameDefinition contract (init, onMessage, onTimer, onPlayerJoined/Disconnected/Reconnected, snapshotFor) that every game implements as one pure module, registered in one map (GAMES). The engine runs whichever game is active through that interface without knowing its internal implementation, and without ever branching on which game it is. It just namespaces the game's timers and wraps its broadcasts. I proved the contract was actually game-agnostic by building tap-race, a deliberately trivial "everyone taps until the timer runs out" game whose only purpose is to exercise every hook. If the contract were subtly shaped around Imposter's needs, a second real game (Wavelength) plus this synthetic one would have found the gap.

2. React StrictMode turning one connection into two

The problem: In development, StrictMode intentionally mounts an effect, unmounts it, and re-mounts it in the same tick. A lobby WebSocket connection opened naively in a useEffect opens twice under that pattern: connect(), then disconnect(), then connect() again. The two navigator.locks.request() calls that each connect() fires are both async, so the second one can fire before the first has resolved, and both can end up believing they legitimately own the connection.

What I considered and rejected:

  • Disable StrictMode. That makes the symptom disappear without fixing the actual bug, which is that the connection logic wasn't safe against rapid remounts to begin with. A user really opening the same room in two tabs would hit the same race in production.

What I built: The lock request itself, not just its outcome, is memoized in a variable that outlives any single connect()/disconnect() call, and the reaction to the lock being granted is attached exactly once, to that request. A remounting effect's second connect() reuses the same in-flight lock request instead of racing a second one, so there is structurally only ever one navigator.locks.request() in flight per lobby, no matter how many times an effect remounts. This turned a development-only annoyance into a real single-tab-per-room guarantee: a user who genuinely opens the same room in two tabs now gets a clear "already open in another tab" message instead of two competing sockets.

3. Keeping idle rooms cheap without losing their connections

The problem: A room can sit open with people connected but nothing happening, between rounds or while someone reads the rules. A Durable Object holding raw WebSocket references in memory has to stay resident (and billed) the entire time just to keep those sockets alive, even though there's no work to do.

What I considered and rejected:

  • Just accept the cost. For a single Durable Object per room, an idle room that nobody explicitly closes is a hibernation problem, not a request-volume problem. Normal request-based billing intuition doesn't save you if the object can never actually go idle.

What I built: Sockets are accepted through Cloudflare's WebSocket Hibernation API (ctx.acceptWebSocket plus serializeAttachment/deserializeAttachment instead of an in-memory Map<PlayerId, WebSocket>), which lets the runtime evict the object from memory while idle connections stay open, and wake it back up on the next message with the player's identity restored from the socket's serialized attachment. Timers ride the same discipline: every deadline is data in RootState.timers, reconciled to a single DO alarm set to the earliest one on every commit, rather than one setTimeout per timer. A setTimeout would itself have pinned the object in memory the whole time, defeating the point of hibernating in the first place.