Robutler

Build an MMO on Robutler

You can build a massively-multiplayer game that runs entirely in players' browsers, with no game server, and ship it as an app anyone can fork and re-theme. The distributed-systems work is done once, in the substrate; your game is a re-skin of it.

This guide is the how. For the why it scales and how the trust model works, see the companion paper A Remixable Browser-Native MMO Substrate (docs/papers/remixable-mmo-substrate.md). The reference implementation of everything below is the Elaisium widget, public/widgets/elaisium/.

Status legend. Throughout, ✅ Built marks substrate pieces you can use today (the Elaisium code and the host SDK), and 🔶 Designed marks the hardening layer that is specified in the paper but not yet in the reference code. Read the Trust model section before you put anything of value on the client.

What you get from the platform

An MMO needs four things a game server usually provides. The host gives you all four as ordinary SDK surface; they exist for collaborative documents, and a game is a thin layer on top:

NeedHost primitive
Identity you can trusthost.user, room tokens bound to a user id
Shared world statehost.collab, Yjs CRDT rooms with cross-pod fan-out
Low-latency positionshost.collab.getTurnCredentials + your own RTCPeerConnection mesh (multi-party RTC)
Custody of anything valuablehost.fn server functions, currency, minting, transfers

You bring the CRDT runtime (Yjs) and the game loop. You never run a game server.

The one idea that makes it scale: shard the world

Do not put the whole world in one room. Partition it into fixed-size square regions, one CRDT document each, addressed by a code derived from the region's integer coordinates. A player joins only the region they stand in, plus neighbor regions when near a border (1 room interior, 2 at an edge, 4 at a corner).

Why this is the whole game: presence fan-out within a region is O(N²), but regions are independent (nothing is shared between two non-adjacent regions), so total load across the world is linear in the number of players and scales only with local density, which you cap by design (~30 co-present players reads as a crowd; the network could carry far more). A million players spread over forty thousand active regions cost the same per region as five thousand. See the paper's §3.4 and the runnable model, scripts/elaisium-concurrency-model.py.

Reference: world/region-codec.mjs (coordinate ↔ room code, desiredRegions), net/region-manager.js (ref-counted membership with a release grace so border pacing never churns tokens).

// region-codec.mjs: a player wants their region + edge-buffer neighbors
export function desiredRegions(gx, gy, buffer = EDGE_BUFFER) {
  const { rx, ry } = regionOf(gx, gy);
  const dxs = [0], dys = [0];
  if (localX < buffer) dxs.push(-1); else if (localX > REGION_SIZE - buffer) dxs.push(1);
  // ...same for y → 1, 2, or 4 rooms
  return codesFor(dxs, dys);
}

Free geography: derive, don't store

Make terrain, resources, and landmarks a pure function of (worldSeed, regionCoords). Then geography is never stored or sent: every client derives the identical world from the seed, and a fork inherits it for free. Reference: world/terrain-gen.mjs, world/city-gen.mjs.

Durable state vs. ephemeral presence

Two kinds of state, two transports. Getting this split right is most of the performance story.

  • Durable (player-built structures, terrain edits, depletion): CRDT maps in the region document. Converges automatically, persists, survives reloads. Write it through Yjs; read it from an observer.
  • Ephemeral (positions, animation, heading): the room's awareness channel (transient per-client state), never the durable doc. Publish into every joined room so peers in the next region see you while you're still in the edge buffer. That symmetric publish is what makes crossing a border seamless.

Reference: net/presence.js.

// presence.js: publish my character into every joined room
for (const { room } of getReadyRooms()) {
  room.awareness.setLocalStateField('char', {
    u: { id: me.id, name: me.name, color: me.color },
    x, y, h: heading, anim, t: Date.now(),
  });
}

Take positions off the server: the WebRTC mesh

