skip to content

Rebuilding my games app from polling to realtime state sync

Try it live (opens in a new tab)

/ 18 min read

React , Vite , Hono , Node.js , Rocicorp Zero , PostgreSQL , Drizzle , TypeScript , Turbo , pnpm

Overview

This post is about the difference between two versions of the same idea.

The old version lives in the archived repo at github.com/oyuh/games-arch.

The new version is the refactor in this repo.

They are both trying to solve the same product problem: let people create a room, share a code, join fast, and play party games without accounts. But technically they are very different systems.

The short version is this:

  • games-arch was a single Next.js app with page-specific route handlers, polling, and a lot of game logic spread between routes and big client pages.
  • games-refac is a split system: React/Vite frontend, Hono API, shared schema/contracts package, Zero for realtime query/mutation sync, Postgres as the durable source of truth, and a dedicated presence WebSocket.

That sounds like a normal “I rewrote it with more tools” story. It is not really that. The interesting part is why the rewrite happened and what got better versus what got more annoying.

Because yes, this version is better.

It is also more infrastructure.

What the old version was actually doing

Before I talk about the refactor, it is worth being fair to the old build.

games-arch was not bad. It was a real application that worked, and it had a lot of practical game logic in it. The repo README described it pretty clearly:

  • Next.js 15
  • React 19
  • Drizzle
  • PostgreSQL
  • Next.js Route Handlers under /api
  • polling for realtime updates

That version had a very direct architecture:

  1. A page rendered in Next.
  2. Client components called fetch('/api/...') against route handlers.
  3. The route handlers mutated or returned game rows from Postgres.
  4. The page polled every few seconds to stay “live enough.”

That approach has one huge advantage:

it is easy to understand at first.

There is no separate API service. No Zero cache service. No explicit contract package. No extra deployment surface for presence. A lot of the product lived in one place, and when the app was smaller, that was genuinely productive.

The old strengths

I do not want to flatten games-arch into “bad old code.” It had some very real strengths:

  • everything lived close together
  • shipping a new game flow was fast because you could just add a page and some API routes
  • deployment complexity was lower
  • the mental model was familiar if you already understand Next App Router
  • it let me prove the product idea before over-engineering the foundation

That matters.

If I had started with the new stack immediately, I probably would have slowed myself down before I had even proven people wanted to use the thing.

Where the old architecture started to hurt

The problems showed up once the app stopped being “a couple routes and some state” and became “a multiplayer system with multiple games, multiple phases, reconnection, cleanup, and edge cases.”

1) Polling everywhere

This is the biggest architectural difference between the two repos.

In games-arch, the UI stayed current by repeatedly calling the API.

You can see it directly in the old pages:

  • Imposter polled aggressively with setInterval(() => fetchGame(false), 1000)
  • Imposter results polled every 2 seconds
  • Password lobby polled every 5 seconds
  • Password gameplay polled every 3 seconds

That works. It is also a tax on almost everything:

  • extra requests even when nothing changes
  • more duplicated loading/error state in pages
  • more “eventually catches up” behavior instead of actual synchronization
  • more race conditions around transitions, redirects, and timer-based phases

Polling is fine when the state surface is small.

Polling starts feeling bad when the page has to answer questions like:

  • did everyone submit?
  • did the phase advance?
  • did someone disconnect?
  • did the game end?
  • did I get kicked?
  • do I redirect yet or wait one more poll?

That was a lot of the old code.

2) The API surface kept expanding per game

The archived repo had a long route list.

For example, Imposter alone exposed endpoints for:

  • create
  • fetch game state
  • start
  • clue
  • vote
  • should-vote
  • heartbeat
  • leave

Password had its own parallel route tree for:

  • create
  • join-by-code
  • fetch game state
  • start
  • vote-category
  • select-word
  • clue
  • guess
  • end-round
  • next-round
  • end-game
  • leave
  • team join/leave flows

That kind of route tree is not wrong, but it starts creating a maintenance problem:

  • each game invents its own API shape
  • the client and server contracts drift unless you are very disciplined
  • shared behavior gets reimplemented slightly differently in different route files
  • “one small rule change” can touch multiple endpoints and multiple pages

The old architecture optimized for feature-by-feature shipping, not for consistency under growth.

3) Too much state shaping lived in pages and route handlers

In the old build, the page components were often large and very stateful.

The old Imposter page is a good example. It handled:

  • local loading state
  • poll loops
  • clue submission
  • vote submission
  • disconnection voting
  • redirect logic
  • notification state
  • leave behavior
  • heartbeat wiring
  • history rendering
  • special cases for missing players and results transitions

That is a lot for a single page file.

The old Password flow had similar pressure, especially because it had team-specific and global phases at the same time, plus compatibility fields for older UI expectations.

Once a page is doing all of that, the UI file stops being “render logic” and becomes an ad hoc controller for the entire game.

4) Realtime and presence were bolted onto request/response flows

The archived repo did support multiplayer updates and connection tracking, but it did it through a combination of:

  • polling page state
  • heartbeat endpoints
  • disconnection logic stored inside game-specific data blobs

That meant “presence” was not really a first-class system. It was something each game had to keep handling.

For example:

  • Imposter had heartbeat/disconnection vote logic in its own route
  • Password had its own heartbeat route
  • the client had to think about visibility, unload, timers, and periodic heartbeats as part of page behavior

That is the kind of thing that feels manageable until you want it to be reliable.

5) The old codebase mixed product progress with architectural debt

This is the subtle one.

The old version clearly contains real iteration. You can see backwards-compatibility comments, legacy fields kept alive for UI compatibility, extra derived state copied into game_data, and route handlers that are doing both transition logic and migration-ish cleanup at the same time.

That is normal in a fast-moving project.

It is also a signal that the architecture is asking the app to remember too much history.

What changed in the refactor

The new repo is not “Next.js, but cleaner.” It is a different layout and a different runtime model.

The new structure

games-refac is split into three main parts:

  • apps/web for the browser app
  • apps/api for the backend service
  • packages/shared for shared schema, types, queries, and mutators

That one move changes almost everything.

Now the project has an actual contract layer instead of an implied one.

The new frontend is just the frontend

The web app is React 19 + Vite + React Router.

That sounds like an unimportant tool swap, but it cleaned up the application shape a lot.

The frontend is now responsible for:

  • rendering pages
  • storing lightweight local browser identity
  • opening realtime connections
  • subscribing to data
  • invoking shared mutators

It is not pretending to be the API runtime anymore.

That split alone makes the frontend easier to reason about because pages mostly render current state and trigger actions instead of manually orchestrating a bunch of fetch/poll loops.

The new backend is an actual service

The API is now a Hono app running on Node.

It owns:

  • /api/zero/query
  • /api/zero/mutate
  • /health
  • /debug/build-info
  • /api/cleanup
  • /presence WebSocket upgrades

That is a much smaller and cleaner external surface than “one route tree per game mechanic.”

The main difference is that the backend no longer exposes a public endpoint for every tiny action. Instead, the client invokes named mutators and the server resolves them against shared definitions.

That is a huge maintainability win.

Shared contracts became a first-class part of the repo

This is probably the biggest engineering improvement.

The shared package contains:

  • Drizzle schema
  • Zero schema
  • query definitions
  • mutator implementations
  • shared game types

That means the system has a real center of gravity now.

In the old version, a lot of behavior lived in the relationship between a page and a route handler.

In the new version, behavior lives in shared mutators and shared queries.

That sounds abstract, but in practice it means:

  • fewer mismatches between client and server expectations
  • easier refactors because contracts are imported rather than re-described
  • a clearer place to look when game logic changes
  • less duplicated request/response glue

The biggest upgrade: from polling to synchronized state

This is the core of why the new version is better.

The old repo used polling to simulate liveness.

The new repo uses Rocicorp Zero to synchronize query and mutation state through a dedicated cache service, with Postgres behind it.

What that changes in practice

In the new version:

  1. the browser creates a single Zero client
  2. pages subscribe with useQuery(...)
  3. mutations are invoked through shared mutators
  4. Zero forwards query/mutation work through the API
  5. updated data comes back through the subscription path

