Easy Multiplayer Quickstart
You already know how to make a game. Easy Multiplayer lets you make that game online without building a game server, synchronizing packets by hand, or writing rollback machinery.
Players open the same URL. EM connects them peer to peer, runs the same deterministic game on every machine, predicts missing input, and corrects only the decisions affected by late input.
Build Coin Dash
The complete simulation is this:
const moveAxis = (inputs) =>
Number(inputs[0]?.right === true) - Number(inputs[0]?.left === true);
const join = (input) => input?.join === true;
export function createCoinDashGame() {
return {
initialState: () => ({
players: {},
coin: { x: 320, y: 160 },
}),
queries: { join, moveAxis },
step(ctx) {
ctx.discoverParticipants(join, 4);
for (const id of ctx.participants()) {
const player = ctx.state.players[id] ??= { x: 100, score: 0 };
player.x += ctx.query(id, null, 'moveAxis') * 6;
if (Math.abs(player.x - ctx.state.coin.x) < 18) {
player.score++;
ctx.state.coin.x = 40 + ((ctx.tick * 97) % 560);
}
}
},
};
}
Connect it:
const session = EasyMultiplayer.performanceMode.createSession({
room: { id: roomFromUrl, appId: 'coin-dash' },
game: { module: new URL('./game.js', import.meta.url), export: 'createCoinDashGame' },
getLocalInputs: () => ({
discoverable: true,
input: { join: true, left: keys.left, right: keys.right },
}),
});
session.connect();
await session.ready();
Render the synchronized state:
function frame() {
session.pump();
draw(session.getMirror().state);
requestAnimationFrame(frame);
}
frame();
And that is the multiplayer integration. No authoritative application server, packet handlers, interpolation buffer, or rollback implementation.
Try the complete example:
examples/quickstart-coin-dash/. Serve the repository
over HTTP, open examples/quickstart-coin-dash/, then send its generated ?room= URL to
another browser.
What Each Part Does
Canonical state
initialState() returns the one state every peer agrees on. In v0.4 the default is a
plain-object, schemaless state tree. Mutate it only inside step(ctx):
ctx.state.players[id].score++;
EM tracks those writes, hashes them, mirrors them to the renderer, and restores them during rollback.
The deterministic step
step(ctx) runs once per simulation tick on every simulation bus. It must produce the
same result from the same state and queried inputs.
Use ctx.tick and canonical counters for time. Do not use Date.now(),
setTimeout(), or Math.random() in simulation code. Under dev: true, EM catches
many of these mistakes.
Joining
Before admission, a peer is a spectator. discoverParticipants() is the only operation
that may inspect discoverable spectator input:
ctx.discoverParticipants(join, 4);
The registered join predicate decides which candidates become participants. After
admission, ctx.participants() returns their stable IDs.
Why inputs are queried
EM does not hand the step a raw input map by default. The game asks for the smallest decision that affects simulation:
const axis = ctx.query(id, null, 'moveAxis');
moveAxis combines left and right into -1, 0, or 1. If a late packet changes a
raw button but leaves the axis unchanged, the query result did not change, so EM does
not need to roll back that tick.
Queries should:
- Run only in branches where their result matters.
- Read only the participants needed for the decision.
- Return the narrowest value the game uses.
- Be pure functions of
(inputs, frozenContext). - Be registered by name in
queries.
That is the central optimization behind EM's rollback behavior.
The Prototype Escape Hatch
For a quick migration or prototype, opt into field-at-a-time access:
export function createGame() {
return {
simpleMode: true,
initialState: () => ({ players: {} }),
step(ctx) {
const inputs = ctx.rawInputs();
for (const id of ctx.participants()) {
if (inputs[id].left && !inputs[id].right) {
ctx.state.players[id].x--;
}
}
},
};
}
Every property read becomes an independent query. This is easy, but generally broader
than a decision query. Under dev, EM warns once when rawInputs() is used.
rawInputs() throws unless the game declares simpleMode: true.
Use it to get moving; use named queries for production-sensitive decisions.
Rendering
The renderer reads a frozen mirror:
const state = session.getMirror().state;
Never mutate the mirror. It is presentation data, not the simulation. Pull it each animation frame because rollback or bus selection may replace the mirrored snapshot.
For smooth movement, author trails with ctx.trail(...) and sample
session.getPresentationHints(). For sounds and other one-shot presentation, use
ctx.emit(...) and consume finalized events rather than playing effects directly
inside step.
Add Input Delay Deliberately
The default favors responsiveness. A small input delay gives remote input more time to arrive before prediction:
const session = EasyMultiplayer.performanceMode.createSession({
// ...
advanced: {
engine: { inputDelay: 1 },
},
});
One tick is often a useful starting point. More delay generally means fewer corrections but more local latency. Measure with the actual game and target network.
Upgrade to a Schema
Schemaless proxied state is the default and easiest authoring tier. Add schema only
when profiling shows the state backend matters:
const schema = {
players: schemaTable(schemaRecord({
x: schemaScalar('number'),
score: schemaScalar('number'),
}), { id: 'string', order: 'id' }),
};
Schema mode uses typed tables and span patches for maximum throughput. It changes state authoring, not networking semantics: queries, participants, workers, rollback, mirrors, and transports remain the same.
Use Your Own Transport
The room form provides the built-in peer-to-peer transport:
room: { id: roomId, appId: 'my-game' }
Advanced hosts may inject:
transport: myTransport
Pass room or transport, never both. A transport implements message delivery,
peer discovery, liveness, and clock-sync attachment. See
the transport contract.
Production Checklist
- Serve over HTTPS for real WebRTC.
- Use a module-reference game so worker mode can import it.
- Set
dev: trueduring development and remove or disable it for production. - Keep all simulation state canonical.
- Use stable IDs instead of object aliases or array positions for identity.
- Query only admitted participants.
- Use named decision queries for rollback-sensitive behavior.
- Test disconnect, rejoin, delayed input, and correction paths.
Next: Consumer API reference.