API Reference

Complete reference for the EasyMultiplayer class.

Constructor

new EasyMultiplayer(options)
OptionTypeRequiredDescription
roomstringYesRoom name. Players with the same room join together.
stateobjectNoInitial game state. Must be JSON-serializable. Defaults to {}.
tickRatenumberNoTicks per second. Default: 20.
inputDelaynumberNoInput delay in frames. Higher = fewer rollbacks but more latency. Default: 4.
canvasHTMLCanvasElementNoCanvas element passed to the draw callback.
debugbooleanNoEnable debug mode (detailed logging, incompatibility reports). Default: false.

Methods

defineInput(name, sampler)

Register a named input channel. The sampler function is called every tick to read the local player's input.

game.defineInput('left', () => keys.ArrowLeft || false);
game.defineInput('right', () => keys.ArrowRight || false);
game.defineInput('action', () => keys.Space || false);

Return values must be JSON-serializable. Each input becomes a field in the per-frame input data sent to other peers.

on(event, callback)

Register a callback for a game event. Events:

'tick'

Runs at the fixed tick rate. This is your deterministic game logic.

game.on('tick', (ctx) => { ... });
ctx fieldTypeDescription
stateobjectYour mutable game state. Changes are automatically captured.
query(playerId, predicate)function → booleanAsk a question about a player's input. The predicate receives the full input object and returns a boolean. Records the query for smart rollback avoidance.
getInput(playerId)function → anyGet raw input data for a player (all defineInput values). Prefer query() over this.
random()function → numberDeterministic random number [0, 1]. Synced across peers and rollbacks.
playersstring[]Array of active player IDs for the current frame.
framenumberCurrent frame number.
deltaTimenumberMilliseconds per tick (1000 / tickRate).
timenumberCurrent tick time in milliseconds.
emit(type, data)functionEmit a transient event (sound, explosion, etc.). Rollback-aware: events are confirmed or cancelled during resimulation.
hints.trail(id, data, t)functionRecord a presentation hint — a position/data point for smooth interpolation. Not part of state.

'draw'

Runs every animation frame (~60fps). Render your game here.

game.on('draw', (ctx) => { ... });
ctx fieldTypeDescription
stateobjectCurrent game state (treat as read-only during draw).
myIdstringLocal player's ID.
canvasHTMLCanvasElementThe canvas from the constructor options.
framenumberCurrent frame number.
tnumberCurrent draw time (for interpolation between ticks).
eventsobjectAccess to emitted events. events.active(type), events.new(type), events.cancelled(type), events.forEach(type, {onNew, onCancelled}).
hints.sample(id, t)functionGet interpolated presentation hint data for an entity at time t.
hints.clearTrail(id)functionRemove all hint data for an entity (e.g., when destroyed).

'playerJoined'

Called when a player joins. Initialize their state here.

game.on('playerJoined', ({ playerId, state, random }) => {
    state.players[playerId] = { x: 0, y: 0 };
});
Determinism Required This callback fires during rollback resimulation. Use only random() for randomness. No Math.random(), no Date.now(), no DOM manipulation.

'playerLeft'

Called when a player disconnects.

game.on('playerLeft', ({ playerId, state }) => {
    delete state.players[playerId];
});

manageState(exportFn, importFn)

For complex games that manage their own state objects instead of using the plain state object. The library calls these instead of its default JSON serialization.

game.manageState(
    () => myGame.ExportState(),     // called every tick to snapshot
    (s) => myGame.ImportState(s)     // called to restore during rollback
);

See Examples: Migrating an existing game for a full walkthrough.

onEvent(type, callback)

Subscribe to events of a specific type. The callback fires when the event is emitted (but not when it's re-confirmed during rollback).

game.onEvent('explosion', (data, frame) => startExplosionEffect(data));

onEventCancelled(type, callback)

Subscribe to event cancellations. Fires when a rollback undoes an event that was previously emitted.

game.onEventCancelled('explosion', (data, frame) => stopExplosionEffect(data));

start()

Connect to the room and start the game loop. Creates the P2P connections, syncs clocks, and begins ticking/drawing.

stop()

Stop the game loop. Does not disconnect from the room.

destroy()

Stop the game loop and clean up all resources.

Properties

PropertyTypeDescription
myIdstringThe local player's unique peer ID. Available after start().
playerCountnumberNumber of currently active players.
framenumberCurrent frame number.
sceneSyncedSceneThe underlying SyncedScene instance. For advanced use only.

The Query System

query(playerId, predicate) is the recommended way to read input. The predicate receives the full input object (all defineInput values) and returns a boolean. The query result is recorded. When corrected input arrives, the predicate is re-evaluated. Rollback only occurs if a result changes.

Why use query() instead of getInput()?

With getInput(), any change to raw input triggers a rollback. With query(), only meaningful changes trigger rollback.

// BAD: raw input comparison. Any change = rollback.
var input = getInput(pid);
if (input.left) player.x--;

// GOOD: predicate over full input. Rollback only if this answer changes.
if (query(pid, inputs => !!inputs.left)) player.x--;

Compound Predicates

Since the predicate receives all inputs at once, you can express compound intent in a single query:

// ONE query with compound logic: "wants left AND NOT also right"
var wantsLeft = query(pid, inputs => !!inputs.left && !inputs.right);

// If prediction was "no buttons" and actual is "both buttons",
// wantsLeft is still false — no rollback needed!
// This is ONE recorded query with ONE boolean result.
Key insight Put the entire decision logic inside a single predicate. Two separate queries (query(pid, i => i.left) + query(pid, i => !i.right)) would record two results, either of which could trigger rollback. One compound query records one result — only the final boolean matters.

Context-Sensitive Queries

Only query when the answer matters:

// At a junction with only [up, right] available:
// Don't query 'left' or 'down' — walls block them anyway.
// Don't query 'right' if already going right — it's the default.
// Only query 'up' — the one direction that could change the outcome.

for (var dir of availableDirections) {
    if (dir === currentDirection) continue; // default, no query needed
    var opp = opposite(dir);
    if (query(pid, inputs => !!inputs[dir] && !inputs[opp])) {
        chosenDirection = dir;
        break;
    }
}