The important thing is not just “it is realtime now.”

The important thing is the UI no longer has to fake realtime by constantly re-asking the same question.

That removes a lot of incidental code:

  • no more page-specific fetch loops just to stay current
  • fewer manual refresh states
  • fewer “poll then redirect” transitions
  • less edge-case code around stale snapshots

It also changes how you think about the app.

Old mental model:

fetch the latest game over and over and hope the UI always catches up at the right time.

New mental model:

subscribe to the slice of state you care about and mutate the source of truth.

That is a much better model once you have multiple games, multiple players, and phase transitions happening on timers.

Presence became a system instead of a side effect

The refactor did something else that I think was necessary: it separated realtime game data from presence.

The app now uses a dedicated presence WebSocket at /presence.

That WebSocket updates sessions.lastSeen and game attachment state on a heartbeat interval. Presence is no longer hidden inside game-specific heartbeat endpoints.

That matters because it gives the project a cleaner split:

  • Zero handles data synchronization
  • the presence socket handles “is this browser still alive and in this room?”

That is much easier to generalize across games.

It also makes cleanup and reconnection logic easier to reason about because session liveness is tracked in one place.

The data model is still pragmatic, but it is better organized

Both versions are pragmatic about game state. Neither one is trying to normalize every clue, vote, or round into fifteen relational tables.

That part did not really need to change.

What did change is the consistency of the model.

The new schema has clear tables for:

  • sessions
  • imposter_games
  • password_games
  • chain_reaction_games
  • chat_messages

Each game table still stores a lot of state in JSON columns. That is fine. Party-game state is highly mutable and phase-shaped; snapshot-style JSON is often the right trade.

The win is that the shared schema, shared types, and shared mutators now point at the same shape.

So instead of “the page thinks the state looks like this and the route handler patches whatever is currently in game_data,” the repo is much more explicit about the shape it intends to keep stable.

The frontend got smaller in the right places

This is one of my favorite differences.

The old repo had pages that were doing a lot of work just to remain coherent.

The new pages still have logic, but it is more focused:

  • subscribe to the current game
  • subscribe to sessions in the current game
  • open presence socket
  • invoke mutators for actions
  • react to announcements, kick/end state, and timers

The pages are still stateful, because multiplayer game pages always are.

But they are less “mini framework.”

That is a big improvement for future edits because it makes UI changes less likely to require backend-shaped rewrites.

Observability is way better now

This is one of those quality-of-life things that sounds minor until you have to debug a broken deploy.

The new app includes explicit connection debug state for:

  • Zero connection state
  • Zero online/offline events
  • presence WebSocket state
  • presence connect latency
  • API metadata probe status
  • build/commit info from /debug/build-info

The old version had debug logs and practical runtime checks, but the new version is much more intentional about operational visibility.

That is a real upgrade.

When you have multiple moving parts, you need a fast answer to questions like:

  • is the browser online?
  • is Zero connected?
  • is presence connected?
  • is the API reachable?
  • which backend build is this browser talking to?

The refactor gives the system much better answers to those questions.

Chat is a good example of why the new structure is better

The refactor adds a shared chat model with:

  • a chat_messages table
  • shared chat.byGame queries
  • shared chat.send mutators
  • a reusable ChatWindow component

That is exactly the sort of feature that would have been more awkward in the old design because you would be adding more route handlers, more polling or refresh logic, more response shapes, and more per-page glue.

In the new design, chat looks like what it should look like:

  • a shared data concern
  • a shared contract
  • a reusable UI subscriber

That is the kind of “this architecture is paying rent now” feature I care about.

The deployment model is more honest now

The old version had the appeal of a more consolidated app model.

The new version is much more explicit about what it actually is in production:

  • 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 pieces, yes.

It is also a better match for the runtime responsibilities.

The app now has distinct roles:

  • static frontend delivery
  • backend request handling
  • realtime state cache/sync layer
  • durable storage

Pretending those are all the same thing would make the system feel simpler while actually making it more fragile.

So why is the new version better?

Here is the honest answer.

Better architecture boundaries

