Rebuilding Stacks Wars from the foundation
Why I tore down a working multiplayer Stacks game platform and rebuilt wallets, vaults, auth, realtime, and the game plugin boundary.
Aug 1, 2026 · 13 min read
Stacks Wars started as a bet that people would play skill games if the money path felt real. After a few seasons, that part held up. What did not hold up was everything around it: onboarding, contracts, realtime plumbing, auth, and the way games were wired into the platform.
This note is about the rebuild that followed. I am not announcing a launch. I am writing down the engineering reasoning for tearing down a working product and putting a different foundation under it, while the lessons from live seasons were still sharp.
Why rebuild?
The old system could run seasons. That was enough to learn what actually hurt.
Onboarding was still too Web3-heavy. Create a wallet extension, fund it, approve a deploy or a join, wait for confirmation, then maybe get into a lobby. A lot of people never reached their first game. From the outside that looks like copy and buttons. From the inside it was structural: play funds lived in browser wallets, and every lobby had its own vault contract to deploy.
Debt piled up where product pressure was highest. Contracts were glued to lobby lifecycle. Frontend routes grew around whatever the last season needed. Adding a game meant touching core server maps, room handlers, and a parallel frontend registry that drifted from Rust metadata. None of those was catastrophic alone. Together they made every change expensive.
At some point you stop asking whether a refactor is worth it and start asking whether the current shape can carry the next year. For us, the answer was no. The rebuild became inevitable.
What we wanted out of it:
- Faster onboarding without pretending the chain does not exist
- A simpler path from signup to a seated match
- A backend that verifies money state before trusting seats
- A plugin boundary so games stop leaking into platform code
- One realtime surface instead of two sockets and two bootstrap stories
We were not trying to invent a new category of product. We were trying to make the one we already had survivable.
Custodial wallets
Every user now gets a dedicated custodial Stacks wallet. I want to be direct about why, because custody is not a small product choice.
Browser wallets are fine when someone is making an intentional settlement. They are a poor fit when the product needs timed interactions around a multiplayer match. Asking a player to approve another popup while a lobby is filling is how you lose the session. We watched that happen enough times that “just improve the prompts” stopped being a serious answer.
With custodial wallets, signup can provision a play address immediately. Users fund once. Gameplay moves without Leather or Xverse in the critical path. Withdrawals still exist. Custody is not a trapdoor. It is a boundary: the platform holds keys so play can feel closer to a normal online game.
Wallets are generated server-side. Private keys are never stored in plaintext. The flow is generate, encrypt, persist ciphertext, discard plaintext. Encryption is wrapped with Google Cloud KMS. The application decrypts only for the short window needed to sign, then forgets the key material again.
I chose KMS less because the brochure says secure and more because operational safety matters when you are the custodian. Key material should not live next to application secrets in a dotenv file. Access should be auditable. Permission boundaries should be boring. Envelope encryption with a cloud KMS gets you that without inventing a key-management product inside the game server.
The tradeoff is obvious: custody moves from the user to the platform. That is an operational and regulatory surface. We accepted it because the alternative was an onboarding funnel that never recovered enough players to justify the cleaner trust model.
Sponsored transactions
Even with custodial wallets, players should not have to think about fee UTXOs while they are trying to play. Sponsored transactions matter because they remove that tax from the experience.
From a player's point of view, the difference is simple. Instead of waiting on fee-bearing transactions every few minutes, the platform sponsors the appropriate interactions. Settlement still happens on Stacks. The vault still moves USDCx. The chain remains the place where money becomes final. The experience just stops feeling like a wallet tutorial.
Under the hood, Next decrypts and signs player vault transactions when needed, and a platform key sponsors fees. Rust does not blindly trust that a client said a claim happened. It verifies join and claim transactions against Hiro before seating someone or refreshing balances. Sponsorship makes the UX smoother. Verification keeps the money path honest.
Centralized vault contract
The old money path deployed a vault per lobby. Create meant templating one of several Clarity contracts, then asking the client to deploy it. Join checked on-chain state. Claims used a trusted-signer signature and a client-reported transaction id on the room socket.
That design failed in ways we kept rediscovering:
- Deploy cost and latency on every lobby
- Four contract templates to keep in sync
- Wallet UX for every stake
- A claim path that trusted client-reported tx ids too early
- Multi-token markets plus floating-point amounts in app code
The new design is one shared vault contract. The live mainnet deployment is sw-vault-v0-0-1. Lobby identity is a path string, not a fresh contract address. Players join into a path-keyed pool. Leave and kick are oracle-signed refunds. Claim splits platform fee, optional game fee, and remainder on-chain.
Oracle signatures authorize the sensitive actions. Replay protection lives with those intents. The contract keeps enough state to defend itself: who joined, what was paid, what has already been claimed. The server cannot invent a drain just because a Postgres row says so.
That last point is why the contract keeps its own brain. Backend state is useful for matchmaking and UX. It is not a substitute for escrow rules. If we ever move play wallets back toward user custody, contract-side verification becomes even more important, because the server would have less control over who can submit what. Designing the vault as if the chain must check the work is the conservative choice either way.
Narrowing to USDCx was intentional. Supporting STX plus SIP-010 tokens with price conversion made leaderboard and payout logic fragile. Integer micro-units of one asset end to end is dull in a useful way. We gave up multi-token markets to get arithmetic we could trust.
Real-time architecture
Previously we ran two WebSocket endpoints: one for the lobby browse feed, one for room lifecycle. An active player often held both. Each connection had its own auth and bootstrap story. Reconnect behavior was room-handler-specific. Game traffic was nested inside room frames in a way that made the protocol harder to reason about than it needed to be.
The rebuild collapses that into one multiplexed endpoint. A session manager owns connections and outbound send. A subscription manager maps topics to connections, with a hard cap on how many topics a client can hold. The frontend keeps a singleton socket with refcounted topic subscriptions, JWT re-auth on reconnect, exponential backoff, and subscription restore.
Topics are the unit of fan-out:
appfor lobby feed, activity, leaderboard, and match finished eventslobby:{uuid}for room snapshot, presence, chat, lobby events, and finished restorelobbyPath:{path}so share links can resolve without knowing the uuid up frontuser:{uuid}for private wallet updates and personal events, bound to the authenticated identity
The important synchronization trick is snapshot-on-subscribe. When you subscribe to a lobby, the server pushes a full lobby snapshot: lobby row, players, chat, join requests, presence, engine snapshot, finished payload if needed. Then presence. That closes the old race where the client HTTP-fetched a room and hoped the socket caught up before anything moved.
HTTP is still there for creates, reserves, joins, and other intentional writes. Live state mostly comes from subscriptions. That split matches how the product feels: do something, then stay on the wire.
Tradeoffs remain. The protocol is denser. One bad client handler can affect more surface area, which is why topic ACLs matter. Snapshot payloads are larger than the old incremental bootstrap. We accepted the bandwidth cost for correctness on reconnect.
Authentication
The previous auth stack treated wallet connection as login. The client sent a wallet address, the server minted an HS256 JWT into a cookie, and revocation lived in Redis jti keys. There was no real signature challenge on register. Wallet string spoofing was too close to session minting for comfort.
We moved identity to Neon Auth. Tokens are EdDSA JWTs verified through JWKS. Rust caches keys by kid for about an hour. HTTP requests carry Authorization: Bearer. The WebSocket authenticates after connect with the same verifier, then binds the connection to a user id. After that, later messages do not need to drag a token along. The session already knows who it is.
Postgres users.id is the Neon subject. No second UUID mapping. That sounds minor until you have spent time chasing “auth user versus app user versus wallet principal” bugs across HTTP, sockets, and settlement. One identity across auth DB, app DB, WebSocket sessions, and developer ids removes a whole class of glue code.
Neon subjects are UUIDv7 values, which is convenient for time-sortable ids without inventing our own scheme. Admin access is email allowlist plus verified email, not a wallet allowlist. That matches custodial play: the account is the person, not the address they happened to paste.
The dependency tradeoff is real. We now depend on Neon Auth availability. In exchange, Next and Rust no longer share an HMAC secret for login, and connecting a wallet is no longer a backdoor into a session.
Balances and on-chain activity
We do not keep an application accounting table as the money ledger. Balance truth comes from the chain. Hiro FT for USDCx is what we read when we need to know what a wallet holds. Redis is a cache and a withdraw lock so two requests cannot race the same funds. It is not a second ledger that can disagree with the vault.
User activity is driven the same way. Deposits, joins, claims, and withdrawals resolve on-chain first. Once Hiro confirms a transaction, we surface it in the wallet and activity views. If a join is still pending, the UI can wait on that tx. Spendable balance does not move because a client said it moved.
Seat holds still exist for lobby capacity. Those are short-lived Redis reservations so two players cannot pay for the last seat at the same time. They are not balance rows. Money still has to clear on-chain before we seat anyone or refresh what they see.
The rule we kept repeating during the rebuild: never invent balances in the database. Resolve on-chain, then show the result.
Lobby lifecycle
Lifecycle used to be a hopeful sequence: Postgres lobby, Redis runtime, on-chain join, socket join, ready, in-memory engine, finished, client claim. Races showed up under load. Two players could pay for the last seat before Redis admitted either. Claims could update Redis from a client tx id without a hard confirm gate in the socket handler.
The new machine is stricter:
- Reserve a seat with a short-lived Redis hold so capacity is claimed before money moves
- Create or join with a vault transaction id, then verify join on Hiro before writing participants and pot
- Start builds an engine context, spawns a lobby actor, and flips the lobby to in progress
- Finish goes through the game host settle path once, writes finished state, and emits claim intents
- Claim only through the vault plus a confirm endpoint. There is no parallel “server pays you” path
Free lobbies can be cleaned up in Rust. Paid expiry and refunds go through a Next cron that already owns oracle signing. That split is awkward to explain in a diagram and correct in production: secrets stay where signing already lives.
More HTTP steps for paid play is a real UX cost. Reserve, pay, join. We took that cost because the previous path lost money races that no amount of nicer copy could fix.
Plugin architecture
Games used to sit too close to the platform. The factory was effectively a function pointer. The host exposed an escape hatch that let server code downcast back into full application state. Live engines lived in a mutex map. Room handlers locked that map around actions and snapshots. Engines often returned raw JSON values for someone else to broadcast, so I/O ownership leaked upward.
The rebuild publishes a cleaner boundary. A domain crate holds ids, metadata, and lobby or accounting DTOs with no I/O. A plugin crate defines factory, engine, host, registry, and a nop host for tests. Game crates depend only on those. The server implements the real host and registers factories.
Factory is now a trait with metadata plus create. Engine context carries lobby id, players, pot, entry, sponsored flag, and settings at construction. No late mutation of lobby context after the engine already started thinking.
Runtime changed with it. Each lobby gets a dedicated actor with a bounded mailbox. Actions use try-send and backpressure instead of holding a global mutex across handle-action. The host has no downcast escape hatch. Broadcast, complete match, and save player result are the platform surface. The host is already lobby-scoped, so every call does not need to repeat the lobby id.
Frontend registration is UI-only. Catalog truth comes from GET /games backed by Rust metadata. Game id strings have to match. That is boring and correct.
Extensibility is still gated. Third-party games need a server PR to register. Action schemas over the wire are still ad hoc JSON. Engines still run in-process, so we do not magically get multi-node sticky-free HA. What we did get is the ability to unit-test engines against a nop host and keep game rules out of settle and WebSocket code.
Frontend redesign
The UI was rebuilt from scratch. Not because the old UI was ugly. Because the old UI was a map of the old architecture.
When money required wallet popups and per-lobby deploys, screens taught that flow. When rooms and browse lived on different sockets, pages fetched, subscribed, and hoped. When game plugins were half-server and half-ad-hoc client registries, game pages accumulated special cases.
The new surface follows the new boundaries. The games page reads catalog metadata from the server. Individual game pages and room pages subscribe for live lobby state. Developers supply game views through component registration that lines up with server game ids. Realtime rendering assumes a snapshot first, then events.
Architecture shows up in UI whether you design for it or not. We finally designed for it.
Engineering philosophy
A few decisions guided more of the rebuild than any single diagram.
We optimized for fewer moving pieces. One vault. One asset. One multiplexed socket. One identity. Every time we kept a parallel path “just in case,” it became a production path with half the tests.
We removed features that increased complexity without increasing value. Multi-token markets looked impressive in a pitch. They made payout math fragile. Per-lobby deploys looked pure. They made create slow and operations messy. Dual WebSockets looked modular. They made reconnect a research project.
We intentionally narrowed supported assets to USDCx. Reducing scope is often framed as temporary. Here it was the product. A smaller money surface let us make stronger guarantees about fees, claims, and balance views.
I keep coming back to this: simplicity is not what you get when you stop working. It is what you get when you delete the parts that only existed because an earlier constraint forced them.
What surprised me
Shipping seasons revealed problems architecture diagrams never did. The claim path trusted client tx ids longer than anyone would defend in a design review. Seat races only became obvious when lobbies filled under real contention. Float amounts looked fine until leaderboards and pots disagreed by dust.
Developer experience mattered as much as player experience. If adding a game required reading half the server crate, we were not going to get the library of games the product needs. Plugin boundaries are player features with a longer fuse.
Running real users also changed priorities. Elegant multi-asset support lost to “can a new player reach a match tonight.” Wallet purity lost to custodial onboarding. I still care about the elegant versions. I care more about the funnel that actually converts.
The hardest part was not writing the new vault or the actor mailbox. The hardest part was deleting systems that still worked while knowing why they would not scale with the next set of seasons.
What's next
I want more games on the plugin boundary, better tooling for people building those games, and a tighter loop between community events and the live platform. Third-party developers become more realistic once registration and schemas are less awkward. Performance work never really ends while engines are still in-process.
There are cliffs we have not climbed. Multi-node game HA is still an open problem while engines live in one process. The oracle key is still a hot trust root. Game action schemas are still informal JSON. Those are the next constraints, not the ones this rebuild solved.
If you are building something adjacent on Stacks, or thinking through custodial play, shared escrow, or realtime game hosts, I am usually happy to compare notes. The useful conversations are the ones about tradeoffs, not slogans.