Why the server has to be in charge
Online co-op uses a Socket.IO connection to an authoritative game server. The clients do not decide whether a tower was placed, merged, evolved, or upgraded successfully. They send an intent, such as "summon on this cell," and the server validates the action before changing the match state. We chose that model because a merge tower defense has too many connected outcomes to trust independently on two phones. Currency, board occupancy, tower levels, enemy health, and shared base health all need to agree. If each client simulated the final result and tried to reconcile afterward, small differences would compound quickly.From full snapshots to events
The first version solved consistency by sending full match snapshots frequently. It worked, but it was wasteful and made the battle feel less responsive. Our current protocol sends one full state when a player joins, then uses discrete gameplay events with lightweight periodic reconciliation. The server remains authoritative, but the renderer can animate events immediately instead of repeatedly rebuilding the whole scene. That made the match feel better. It also made a different class of bug much more visible.The difficult bug was initialization
The most difficult bug was not combat. It was initialization. A player could authenticate, join a match, and receive the one-time bootstrap state before the PixiJS battle runtime had finished creating its geometry and HUD. The socket layer had technically delivered the message, but the consumer was not ready. For the second player, that bootstrap also contained the seat information needed to place their own lane at the bottom of the screen. If the message was lost, the match could be valid on the server while the local view was wrong. We fixed this at two boundaries. The socket service keeps the latest bootstrap and replays it when the battle subscribes. The runtime also keeps a pending snapshot if its asynchronous startup has not finished, then applies it after the board and HUD exist. The duplication is intentional: one buffer covers a late subscriber, while the other covers a runtime that has subscribed but is not yet ready to render.One board, two perspectives
Perspective created a separate problem. Both players should feel that their own lane is the primary lane at the bottom of a portrait screen, but there is only one authoritative board. We did not create a second mirrored simulation. Instead, the runtime converts board cells to screen positions through a shared coordinate boundary. For the opposite seat, that mapping flips the board visually while input is converted back to the original server coordinates. Sprites stay upright. Only the board perspective changes. This matters beyond presentation. A tap on the bottom-left build cell must become the correct authoritative cell, regardless of which seat the player occupies. Keeping that logic inside the coordinate conversion functions prevents rendering, input, and simulation from developing separate ideas of where a cell is.Shared health without shared boards
The shared-health design also needed restraint. Letting both players edit both boards would make a stronger player take over the match. Keeping boards separate preserves ownership, while the shared base means both lanes still matter. A stable player may need to spend resources defensively because the team cannot afford another leak from either side, but they cannot simply play their partner's turn.Solo mode uses the same two lanes
Solo mode keeps the same two-lane structure and supplies an AI ally. That was a useful design decision, not only a fallback for matchmaking. Players learn the same shared-health rhythm before going online, and the UI does not need a separate mental model for solo and co-op.Protect every transition
The main lesson was that mobile co-op reliability is less about one clever networking technique and more about protecting every transition: connection to authentication, authentication to matchmaking, matchmaking to bootstrap, bootstrap to rendering, and input back to authoritative state. A packet can arrive successfully and still be functionally lost if the next layer is not ready for it. For a small team, the practical answer was to keep the simulation authoritative, keep the client messages narrow, buffer the one irreplaceable startup state, and make perspective a rendering concern rather than a gameplay concern.Play Starvoxel: Defender
Starvoxel: Defender is available free on iOS and Android. No ads.- Google Play (opens in a new tab)
- iOS App Store (opens in a new tab)
- Gameplay trailer (opens in a new tab)
- Website (opens in a new tab)


