
A technical walkthrough of how Rainboids does authoritative-server multiplayer. Written for engineers who have shipped software before but have never built a multiplayer game.
1. The problem
Rainboids is a browser-based co-op shooter. Single-player works fine: one tab, one bundle of JavaScript, one player making decisions about where their ship is. Adding multiplayer breaks that simplicity in three places at once.
Authority. When two players each run their own copy of the game, somebody has to be in charge. If player A’s computer says the enemy died at position X and player B’s computer says it died at position Y, those are two different games. Somewhere, a single source of truth has to decide what actually happened.
Latency. A signal traveling from a player’s keyboard, through their router, across the public internet, into our server, and back to all four players, takes between 30 and 150 milliseconds in practice. The game runs at 60 frames per second, which is one frame every 16 ms. If we made the player wait for that round-trip before their ship moved, it would feel awful. The local ship has to feel snappy anyway, even though the server hasn’t approved the move yet.
Cheating. A client that runs its own simulation can lie about it. “I shot the enemy” can come from a real keypress or from a script. The server has to verify everything important, or someone will eventually write a tool that says “I have infinite gold.”
The standard solution, used by basically every shooter since Quake III in 1999, is called authoritative server with client prediction:
- One server runs the real game. It decides what happens.
- Every client connects to the server and sends their inputs (move forward, fire, etc).
- The server runs the simulation 60 times per second and tells everyone what the world looks like.
- For the local player’s own ship only, the client predicts where it should be without waiting for the server. When the server’s answer arrives, the client checks how wrong it was and corrects.
- For everyone else’s ships and enemies and asteroids, the client just shows whatever the server said, smoothed over time.
What is new in our version: the server is written in Rust while the client is JavaScript. That means the same simulation has to exist twice, once in each language, and they have to agree bit-for-bit on the parts where the client predicts. A test rig in continuous integration (CI) runs both implementations on the same inputs and fails the build if they disagree.
This article explains how all of that is put together. The companion planning documents in docs/ go deeper on individual pieces; this one is the overview.
2. A few terms before we go further
If you are a working programmer who has not built networked games before, a handful of words come up over and over. Quick definitions:
- Authoritative server. The one server everyone connects to, which makes the final decision about what is true in the game. The opposite is peer-to-peer, where clients negotiate with each other and there is no referee.
- Tick. One step of the simulation. Rainboids ticks 60 times per second. Each tick, every entity updates by 1/60 of a second of game time.
- Snapshot. A message from server to clients describing the state of the world at one tick: where each ship is, where each enemy is, etc.
- Event. A message from server to clients describing a discrete thing that just happened: bullet fired, enemy destroyed. Snapshots tell you what is, events tell you what just happened.
- Client-side prediction. When you press W, your ship moves immediately, before the server has confirmed the move. The client simulates locally so the game feels responsive.
- Reconciliation. When the server’s snapshot arrives, the client compares the server’s idea of the ship’s position to its own predicted position. If they disagree, the client corrects.
- Interpolation. Showing remote entities slightly in the past, smoothly tweening between the last two snapshots, so that even though snapshots arrive 50 ms apart you see continuous motion.
- Determinism. Given the same inputs and the same starting state, the simulation produces exactly the same output. Required for prediction to work without constant correction.
- mpsc. “Multiple producer, single consumer” — a kind of message queue (channel) where many tasks can send messages into it but only one task reads them out. The standard way Rust tasks talk to each other.
- Actor. A unit of state plus a mailbox. Other code interacts with it by sending messages to its mailbox; only the actor itself reads its own mail and changes its own state. No shared memory, no locks, no races.
- WebSocket. A persistent two-way connection over TCP, set up by upgrading an HTTP connection. It’s how browser JavaScript talks to a server in real time without polling.
- bincode. A binary serialization format for Rust. You annotate a struct with
#[derive(Serialize, Deserialize)]and bincode produces compact bytes from it.
That should be enough vocabulary. If something else is unfamiliar later, the surrounding sentence usually explains it.
3. The big picture

