The Refactor of My Games App
A technical comparison between the archived games-arch build and the newer games architecture.
Try it live (opens in a new tab)Two builds of the same thing
This one compares the archived build sitting in games-arch against the refactor that actually ships today.
Both solve the same problem. Someone makes a room, shares a code, friends join fast, everybody plays party games without making an account. The product goal never moved. The runtime model underneath it moved a lot.
The old build was one Next.js app. Route handlers, polling, and game logic spread across some genuinely enormous client pages. The new one, games-refac, splits it into real parts: a React and Vite frontend, a Hono API, a shared contract package, Zero for synced query and mutation state, Postgres to actually store things, and a dedicated presence WebSocket.
The tool list isn’t the interesting part though. The interesting part is why the rewrite became necessary, what got better, and what costs more to run now.
Being fair to the old one
games-arch wasn’t a bad app. It shipped real game logic, solved a real use case, and the architecture was about as direct as it gets.
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 huge thing going for it: you could understand the whole app instantly. No separate API service, no sync service, no shared package, no presence deploy target. For a smaller app that simplicity is exactly what let me ship fast.
What it was good at
All practical stuff. Everything lived close together. Adding a game flow usually meant a page and a few API routes. Deployment stayed simple. The mental model matched what most people already know from the Next App Router.
Best of all it let me find out if the thing was even fun before signing up for heavier architecture. Starting with the new stack on day one would have slowed the project down before I knew what the product even needed.
Where it started hurting
Once the app outgrew a few routes and some UI state. Multiplayer games need phases, reconnect rules, cleanup paths, and timing edge cases, and the original shape just stopped fitting.
Polling everywhere
Biggest difference between the two builds. games-arch kept the UI current by asking the API the same questions over and over. The old Imposter pages polled hard, and the Password pages did the same with different intervals.
It kept the app live enough, but it cost you at almost every layer. Polling adds requests even when nothing changed. The same loading and error code gets copied onto every page. It pushes the whole app toward late state instead of synced state, and it breeds races around redirects, timers, and phase changes.
Polling feels fine while the state surface is small. It starts to drag once a single page is asking this 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?
And the request count climbs with every player in the room, since more players means more poll loops, each one repeating whether the state moved or not.
Routes multiplying per game
The archived repo exposed a lot of route trees. Imposter alone had create, fetch, start, clue, vote, should-vote, heartbeat, and leave endpoints. Password had its own parallel set: create, join, start, category vote, word selection, clue entry, guesses, round transitions, game end, and leave flows.
None of that is wrong on its own. It piles up. Each game invents its own API shape, shared behavior gets rebuilt in slightly different ways, one rule change touches several endpoints and pages, and the client and server drift apart unless you’re extremely disciplined about it.
The old build was optimized for shipping one feature at a time, not for staying consistent as the app grew. Every new game widened the public route surface and that surface just kept going.
Pages doing way too much
The old page components carried a ridiculous amount. 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, with team-specific and global phases living together so the page had to sort through both.
Once a page takes on that much it stops being a view. It’s a custom controller for the entire game, and every future UI edit gets harder than it should be.
Realtime and presence bolted on
The old app did support multiplayer updates and connection tracking, but the work was spread all over the place. Presence leaned on polling, heartbeat routes, and game-specific disconnect logic stored inside the game data itself.
So presence was never a system. It was the same problem every game had to solve again from scratch. That held up right until reconnection and cleanup needed to be dependable, and then all those copies of the same logic turned into a real maintenance problem.
Progress and debt tangled together
The archived repo shows a lot of back and forth, which I’m honestly not embarrassed by. Compatibility comments, legacy fields kept around for old UI expectations, route handlers mixing transition logic with cleanup.
Normal for a fast-moving app. Also a sign the architecture was carrying too much history in too many places.
What the refactor changed
It’s not Next.js with cleaner files. Different layout, different runtime model.
The structure
games-refac splits the repo into apps/web, apps/api, and packages/shared.
That one move changes almost everything, because the project finally has a real contract layer instead of one you just had to know about.
Frontend
React 19, Vite, React Router. It renders pages, stores lightweight browser identity, opens realtime connections, subscribes to state, and calls shared mutators. It no longer pretends to also be the API runtime.
That split cleaned up the page model a lot. Pages mostly render current state and trigger actions now instead of running poll loops and route-specific fetch logic.
Backend
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.
Way smaller external surface than one route tree per game mechanic. Actions changed the most: the client calls named mutators that resolve against shared definitions instead of hitting a public route for every small thing.
Shared contracts
The shared package holds the Drizzle schema, the Zero schema, query definitions, mutator implementations, and shared game types.
In the old build, behavior often lived in the unspoken relationship between a page and a route handler. In the new build it lives in shared queries and mutators.
So now the repo has one place where the rules live. Contracts get imported instead of re-described, refactors touch fewer files, and when you need to find where a game rule comes from you look in one spot.
Polling out, synchronized state in
Main upgrade. games-arch used polling to fake being live. games-refac uses Rocicorp Zero to actually synchronize query and mutation state through a cache layer backed by Postgres.
In practice
The browser creates one Zero client. Pages subscribe with useQuery(...). Mutations run through shared mutators, and Zero forwards the work through the API and pushes updated state back through subscriptions.
The biggest win isn’t the word “realtime.” It’s that the UI stopped asking the same question over and over. That alone removed page-specific fetch loops, manual refresh state, poll-then-redirect logic, and a whole family of stale snapshot edge cases.
The speed gap is easy to feel in an actual game. Polling reflects a change on its next loop, so the screen sits there waiting out part of the interval. Subscriptions push the change as it happens. Tap the Δ Compare toggle to see the gap between the two builds.
The mental model changed too. Old model was fetch the latest game and hope the UI catches up at the right moment. New one is subscribe to the state slice and mutate the source of truth.
For multiplayer phases and timers that second model fits so much better.
Presence became an actual system
The refactor splits realtime game data from presence entirely. There’s a dedicated presence WebSocket at /presence that updates sessions.lastSeen and game attachment state on a heartbeat interval.
Presence doesn’t hide inside game-specific heartbeat routes anymore. Zero handles data sync, and the presence socket answers a completely different question: is this browser alive and attached to this room?
Once session liveness lives in one place, cleanup and reconnection both get a lot easier to reason about.
The data model
Both builds keep game state simple, and neither one tries to split every clue, vote, and round into a long chain of relational tables. That part never needed to change.
The new build is just more consistent about it. Clear tables for sessions, imposter_games, password_games, chain_reaction_games, and chat_messages, and each game table still stores plenty of state in JSON columns, which fits phase-shaped party game state fine.
The difference is alignment. The shared schema, shared types, and shared mutators all point at the same shape now.
Frontend size
The old pages carried a ton of custom wiring. The new pages still hold real multiplayer logic, but the job is much narrower: subscribe to the current game, subscribe to room sessions, open the presence socket, call mutators, react to announcements, kick or end state, and timers.
Pages feel less like a mini framework and more like focused UI sitting on top of shared state, which is what they should have been the whole time.
Observability
Sounds small right up until production breaks.
The new build 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 build had useful logs, sure. But the new build answers the basic questions fast:
- Is the browser online?
- Is Zero connected?
- Is presence connected?
- Is the API reachable?
- Which build is the browser talking to?
Chat is the best example
Chat is my favorite proof that the new architecture earns its place. The refactor adds a shared chat model: a chat_messages table, shared chat.byGame queries, shared chat.send mutators, and a reusable ChatWindow component.
In the old shape chat would have meant more route handlers, more refresh logic, more response shapes, and more page glue. In the new shape it’s a shared data concern with a shared contract and a reusable subscriber. That’s it.
Deployment
The old build had the appeal of one app in one place. The new build is more honest about what production already needed.
Vercel serves the frontend SPA. Railway runs the API service and a separate Zero cache service. Postgres lives in Railway Postgres or Neon.
Yeah, that’s more moving parts. It also lines up with the jobs that actually exist: serving static files, handling the API, syncing in realtime, and storing things.
Why the new one is better
The answer isn’t any one tool.
The new build has clean boundaries between UI, backend, contracts, schema, synchronization, and presence. Realtime behavior comes from state subscriptions instead of polling. Mutators and queries scale better than an ever-growing list of game-specific routes.
Shared contracts cut drift, observability is stronger, and the next game I build has a real chance of fitting the system instead of forcing yet another one-off pattern into it.
It costs more too
The new build is better and it’s harder to understand and run. Both things are true.
More moving pieces
The old build was mostly a Next app plus Postgres. The new one is a web app, an API service, a shared package, a Zero cache service, Postgres, a presence socket, and the deployment wiring between all of them. That’s real complexity, not just a longer README.
Zero is another system
Zero is not fetch with less code. It changes how queries, subscriptions, mutations, cache behavior, and client lifecycle all work. Once the model clicks it’s a huge help, but it’s still another system I own now.
Two realtime channels
Presence and synchronized state ride separate channels. The split fits the app but it’s one more thing to keep in my head. I now think about Zero being connected, the presence socket being connected, and how fresh presence is compared to game state.
Pickier infrastructure
Local and production setups both got fussier. Development wants Docker-backed Postgres, 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.
Manageable once it’s documented. Still heavier than a monolith.
Foundation over feature parity
The archived repo actually had more stuff in it in some places, with more half-experiments baked into the old structure. The refactor picks fewer things and builds the base properly.
The new build is stronger as a platform even before every old idea moves over one-to-one, and I think 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 unspoken | A real 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, it’s all one app | More services, but each one spelled out |
| Debuggability | Mostly page and route level | Connection debug plus build-info plus service separation |
| Extensibility | Fast to hack | Better long-term structure |
The lesson, if there is one
Not every app needs more architecture, and that’s not what I’m arguing. The narrower version: a simple architecture stays simple only as long as the product still fits inside it.
games-arch was the right first version. It found the real product and turned up the real gameplay problems fast. games-refac is the right version now, because the project has to survive more games, more shared state, more multiplayer edge cases, and more deployment reality.
The old build was easier to ship. The new build is easier to trust. For a multiplayer app with timers, room state, reconnects, and several game modes, that trust matters way more than saving one more route file.
Play the current build at games.lawsonhart.me, source lives here: