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)Overview
This post compares two builds of the same product: the archived build sitting in the games-arch repo, and the refactor that actually ships today.
Both builds solve the same problem. Someone makes a room, shares a code, friends join fast, and everyone 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 very large client pages. The new one, games-refac, splits the system into real parts, a React and Vite frontend, a Hono API, a shared contract package, Zero for synchronized query and mutation state, Postgres for durable storage, and a dedicated presence WebSocket.
But honestly, the tool list isn’t the interesting part. The interesting part is why the rewrite became necessary, what got better, and what costs more to run now.
What The Old Version Did Well
Let me be fair to games-arch first, because it wasn’t a bad app. It shipped real game logic and 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 big thing going for it: you could grasp the whole thing immediately. 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.
Old Strengths
The strengths were all practical ones. Everything lived close together. Adding a game flow usually meant a page and a few API routes. Deployment stayed simple, and the mental model matched what most people already know from the Next App Router.
Best of all, the old build let me prove the product before committing to heavier architecture. Starting with the new stack on day one would have slowed the project down before I even knew what the product needed.
Where The Old Architecture Started To Hurt
The weak spots showed up 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 that product.
Polling Everywhere
This is the 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 thing with different intervals.
It kept the app live enough, but it taxed almost every layer. Polling adds requests even when nothing changed. It duplicates loading and error logic across pages. It pushes the whole app toward delayed state instead of synchronized 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, because more players means more poll loops, each one repeating whether the state moved or not.
Per-Game API Growth
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. The trouble compounds 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, and the client and server drift apart unless you’re extremely disciplined.
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 growing with the roster.
State Shaping In Pages And Routes
The old page components carried way too much. 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 becomes a custom controller for the entire game, and every future UI edit gets harder than it should be.
Realtime And Presence Were Bolted On
The old app did support multiplayer updates and connection tracking, but the work was scattered everywhere. 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 a repeated concern that every game had to solve again from scratch. That held up right until reconnection and cleanup needed to be dependable, and then the duplicated logic turned into a genuine maintenance problem.
Product Progress And Debt Mixed Together
The archived repo shows real iteration, which I’m not embarrassed by. It has compatibility comments, legacy fields kept around for old UI expectations, and route handlers mixing transition logic with cleanup.
That’s normal for a fast-moving app. It’s also a sign the architecture was carrying too much history in too many places.
What Changed In The Refactor
The refactor isn’t Next.js with cleaner files. It’s 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, because the project finally has an explicit contract layer instead of an implied one.
Frontend
The web app runs on React 19, Vite, and 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
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’s a much smaller external surface than one route tree per game mechanic. Actions changed the most: the client calls named mutators that resolve against shared definitions, rather than hitting a public route for every small action.
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.
That gives the repo a center of gravity. Contracts get imported instead of re-described, refactors touch fewer surfaces, and when you need to find the source of a game rule, there’s one place to look.
From Polling To Synchronized State
This is the main upgrade. games-arch used polling to imitate liveness. games-refac uses Rocicorp Zero to actually 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, 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. The old model was fetch the latest game and hope the UI catches up at the right moment. The 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 A System
The refactor splits realtime game data from presence entirely. The app now uses a dedicated presence WebSocket at /presence, which updates sessions.lastSeen and game attachment state on a heartbeat interval.
Presence no longer hides inside game-specific heartbeat routes. 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.
Data Model
Both builds stay pragmatic about game state, and honestly, neither tries to normalize 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. The schema has 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 key 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 orchestration. 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, and 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 all along.
Observability
This one 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 Shows The New Shape
Chat is my favorite example of the new architecture earning 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 just a shared data concern with a shared contract and a reusable subscriber. Done.
Deployment Model
The old build had the appeal of one consolidated app. 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.
Yes, that’s more moving parts. It also maps directly onto the real runtime jobs: static delivery, API handling, realtime sync, and durable storage.
Why The New Version 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.
The Refactor Is Not Free
I’ll be honest about the costs, because the new build is better and more expensive to grasp and operate.
More Moving Pieces
The old build was mostly a Next app plus Postgres. The new build 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 just 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 adds nuance: I now think about Zero connectivity, presence socket connectivity, and how presence freshness relates to game state.
Stricter Infrastructure
Local and production setups both got pickier. 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.
It’s all manageable once documented. It’s still heavier than a monolith.
Foundation Versus Feature Parity
The archived repo actually had more breadth in some areas, with more experiments baked into the old structure. The refactor is more selective and focuses on foundation.
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 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, and that’s not the lesson here. The lesson is narrower: 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 surfaced 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 project went from a working app with a growing pile of exceptions to a platform with explicit boundaries.
Closing
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 a lot more than saving one more route file.
Play the current build at games.lawsonhart.me, and the source lives here: