Build a multiplayer tic-tac-toe widget
Step-by-step build of the
multiplayer-tictactoereference 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.collabSDK 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:
- Turn-enforcement is client-side. A malicious peer could
bypass it and write to
boarddirectly. 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. transact(() => { … })wraps the move into one observation so other peers see one re-render, not three.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
- Open the widget on two browsers signed into workspace members.
- Both see "X: open / O: open".
- Click "Play as X" on browser 1, "Play as O" on browser 2.
- Click cells alternately; both browsers see each move within ~50ms.
- Win the game; the score increments on both sides.
- Close browser 1; reload; the score is still there (Yjs persisted).
- Close both browsers; reopen 30 minutes later; score still there.
What you learned
- The
host.ready→host.collab.getToken→ dynamic-import-Yjs →new HocuspocusProviderskeleton that every collab widget follows. - How to decide what goes in
ydoc(durable game state, scores) vsawareness(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.