How It Works

Rollback netcode, the query system, and deterministic simulation.

Rollback Netcode

Easy Multiplayer uses rollback netcode, the same approach used in competitive fighting games. The core idea:

  1. Predict — Every peer runs the full game simulation locally. For remote players whose inputs haven't arrived yet, the engine predicts their input (usually: same as last known input).
  2. Verify — When the real input arrives over the network, compare it to the prediction.
  3. Rollback — If the prediction was wrong, rewind the game state to the point of error and resimulate forward with the correct inputs.

This means the game always feels responsive for the local player — there's no "waiting for the server" lag. The tradeoff is that remote players may occasionally snap to corrected positions when a prediction was wrong.

Frame Lifecycle

Every tick follows this cycle:

PreTick      // Increment frame, check player joins/leaves
TakeInputs   // Sample local input, send over network
Tick         // Your game logic (the 'tick' callback)
PostTick     // Snapshot state, compute hash, check for desyncs
  ...
Draw         // Your rendering (the 'draw' callback), runs at display FPS

State History

The engine keeps a buffer of recent state snapshots (one per frame). When a rollback is needed, it restores the snapshot at the error frame and resimulates all frames from there to the present. Your tick callback runs again for each resimulated frame — this is why it must be deterministic.

The Query System

Traditional rollback compares raw input values: if the predicted input was "none" and the real input is "left", a rollback always happens. But many input changes don't actually affect the game.

Easy Multiplayer introduces predicate-based input queries. Instead of reading raw input, you ask questions:

// "Does the player want to go left (and not also right)?"
var goLeft = query(pid, inputs => inputs.left && !inputs.right); // returns true or false

The predicate receives the full input object and returns a boolean. The engine records each query: the player ID, predicate function, and the boolean result. When corrected input arrives, the engine re-evaluates the predicates with the new input values. Rollback only occurs if a query's result changes.

Why This Matters

Consider a Pac-Man game. The player is moving right through a corridor with walls above and below:

Predicted inputActual inputTraditional rollback?Query rollback?
nonepressing upYes (input changed)No (wall above — query for 'up' was never asked)
nonepressing left + rightYesNo (they cancel out — the compound predicate result is the same)
nonepressing rightYesNo (already going right — the query was never asked since it's the default direction)
nonepressing leftYesYes (turn-around query result changed from false to true)

In 3 out of 4 cases, the query system avoids a rollback that traditional netcode would perform. Fewer rollbacks means smoother gameplay.

Best Practices

Determinism

Every peer must produce the exact same game state from the same inputs. If two peers diverge, the game desyncs. This requires:

Rules for Deterministic Code

Networking

Easy Multiplayer uses WebTorrent (via the Trystero library) for peer-to-peer connections. No server is needed beyond the WebTorrent tracker for initial peer discovery.

What Gets Sent

Topology

All peers connect to all other peers (full mesh). This works well for 2-8 players. Beyond ~20 peers, bandwidth becomes a bottleneck and a relay server would be needed.

State Snapshots

Every tick, the engine calls JSON.stringify on your state object and stores the snapshot in a history buffer. During rollback, it calls JSON.parse to restore.

Performance Note State serialization is the main per-tick cost. Keep your state object small. Avoid storing visual-only data (sprite frames, animation timers) in state — derive those in draw.

For complex games that need custom serialization, use manageState(exportFn, importFn) to provide your own snapshot/restore functions.

Three Types of Data

Multiplayer games produce three distinct types of data, each handled differently by the framework:

TypePersisted?Rolled back?Example
StateSnapshotted every tickRestored from snapshotPositions, scores, lives, which dots are eaten
EventsNo (transient)Confirmed/cancelled after rollbackExplosions, sounds, coin collected, screen shake
Presentation HintsNo (rebuilt each tick)Rebuilt during resimulationPosition trails for interpolation, animation keyframes

Events

Events are transient signals produced during tick: "an explosion happened", "play this sound", "show a score popup". They are NOT part of game state. The framework tracks them by frame number and uses a confirm/cancel protocol during rollback:

  1. Normal play: emit('explosion', {x, y}) records the event and notifies subscribers
  2. Rollback starts: events after the rollback frame are marked unconfirmed
  3. Resimulation: if the same event is re-emitted (same type + frame + data), it's confirmed. Subscribers are NOT fired again.
  4. Rollback finishes: unconfirmed events are cancelled. onEventCancelled fires for each.

This means a sound that plays during a rolled-back-and-resimulated frame keeps playing (confirmed). A sound from a frame that played differently after rollback gets stopped (cancelled) and the new sound plays instead.

Presentation Hints

Presentation hints are continuous data for smooth rendering — e.g., a trail of positions that Draw interpolates through. They're produced during Tick and consumed during Draw. They're not state (never snapshotted) and don't need the confirm/cancel protocol because they self-correct:

// In tick: record entity positions with timestamps
hints.trail('pacman', { x: 10, y: 5, sprite: 'walk' }, time);
hints.trail('pacman', { x: 10, y: 4, sprite: 'walk' }, time + moveTime);

// In draw: get smooth interpolated position
var pos = hints.sample('pacman', drawTime);
drawSprite(pos.sprite, pos.x, pos.y);

On the first trail() call per entity per tick, entries at or after the tick's start time are truncated. During rollback resimulation, this automatically removes stale data from the invalidated simulation and rebuilds it from the correct one.

Desync Detection

Periodically, peers exchange state hashes for confirmed frames (frames where all player inputs have been received). If hashes don't match, a desync has occurred. The engine logs the mismatch and can generate detailed incompatibility reports showing exactly which fields diverged.

The visual test suite provides tools for detecting and debugging desyncs.