Awareness is server-relayed, so at 10 Hz it is your dominant cost. Add a per-region WebRTC DataChannel overlay for positions: unreliable + unordered (maxRetransmits: 0, so a stale packet drops instead of head-of-line-blocking), with awareness demoted to signaling and fallback. Signal offers/answers/ICE over an awareness rtc field; get TURN from host.collab.getTurnCredentials(). See multi-party RTC for the connection recipe.

Do not build a full mesh. Every peer connecting to every other peer is O(N²) connections; a browser holding 200 live RTCPeerConnections under load is not viable, and a crowd collapses it. The reference implementation uses a bounded-degree overlay with gossip forwarding (net/rtc.js):

  • Each client holds at most MAX_DEGREE direct channels, chosen as the nearest interest peers (spatial priority: the ones you see and interact with) plus a few stable long-range links for small-world connectivity. Set the cap at (or just above) your per-region soft cap (~32), so any single-region crowd, and normal edge-buffer overlap, is an all-direct 1-hop mesh with zero gossip. It is a cap, not a target, so small groups still connect to exactly their peer count; raising it only helps busier ones, at the cost of a few more live sockets.
  • When a region holds more than MAX_DEGREE peers, position frames gossip: on receipt, deliver locally and re-broadcast to your other direct peers, deduplicated by a per-origin sequence number, bounded by a TTL, and never echoed to the sender (split horizon). Everyone in range is reached in a few hops without a direct link to each.
  • Forwarding is overflow-gated, so small groups pay nothing (they are already a direct mesh). A grace window on disconnect stops a peer at the degree boundary from thrashing connections.

The pure selection and dedup logic (selectMeshNeighbors, gossipAccept) is exported and unit-tested; the stateful part manages the RTCPeerConnections. This is what lets density scale past the browser's connection ceiling and lets mega-gatherings degrade gracefully instead of failing.

Do it graduated, not all-or-nothing. The trap: only drop awareness to a heartbeat when every peer is meshed, and then one un-meshed peer (hard NAT, mid-connect, churning) forces full-rate broadcast to everyone. Instead, scale the awareness rate to the un-meshed minority:

Mesh coverageAwareness rateWho relies on what
Fully meshed1 Hz (heartbeat)positions ride P2P
Minority off-mesh~5 Hzoff-mesh peers use awareness; meshed peers use P2P and ignore it
Mostly off-mesh / no RTCfull 10 Hzsafe fallback

Receivers merge both sources by freshest timestamp, so the slower fallback never clobbers fresher P2P data. Reference: net/presence.js (publish(char, maxHz)), net/rtc.js (coverageInfo(){ total, uncovered }). This alone roughly triples per-pod capacity at good coverage.

Who computes the monsters? Host election

Environmental simulation (mob spawning, movement, combat) needs one node per region or it double-runs. Elect the lowest awareness clientID in the room as host; re-evaluate every tick so migration on join/leave is automatic and needs no protocol. The host ticks the sim and publishes mob snapshots over awareness for everyone else to render cheaply. Empty regions freeze; a new host adopts the last snapshot instead of cold-starting.

Reference: net/authority.js (isRegionHost), sim/mobs.mjs.

// authority.js: the whole election
export function isRegionHost(room) {
  const mine = room.awareness.clientID;
  let min = mine;
  room.awareness.getStates().forEach((_st, id) => { if (id < min) min = id; });
  return min === mine;
}

This is the weak form of authority: lowest-clientID is game-able by an adversary who forges a low id. It is safe only because the host computes nothing of value; see the trust model next.

Trust model (honest status)

Rule zero: anything of value is a server function, not client state. Currency balances, minting, and transfers go through host.fn, server-side, where no peer can touch them. Do this and a cheating host can, at worst, lie about cosmetic region state, never about your economy. Elaisium keeps VIBE, minting, and artifact transfers entirely in server functions for exactly this reason.

What the substrate ships today (✅ Built): a tamper tripwire. Every few seconds each client hashes the region doc's durable subtree (FNV-1a over sorted structure keys) and publishes the digest over awareness. Since a CRDT must converge, two fresh clients disagreeing means a sync bug or tampering → a HUD warning and a divergence entry in the region ledger. Reference: net/authority.js (publishAudit, checkAudits, recordDivergence).

