HELIX Docs
Guides

Build your first multiplayer world

Start from the multiplayer scaffold, add one server-validated action, and publish. HELIX multiplayer is declarative, so there is no server code to write.

This extends the project from the Quickstart. You will add a "Claim bonus" button whose result the server decides, which is the server authority rule in its smallest form. Every snippet here was added to a real helix init --kind multiplayer project, built, type-checked and validated with the CLI.

There is no server file

HELIX multiplayer is server-authoritative and declarative. A platform-owned room holds shared state and runs the rules you declare in public/helix.json at a fixed 20 Hz. You write no server code. Clients read synced state and send intents (actions); the room validates them and applies the effects.

1. What the scaffold already does

helix init my-world --kind multiplayer gives you presence out of the box. One call, CharacterMultiplayer.create, signs the player in, loads their equipped avatar, joins the room (falling back to single player for guests) and renders everyone else with a nameplate and proximity voice:

src/main.ts
const character = await CharacterMultiplayer.create({
  helix: Helix, renderer, scene, camera, body, input,
  domElement: renderer.domElement,
  assetBase: SYSTEM_ASSET_BASE, transcoderPath: TRANSCODER_PATH,
  spawn: SPAWN,
  join: true,
});

character.room is the live room handle. It is null when the player is not in a room (guest, standalone, or join failed), so single player always works. Its full type is HelixRoom in the Web SDK reference.

2. Declare state and a rule

Add per-player state, an action the client may send, and one rule that handles it. Everything goes under the existing multiplayer block of public/helix.json:

public/helix.json
"multiplayer": {
  "authoritative": true,
  "state": {
    "playerVars": {
      "coins": { "type": "number", "default": 0, "min": 0, "integer": true },
      "bonusClaimed": { "type": "boolean", "default": false }
    }
  },
  "actions": { "claimBonus": { "args": {} } },
  "rules": [
    {
      "when": { "on": "action", "name": "claimBonus" },
      "if": { "op": "==", "a": { "var": "self.bonusClaimed" }, "b": false },
      "then": [
        { "do": "add", "target": "self.coins", "by": 50 },
        { "do": "set", "target": "self.bonusClaimed", "to": true }
      ]
    }
  ]
}

When a client sends claimBonus, the room fires the rule with self bound to that player. The condition runs on the server, so a player who edits their client cannot claim twice. If you misspell a verb or a variable, helix validate rejects it and suggests the right name. The complete grammar (entities, zones, timers, state machines, collections) is in the multiplayer logic reference that the MCP server serves as read_doc({ name: "multiplayer-logic" }).

3. Ask from the client

The client never changes coins. It asks, then reads the synced value:

src/main.ts
const bonusButton = document.createElement('button');
bonusButton.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:10';
bonusButton.onclick = () => character.room?.sendAction('claimBonus', {});
document.body.append(bonusButton);

// inside your render loop, read state fresh each frame
bonusButton.textContent = `Claim bonus (${character.room?.me.num('coins') ?? 0} coins)`;

room.me, room.vars and room.player(id) are guarded live views over the synced state with safe defaults, so there is nothing to cast and no "first patch has not landed yet" check.

Why this split matters

If the client could grant its own bonus, players would script infinite coins. Movement and cosmetics may be client-driven; anything of value is decided by a declared rule. This is the same rule as for inventory and LIX.

4. Check and publish

npm run build
helix validate dist
helix publish dist

Open the play link in two browser windows. You will see both players, and each can claim the bonus once.

Next

On this page