Robutler
GuidesWidgets

Build a multiplayer tic-tac-toe widget

Step-by-step build of the multiplayer-tictactoe reference widget. By the end you'll have a working two-player game running on your own Robutler canvas, and you'll understand every line of the source. Companion guide: community-collab-widget-authoring.md.

What we're building

A 3×3 tic-tac-toe board. Two players claim X and O; anyone else in the workspace watches as a spectator. Game state lives in a Yjs Map so the board survives all players leaving and re-joining. Cursors and "I'm hovering this cell" indicators broadcast through Yjs awareness so they vanish when the peer leaves.

Total source: ~280 lines of vanilla HTML + JS, no build step.

Prerequisites

  • A Robutler workspace where Plan 1 (the host.collab SDK and the Hocuspocus collab pod) has shipped.
  • Two browser tabs (or two devices) signed into accounts that share workspace membership.

Step 1 — scaffold the HTML

Create public/widgets/multiplayer-tictactoe/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="robutler:widget" content='{"name":"multiplayer-tictactoe","title":"Multiplayer Tic-Tac-Toe","size":{"width":360,"height":460}}' />
    <script src="/widgets/sdk.v2.js"></script>
    <style>/* inline CSS — see the full file */</style>
  </head>
  <body>
    <div class="root" id="root">
      <div class="topbar"><span id="slotX">X: open</span><span id="slotO">O: open</span><span id="scores">0 – 0</span></div>
      <div class="status" id="status">Connecting…</div>
      <div class="board" id="board"></div>
      <div class="controls">
        <button id="claimX">Play as X</button>
        <button id="claimO">Play as O</button>
        <button id="newGame">New game</button>
      </div>
    </div>
    <script type="module"><!-- step 2+ --></script>
  </body>
</html>

The <meta name="robutler:widget"> block is the registry's source of truth for the widget's name + size; the seed script reads it when wiring the widget into the marketplace.

Step 2 — wait for the bridge

await window.host.ready();
const workspaceId = window.host.workspace.workspaceId;
if (!workspaceId) throw new Error('no workspace context');

host.ready() resolves once the parent canvas has finished the postMessage handshake (it pushes the workspace context, user, etc. through the bridge). After this, host.workspace.workspaceId is populated.

Step 3 — mint a collab JWT

const tok = await window.host.collab.getToken('workspace', workspaceId);

The portal returns:

{
  token:     'eyJhbGciOi…',         // short-lived JWT
  wsUrl:     'wss://collab.…/ws',   // Hocuspocus endpoint
  roomId:    'workspace:<uuid>',    // your room name
  expiresAt: 1748295600,
  turn:      { url, username, credential, expiresAt },  // for RTC widgets
}

A 503 here means the collab pod isn't enabled on this environment. Catch and render a friendly notice; don't crash.

Step 4 — load Yjs from the CDN

const Y = await import('https://cdn.jsdelivr.net/npm/yjs@13.6.30/+esm');
const { HocuspocusProvider } = await import(
  'https://cdn.jsdelivr.net/npm/@hocuspocus/provider@3.1.4/+esm'
);

The widget's per-path CSP whitelists cdn.jsdelivr.net for exactly this. No bundler, no package.json — runtime-loaded ESM.

Step 5 — join the room

const ydoc = new Y.Doc();
const provider = new HocuspocusProvider({
  url: tok.wsUrl,
  name: tok.roomId,
  token: tok.token,
  document: ydoc,
});

That's it — the websocket connects, the JWT authenticates, the Y.Doc syncs the existing room state (if any), and from this point on every write to ydoc propagates to every other connected peer.

Step 6 — model the game state

const game = ydoc.getMap('game');

provider.on('synced', () => {
  ydoc.transact(() => {
    if (!game.has('board')) game.set('board', new Array(9).fill(null));
    if (!game.has('nextPlayer')) game.set('nextPlayer', 'X');
    if (!game.has('winner')) game.set('winner', null);
    if (!game.has('scores')) game.set('scores', { X: 0, O: 0 });
    if (!game.has('playerSlots')) game.set('playerSlots', { X: null, O: null });
  });
});

Defaults are written idempotently — multiple peers all running the same if (!has) set(...) block converge to identical state because the writes are no-ops once the key exists.

Step 7 — render on every observe

function render() {
  const board = game.get('board') || new Array(9).fill(null);
  for (let i = 0; i < 9; i++) {
    cellEls[i].textContent = board[i] || '';
    cellEls[i].className = 'cell ' + (board[i] || '');
  }
  // … slot labels, score, status …
}

game.observe(render);

Yjs fires observe whenever any peer (including this one) writes to the Map. We re-derive the entire UI from the current Yjs state on every observe — simpler than diff tracking and fast enough for a 9-cell board.

Step 8 — handle clicks

