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:

  1. Run only in branches where their result matters.
  2. Read only the participants needed for the decision.
  3. Return the narrowest value the game uses.
  4. Be pure functions of (inputs, frozenContext).
  5. 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

Next: Consumer API reference.