The app has a cleaner separation between:

  • UI
  • backend service
  • shared contracts
  • database schema
  • realtime synchronization
  • presence tracking

That makes the codebase easier to extend without every new feature becoming its own pattern.

Better realtime behavior

The project moved from “poll until the UI feels live enough” to “subscribe to state and mutate the source of truth.”

That is a foundational improvement.

Better maintainability

The mutator/query model is a better long-term fit than a growing forest of game-specific route handlers.

Better consistency

The shared package gives the repo a real contract layer, which means fewer mismatches and less accidental drift.

Better observability

The connection debug tooling and build-info endpoint are the kind of operational features that make production support much less annoying.

Better room to add games

This one matters a lot.

The old repo made it easy to add another page with another pile of endpoints.

The new repo makes it easier to add another game that still fits the same system.

That is a much better kind of scalability.

But the refactor is not free

This is the part I care about being honest about, because a lot of refactor posts skip it.

The new version is better. It is also more expensive to understand and operate.

1) There are more moving pieces now

The old version could mostly be thought about as:

  • Next app
  • Postgres

The new version is:

  • web app
  • API service
  • shared package
  • Zero cache service
  • Postgres
  • presence WebSocket
  • deployment wiring between all of them

That is real complexity, not fake complexity.

2) Zero is powerful, but it is another system to learn

Zero is not “fetch, but faster.” It changes how the app thinks about queries, mutations, subscriptions, cache behavior, and client lifecycle.

That is good once it clicks.

It is also extra architecture you now have to own.

3) Presence and realtime are now two channels

This is the right design for this app, but it is still more subtle than the old version.

Now I have to reason about:

  • Zero data connectivity
  • presence socket connectivity
  • the interaction between presence freshness and game state

That split is cleaner, but it means debugging connectivity is more nuanced.

4) Infrastructure requirements got stricter

The local and production setup now needs things like:

  • Docker-backed Postgres for local development
  • a Zero cache process
  • wal_level=logical on Postgres
  • a direct upstream Postgres connection for Zero
  • separate environment variables for web, API, and Zero

That is not “hard” once it is documented.

It is definitely more demanding than a simpler monolithic fullstack app.

5) Feature parity is not the same thing as foundation quality

This is the part people usually argue about in refactors.

The archived repo had breadth. It had more experiment-shaped surfaces and some flows that were already built directly into that old app structure.

The refactor is more selective and more foundation-first.

That means the new version is stronger as a platform even when not every idea from the old version has been carried over 1:1 yet.

I think that is the right trade.

But it is still a trade.

Old vs new, in one table

Areagames-archgames-refac
App shapeOne Next.js appSplit monorepo with web, api, shared
FrontendNext.js client pagesReact 19 + Vite SPA
BackendNext Route HandlersHono Node service
Shared contract layerMostly implicitFirst-class shared package
Realtime modelPollingZero subscriptions + mutations
PresenceGame-specific heartbeat routesDedicated /presence WebSocket
API surfaceMany per-game endpointsSmall service surface + shared mutators
DeploymentSimpler single-app mental modelMore explicit multi-service model
DebuggabilityMostly page/route levelConnection debug + build-info + service separation
ExtensibilityFast to hackBetter long-term structure

The real lesson

I do not think the lesson here is “always rewrite your app with more architecture.”

The lesson is more specific:

a simple architecture is only simple while the shape of the product still fits inside it.

games-arch was the right version to build first because it let me find the real product and gameplay problems quickly.

games-refac is the right version to build now because the project needed a system that can survive more games, more state, more multiplayer edge cases, and more deployment reality.

That is why this version feels better.

Not because it uses newer tools.

Because it moved the project from “working app with accumulating exceptions” to “coherent platform with explicit boundaries.”

Closing

If I had to summarize the difference in one sentence, it would be this:

The old version was easier to ship quickly. The new version is easier to trust.

And for a multiplayer app with timers, room state, reconnects, and multiple game modes, trust in the architecture matters more than shaving off one more route file.

So yes, the refactor added complexity.

But it added the kind of complexity that buys clarity instead of the kind that leaks out of every page.

That is a trade I will take every time.

Repo: github.com/oyuh/games