┌──────────┐ WebSocket ┌──────────────────────┐
│ Browser │ (binary bincode frames │ rainboids-server │
│ client │ ──────over TCP, with TLS─── │ (Rust, 1 process) │
│ │ in production) │ │
└──────────┘ └──────────────────────┘
│ │
│ runs js/sim/ (the same simulation, │ runs server/src/sim/
│ for solo play and local prediction) │ authoritative
▼ ▼
parity tests in CI run both on identical inputs and diff
The transport is plain WebSockets over TCP. The browser does new WebSocket("wss://..."), the server accepts the connection, both sides exchange binary frames until somebody disconnects. No UDP, no WebRTC, no fancy custom transport.
Three claims hidden in the picture:
The server’s simulation is canonical. HP, enemy positions, who killed what, who picked up the orb — the server decides, and clients display.
The client runs its own copy of the simulation. Two reasons. Solo play needs no server at all and still works offline. Online play uses the same simulation to predict the local ship, so input feels instant.
The two simulations are kept in lockstep by engineering discipline. Mirrored directory layouts (js/sim/ship.js next to server/src/sim/ship.rs), shared constants, fixed-point arithmetic on the predicted fields, and an automated parity test that runs both implementations and diffs the results.
The cost of choosing Rust here is paying the implementation twice. The benefit is a server with predictable latency, low memory use, and a static binary deploy. If you weren’t going to operate the server for years, you would write the server in JavaScript and pay neither cost.
4. The tech stack and why each piece is there
From server/Cargo.toml:
tokio = { version = "1", features = ["full"] }axum = { version = "0.7", features = ["ws", "macros"] }serde = { version = "1", features = ["derive"] }bincode = "1.3"rand_pcg = "0.3"uuid = { version = "1", features = ["v4", "serde"] }nanoid = "0.4"tracing = "0.1"metrics = "0.23"metrics-exporter-prometheus = "0.15"
tokio is the async runtime. Async means a single OS thread can juggle many simultaneous tasks by suspending whichever ones are waiting on I/O. Tokio gives us the primitives: tasks, timers, channels, and a select! macro that competes multiple async operations and resumes whichever finishes first.
axum is a small HTTP router. We use three endpoints: /health (is the server up?), /metrics (Prometheus scrape target), and /ws (the WebSocket upgrade endpoint).
bincode is the wire codec. You annotate a Rust struct with Serialize/Deserialize and bincode encodes it as a compact binary stream. No field names on the wire — just the values back-to-back, in declaration order. The JavaScript side has a hand-written matching decoder.
rand_pcg::Pcg64 is the random number generator. We need a generator whose algorithm is specified in writing (not “whatever the standard library does”), because we mirror it in JavaScript. PCG-64 is fast, statistically excellent, and tiny.
uuid produces session tokens, nanoid produces short human-typeable room codes (6 characters from a 28-character alphabet that excludes confusable glyphs like 0 and O).
tracing + metrics are structured logging and Prometheus-style metrics. Running a multiplayer server without these is flying blind: when something is slow at 2 AM you want a graph, not a guess.
What we deliberately don’t use:
- No entity-component system (ECS). With ≤200 entities per room,
Vec<Enemy>with explicit fields is simpler than archetype storage. - No async sleeping inside the simulation. “Spawn in 2 seconds” becomes “spawn at tick + 120”. Time is a number, not a clock call.
5. The actor model: how tasks talk to each other
The server is a tree of async tasks. Each one owns some state and only that one task ever modifies it.

