A real-time multiplayer 3D battle arena that runs in a browser tab: custom kart physics on top of Three.js, an authoritative Node game server, and a synchronisation model that keeps six players at a stable frame rate on consumer hardware over ordinary residential connections.
Brief
| Role | Solo author |
| Year | 2024 |
| Client | Three.js, TypeScript |
| Server | Node.js, Socket.io |
| Infrastructure | Google Cloud Platform, Vercel |
| Players per arena | 2 - 6 |
| Frame target | 60 fps |
The problem
Multiplayer browser games fail in one of two directions. Either you make the server authoritative about everything, and every input takes a round trip, and the car feels like it is being driven through molasses; or you let the client simulate freely, and the first person with a bad connection is teleporting across the arena and nobody trusts a hit they land.
Both failure modes appeared in early builds of this game, in that order.
There is no such thing as a shared world. There are six slightly different local simulations, and the entire craft is in keeping them within a tolerable distance of each other.
How I got there
Attempt one: broadcast positions. The server runs the simulation and sends every player's transform to everyone at each tick. It is the simplest correct thing, and it is unplayable: at 20 Hz updates, remote karts move in visible jumps, and a 120 ms round trip means you are watching the past. Interpolating between received snapshots smoothed the motion but not the input latency, and the local kart still responded a fifth of a second after I pressed a key.
Attempt two: client-authoritative everything. Instant response, and total anarchy. Two clients disagreed about who had been hit first, and a player with a 300 ms ping could shoot someone who had already turned a corner. The arena state diverged monotonically and never came back. This is the failure that taught me the actual lesson: authority is not a moral choice about cheating, it is a bookkeeping choice about who is allowed to be right.
What shipped is the standard three-part answer, implemented from scratch because I wanted to understand it: an authoritative server simulation, client-side prediction of the local kart only, and server reconciliation with interpolation for remote karts.
Kart physics
The kart is a raycast vehicle in spirit and a hand-rolled integrator in fact. Full rigid-body vehicle simulation was overkill and hard to make deterministic, so the chassis is a point mass with yaw, and four wheel rays resolve against the arena mesh.
Per step: engine force along the heading, lateral friction that scales with slip angle so the kart drifts when you exceed it rather than sliding on ice, longitudinal drag and rolling resistance, and a downforce term that increases grip with speed. Ground contact comes from the raycast; when no ray hits, the kart is airborne and only gravity and angular damping apply.
The numbers were tuned against feel, not against a reference. The specific change that made it good was decoupling steering rate from slip angle: giving the player a fixed steering input and letting friction produce the yaw, instead of rotating the chassis directly. Direct rotation feels like driving a cursor. Friction-driven yaw has weight, and the drift becomes something you modulate with the throttle rather than a button.
The network model
The server owns the world: positions, velocities, health, projectiles, pickups, and the outcome of every collision. Clients send intents - throttle, steer, brake, fire - with a monotonically increasing sequence number, and never send state.
Prediction. The local client runs the same integrator immediately on input, and keeps a history of (sequence, resulting state). Every server snapshot carries the last sequence it consumed, so the client can find where its own state should have been, compute the error, and apply the difference. Because the integrator is cheap and identical on both sides, the correction is usually invisible.
Reconciliation. When the error exceeds a threshold, the client snaps toward the server state and replays the unacknowledged intents from the corrected starting point. Threshold is deliberate: correcting small errors continuously by lerping produces the rubber-banding that feels worse than the original divergence.
Interpolation. Remote karts are never drawn at their received position. The client renders them roughly one interpolation buffer behind - about 100 ms - so it is always drawing between two snapshots rather than chasing the newest one. It looks smooth because it is behind, not because it is fast.
The game server
The server is a plain Node process running a fixed 20 Hz tick with an accumulator, structured as input queue, simulate, broadcast. It holds no per-connection mutable game logic; connections are thin wrappers around a socket and an assigned player slot, which means a disconnect is a state transition in the world rather than a cleanup routine scattered through handlers.
Rooms are the unit of authority. A room owns its arena instance, its player set, and its tick loop, and it is created on demand and destroyed when the last player leaves. That is also the scaling boundary: rooms do not share state, so adding capacity means adding processes and hashing players onto them, which is a routing problem rather than a distributed-simulation one. Getting that boundary right early is the only reason the server never became the hard part.
Snapshots are the full authoritative state of everything a client needs to render: each player's transform, velocity, health and weapon state, plus live projectiles and pickups. Full state rather than deltas, at 20 Hz, for six players, is a few hundred bytes per tick - comfortably cheap at this scale, and much easier to reason about than a delta scheme with its own bug surface.
Combat and authority
Projectiles are server-spawned and server-integrated; the client only draws a muzzle flash and a tracer immediately as cosmetic feedback. Hit detection is a sphere test against the interpolated remote position plus a small radius compensation, because a target rendered 100 ms in the past is not where it is now. That compensation is the difference between a shooter that feels fair and one that feels broken, and it is entirely a latency artifact rather than a gameplay feature.
Damage, knockback and elimination are applied only on the server and confirmed by snapshot. A client that claims a kill it did not get is ignored, because it never had the authority to claim anything.
Joining, leaving, reconnecting
A room that only works when everyone's connection is perfect is a room that does not work. Three cases needed explicit handling. A late joiner receives a full snapshot rather than the next tick's broadcast, so the arena is populated before they spawn, and their spawn point is chosen away from the pack. A disconnect turns the player into an inert body in the world on a short grace window; if they return inside it, the new socket is re-bound to the same slot and the same state, which is why a dropped wifi connection is an inconvenience rather than a lost match. A server process restart is unrecoverable by design - rooms hold no durable state - so the honest behaviour is that the match ends and the client says so, instead of spinning on a reconnect that has nothing to come back to.
Rendering
The arena is a static merged mesh so it costs one draw call, with a handful of dynamic objects on top. The frame budget goes to shadows and the projectile trails and everything else is deliberately cheap: no post-processing stack, no per-object shadow maps, geometry instanced for anything repeated, and materials shared across every kart with per-instance colour pushed through a uniform rather than cloned into new material objects.
The synchronisation and the render loop are separated. Rendering runs on requestAnimationFrame at whatever the display allows; the simulation ticks at a fixed rate with an accumulator, so physics does not change with frame rate. That sounds like a detail. It is the reason two players at 144 fps and 45 fps agree about where the kart ended up, and it is also the reason a dropped frame produces a small catch-up rather than a teleport.
The last rendering decision that mattered is that the camera is part of the feel, not part of the presentation. A chase camera locked to the chassis transmits speed badly; the one that works is slightly lagged, with a small look-ahead in the direction of travel, so the kart visibly leads the camera out of a drift. That costs nothing and is most of why the thing feels like driving.
Results
| Metric | Value |
|---|---|
| Server tick rate | 20 Hz |
| Interpolation delay | ~100 ms |
| Client frame rate target | 60 fps |
| Players per arena | 6 |
| Snapshot size per tick | <!-- AUTHOR: replace with real number --> |
| Draw calls, arena | <!-- AUTHOR: replace with real number --> |
| Peak concurrent rooms on one instance | <!-- AUTHOR: replace with real number --> |
| Round-trip latency budget | <!-- AUTHOR: replace with real number --> |
| Desync incidents after reconciliation | <!-- AUTHOR: replace with real number --> |
The numbers I can stand behind without instrumentation are the ones that changed how it feels: input response went from a full round trip to effectively local, and the game stopped arguing with itself about who got shot. Everything else in that table came from watching a debug overlay and counting, not from a load test I would publish.
What I'd change
Snapshot delta-compression and a proper interest-management scheme, because the full-state snapshots do not scale past a handful of players. Deterministic lockstep for the physics would remove reconciliation entirely, at the cost of requiring everyone to be in sync before the game can advance - which is the wrong trade for a casual browser game but the right one for a competitive one.
And I would build the net debugger first. A significant fraction of development time went into reasoning about desync from logs, when an overlay showing each player's error over time would have made it obvious.
Credits
- Gabriel Gambetta, Fast-Paced Multiplayer - the canonical series on prediction, reconciliation and interpolation, which is the shape the final model has
- Valve's Source networking documentation
- The Quake world-server model, for the fixed-tick-with-accumulator pattern
- Mike Acton and the general data-oriented literature on why a fixed timestep is a correctness feature
- Three.js, Socket.io, and the authors of the raycast-vehicle write-ups this physics model is derivative of

