Board, tile and card games — boards that fit a phone, deals that repeat, rules that hold
prompts/pkg/llms/board-games.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
Reach for this whenever the app is played on a board, a grid, a set of tiles or a deck of cards:
bingo cards, a daily word guess, a mini crossword, a word search, a grouping puzzle, a letter
hive, sudoku, minesweeper, a 2048 merge, checkers or chess, go, hex, mancala, solitaire, a memory
match, a trick-taking card game, a trivia board, a dice scoresheet. Real-time action games
(platformers, pinball, paddle-and-ball) are a p5 or three-js job; this skill is for games
that move one tap, one guess or one card at a time.
Every game here has the same three parts, and each part has one home:
- Rules are pure functions.
legalMoves(state),applyMove(state, move),winner(state)take plain data and return plain data. The board draws state; the rule functions decide. - The deal comes from a seed. One string in, the same board out, on every device.
- What persists is records, keyed by who wrote them. A game doc, one doc per player's
marks or guesses, one doc per move or a single state doc whose turn field passes along —
and
access.jssays who writes each (see Who sees what below).
Draw the board in SVG, the chrome with components
The board is one <svg> with a viewBox, and every fill and stroke on it is a theme utility class such as fill-card, stroke-border or fill-primary, so the board restyles with the app's palette.
- Square, responsive, crisp.
viewBox="0 0 500 500"withclassName="w-full max-w-md aspect-square"scales the whole board — squares, lines, letters — as one picture, from a phone to a laptop. Geometry is in board units (one cell = 100), so the maths stays integer. - Colour through the room's tokens.
fill-card stroke-borderfor a quiet cell,fill-primarywithfill-primary-foregroundtext for a marked one,fill-success/fill-warning/fill-mutedfor a guess's right / present / absent,stroke-primaryfor a winning line. The utilities are the same names the page's other elements use, so an owner's palette swap restyles the board too, and the look lint reads them as tokens. - Shapes SVG draws in one element: a hex cell (
<polygon>), a letter hive, the lines a box-chain word draws between edge letters, a wrapped torus edge, a strike-through across a winning row, a cage border in killer sudoku, the track of a race game. - The chrome around the board is the look's components — the title, a
Cardfor the scoreboard,Buttons for New game / Share / Undo,Badges for streaks,Dialogfor a win. The board itself is plain SVG elements, which take utility classes. - Tap targets. A cell is at least 44 CSS px on a 375 px phone: a 5×5 board at full width gives 70 px cells, a 9×9 sudoku 40 px — so a 9×9 board drops its outer margin and grows to the screen edge. Put
touch-manipulation select-noneon the<svg>so a quick double tap marks twice instead of zooming. - Each cell is a focusable button.
<g role="button" tabIndex={0} aria-label={text} aria-pressed={on} onClick onKeyDown>— Enter and Space toggle it, so the board plays from a keyboard and a screen reader names every square. - Text that fits. Words wrap into at most four
<tspan>lines and the font steps down as lines grow (wrapLabelbelow). Cap what a host can type at about 40 characters per square, and show the count while they type, so every square reads at arm's length.
Seeds — the same deal on every device
Every deal, shuffle and daily puzzle comes from a seed, so the same player sees the same card on every reload and every device.
js// A string → 32-bit seed, then a small seeded generator (mulberry32).
function hashSeed(text) {
let h = 1779033703 ^ text.length;
for (let i = 0; i < text.length; i++) {
h = Math.imul(h ^ text.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
return h >>> 0;
}
function seeded(seed) {
let a = seed;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), a | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle(list, rand) {
const out = [...list];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
- Per player: seed with
`${gameId}:${playerHandle}`— one shared pool, a different card for every player, and the same card when they come back. The card is derived, so nothing about it needs saving; only the marks do. - Daily: seed with the local date,
`daily:${new Date().toLocaleDateString("en-CA")}`(YYYY-MM-DD), so everyone plays the same puzzle today and a new one tomorrow. Puzzle number = days since a launch date, for the share line. - New game: a fresh seed is a fresh id (
crypto.randomUUID()) saved on the game doc, so a rematch is one new doc and every player's board follows it. - A seed is public. Anyone holding it can recompute the deal, which is exactly right for bingo cards, daily puzzles and solitaire. A deal that hides cards from other players comes from the server instead — see Hidden hands below.
Bingo — one shared pool, a different card for each player
A host writes the pool of squares (the buzzwords a speaker repeats, the sights on a drive, a class's vocabulary words); each player gets a card dealt from that pool by their own seed; each player marks only their own card; everyone sees who is close and who called it.
A bingo checks every row, every column and both diagonals, with the free center counted as marked.
The table is public (anyone with the link watches the board and the race), the host alone edits the pool, and each player writes only their own marks — the open-nature shape from the access skill.
App.jsx
jsximport React, { useState } from "react";
import { useFireproof } from "use-fireproof";
import { useVibe, useViewer } from "use-vibes";
import { Badge, Button, Card, Field, Heading, Row, Skeleton, Spacer, Stack, Text, Textarea } from "@vibes.diy/pouf";
const N = 5;
const FREE = 12; // the center square
const S = 100; // one cell, in board units
function hashSeed(text) {
let h = 1779033703 ^ text.length;
for (let i = 0; i < text.length; i++) {
h = Math.imul(h ^ text.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
return h >>> 0;
}
function seeded(seed) {
let a = seed;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), a | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle(list, rand) {
const out = [...list];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
// The same game and player always get the same 24 squares, on every device.
function dealCard(pool, gameId, handle) {
const picks = shuffle(pool, seeded(hashSeed(`${gameId}:${handle}`))).slice(0, N * N - 1);
return [...picks.slice(0, FREE), "FREE", ...picks.slice(FREE)];
}
function bingoLines(n) {
const idx = [...Array(n).keys()];
return [
...idx.map((r) => idx.map((c) => r * n + c)),
...idx.map((c) => idx.map((r) => r * n + c)),
idx.map((i) => i * n + i),
idx.map((i) => i * n + (n - 1 - i)),
];
}
const LINES = bingoLines(N);
const winningLine = (marked) => LINES.find((line) => line.every((i) => i === FREE || marked.includes(i)));
// Greedy word wrap into at most four lines; the font steps down as lines grow.
function wrapLabel(text, width = 11) {
const lines = [];
let line = "";
for (const word of text.split(/\s+/)) {
if (line && `${line} ${word}`.length > width) {
lines.push(line);
line = word;
} else line = line ? `${line} ${word}` : word;
}
if (line) lines.push(line);
return lines.slice(0, 4);
}
const center = (i) => [(i % N) * S + S / 2, Math.floor(i / N) * S + S / 2];
function BingoBoard({ card, marked, onToggle, line }) {
return (
<svg viewBox={`0 0 ${N * S} ${N * S}`} className="w-full max-w-md aspect-square touch-manipulation select-none" role="group" aria-label="Your bingo card">
{card.map((text, i) => {
const [cx, cy] = center(i);
const on = i === FREE || marked.includes(i);
const lines = wrapLabel(text);
const size = lines.length > 2 ? 13 : 16;
const toggle = () => i !== FREE && onToggle(i);
return (
<g
key={i}
role="button"
tabIndex={0}
aria-label={text}
aria-pressed={on}
onClick={toggle}
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && (e.preventDefault(), toggle())}
className="cursor-pointer"
>
<rect x={cx - S / 2 + 3} y={cy - S / 2 + 3} width={S - 6} height={S - 6} rx="10" strokeWidth="2" className={on ? "fill-primary stroke-primary" : "fill-card stroke-border"} />
<text textAnchor="middle" fontSize={size} className={on ? "fill-primary-foreground" : "fill-card-foreground"}>
{lines.map((l, k) => (
<tspan key={k} x={cx} y={cy + (k - (lines.length - 1) / 2) * size * 1.2 + size * 0.35}>
{l}
</tspan>
))}
</text>
</g>
);
})}
{line && (
<line
x1={center(line[0])[0]}
y1={center(line[0])[1]}
x2={center(line[N - 1])[0]}
y2={center(line[N - 1])[1]}
strokeWidth="10"
strokeLinecap="round"
className="stroke-accent opacity-80 pointer-events-none"
/>
)}
</svg>
);
}
export default function App() {
const { database, useLiveQuery } = useFireproof("bingo");
const { me, can, ready } = useVibe("bingo");
const { ViewerTag, requestLogin } = useViewer();
const { docs: games } = useLiveQuery("type", { key: "game" });
const { docs: marks } = useLiveQuery("type", { key: "marks" });
const [pickedId, setPickedId] = useState(undefined);
const [draft, setDraft] = useState(undefined);
// Every verdict and `me` read pending until useVibe resolves: hold the page on a quiet
// checking state, so the sign-in ask and the start button appear only once they are true.
if (!ready) {
return (
<main className="min-h-screen bg-background text-foreground font-sans p-4 flex flex-col items-center gap-4">
<Skeleton />
</main>
);
}
const handle = me?.userHandle;
const game = games.find((g) => g._id === pickedId) ?? games.find((g) => g.hostHandle === handle) ?? games[0];
const pool = game?.squares ?? [];
const poolFull = pool.length >= N * N - 1;
const newGame = { _id: `game-${handle}`, type: "game", hostHandle: handle, title: "Bingo", squares: [] };
const canStart = handle && can.create(newGame).ok && !games.some((g) => g._id === newGame._id);
const lines = (draft ?? pool.join("\n")).split("\n").map((s) => s.trim().slice(0, 40)).filter(Boolean);
const canEditPool = game && can.edit({ ...game, squares: lines }).ok;
const mineId = game && handle ? `marks:${game._id}:${handle}` : undefined;
const mine = marks.find((m) => m._id === mineId);
const marked = mine?.marked ?? [];
const nextMarks = (i) => ({ _id: mineId, type: "marks", gameId: game?._id, playerHandle: handle, marked: marked.includes(i) ? marked.filter((x) => x !== i) : [...marked, i] });
const canMark = mineId && (mine ? can.edit(nextMarks(0)).ok : can.create(nextMarks(0)).ok);
const card = game && handle && poolFull ? dealCard(pool, game._id, handle) : undefined;
const players = marks.filter((m) => m.gameId === game?._id);
return (
<main className="min-h-screen bg-background text-foreground font-sans p-4 flex flex-col items-center gap-4">
<Heading level={1}>{game?.title ?? "Bingo"}</Heading>
{games.length > 1 && (
<Row gap={2} wrap>
{games.map((g) => (
<Button key={g._id} type="button" size="sm" variant={g._id === game?._id ? "solid" : "quiet"} tone="info" onClick={() => setPickedId(g._id)}>
{g.title}
</Button>
))}
</Row>
)}
{canStart && <Button type="button" onClick={() => database.put(newGame)}>Start a bingo game</Button>}
{card ? (
<BingoBoard card={card} marked={marked} line={winningLine(marked)} onToggle={(i) => canMark && database.put(nextMarks(i))} />
) : game && !handle ? (
<Button type="button" onClick={requestLogin}>Sign in to get your own card</Button>
) : game ? (
<Text muted>The card deals as soon as the pool has {N * N - 1} squares ({pool.length} so far).</Text>
) : null}
{players.length > 0 && (
<div className="w-full max-w-md">
<Card>
<Stack gap={3}>
<Heading level={2}>Players</Heading>
{players.map((p) => (
<Row key={p._id} gap={2} align="center">
<ViewerTag userHandle={p.playerHandle} />
<Spacer />
{winningLine(p.marked ?? []) ? <Badge tone="up">BINGO</Badge> : <Text size="sm" muted>{(p.marked ?? []).length} marked</Text>}
</Row>
))}
</Stack>
</Card>
</div>
)}
{canEditPool && (
<div className="w-full max-w-md">
<Card>
<Stack gap={3}>
<Heading level={2}>Squares — one per line</Heading>
<Field label="Squares">
{(id, describedBy) => <Textarea id={id} describedBy={describedBy} rows={8} value={draft ?? pool.join("\n")} onChange={setDraft} />}
</Field>
<Button type="button" variant="quiet" onClick={() => (database.put({ ...game, squares: lines }), setDraft(undefined))}>Save {lines.length} squares</Button>
</Stack>
</Card>
</div>
)}
</main>
);
}
access.js
jsexport function bingo(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in to play" };
const table = (gameId) => `game:${gameId}`;
if (doc.type === "game") {
// The host owns the game and its pool; the table is public from the first write.
const host = oldDoc ? oldDoc.hostHandle : doc.hostHandle;
if (host !== user.userHandle) throw { forbidden: "the host edits the squares" };
if (oldDoc && doc.hostHandle !== oldDoc.hostHandle) throw { forbidden: "the host is fixed" };
return { channels: [table(doc._id)], grant: { public: [table(doc._id)] } };
}
if (doc.type === "marks") {
// Each player writes only their own card's marks, on the game's public table.
const player = oldDoc ? oldDoc.playerHandle : doc.playerHandle;
if (player !== user.userHandle) throw { forbidden: "mark your own card" };
if (oldDoc && (doc.playerHandle !== oldDoc.playerHandle || doc.gameId !== oldDoc.gameId)) throw { forbidden: "marks stay on their card" };
return { channels: [table(doc.gameId)] };
}
throw { forbidden: "unknown document type" };
}
Guess games — rows of tiles and two keyboards
A daily word, number, colour or emoji guess: a fixed number of rows, one row per guess, each slot coloured right / present / absent, and a keyboard that echoes the colours.
A guess game listens to the physical keyboard and draws an on-screen keyboard, so it plays the same on a laptop and a phone.
jsconst KEY_ROWS = ["QWERTYUIOP", "ASDFGHJKL", "↵ZXCVBNM⌫"];
// Score in two passes so a doubled letter is counted once per match in the answer.
function score(guess, answer) {
const out = Array(guess.length).fill("absent");
const left = {};
[...answer].forEach((ch, i) => (guess[i] === ch ? (out[i] = "right") : (left[ch] = (left[ch] ?? 0) + 1)));
[...guess].forEach((ch, i) => {
if (out[i] !== "right" && left[ch] > 0) (out[i] = "present"), left[ch]--;
});
return out;
}
// The keyboard's colour for a letter is the best result it has earned so far.
const RANK = { right: 3, present: 2, absent: 1 };
function keyStates(guesses, answer) {
const best = {};
for (const g of guesses) score(g, answer).forEach((s, i) => (RANK[s] > (RANK[best[g[i]]] ?? 0) ? (best[g[i]] = s) : null));
return best;
}
// One listener for the physical keyboard; the on-screen keys call the same `press`.
useEffect(() => {
const onKey = (e) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === "Enter") press("↵");
else if (e.key === "Backspace") press("⌫");
else if (/^[a-z]$/i.test(e.key)) press(e.key.toUpperCase());
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [press]);
- Tiles and keys are both SVG in the same token classes (
fill-success,fill-warning,fill-muted,fill-card), so a key and the tile it matches are always the same colour. Each key is a<g role="button" aria-label="Q">; the board flips a row on submit with a CSS transition on the row'stransform. - Valid words: embed a small list (a few hundred answers and a few thousand allowed guesses as one space-separated string,
split(" ")into aSet), so checking is instant. For themed or open vocabularies, askcallAIwith a schema ({ valid: boolean }) and cache each answer in a doc so a word is judged once. - Today's answer:
ANSWERS[hashSeed(dailyKey) % ANSWERS.length]. - Share the result grid. Build the emoji grid from the scores —
🟩right,🟨present,⬛absent — with the puzzle number and3/6, and hand it tonavigator.sharewhen present, else the clipboard. The grid carries the shape of the game and none of the letters. - Streaks: one
resultdoc per day per player (_id: result:${dailyKey}:${handle}); the streak is consecutive days counted back from today.
Other board shapes
All of them are the same move: a list of cells with an id, a position for drawing, and neighbours for the rules. Drawing reads positions; rules read neighbours.
- Square grid — cell
iat(i % W, ⌊i / W⌋); neighbours by±1, ±W. A torus wraps with((x % W) + W) % W, so a rook on the left edge attacks the right one. Sudoku boxes are⌊r/3⌋*3 + ⌊c/3⌋. - Hex grid — axial coordinates
(q, r); six neighbours(±1,0) (0,±1) (+1,−1) (−1,+1); pixel centrex = size·√3·(q + r/2),y = size·1.5·r; draw each cell as a six-point<polygon>. A board of radiusRis every(q, r)with|q|, |r|, |q+r| ≤ R. - Ring or track (race games, mancala, a looping board path) — an ordered list of spaces; a move is
(pos + roll) % spaces.length; positions come from walking the perimeter of a rectangle or from angles round a circle (cos,sinof2π·i/n). Mancala is two rows of pits plus two stores on one ring that skips the opponent's store. - Board as a graph (a box of edge letters, territories on a map, a tournament bracket) —
nodeswithx, yand anedgeslist; draw each edge as a<line>and each node as a<circle>plus<text>; the rules walk the adjacency. A box-chain puzzle's rule — the next letter is on a different side — is one comparison of side ids. - Stacked or tiled (mahjong solitaire, a pile of dominoes) — each tile has
x, y, z; draw inzorder; a tile is free when nothing sits on top of it and one long side is open. - Shapes into a silhouette (tangram-style fill puzzles) — pieces are
<polygon>s with a transform; snapping compares each piece's position to its target within a tolerance.
Card games — a deck, piles and moves
jsconst SUITS = ["♠", "♥", "♦", "♣"];
const deck = () => SUITS.flatMap((suit) => [...Array(13).keys()].map((i) => ({ id: `${i + 1}${suit}`, rank: i + 1, suit, red: suit === "♥" || suit === "♦" })));
// Klondike: seven tableau piles, four foundations, stock and waste — every pile is a plain array.
function dealKlondike(seed) {
const cards = shuffle(deck(), seeded(hashSeed(seed)));
const tableau = [...Array(7).keys()].map((p) => cards.splice(0, p + 1).map((c, k) => ({ ...c, up: k === p })));
return { tableau, foundations: [[], [], [], []], stock: cards, waste: [] };
}
const canStack = (card, onto) => (onto ? onto.up && onto.red !== card.red && onto.rank === card.rank + 1 : card.rank === 13);
const canFound = (card, pile) => (pile.length ? pile.at(-1).suit === card.suit && pile.at(-1).rank === card.rank - 1 : card.rank === 1);
- A move is data —
{ from: "t3", index: 4, to: "f1" }— checked by the samecanStack/canFoundthe hints use, then applied by a pureapplyMove. Undo is a stack of previous states (setHistory((h) => [...h, state]), pop to undo); a saved game stores the seed plus the move list, which replays to the same table. - Tap to move, drag to move. Tap a card to select it (its pile highlights in
stroke-primary), tap a pile to drop; a double tap sends a card to its foundation. Drag uses pointer events on the SVG (onPointerDown→setPointerCapture→ move a ghost → drop on the pile under the pointer), and tap stays available beside it for phones. - Cards are SVG too: a rounded
<rect>infill-card stroke-border, the rank and suit as<text>, red suits infill-destructive, a back infill-primary. Overlap a tableau pile by drawing each card 24 units below the last. - Scoresheet games (dice, partner trick-taking, a tile game's hand settlement) are a card game with the table removed: one
rounddoc per hand with each player's numbers, totals computed by summing the rounds, so any round can be corrected and the totals follow. - House rules are settings the scoring reads. One
rulesdoc of named toggles and values ({ fullHouse: 30, jokerRule: true, mustEnterAt: 500 }), shown as switches beside the scoresheet; every scoring function takesrulesas an argument, so changing a rule rescores every round on screen.
Turns, rooms and opponents
- Pass-and-play — one device, players take turns: the state names
turn, the screen between turns says "Hand it to Sam" with a tap-to-reveal, and nothing else changes. It is the fastest way to play two-up on a couch. - Two phones — the game is one
statedoc; a move writes the next state withturnpassed to the next seat, and both boards follow the live query.useLiveQuerydelivers the opponent's move; a short highlight on the moved piece shows what changed. - Host-run rounds (trivia boards, a charades round, a quiz battle) — the host's
roomdoc holds{ round, phase: "question" | "reveal", endsAt }; every phone renders from it, so the whole room flips together. The countdown readsendsAt - Date.now()on each phone, so they tick together. Each team writes oneanswerdoc per round; the host taps Reveal to changephase. The host can fill a round withcallAI("ten questions about Q3 launches", with a schema of{ q, choices, answer }). - An AI opponent picks from the legal moves. Send
callAIthe position and the list fromlegalMoves(state)with a schema{ move: { enum: [...] } }, apply its pick through the sameapplyMove, and the rules stay the app's own. A small search (minimax a few plies deep) is a fine opponent for checkers, hex or mancala and answers instantly. - Vision into a position — a photo of a board read by
callAIwith an image and a schema for the cells (for chess, a FEN string) — fills the same state the board draws.
Who sees what — access shapes for games
The access skill's rule for playful apps is the default here: An open-nature app — an image or meme wall, a bingo card, a party game — puts its records on an always-on grant.public channel from the first rules it ships, builds no publish step, and admits anonymous readers. So a game's table, board, moves and results are public to anyone with the link, and its own rules say who may play — the app's screens carry that, through can. Read access.md for the shapes themselves; these are the game-specific arms.
Hidden information stays per-player readable inside a public game: a player's own hand, or their guesses before they share, routes to a channel only that player holds.
- Bingo — the host edits the pool on the game doc; each player writes only their own marks; all of it on the game's public channel (the worked example above).
- Turn-based — only the player whose turn it is can write a move. The single
statedoc's previous version names whose turn it was, so the rule readsoldDoc:
jsif (doc.type === "state") {
if (oldDoc) {
if (oldDoc.turn !== user.userHandle) throw { forbidden: "wait for your turn" };
if (doc.seats.join() !== oldDoc.seats.join()) throw { forbidden: "seats are fixed" };
const next = oldDoc.seats[(oldDoc.seats.indexOf(user.userHandle) + 1) % oldDoc.seats.length];
if (doc.turn !== next && !doc.winner) throw { forbidden: "the turn passes to the next seat" };
} else if (!doc.seats.includes(user.userHandle)) throw { forbidden: "take a seat to start" };
return { channels: [`game:${doc._id}`], grant: { public: [`game:${doc._id}`] } };
}
- Daily puzzles — everyone shares one seed. A player's in-progress guesses live on their own
user:<handle>channel; tapping Share writes aresultdoc (the emoji grid, the count, no letters) to the public results channel, which feeds the leaderboard.
jsif (doc.type === "guesses") {
if (doc.playerHandle !== user.userHandle || (oldDoc && oldDoc.playerHandle !== user.userHandle)) throw { forbidden: "your own guesses" };
const mine = `user:${user.userHandle}`;
return { channels: [mine], grant: { users: { [user.userHandle]: [mine] } } };
}
if (doc.type === "result") {
if (doc.playerHandle !== user.userHandle || (oldDoc && oldDoc.playerHandle !== user.userHandle)) throw { forbidden: "your own result" };
return { channels: ["results"], grant: { public: ["results"] } };
}
- Hidden hands — the table (discards, trick, bids, scores) is public; each hand routes to
hand:<gameId>:<player>, granted to that player alone. A deal that hides cards comes from the server: abackend.jsfetchroute, called withapiFetchby whoever presses Deal, shuffles withcrypto.getRandomValuesand writes each hand withctx.db.put, and the hand rule admits only that route — so every browser holds only its own hand.
jsif (doc.type === "hand") {
ctx.requireBackend("fetch"); // dealt by the server; players read, the deal writes
const ch = `hand:${doc.gameId}:${doc.playerHandle}`;
return { channels: [ch], grant: { users: { [doc.playerHandle]: [ch] } } };
}
- Solo games (solitaire, sudoku, a memory match) follow the same public default: a finished game's result and the leaderboard are on a public channel, and the game in progress is the player's own doc. A person who says "just me" or "only I" gets the access skill's private per-user channel for everything.
- A group game is a members' channel instead of a public one. When the ask names a standing group — "our club", "our foursome", "regulars", "our league", "the family" — the club is an object with its own channel (
club:<id>), the creator invites the regulars withHandleInputthroughmemberdocs (the per-object collaboration shape inaccess.md), and every table, score and history doc routes to the club channel with nogrant.public. Hidden hands keep their per-player channel inside it. The club's own screen is where people are invited, so who plays is the app's own logic.
Menagerie
Each entry: the board shape, the state, the one trick — and who sees what.
- Bingo — 5×5 SVG grid, free centre. State: host's pool on the game doc, a marks doc per player. Trick: card =
shuffle(pool, seed(gameId:handle)), so cards differ and the card itself needs no saving. Public table; the host writes the pool, each player their own marks. - Word guess (five letters, six rows) — 6×5 tile grid plus SVG keyboard. State: guesses per player per day. Trick: two-pass scoring for doubled letters; keyboard keeps each letter's best colour. Shared daily seed; guesses private until Share posts the grid. Variants from the same engine: colour names, emoji, notes on a keyboard, digits with high/low arrows.
- Grouping (sixteen into four) — 4×4 tiles. State: solved groups, lives left. Trick: a tile set is one array of
{ word, group }; a submitted four is right when all share a group; a solved row collapses to the top in its group's colour; four lives. Share grid of coloured squares by difficulty. Public daily seed; progress private until shared. - Mini crossword — square grid with black cells. State: letters per cell, direction. Trick: number cells by scanning — a white cell starts an Across if its left is black or edge, a Down if above is black; typing auto-advances along the current direction, tapping the same cell flips it; Check and Reveal compare to the answer grid. A rebus cell holds a short string; a diagonal answer is a third clue list. Public puzzle; best times on the public results channel.
- Word search / theme trail — letter grid. State: found words, current trace. Trick: drag-trace with pointer capture, accept a path when each step is one of the eight neighbours and the string is in the theme list; the spanning word touches two opposite edges. Found words draw as
<polyline>s in token colours. Public daily theme. - Letter hive — seven hexes, centre required. State: found words, score. Trick: a word is valid when it is ≥4 letters, uses only hive letters, and contains the centre; a pangram uses all seven; ranks are score fractions of the max. Public daily letters; found lists private until shared.
- Box chain — letters on the sides of a square (or triangle, pentagon) as a graph. State: word chain. Trick: consecutive letters sit on different sides, each word starts with the last word's last letter; draw the chain as lines between letter nodes, used letters dimmed. Public daily box.
- Sudoku — 9×9 (or 4×4, 6×6, colours or glyphs as symbols). State: givens, entries, notes. Trick: conflicts are any duplicate in a row, column or box, highlighted in
fill-destructive; notes mode stores a set per cell; a killer cage is a dashed<path>with its sum. Seeded puzzle; best time per difficulty on the public board. - Minesweeper — square grid. State: mines, revealed, flags. Trick: place mines after the first tap so it is always safe, flood-fill zeros with a queue; long-press flags on phones. Seeded board; public best times.
- 2048 / colour merge — 4×4. State: tile grid, score. Trick: one
slideRowfor left, and rotate the grid for the other three directions; spawn from the seed so a replay matches; swipe = pointer delta > 30 px, arrows on a keyboard. Public high scores. - Checkers / chess — 8×8 (or a torus, hex chess, stacked layers). State: one state doc with the position,
turn,seats. Trick:legalMovesdrives highlights, the AI and validation alike; a coverage puzzle colours every attacked square from the same move generator. Public table; only the seat whose turn it is writes. Three stacked layers are a three-js view over the same rules. - Go — 9×9 or 19×19 intersections (draw lines, stones at crossings). State: stones, captures,
turn. Trick: after a placement, remove each adjacent enemy group with no liberties (flood fill), then reject a placement that leaves its own group with none. Public table; turn rule. - Hex — rhombus of hexagons. State: owners per cell. Trick: a win is a union-find (or flood fill) path from your two edges; no draws are possible. Placement games on a hex board with holes run on the same cells. Public table; turn rule.
- Mancala — ring of pits and two stores. State: seeds per pit. Trick: sow one seed per pit round the ring, skipping the opponent's store; last seed in your store earns another turn; last seed in your empty pit captures the opposite pit. Public table; turn rule.
- Solitaire (Klondike) — tableau, foundations, stock, waste. State: seed plus move list. Trick: moves are data checked by
canStack/canFound; undo is a state stack; double tap sends to a foundation. Public results; the game in progress is the player's own doc. A chain-link solitaire is the same engine with a differentcanStack. - Memory / concentration — grid of face-down tiles. State: order (seeded), flipped pair, matched set. Trick: flip two, compare, and turn back after 800 ms with a timer cleared on unmount; deck art can be geometric patterns or images generated once per deck and stored on the deck doc. Public best scores per grid size.
- Crazy eights — hidden hands plus a public discard pile. State: per-player hand docs, table doc with discard, draw count and
turn. Trick: a play is legal when it matches the discard's suit or rank, or is an eight that names the next suit. Public table, private hands dealt by the server, turn rule on the table. - Euchre club (members-only) — four seats round a table, two partnerships, a dealer seat that rotates each hand. State: per-player five-card hands dealt by the server, a public-to-the-club
calldoc for each trump decision (order up, pass, name a suit, go alone), the trick in play on the table doc, and aseasonscoreboard doc that carries across weeks. Trick: the dealer seat ishandNumber % 4; the right bower (jack of trump) and left bower (the other jack of the same colour) rank above the ace, sorank(card, trump)is the one function every trick reads. Club channel for invited regulars; each hand private to the player holding it. - Trivia board — categories × values grid, or one question at a time for a room. State: host's room doc, one answer doc per team per round. Trick: every phone renders from the host's
phaseandendsAt, so the reveal lands everywhere at once;callAIfills a category on request. Public room; answers route to a host-only channel until the reveal writes the tallies to the room. - Dice scoresheets with house rules — table of players × categories or rounds. State: one doc per round, one
rulesdoc. Trick: scoring takesrulesas an argument, so a toggled rule rescores everything on screen; a "running tally" shows the turn before it banks. Public sheet for the table that is playing; a standing league is the members' channel. - Party rounds (would-you-rather, most-likely-to, charades by emoji, a snack bracket) — a prompt, a set of votes, a reveal. State: round doc, one vote doc per player. Trick: a player's vote doc
_idisvote:${round}:${handle}, so revoting replaces it; a bracket is a graph of matchups whose winners feed the next round; charades scores partial credit by edit distance. Public rounds. - Flow / connect the pairs — square grid with coloured endpoints. State: one path per colour. Trick: a drag extends the current path to a neighbouring cell and cuts any path it crosses; solved when every cell is covered. Seeded levels; public best times.