axum HTTP listener
│
▼
┌────────────────────┐
│ ConnectionTask │ one per WebSocket
│ (reads frames, │
│ writes frames, │
│ forwards to a │
│ room or to MM) │
└──────────┬─────────┘
│
┌────────┴────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Matchmaker │ │ RoomActor │
│ (singleton) │ │ (one per │
│ │ │ active room)│
└──────────────┘ └──────────────┘
60 Hz tick
The pattern in one sentence: one async task per stateful object, and the only way to modify that object is to send a message to its mailbox. This is the actor model. Erlang made it famous; Rust falls into it naturally because the borrow checker is hostile to shared mutable state.
In Rust this lands cleanly:
- The actor owns its state by value. No
Arc<Mutex<...>>. - The mailbox is a
tokio::sync::mpsc::Receiver. mpsc = multiple producer, single consumer: many tasks can clone the sender end and push messages in, but only one task (the actor) holds the receiver and drains them. - The compiler enforces that nobody else holds a reference to the actor’s state. Race conditions on that state are not just unlikely, they are impossible to express.
Here is the room’s state (server/src/room/mod.rs:95):
pub struct Room { id: RoomId, code: String, state: GameState, sim_state: RoomState, // WaitingForPlayers | Playing | … inputs: HashMap<PlayerId, InputBuffer>, rng: Pcg64, seed: u64, tick: u32, players: Vec<Player>, cmd_rx: mpsc::Receiver<RoomInbound>, pending_events: Vec<GameEvent>, grace: HashMap<PlayerId, GraceTimer>, cfg: std::sync::Arc<Config>, encode_buf: Vec<u8>,}
Everything the room needs is in one struct, owned by one task. Connection tasks hold a RoomHandle, which is just a thin wrapper around the room’s mpsc sender:
pub struct RoomHandle { tx: mpsc::Sender<RoomInbound>, id: RoomId,}
When a connection has a new input from its player, it does room_handle.send(RoomInbound::Input { ... }).await. The message lands in the room’s inbox. The room reads it, applies it. Nobody else touched the game state. There is no lock to forget to take.
RoomInbound is the complete list of things that can happen to a room (server/src/room/mod.rs:32):
pub enum RoomInbound { Join { player_id, display_name, out }, Reattach { player_id, display_name, out }, Leave { player_id, reason }, Disconnected { player_id }, Input { player_id, tick, packed }, Ack { player_id, snapshot_tick }, Summary { reply }, // matchmaker reads cheap counts Shutdown,}
Notice each variant carries the player id. The room does not trust the sender to honestly identify themselves; the connection task already knows which player it represents (we issued them an id during the welcome handshake) and stamps every outgoing message with that id.
Why one task per room instead of one event loop for all rooms
Two reasons. First, CPU isolation: a slow tick in room A cannot stall room B if the two are scheduled on different worker threads. Second, mental clarity: inside a room there is exactly one execution context, so you never have to think “what if input arrives during the tick” — the message loop makes that case structurally impossible.
6. The per-room simulation loop
This is the most important piece of code in the server. From server/src/room/mod.rs:430:
async fn run(mut room: Room) { let tick_dur = Duration::from_secs_f64(1.0 / room.cfg.tick_hz as f64); let mut tick_interval = interval(tick_dur); tick_interval.set_missed_tick_behavior(MissedTickBehavior::Burst); let snapshot_every = (room.cfg.tick_hz / room.cfg.snapshot_hz).max(1); let mut tick_counter: u32 = 0; loop { tokio::select! { biased; cmd = room.cmd_rx.recv() => { match cmd { Some(c) => room.enqueue(c), None => break, } } _ = tick_interval.tick() => { room.drain_inbound(); let started = Instant::now(); room.simulate_one_tick(); metrics::histogram!("rainboids_tick_duration_seconds") .record(started.elapsed().as_secs_f64()); tick_counter = tick_counter.wrapping_add(1); if tick_counter % snapshot_every == 0 { room.broadcast_snapshot(); } room.broadcast_pending_events(); room.reap_grace(); if room.should_shutdown() { break; } } } } room.cleanup();}
Let’s go through it line by line.
6.1 Tick pacing
The simulation runs at 60 Hz, so the tick interval fires every 16.66 ms. tokio::time::interval is a timer that fires repeatedly at a fixed cadence.
MissedTickBehavior::Burst matters. If the runtime is briefly busy and three tick deadlines elapse before we get a chance to poll the timer, we want the next three tick() calls to return immediately so the simulation can catch up. The other options would either skip those ticks (the simulation silently runs slower than the wall clock) or delay all future ticks (same effect). Burst is the only correct choice for a fixed-cadence simulation.
Snapshots go out every 3 ticks, which gives 20 Hz. The reasoning:
- Inputs come in at 60 Hz because input lag is what the player feels.
- Snapshots go out at 20 Hz because that is enough to interpolate smoothly between (50 ms apart) and saves bandwidth compared to 60 Hz.
- Events go out as soon as they happen, because some events have to feel synchronized (a bullet appearing, an enemy exploding).
6.2 biased; and why ordering matters
tokio::select! is normally random across its branches: when multiple are ready, it picks one pseudo-randomly. The biased; keyword makes it poll branches in the order they are written. We put the inbound channel before the tick on purpose: if a batch of inputs and a tick deadline are both ready, we want to drain the inputs first so the tick sees the latest data. The opposite order would simulate one tick with stale input.
6.3 drain_inbound() and the latest-input rule
When a tick fires, we drain whatever inputs piled up since the last tick:
fn drain_inbound(&mut self) { while let Ok(msg) = self.cmd_rx.try_recv() { self.enqueue(msg); }}
For RoomInbound::Input, only the most recent input per player is kept (server/src/room/mod.rs:161):
RoomInbound::Input { player_id, tick, packed } => { let buf = self.inputs.entry(player_id).or_default(); if tick >= buf.last_tick { buf.latest = packed.into(); buf.last_tick = tick; }}
This is last-write-wins, with a tick-number guard against packets that arrive out of order. The trade-offs:
- The simulation never has to know about jitter. Each tick sees exactly one input per player.
- A player who lags briefly catches up automatically — their next packet replaces the stale one.
- Bursts of inputs that arrive between two server ticks are dropped. If a player taps fire-stop-fire faster than 60 Hz, the intermediate state never reaches the server.
For a co-op shooter that’s the right call. For a fighting game it would be wrong — you would need an input queue drained one per tick instead.
6.4 The actual simulation
fn simulate_one_tick(&mut self) { if !matches!(self.sim_state, RoomState::Playing) { return; } let dt = 1.0 / self.cfg.tick_hz as f32; let inputs: PlayerInputs = self.inputs.iter() .map(|(id, buf)| (*id, buf.latest)) .collect(); simulate_tick( &mut self.state, &inputs, dt, &mut self.rng, &mut self.pending_events, ); self.tick = self.tick.wrapping_add(1);}
simulate_tick itself (server/src/sim/mod.rs:28) is a pure function. “Pure” means: same inputs in, same outputs out, no side effects on the outside world (no I/O, no clocks, no logging, no random calls that aren’t seeded). Everything it needs is passed in as an argument.
pub fn simulate_tick( state: &mut GameState, inputs: &PlayerInputs, dt: f32, rng: &mut Pcg64, events: &mut Vec<GameEvent>,) { ship::update_all(&mut state.ships, inputs, dt, events); enemy::update_all(&mut state.enemies, &state.ships, dt, rng, events); asteroid::update_all(&mut state.asteroids, dt, events); collision::detect_and_resolve(state, events); drops::update(&mut state.drops, &state.ships, dt, events); wave::tick(&mut state.wave, &mut state.enemies, dt, rng, events);}
This is the single most important factoring in the whole project. Because simulate_tick is a pure function:
- It runs inside
cargo testwith no runtime, no sockets, no setup. - The JavaScript client runs the same algorithm for prediction.
- The parity test can run both implementations on the same input and diff the output.
- Replay (run the same seed and inputs again and get the same world) is trivial.
If you take one thing from this article: make your simulation a pure function early. Every other piece of multiplayer is easier when this holds.
6.5 Events: the simulation pushes, the network broadcasts
simulate_tick takes a &mut Vec<GameEvent> parameter. When something noteworthy happens, the subsystem appends an event:
events.push(GameEvent::BulletDespawn { id, reason: DespawnReason::Hit });events.push(GameEvent::AsteroidDestroy { id, by: Some(player), fragments });
After the tick, the room loop drains those events and sends each one as a ServerMsg::Event frame:
fn broadcast_pending_events(&mut self) { for ev in self.pending_events.drain(..) { let msg = ServerMsg::Event { tick: self.tick, event: ev }; for p in &self.players { if !p.lagging { let _ = p.out.try_send(msg.clone()); } } }}
On the client, the same event stream drives presentation: particles, screen shake, audio cues. The simulation never imports the audio manager or touches the DOM directly — it pushes an event and a separate effect layer subscribes. This is the cleanest line in the architecture: simulation produces events, presentation consumes them.
6.6 Snapshots vs events, concretely
Two different kinds of state flow from server to clients:
| Frequency | Carries | OK to lose? | |
|---|---|---|---|
| Snapshot | 20 Hz | Slow-changing bulk state: ship/enemy/asteroid/drop positions, HPs | Yes — the next snapshot replaces it |
| Event | On demand | Discrete moments: bullet spawn, enemy destroyed, orb collected, wave clear | No — but events are additive, dedup is cheap |
Snapshots tell you what is. Events tell you what happened. Never put “enemy destroyed” inside a snapshot; never put “ship position” inside an event. They have different delivery requirements and mixing them confuses both sides.
The snapshot payload right now is SnapshotPayload { ships, enemies, asteroids, drops }. Each entity is a small struct with id, position, velocity, HP — roughly 16–32 bytes. With 4 ships + 50 enemies + 30 asteroids + 10 drops at 20 Hz, that’s about 3 KB/s per player. Comfortably under any home internet uplink.
A future optimization is delta encoding: instead of sending the full snapshot every time, send only what changed since the receiver’s last acknowledgement. The wire format already plumbs a base_tick: Option<u32> field for this; v1 just always sends full snapshots.
6.7 Backpressure: when a client can’t keep up
The outbound channel from room to client is bounded (256 messages, set in server/src/server/connection.rs). If the room tries to broadcast and the channel is full, that means the client’s WebSocket isn’t draining fast enough:
match p.out.try_send(msg.clone()) { Ok(_) => {} Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { p.lagging = true; warn!(player_id = %p.id, "client lagging; dropping snapshots"); } Err(_) => {}}
While lagging is set, we skip snapshots to that player (events still try, because they shouldn’t be lost). The plan is to disconnect the client after sustained lag; v1 just sets the flag.
The principle is the most important part: never let a slow client back up the server. The server’s tick rate is the contract.
7. The wire protocol
When bytes leave the server, what do they mean? When the client receives bytes, how does it parse them?
7.1 The schema is the source of truth
A surprising choice: neither the Rust code nor the JavaScript code is the authoritative description of the wire format. The authoritative description lives in a separate file, schema/protocol.toml, that neither implementation reads at runtime but which both have to match:
wire_version = 1sim_version = 1codec = "bincode-1.x-default-with-fixint-le"[[message.client]]name = "Hello"fields = [ { name = "wire_version", type = "u16" }, { name = "sim_version", type = "u16" }, { name = "client_version", type = "String" }, { name = "display_name", type = "String" }, { name = "session", type = "Option<Uuid>" },][[message.client]]name = "Input"fields = [ { name = "tick", type = "u32" }, { name = "packed", type = "PackedInput" },]
Why a third file:
- If Rust and JavaScript disagree, neither side gets to be “right by default.” The TOML is the tiebreaker. A CI script (
tools/check-schema.mjs) reads it and verifies both implementations match. - It is a codegen target. In v2, a script reads the TOML and emits both the Rust struct definitions and the JavaScript decoder. v1 is hand-mirrored; the shape is already designed for codegen later.
- Documentation. Variant ordering, field types, version numbers, the predicted subset of the simulation — all in one file.
7.2 The codec: bincode with fixed-width integers
fn opts() -> impl bincode::Options { bincode::DefaultOptions::new() .with_fixint_encoding() .with_little_endian()}
Three choices, each load-bearing:
- Little-endian matches every browser and almost every modern CPU. No byte-swapping anywhere.
with_fixint_encoding()means every integer is written at its natural width: u8 is 1 byte, u16 is 2, u32 is 4, u64 is 8. Bincode’s default uses varints (smaller for small numbers, variable size), which would make the JavaScript decoder vastly more complex. The fixed-width choice trades a handful of bytes per message for a decoder that fits on a page.- Default field order. Bincode doesn’t write field names — just the values back-to-back, in struct declaration order. Add a field in the middle of a struct and you silently break the wire. Discipline: append-only struct fields, append-only enum variants. The TOML schema enforces it; the parity tests catch mistakes.
7.3 Concrete byte layout
The byte-level spec lives in docs/Multiplayer Wire Format – 2026-05-09.md. Each WebSocket frame is one binary message containing one fully encoded ClientMsg or ServerMsg. No length prefix wrapping it — the WebSocket frame already knows its own length, so we don’t repeat that.
For example, ClientMsg::Hello { wire_version: 1, sim_version: 1, client_version: "5.79.62", display_name: "Pilot", session: None } encodes as:
| Bytes | Meaning |
|---|---|
00 00 00 00 | u32 enum tag — Hello is variant 0 of ClientMsg |
01 00 | u16 wire_version |
01 00 | u16 sim_version |
07 00 00 00 00 00 00 00 | u64 length prefix for "5.79.62" |
35 2e 37 39 2e 36 32 | UTF-8 bytes of "5.79.62" |
05 00 00 00 00 00 00 00 | u64 length prefix for "Pilot" |
50 69 6c 6f 74 | UTF-8 of "Pilot" |
00 | Option tag = None |
Golden hex dumps for every message variant live in server/tests/wire_golden.rs. The JavaScript test fixtures assert byte-identical output. If a struct field changes, the test fails and someone has to bump WIRE_VERSION.
7.4 The message vocabulary
Three tagged unions describe everything that can happen over the wire:
ClientMsg— what the client can say. Hello (the handshake), room intents (QuickMatch, BrowseRooms, CreateRoom, JoinRoom, JoinRoomByCode, LeaveRoom), per-tick play (Input, Ack, Pong, PowerupChoose, Revive, Chat).ServerMsg— what the server says back. Welcome, Error, RoomList, RoomJoined, RoomLeft, PeerJoined, PeerLeft, Snapshot, Event, Ping.GameEvent— discrete in-game things, sent wrapped inServerMsg::Event { tick, event }. BulletSpawn, BulletDespawn, EnemyDestroy, AsteroidDestroy, OrbCollect, PlayerDamaged, PlayerDowned, PlayerRevived, WaveStart, WaveClear, PowerupOffer, PowerupChosen, HitFlash, DamageNumber.
The full list is in schema/protocol.toml. Three things worth pointing out:
ClientMsgandServerMsgare different enums on the same wire — no multiplexing layer.- Exactly one message per WebSocket frame. No batching in v1.
- Both
InputandSnapshotcarry aticknumber. That tick number is what makes prediction reconciliation possible: the client can match “the snapshot I just got” to “the input I sent on that tick” and replay forward.
7.5 Input packing: seven bytes per tick
The smallest and most frequent message:
#[derive(Serialize, Deserialize, Copy, Clone)]pub struct PackedInput { pub move_x: i8, // -127..127 normalized pub move_y: i8, pub aim_x: i16, // -32767..32767 unit vector pub aim_y: i16, pub buttons: u8, // bit 0=shoot, 1=dash, 2=ability1, 3=ability2}
Seven bytes plus a u32 tick stamp plus a u32 enum tag is 15 bytes per input. At 60 Hz that’s 900 bytes/s upstream per player, before WebSocket framing overhead. Negligible.
The server unpacks immediately into a friendlier PlayerInput with f32 fields:
impl From<PackedInput> for PlayerInput { fn from(p: PackedInput) -> Self { PlayerInput { move_x: (p.move_x as f32) / 127.0, move_y: (p.move_y as f32) / 127.0, aim_x: (p.aim_x as f32) / 32767.0, aim_y: (p.aim_y as f32) / 32767.0, shoot: p.buttons & 0x01 != 0, dash: p.buttons & 0x02 != 0, ability1: p.buttons & 0x04 != 0, ability2: p.buttons & 0x08 != 0, } }}
The packed form is a wire concern; the simulation only sees the unpacked form.
8. Determinism: the hard problem
Client-side prediction relies on a strong claim: given the same starting state and the same inputs, the client’s simulation produces the same answer the server’s does. If that’s true, prediction usually agrees with the server and the player never sees a correction. If it’s not true, every tick produces a small disagreement and the ship visibly snaps around.
The obvious approach — “just use 32-bit floats everywhere” — fails the moment you call sin or cos. JavaScript’s Math.sin and Rust’s f32::sin are not bit-identical on all inputs because they use different math library implementations underneath. The drift is microscopic per call but accumulates over thousands of ticks.
We solve this in three layers.
8.1 Limit the scope
From schema/protocol.toml:
[prediction]relevant_fields = [ "Ship.x", "Ship.y", "Ship.vx", "Ship.vy", "Bullet.x", "Bullet.y", "Bullet.vx", "Bullet.vy",]
Only these fields need to be bit-identical. Ship position and velocity (because we predict the local ship), and bullet spawn position and velocity (because we want the bullet to appear immediately when the player fires).
Everything else — enemy HP, enemy position, asteroid position, drop position — is server-authoritative and interpolated on the client. The client doesn’t try to predict any of that; it just smoothly blends between the last two server snapshots. f32 drift between Rust and JavaScript is invisible because the client isn’t running those calculations.
This is the most important determinism decision: don’t try to make the whole simulation deterministic. Pick the smallest necessary subset, lock it down hard, and let everything else be approximate.
8.2 Fixed-point math on the relevant fields
The current scaffold (server/src/sim/fxp.rs):
#[derive(Serialize, Deserialize, Copy, Clone, Default, PartialEq, Eq)]pub struct Fxp(pub i32);const FRAC_BITS: u32 = 16;const ONE: i32 = 1 << FRAC_BITS;impl std::ops::Mul for Fxp { type Output = Self; fn mul(self, rhs: Self) -> Self { Fxp(((self.0 as i64 * rhs.0 as i64) >> FRAC_BITS) as i32) }}
Fxp is I16F16, a 32-bit integer where the top 16 bits hold the integer part and the bottom 16 bits hold the fractional part. The value 1.0 is 0x00010000. To multiply, you widen to i64 (so the intermediate doesn’t overflow), multiply the raw integers, then shift right by 16 to get back to I16F16.
This works for cross-language determinism because integer arithmetic is exactly specified. Two’s complement addition and subtraction, plus integer multiply with truncating shift, behave the same on every CPU and in every language with a 32-bit integer type. There is no rounding mode to disagree about, no NaN bit pattern, no transcendental function with subtly different implementations.
Range: roughly ±32,000 with 1/65,536 ≈ 15 µm precision on a 32,000-unit playfield. Plenty.
The JavaScript side implements the same type over plain number values truncated with value | 0 (which forces a 32-bit signed result). Multiplication uses Math.imul for the low 32 bits plus split-half math for the high bits. Roughly 3× slower than native float multiplication, irrelevant for the ~10 multiplies per ship per tick.
8.3 Polynomial trig
You can’t use sin from the math library. But you can’t avoid sin either — ship aim, bullet velocity, all of it is angles.
[trig.sin_coeffs_f64]c0 = 1.0c1 = -0.16666666666666666 # -1/3!c2 = 0.008333333333333333 # 1/5!c3 = -0.0001984126984126984 # -1/7!c4 = 0.0000027557319223985893 # 1/9!
Both sides implement sin(x) for x in [-π, π] as a degree-9 Taylor polynomial in fixed-point. Same coefficients, same operation order, same intermediate truncation — same output, bit for bit. cos(x) = sin(x + π/2); atan2 follows the same approach.
Cost: about 8 fixed-point multiplies per call. The hardware FPU’s sin is faster, but it’s faster in different ways on different machines. For the handful of trig calls per tick, the slower-but-predictable polynomial wins.
8.4 The seeded random number generator
Anything random in the simulation flows through a seeded PCG-64:
pub fn from_seed(seed: u64) -> Pcg64 { /* seed_from_u64 */ }
PCG-64 is a generator with 128 bits of internal state, defined down to the multiplier constant (2360ED051FC65DA44385DF649FCCF645 in hex) and the seeding procedure (use SplitMix64 to expand 64 bits into 32 bytes, then initialize). Both languages implement the same algorithm.
The JavaScript side uses BigInt for the 128-bit state. About 5–10× slower than Math.random, but RNG only gets called for spawn decisions, drop rolls, and asteroid splits — a handful of times per tick at most, none of it in the prediction-hot path.
When a room is created (server/src/room/mod.rs:117):
let seed: u64 = rand::random();rng: sim_rng::from_seed(seed),
The seed is broadcast to clients in ServerMsg::RoomJoined. They seed their local RNG from the same value. From that point on, both sides produce identical RNG sequences given identical inputs — the foundation of deterministic replay: same seed + same inputs gives same world.
8.5 Discipline rules
schema/SIM_SPEC.md codifies what you can’t do in simulation code:
- No
Math.random,Math.sin,Math.cos,Math.atan2,Date.now,performance.now,setTimeoutinjs/sim/. - No
rand::random,Instant::now,tokio::sleepinserver/src/sim/. - All randomness comes from
state.rng. All time comes fromstate.tick. - Anything in the predicted-fields list must be
Fxp, notf32.
The plan is to enforce these with lint rules (ESLint on the JS side, a grep-style check in CI for Rust). For v1 it’s code review plus the parity tests. The tests are the safety net; the spec is the upstream filter.
8.6 The parity harness
The CI step that proves the two simulations match. The harness:
- Generates a fixture: seed + initial GameState + input sequence + tick count.
- Runs the Rust
simulate_tickover the fixture and records the resulting state at every tick. - Runs the JavaScript
simulateTickover the same fixture and records its state. - Diffs the predicted fields. Any drift fails the build.
Fixtures live in schema/snapshots/. The Rust side has parity tests in server/tests/parity_*.rs: parity_asteroid.rs, parity_bullet.rs, parity_collision.rs, parity_drops.rs, parity_enemy.rs, parity_enemy_bullet.rs, parity_vectors.rs, parity_wave.rs, pcg64_trace.rs. Each one drives a small scenario through the Rust simulator and checks the output against a recorded JS-side snapshot.
This is the test suite that holds the project together. Without it, the two simulations drift silently and you find out months later when a player reports their ship teleports during lag. With it, every pull request that touches simulation code either passes the existing fixtures or has to ship updated fixtures — which means the JavaScript and Rust changes land in the same commit.
9. The connection lifecycle
A WebSocket arrives. What happens?
9.1 Split the socket and spawn a writer
let (mut ws_tx, mut ws_rx) = ws.split();let (out_tx, mut out_rx) = mpsc::channel::<ServerMsg>(OUTBOUND_BUFFER);let writer = tokio::spawn(async move { while let Some(msg) = out_rx.recv().await { let bytes = match codec::encode_server(&msg) { ... }; if ws_tx.send(Message::Binary(bytes)).await.is_err() { break; } }});
A WebSocket has a read half and a write half. We split them and give the write half to its own task. That task drains a channel (out_rx) and writes binary frames. The sender end of that channel (out_tx) is what we hand to rooms — that’s how the room “writes to a WebSocket” without knowing what a WebSocket is.
Concretely: rooms broadcast to N players by holding N mpsc::Sender<ServerMsg> clones. They never block on actual network I/O. The bounded channel is the backpressure point.
9.2 Hello, with a timeout
let hello = match tokio::time::timeout(HELLO_TIMEOUT, read_hello(&mut ws_rx)).await { Ok(Ok(h)) => h, _ => { drop(out_tx); let _ = writer.await; return; }};
Three seconds. If the client doesn’t send Hello in that window we close the connection. This protects against denial-of-service via sockets that open and never speak.
9.3 Version check
if !protocol::is_compatible(wire_version, sim_version) { let _ = out_tx.send(ServerMsg::Error { code: ErrCode::Version, msg: format!("server v{}/{}", WIRE_VERSION, SIM_VERSION), }).await; drop(out_tx); let _ = writer.await; return;}
Two version numbers travel together in Hello: WIRE_VERSION bumps when the byte layout changes, SIM_VERSION bumps when simulation rules change in ways that affect deterministic replay. The client uses these to know whether it matches the server.
The drop(out_tx); writer.await dance flushes the error frame before the socket closes. Dropping the sender ends the writer task’s input; awaiting it ensures the last queued message reaches the wire.
9.4 Reattach: surviving brief disconnects
This is the most subtle piece. From server/src/server/connection.rs:105:
let player_id = match hello_session.and_then(|sid| sessions.take_alive(&sid)) { Some(SessionEntry { player_id, room, .. }) => { if room.send(RoomInbound::Reattach { ... }).await.is_ok() { current_room = Some(room); } player_id } None => PlayerId::new(),};
The flow:
- Client first connects. Server issues a
session: Uuidin the Welcome message. Client persists it inlocalStorage. - Client disconnects (laptop closed, WiFi dropped). The server’s room marks the slot as “in grace” — keeps the ship in the world for 60 seconds.
- Client reconnects and sends
Hello { session: Some(uuid) }. - Server looks up the UUID in its
SessionRegistry. If the entry is still alive (room exists, grace timer hasn’t fired), the server tells the room to reattach the player to the same slot. - Room swaps in the new outbound channel, sends a fresh
RoomJoinedso the client gets a fresh snapshot, clears the grace timer.
From the player’s point of view: a brief network blip doesn’t lose your run. Your ship freezes for a moment, then resumes. From the other players’ point of view, nothing happened — the ship just looked AFK for a few seconds.
On disconnect, the room converts the slot to a grace state rather than removing it:
fn handle_disconnect(&mut self, player_id: PlayerId) { let now = crate::util::time::now_ms(); self.grace.insert(player_id, GraceTimer { started_at_ms: now, deadline_ms: now + self.cfg.grace_secs * 1000, });}
The per-tick reap_grace() cleans up players whose grace expired:
fn reap_grace(&mut self) { let now = crate::util::time::now_ms(); let expired: Vec<PlayerId> = self.grace.iter() .filter(|(_, t)| now >= t.deadline_ms) .map(|(id, _)| *id).collect(); for id in expired { self.grace.remove(&id); self.handle_leave(id, LeaveReason::GraceExpired); }}
If you build a multiplayer game today, build grace reconnect from day one. Mobile network connections drop constantly. Players will rage-quit a game they think crashed when they could have rejoined.
9.5 The main message loop
loop { tokio::select! { biased; frame = ws_rx.next() => { // decode and route ClientMsg } _ = ping.tick() => { // emit periodic Ping for RTT measurement } }}
select! competes the inbound frame stream against a 5-second ping timer. Ping is ServerMsg::Ping { client_t, server_t }; the client echoes it back as ClientMsg::Pong and the server records round-trip time. The ping also acts as a passive liveness check — if we can’t queue the Ping because the outbound channel is full, the connection is failing and we break.
The frame router is a pattern match on (decoded_msg, current_room_option):
match (&msg, ¤t_room) { (ClientMsg::Input { tick, packed }, Some(room)) => { room.send(...).await; } (ClientMsg::Ack { snapshot_tick }, Some(room)) => { ... } (ClientMsg::LeaveRoom, Some(room)) => { ...; current_room = None } (ClientMsg::QuickMatch, _) | (ClientMsg::BrowseRooms, _) | ... => { if current_room.is_none() { if let Some(handle) = mm.handle(...).await { current_room = Some(handle); } } } _ => {}}
A few rules embedded in the match:
- In-room messages (
Input,Ack) are dropped if the connection isn’t in a room. The client shouldn’t send them in that state and we don’t crash on misbehavior. - Matchmaking messages are dropped if the connection is already in a room. You can’t
QuickMatchwhile playing. LeaveRoomclearscurrent_roomimmediately on receipt, not when the room acknowledges. Subsequent inputs while the room is shutting down are dropped.
9.6 Clean disconnect
When the loop ends (client closed cleanly, or read error):
if let Some(room) = current_room.as_ref() { let _ = room.send(RoomInbound::Disconnected { player_id }).await; let entry = SessionEntry { player_id, room: room.clone(), expires_at_ms: now_ms() + cfg.grace_secs * 1000, }; sessions.register(session, entry);}
We tell the room “this player just disconnected” and we register the session for possible reattach. The session entry holds a clone of the RoomHandle, so the next time the player reconnects we can immediately route their Hello back to the same room.
10. Matchmaking
The matchmaker is the simplest actor. It owns a map of public rooms and implements a few policies:
- Quick Match picks the smallest non-full public room (or creates one if none exist).
- BrowseRooms returns a
Vec<RoomSummary>of currently visible rooms. - CreateRoom spawns a new
Roomand adds it to the registry. - Join-by-Code looks up rooms by their short alphanumeric code.
The interesting detail is RoomInbound::Summary:
RoomInbound::Summary { reply: oneshot::Sender<RoomSummarySnap> },
When the matchmaker assembles a RoomList to send to a browsing client, it iterates the registry and fires one Summary query at each room. The room replies via the one-shot channel with a tiny { players, wave } snapshot — no game state cloning, no entity traversal. The matchmaker joins all the replies and returns them.
This pattern — “ask the actor for a tiny summary via a one-shot reply” — is the canonical way to read cross-actor state without violating “only the actor touches its own state.” Don’t reach into a room to read its wave; ask the room what its wave is.
11. The client side, briefly
The client engine work has its own document, docs/Multiplayer Rust Client Engine – 2026-05-07.md. A short summary so you can see how the two halves meet:
js/sim/mirrorsserver/src/sim/. Same module names, same algorithms, same fixed-point math on the predicted fields. Solo play and online prediction both call this.js/net/ws-client.jsowns the WebSocket. Reconnects with exponential backoff. Persists the session UUID inlocalStorage.js/net/prediction.jsruns the local ship throughsimulateTickevery frame using the latest local input. Keeps a circular buffer of the last ~120 ticks of(input, predicted_state)pairs.- When a snapshot arrives, prediction does rollback-and-replay: rewind the local ship to the snapshot’s tick, snap to the server’s position and velocity, then replay all the inputs since that tick. If prediction agreed with the server, replay produces the same current state and nothing visible happens. If they disagreed, the ship shifts — but only by however far prediction was wrong, usually millimeters per network blip.
js/net/interpolation.jssmoothly blends remote entities (other ships, enemies, asteroids, drops) between the last two snapshots. The render time is delayed by one snapshot interval (~50 ms) so we always have two snapshots to blend between, never one in the past and one extrapolated.js/net/event-firehose.jsconsumesServerMsg::Eventframes and dispatches them to the existing presentation layer (audio, particles, damage numbers). The simulation never touches the audio manager directly; events are the bridge.
A single mode flag on the engine decides solo vs online. Everything downstream — renderer, audio, input capture, wave UI — is mode-agnostic.
12. Lessons that aren’t obvious from the code
Pure functions buy you everything. Once simulate_tick takes inputs in and returns state out with no side effects, you can test it, replay it, port it to another language, run it as a benchmark, and reason about it without involving the async runtime. Every subsequent multiplayer feature gets cheaper the cleaner this function is.
Make the wire format boring. Bincode with fixed-width integers is boring: every length is a u64, every enum tag is a u32, every integer is its declared width. Boring decoders are correct decoders.
Pick the smallest deterministic subset. Trying to make the whole simulation bit-identical across two languages is a doomed project. Pick the few fields the client predicts, lock those down with fixed-point math and polynomial trig, and let everything else use native floats and interpolation.
Last-write-wins is the right default for inputs. Buffering creates endless edge cases. Latest input every tick is simple, correct, and good enough for any game where players aren’t doing frame-precise inputs.
Snapshots are state, events are instants. Don’t put “enemy destroyed” inside a snapshot; don’t put “ship position” inside an event. They have different delivery guarantees and mixing them confuses both implementations.
Grace reconnect changes the player experience. Mobile network flapping is constant. A game that survives a 30-second blackout is a game the player keeps playing.
Versions are cheap; irreversible mistakes are expensive. Two u16s in your Hello cost nothing and save you the day a wire change ships before a client update.
Metrics from day one. Tick duration, snapshot size, round-trip time, active rooms, online players. The day production has a problem you don’t have time to instrument. Instrument now; have the dashboard waiting.
Single static binary. cargo build --release produces one ELF file. Copy to a VPS, run it. No node_modules, no runtime version pins, no production-only dependency failures. The boring deploy is the deploy you can do at 2 AM.
13. What we haven’t built yet
This is a v1 plan partway through implementation. The gaps tell you what shape the interfaces have to take so future upgrades don’t break the wire:
- Delta-encoded snapshots. Every snapshot is currently a full payload.
base_tick: Option<u32>is plumbed but unread; v2 will use it to send only what changed since the receiver’s last acknowledgement, saving ~70% of bandwidth in steady state. - Ref-counted broadcast. Currently we re-encode the snapshot per recipient. Switching to a shared
Bytesbuffer lets one encode produce N transmits; small win at low N, big win at scale. - Protocol codegen. v1 hand-mirrors the schema. v2 reads
schema/protocol.tomland emits both sides. - Boss fights, powerups, revive interactions. Plumbed in the protocol but not wired into the Rust simulation; they currently exist only as parity tests against the JavaScript reference.
- Horizontal sharding. One process for v1. The plan calls for nginx in front and
room_idcookie routing once we outgrow one box.
Writing down what isn’t built is itself useful. It forces interfaces to leave room for upgrades — that’s why base_tick is in the snapshot struct even though nothing reads it yet.
14. Suggested build order
If you wanted to build something like this from scratch, a sensible order for one engineer with JavaScript experience and limited Rust:
- Extract
simulateTickfrom your existing JavaScript engine. No multiplayer code yet — just refactor until you have a pure function. 1–2 weeks for a real game; it pays for itself before the network layer is written. - Build the event queue. Particle spawns, audio cues, damage numbers all move from inline function calls to events drained by a separate presentation layer. Solo play should behave identically before and after.
- Seed your RNG. Replace
Math.random()withstate.rng.next()using PCG-64. Verify solo replay feels the same. - Stand up the Rust crate. Empty
simulate_tickthat does nothing. An axum WebSocket endpoint, a bincode codec, a room actor that broadcasts empty snapshots. Get a client connected and bytes flowing. - Port one subsystem. Ship physics is the right one — small, self-contained, on the prediction-relevant path. Write the parity test before writing Rust. Iterate until JavaScript and Rust agree bit-for-bit on a 1,000-tick fixture.
- Add fixed-point math for the predicted fields. Convert
Ship.x/y/vx/vytoFxp. Ship the JavaScript side simultaneously. Re-run parity. - Port the remaining subsystems (asteroid, enemy, bullet, wave, drops, collision) one at a time, each with its own parity fixture. Order them by dependency.
- Wire client prediction. Read input locally, predict the local ship via
simulateTick, reconcile against snapshots. Predict only the local ship. - Wire snapshot interpolation. Remote ships, enemies, asteroids render slightly in the past so there are always two snapshots to blend.
- Add grace reconnect. Session UUID, registry, room reattach path.
- Matchmaking, lobby UX, polish.
- Operationalize. Metrics dashboard, structured logs, systemd unit, nginx reverse proxy.
Each step has a clear success criterion. Each step is independently testable. By step 12 you have a working, observable, operable multiplayer game.
15. Closing
Authoritative-server multiplayer is a 30-year-old design. The interesting work in 2026 is not reinventing it; it’s paying for the implementation twice — once in your client language for prediction and solo play, once in your server language for authority — while keeping both honest.
Two pieces make our version work:
- A pure-function simulation that can be ported, tested, replayed, and parity-checked.
- A schema as third-party arbiter: a TOML file that neither language reads at runtime but both have to match, plus a CI harness that runs the same fixtures through both implementations and fails any diff.
If you want bounded tail latency, a single static binary, and CPU headroom for years of feature growth, you pay the duplication cost and get Rust. If you want zero duplication and a fast dev loop, you write the server in the same language as the client and accept that trade. No architecture costs nothing. The interesting question is which costs you’d rather pay.
Further reading inside this repo:
docs/Multiplayer Planning – 2026-05-06.md— the original design doc.docs/Multiplayer Rust Server – 2026-05-07.md— Rust server deep dive.docs/Multiplayer Rust Client Engine – 2026-05-07.md— JavaScript client engine refactor and prediction details.docs/Multiplayer Wire Format – 2026-05-09.md— byte-level codec spec.schema/SIM_SPEC.md— discipline rules for the dual simulation.schema/protocol.toml— the third-party arbiter.server/src/room/mod.rs— the per-room actor and tick loop.server/src/server/connection.rs— the WebSocket lifecycle, hello, reattach.server/src/sim/— authoritative simulation, side by side withjs/sim/.



Leave a Reply