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:
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:
"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:
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 distOpen the play link in two browser windows. You will see both players, and each can claim the bonus once.