$board.addEventListener('click', (ev) => {
  const tgt = ev.target.closest('.cell');
  if (!tgt) return;
  const idx = Number(tgt.dataset.idx);

  const slots = game.get('playerSlots') || {};
  const nextPlayer = game.get('nextPlayer') || 'X';
  if (game.get('winner')) return;
  if (slots[nextPlayer] !== me.peerId) return;
  const board = (game.get('board') || []).slice();
  if (board[idx] != null) return;

  ydoc.transact(() => {
    board[idx] = nextPlayer;
    game.set('board', board);
    const winner = detectWinner(board);
    if (winner) {
      game.set('winner', winner);
      if (winner !== 'draw') {
        const sc = { ...(game.get('scores') || { X: 0, O: 0 }) };
        sc[winner] += 1;
        game.set('scores', sc);
      }
    } else {
      game.set('nextPlayer', nextPlayer === 'X' ? 'O' : 'X');
    }
  });
});

Three observations:

  1. Turn-enforcement is client-side. A malicious peer could bypass it and write to board directly. For a real-stakes game you'd run move validation in a Plan 1 server-side awareness hook or behind an agent skill. Tic-tac-toe between trusted workspace members doesn't need that.
  2. transact(() => { … }) wraps the move into one observation so other peers see one re-render, not three.
  3. board.slice() then write the slice. Yjs Map values are compared by reference — mutating the existing array would not trigger observers on other peers. Always write a fresh value.

Step 9 — claim a slot (intent → claim pattern)

function claim(slot) {
  // 1) stamp intent in awareness — visible immediately.
  provider.awareness.setLocalStateField('presence', {
    ...prev,
    claimingSlot: slot,
  });
  // 2) commit a frame later so a tying peer can react.
  setTimeout(() => {
    const cur = game.get('playerSlots') || {};
    if (cur[slot]) return; // race lost — fall back to spectator
    ydoc.transact(() => game.set('playerSlots', { ...cur, [slot]: me.peerId }));
  }, 16);
}

Two peers clicking "Play as X" at the same time will see one win via Yjs last-write-per-clock; the loser observes the change and re-renders.

Step 10 — broadcast cursor + hover via awareness

document.addEventListener('pointermove', (ev) => {
  const r = $root.getBoundingClientRect();
  provider.awareness.setLocalStateField('presence', {
    ...(provider.awareness.getLocalState() || {}).presence,
    cursor: { x: (ev.clientX - r.left) / r.width, y: (ev.clientY - r.top) / r.height },
  });
});

Normalize cursor coordinates to [0, 1] so peers with different window sizes still see consistent positions. Rate-limit to once per animation frame (the full source uses a pendingPresence buffer) to keep awareness payloads under Plan 1's 16KB p99 alert.

Step 11 — render peer cursors

provider.awareness.on('change', () => {
  $cursors.innerHTML = '';
  const r = $root.getBoundingClientRect();
  provider.awareness.getStates().forEach((s, clientId) => {
    if (clientId === provider.awareness.clientID) return;
    const p = s?.presence;
    const u = s?.user;
    if (!p?.cursor || !u) return;
    const dot = document.createElement('div');
    dot.className = 'cursor';
    dot.style.left = p.cursor.x * r.width + 'px';
    dot.style.top = p.cursor.y * r.height + 'px';
    dot.style.background = u.color;
    $cursors.appendChild(dot);
  });
});

awareness.getStates() returns a Map keyed by clientID; each value is whatever the peer last wrote via setLocalStateField. We skip our own state (we don't render our own cursor — the OS does that).

Step 12 — wire into the seed catalog

Add an entry to scripts/seed-system-widgets.ts:

{
  widgetType: 'multiplayer-tictactoe',
  title: 'Multiplayer Tic-Tac-Toe',
  content: 'Two-player tic-tac-toe with shared Yjs state and live cursors.',
  extraMetadata: { kind: 'iframe', surface: 'inline' },
},

And a registry entry in lib/workspaces/widget-registry.ts:

'multiplayer-tictactoe': {
  kind: 'iframe',
  itemType: 'content',
  size: { width: 360, height: 460 },
  entry: '/widgets/multiplayer-tictactoe/index.html',
  csp: {
    connectSrc: [
      'https://cdn.jsdelivr.net',
      'wss://collab.robutler.local',
      'wss://*.robutler.ai',
    ],
  },
},

Then pnpm db:seed:widgets (or pnpm db:seed:all) materializes the post + content row that surfaces the widget in the Discover tab.

Step 13 — test it

  1. Open the widget on two browsers signed into workspace members.
  2. Both see "X: open / O: open".
  3. Click "Play as X" on browser 1, "Play as O" on browser 2.
  4. Click cells alternately; both browsers see each move within ~50ms.
  5. Win the game; the score increments on both sides.
  6. Close browser 1; reload; the score is still there (Yjs persisted).
  7. Close both browsers; reopen 30 minutes later; score still there.

What you learned

  • The host.readyhost.collab.getToken → dynamic-import-Yjs → new HocuspocusProvider skeleton that every collab widget follows.
  • How to decide what goes in ydoc (durable game state, scores) vs awareness (cursors, slot-claim intent).
  • How to write idempotent defaults, atomic transactions, and the intent-then-claim slot race pattern.

Next: build a multi-party voice/video chat on the same primitives. See multi-party-rtc and the WebRTC chapter of community-collab-widget-authoring.md.

On this page