Rebuilding my games app from polling to realtime state sync
Try it live (opens in a new tab)
/ 12 min read
Overview
This post compares two versions of the same product. The archived version lives at github.com/oyuh/games-arch. The current version is the refactor in this repo.
Both versions solve the same product problem. A player creates a room, shares a code, joins fast, and plays party games without an account. The product goal stayed stable. The runtime model changed.
The old version, games-arch, was one Next.js app with route handlers, polling, and game logic spread across large client pages. The new version, games-refac, splits the system into a React and Vite frontend, a Hono API, a shared contract package, Zero for synchronized query and mutation state, Postgres as the durable store, and a dedicated presence WebSocket.
The tool list is not the useful part. The useful part is why the rewrite became needed, what improved, and what costs more to run now.
What The Old Version Did Well
games-arch was not a bad app. It shipped real game logic and solved a real use case. The architecture was direct.
A Next page rendered. Client code called fetch('/api/...'). Route handlers read or changed Postgres rows. The page polled every few seconds to stay current.
That model had one big advantage. It was easy to understand at first. There was no separate API service, sync service, shared package, or presence deploy target. For a smaller app, that simplicity helped me ship fast.
Old Strengths
The strengths were practical. Everything lived close together. Adding a game flow often meant adding a page and a few API routes. Deployment complexity stayed low.
The mental model matched what many people know from Next App Router. Most of all, the old version let me prove the product prior to heavier architecture.
Starting with the new stack on day one likely slows the project before the product need is clear.
Where The Old Architecture Started To Hurt
The weaknesses showed up after the app outgrew a few routes and some UI state. Multiplayer games need phases, reconnect rules, cleanup paths, and timing edge cases. The original shape stopped fitting that product.
Polling Everywhere
This is the largest difference between the two versions. games-arch kept the UI current by calling the API over and over. The old Imposter pages polled aggressively. Password pages did the same with different intervals.
That kept the app live enough, but it taxed almost every layer.
Polling adds requests even when nothing changes. It duplicates loading and error logic across pages. It pushes the app toward delayed state instead of synchronized state. It creates more races around redirects, timers, and phase changes.
Polling feels fine with a small state surface. It starts to drag once a page asks many questions at once:
- Did everyone submit?
- Did the phase advance?
- Did someone disconnect?
- Did the game end?
- Did I get kicked?
- Do I redirect now or after one more fetch?
Per-Game API Growth
The archived repo exposed many route trees. Imposter had create, fetch, start, clue, vote, should-vote, heartbeat, and leave endpoints.
Password had its own parallel set of routes for create, join, start, category vote, word selection, clue entry, guesses, round transitions, game end, and leave flows.
None of that is wrong by itself. The problem shows up over time. Each game invents its own API shape. Shared behavior gets rebuilt in slightly different ways. One rule change touches several endpoints and pages. Client and server drift without strict discipline.
The old version favored shipping one feature at a time. It did not favor consistency after the app grew.
State Shaping In Pages And Routes
Old page components carried too much responsibility.
The Imposter page handled poll loops, local loading state, clue and vote submission, disconnect logic, redirects, notifications, leave behavior, heartbeat work, history rendering, and special transitions.
Password had the same pressure. Team-specific and global phases lived together, so the page had to sort through both.
After a page takes on that much, it stops being a view. It becomes a custom controller for the whole game. Future UI edits get harder than they should be.
Realtime And Presence Were Bolted On
The old app supported multiplayer updates and connection tracking, but the work was scattered. Presence depended on polling, heartbeat routes, and game-specific disconnect logic stored inside game data.
That meant presence was not a system. It was a repeated concern each game had to solve again.
This worked until reconnection and cleanup needed to be dependable. Duplicated presence logic then became a maintenance problem.
Product Progress And Debt Mixed Together
The archived repo shows real iteration. It has compatibility comments, legacy fields kept for old UI expectations, and route handlers that handle transition logic and cleanup work together.
That is normal in a fast-moving app. It is a sign that the architecture carries too much history in too many places.
What Changed In The Refactor
The refactor is not Next.js with cleaner files. It uses a different layout and a different runtime model.
New Structure
games-refac splits the repo into apps/web, apps/api, and packages/shared.
That one move changes almost everything. The project now has an explicit contract layer instead of an implied one.
Frontend
The web app runs on React 19, Vite, and React Router. The frontend renders pages, stores lightweight browser identity, opens realtime connections, subscribes to state, and invokes shared mutators.
It no longer pretends to be the API runtime.
That split cleaned up the page model. Pages mostly render current state and trigger actions. They do not orchestrate poll loops and route-specific fetch logic.
Backend
The API is a Hono service on Node. It owns /api/zero/query, /api/zero/mutate, /health, /debug/build-info, /api/cleanup, and the /presence WebSocket upgrade path.
That is a smaller external surface than one route tree per game mechanic.
Actions changed most. The client invokes named mutators that resolve against shared definitions instead of calling a public route for each small action.
Shared Contracts
The shared package contains the Drizzle schema, Zero schema, query definitions, mutator implementations, and shared game types.
In the old version, behavior often lived in the relationship between a page and a route handler. In the new version, behavior lives in shared queries and mutators.
That gives the repo a center of gravity. Contracts are imported, not re-described. Refactors touch fewer surfaces. The source of a game rule is easier to find.
From Polling To Synchronized State
This is the main upgrade. games-arch used polling to imitate liveness. games-refac uses Rocicorp Zero to synchronize query and mutation state through a cache layer backed by Postgres.
What Changed In Practice
The browser creates one Zero client. Pages subscribe with useQuery(...). Mutations run through shared mutators. Zero forwards the work through the API and sends updated state back through subscriptions.
The biggest win is not the word realtime. The UI no longer asks the same question over and over. That removes page-specific fetch loops, manual refresh state, poll-then-redirect logic, and stale snapshot edge cases.
The mental model changed too. The old model was, fetch the latest game and hope the UI catches up at the right moment. The new model is, subscribe to the state slice and mutate the source of truth.
That fits multiplayer phases and timers much better.
Presence Became A System
The refactor separates realtime game data from presence. The app uses a dedicated presence WebSocket at /presence.
That channel updates sessions.lastSeen and game attachment state on a heartbeat interval. Presence no longer hides inside game-specific heartbeat routes.
The split is cleaner. Zero handles data sync. The presence socket answers a different question: is this browser alive and attached to this room?
Cleanup and reconnection get easier once session liveness lives in one place.
Data Model
Both versions stay pragmatic about game state. Neither tries to normalize every clue, vote, and round into a long chain of relational tables. That did not need to change.
The new version is more consistent. The schema has clear tables for sessions, imposter_games, password_games, chain_reaction_games, and chat_messages.
Each game table still stores a lot of state in JSON columns. That often fits phase-shaped party game state well.
The key difference is alignment. The shared schema, shared types, and shared mutators point at the same shape.
Frontend Size
The old pages carried a lot of custom orchestration. The new pages still hold multiplayer logic, but the job is narrower.
Subscribe to the current game. Subscribe to room sessions. Open the presence socket. Invoke mutators. React to announcements, kick or end state, and timers.
That makes pages feel less like a mini framework and more like focused UI over shared state.
Observability
This change sounds small until production breaks.
The new version tracks Zero connection state, Zero online and offline transitions, presence socket state, presence connect latency, API probe state, and build metadata from /debug/build-info.
The old version had useful logs. The new version answers basic questions faster:
- Is the browser online?
- Is Zero connected?
- Is presence connected?
- Is the API reachable?
- Which build is the browser talking to?
Chat Shows The New Shape
The refactor adds a shared chat model with a chat_messages table, shared chat.byGame queries, shared chat.send mutators, and a reusable ChatWindow component.
In the old shape, chat needs more route handlers, refresh logic, response shapes, and page glue.
In the new shape, chat is a shared data concern with a shared contract and reusable subscriber. That is one of the clearest signs that the architecture is earning its place.
Deployment Model
The old version had the appeal of one consolidated app. The new version is more explicit about what production already needed.
Vercel serves the frontend SPA. Railway runs the API service. Railway runs a separate Zero cache service. Postgres lives in Railway Postgres or Neon.
That is more moving parts. It matches the real runtime jobs better: static delivery, API handling, realtime sync, and durable storage.
Why The New Version Is Better
The answer is not one tool.
The new version has cleaner boundaries between UI, backend, contracts, schema, synchronization, and presence. Realtime behavior uses state subscriptions instead of polling. Mutators and queries scale better than a growing list of game-specific routes.
Shared contracts reduce drift. Observability is stronger. The next game has a better chance of fitting the system instead of forcing another one-off pattern.
The Refactor Is Not Free
The new version is better. It is more expensive to understand and operate.
More Moving Pieces
The old version was mostly a Next app plus Postgres. The new version has a web app, API service, shared package, Zero cache service, Postgres, a presence socket, and deployment wiring between them.
That is real complexity.
Zero Is Another System
Zero is not fetch with less code. It changes how queries, subscriptions, mutations, cache behavior, and client lifecycle work.
That is helpful after the model clicks. It is still another system to own.
Two Realtime Channels
Presence and synchronized state now use separate channels. The split fits the app, but it adds nuance.
I now have to reason about Zero connectivity, presence socket connectivity, and the relationship between presence freshness and game state.
Stricter Infrastructure
Local and production setup now need Docker-backed Postgres for development, a Zero cache process, wal_level=logical on Postgres, a direct upstream Postgres connection for Zero, and separate env vars for web, API, and Zero.
The setup is manageable after documentation. It is still heavier than a monolithic app.
Foundation Versus Feature Parity
The archived repo had more breadth in some areas. It had more experiments baked into the old structure.
The refactor is more selective. It focuses on foundation. The new version is stronger as a platform, even before every old idea moves over one-to-one.
That trade is worth it.
Old Vs New
| Area | games-arch | games-refac |
|---|---|---|
| App shape | One Next.js app | Split monorepo with web, api, shared |
| Frontend | Next.js client pages | React 19 + Vite SPA |
| Backend | Next route handlers | Hono Node service |
| Shared contract layer | Mostly implicit | First-class shared package |
| Realtime model | Polling | Zero subscriptions plus mutations |
| Presence | Game-specific heartbeat routes | Dedicated /presence WebSocket |
| API surface | Many per-game endpoints | Small service surface plus shared mutators |
| Deployment | Simpler single-app mental model | More explicit multi-service model |
| Debuggability | Mostly page and route level | Connection debug plus build-info plus service separation |
| Extensibility | Fast to hack | Better long-term structure |
The Real Lesson
Not every app needs more architecture. The lesson is narrower. A simple architecture stays simple only for as long as the product still fits inside it.
games-arch was the right first version. It helped me find the real product and gameplay problems fast.
games-refac is the right version now. The project needs to survive more games, more shared state, more multiplayer edge cases, and more deployment reality.
The project moved from a working app with growing exceptions to a platform with explicit boundaries.
Closing
The old version was easier to ship quickly. The new version is easier to trust.
For a multiplayer app with timers, room state, reconnects, and several game modes, that trust matters more than saving one more route file.
Repo: github.com/oyuh/games