This detects disagreement. It does not vote, demote a liar, penalize, or roll back. It is a debugging and tamper-flag aid, not a security boundary. The security boundary is rule zero.

What the paper designs but the code does not yet do (🔶 Designed): sampled-audit sortition, a deterministic state = fold(seed, ledger) any peer can re-derive, Ed25519-signed ledger inputs, a server-seeded, reputation-weighted, unpredictable subset of global auditors who recompute and vote, and optimistic execution with rollback to the last audited checkpoint. This is Byzantine-resistant sampled verification, not a BFT protocol, and it is sufficient precisely because the server already custodies value. See paper §4.2 for the design and the adversary math (an attacker with fraction f of the weighted pool wins a C=5 majority with P≈0.9% at f=0.1; scale C with stakes).

If you build on this today: put value in server functions, treat the audit as a tripwire, and cap per-region density. That is a safe, shippable posture. The sortition layer is an upgrade, not a prerequisite.

AI-driven NPCs, and the determinism boundary

You can drive NPCs with the browser's on-device LLM (host.infer) with a scripted finite-state-machine floor as the always-available fallback. Reference: ai/brain.js, ai/fsm.mjs.

One rule if you also plan to audit the simulation: LLM inference is non-deterministic and must never be inside the verifiable fold. Treat an inference result as a signed input event, not as computation, a node computes "NPC #7 decides FLEE to (x,y)", signs it, appends it to the ledger like a player command; the deterministic sim consumes it. Auditors verify the signature and that the fold consumed it, never that the choice was "good" (unverifiable). Keep deterministic mob physics inside the fold; only the decision crosses the boundary as data.

This boundary is also what lets you distribute inference (🔶 Designed): because a decision is signed data, a strong-GPU node can compute it for a weak node or for the region's NPCs, assigned by the same sortition machinery and paid in the game economy. A bad inference provider can only make an NPC play badly (still schema-valid, still physically legal via the FSM floor); no value flows from inference. See paper §4.3–4.4.

Performance notes from the reference implementation

  • Client is not the limit at realistic densities: ~7–10 ms/frame (~100 fps) in the densest textured city; peer-merge CPU ~0.02 ms even at 200 peers; a 48-mob + 25-structure stress held the sim tick at 0.2 ms median.
  • Cap per-region density (~30) for readability, not because the network can't carry more.
  • Scale message-bus shards with relay pods, a single shard gets worse as pods grow (more cross-pod fan-out per message). This is the first ceiling you hit; see the capacity model.
  • A ` debug console in Elaisium shows live per-region density, mesh coverage, awareness rate, host/sim state, and the LLM/steering trace, copy the pattern; it is how you observe all of the above in play.

Ship it: the remix path

The whole thing is a widget bundle, a directory of static modules the host serves in a sandbox. To make your own MMO:

  1. Fork Elaisium (or scaffold a widget and lift world/, net/, sim/ wholesale). You inherit region sharding, CRDT rooms, the presence mesh, host election, the audit scaffold, and deterministic world-gen.
  2. Re-theme: swap the art, the structure grammar, the terrain seed, the NPC prompts, the economy rules, the win conditions.
  3. Declare collab: true in widget.json so the registry authorizes room-token mints (manifest reference).
  4. Keep value in host.fn server functions.
  5. Publish like any app, see publishing and remix. A fork needs no server provisioning and no database: durable geography is a seed, durable state is a CRDT the platform already hosts.

That is the thesis: fork this, get a scalable MMO. Your distributed-systems layer is already written and running.

See also

  • host.collab, room tokens and TURN
  • Multi-party RTC, the WebRTC mesh recipe
  • host.infer, on-device LLM for NPCs
  • host.fn, server functions for anything valuable
  • Widget manifest, the collab flag
  • Paper: docs/papers/remixable-mmo-substrate.md, the theory, the capacity model, the trust math
  • Reference code: public/widgets/elaisium/

On this page