The site
Front-end kits
The shared building blocks every game’s screens are made from, so all the games look and behave alike.
On this page
- Plain modules, no build
- The house kit
- Telling the kit how your buttons look
- Sheets
- Worked example: Escape with two sheets open
- A sheet’s foot
- The result sheet
- What Share result says
- Celebrations
- Toasts, banners, announcements and the splash
- Your move: prompt and beckon
- Options
- Keys and key caps
- Motion
- The way up
- The Home Screen prompt
- Feedback
- Dailies and the Stats tab
- Every call, at a glance
- house.css: structure only
- The token contract: a game re-skins, never resizes
- The room kit
- start() and its cfg
- Screens, and following a table
- Talking to the table
- Talk: emotes and chat
- Seating, watching and seats changing hands
- Seat identity, tiles and the roster
- The log, slots and the countdown
- Reveal scripts: play and end
- Table motion
- Press and hold
- Your move at a table
- Sharing, and the room’s Options
- The card room
- The pencil-puzzle kit
- The rules the kits hold
- Changing the kits
Every game’s screens are drawn in your browser by plain JavaScript and CSS files. The parts games share, such as dialogs, buttons, Options and the multiplayer lobby, live in four kits, so a fix in one place reaches every game and the games look and behave alike. This chapter explains each kit, how a game plugs into it, and the checks that hold the kits to the site’s design rules.
The kits are stacked, and each builds only on the ones below it:
| Kit | Where | What it gives a game | Decided in |
|---|---|---|---|
| The house kit | games/static/games/house/ |
What every game shares: sheets (the site’s dialogs), the result sheet, toasts, announcements, celebrations, Options, keys, motion, the Stats tab, Feedback | ADR 0025, ADR 0026, ADR 0033, ADR 0051 |
| The room kit | games/static/games/room/ |
Everything a multiplayer table needs but its board: the lobby, seating, the live connection, the log | ADR 0015 |
| The card room | games/static/games/cards/ |
Table styles, card faces and the hand | ADR 0009, ADR 0015 |
| The pencil-puzzle kit | games/static/games/puzzles/ |
Shared engine pieces, and the whole interface around a puzzle’s board | ADR 0023, ADR 0032, ADR 0042 |
Which games use which:
| Games | Kits |
|---|---|
| Euchre, Contract Rummy, Michigan Rummy, Sixty-Six, Bullshit | house, room, card room |
| Bowl, Fictionary, Alphabet Roulette, Last One Standing, Michigan Rails, Auction House, Get In Line | house, room (Get In Line also borrows the card room’s faces, hand and felt) |
| Star Battle, Fillomino, Yin-Yang, Galaxies, Aquarium, Griddles, Low Poly | house, puzzle kit |
| Chicago Traffic, Codebreaker (the brainteasers) | house, puzzle kit, in the toy box look (games/static/games/brainteasers/) |
| Hashi, Chess Puzzles | house, plus the puzzle kit’s look and helpers, with interfaces of their own |
| Solitaire | house, plus the card room’s faces |
| The Register | house |
The server side of tables is the table service; how pages are served is Architecture.
Plain modules, no build#
The files under games/static/games/ are served as written: no framework, bundler, transpiler or runtime node_modules. A page loads one <script type="module">, and the browser follows its import lines. Three habits run through every kit.
Imports are relative paths, such as import { sheet } from '../../house/ui.js', served by Django’s static files (hashed in production). Each kit has one entry module that re-exports its public pieces.
Nothing touches the page until it’s called. Importing a module only defines functions and constants: no listeners, DOM reads or storage. That’s what lets npm run check:house and npm run check:room import every module under Node with a small fake page (tools/house/fakedom.mjs) and test sheets, keys and reveals without a browser. The one exception is games/static/games/house/install.js, which must catch the browser’s install prompt as the page loads, guarded by typeof document !== 'undefined'. games/static/games/house/feedback.js’s one listener is added by listen(), which only a signed-in player’s page head calls.
The kit owns structure; the game owns the look. A kit’s stylesheet lays things out and takes every color, face and edge from custom properties (“tokens”) the game declares on :root. A page loads its look’s tokens, then the kit’s structure, then its own drawing, then house.css. Contract Rummy’s page is the whole pattern:
games/templates/games/contract-rummy.html lines 1–19{% extends "tables/play.html" %}{% load static %}
{% comment %}
Contract Rummy's page: the game's files under games/static/games/contract-rummy/,
in the room (games/static/games/room/) and the card room (games/static/games/cards/;
docs/adr/0009, 0015). Its styles load in that order: the table style's tokens,
the room's structure, the cards, the game's own, then the house kit's sheets,
toasts and Options (games/static/games/house/, ADR 0025), drawn from them.
{% endcomment %}
{% block html_attrs %} data-theme="clubhouse"{% endblock %}
{% block theme_color %}#1d4a3c{% endblock %}
{% block head %}
{# Put on the player's table style before anything paints. #}
<script>try { var t = JSON.parse(localStorage.getItem('uo:cards:theme')); if (t) document.documentElement.dataset.theme = t; } catch (e) {}</script>
<link rel="stylesheet" href="{% static 'games/cards/themes.css' %}">
<link rel="stylesheet" href="{% static 'games/room/room.css' %}">
<link rel="stylesheet" href="{% static 'games/cards/cards.css' %}">
<link rel="stylesheet" href="{% static 'games/contract-rummy/rummy.css' %}">
<link rel="stylesheet" href="{% static 'games/house/house.css' %}">
{% endblock %}
The inline script sets the player’s table style on <html> before anything paints, so a Casino player never sees a flash of Clubhouse green. house.css comes last so its rules win ties of equal specificity; its tokens work differently (the token contract).
The house kit#
games/static/games/house/ is the one implementation of what every game shares (ADR 0025, ADR 0038). It depends on nothing else in the repository. Its entry, ui.js, is only re-exports:
games/static/games/house/ui.js lines 1–21// The house kit: the one implementation of what every UO game shares
// (docs/design-language.md, "Using the house kit"; ADR 0025). A game imports
// from here and loads house.css after its own tokens; the look stays the
// game's, drawn from the tokens its root declares.
//
// import { configure, sheet, toast, openOptions, result } from '../../house/ui.js';
export { h, keep, copy, plural, clockText, configure, button, link, tabStops, handFocus } from './dom.js';
export { sheet, actionRow, sheetsOpen, topSheet, onSheets, setSheetClock, closeTop, closeSheets, hideBoard, escapeOverSheets } from './sheet.js';
export { toast, clearToasts, banner, bannerQueued, skipBanner, clearBanners, catchingUp, splash } from './toast.js';
export { announce, skipAnnouncement, clearAnnouncements, ANNOUNCE_MS } from './announce.js';
export { celebrate, stopCelebration, CHEERS } from './celebrate.js';
export { prompt, beckon, cueOn, cueOption } from './cue.js';
export { prefs, seg, setting, onOff, optionRows, openOptions, feedbackRow, ROW, SECTIONS, HELPERS_NOTE, ON_OFF } from './options.js';
export { shortcuts, typing, isSingleKey, kbd, keyCap, showKeyCaps, finePointer, shortcutsDefault } from './keys.js';
export { MOTION, EASE, reducedMotion, motionMs, play, wait, sweepTiming, dealDelays } from './motion.js';
export { backArrow, backArrowHTML, BACK_PATH, closeIcon, CLOSE_PATH, guideHref, guideLink, guideLinkHTML } from './nav.js';
export { result, shareResult, rateLine, ratingLine, record, STATES, celebrateResults } from './end.js';
export { brag, facts, placeLine, roundRow, podium, medal, bar, EMOJI } from './brag.js';
export { feedbackLink, footFeedback, footFeedbackHTML, openFeedback, feedbackUrl } from './feedback.js';
export { openInstall, homeScreenButton, shouldOffer, detect, environment, isStandalone } from './install.js';
Telling the kit how your buttons look#
The kit builds rows of buttons, but their look belongs to the game (The game’s own look). So before anything opens, a game names the classes for each of the four button weights, and the element that goes inert behind a sheet:
games/static/games/house/dom.js configure · lines 82–89/** configure({ buttons: { primary, secondary, quiet, danger }, app }):
* the class names each button weight wears, and the selector of what goes
* inert behind a sheet. Call it once before anything opens. */
export function configure({ buttons, app } = {}) {
if (buttons) Object.assign(settings.buttons, buttons);
if (app) settings.app = app;
return { buttons: { ...settings.buttons }, app: settings.app };
}
The room passes 'btn', 'btn alt', 'btn quiet', 'btn danger'; the puzzle kit 'btn primary', 'btn', 'btn quiet', 'btn danger'. Every kit button also carries data-weight, which house.css lays out by, so layout never depends on a game’s class names.
The same file has h(), the element builder every kit uses (h('button.btn.alt', { onclick }, 'Deal'), or h('h2#rules') for an id). It splits off the id before creating the element, because Safari refuses createElement('h2#rules'); check:details’ WebKit pass caught the Stats tab crashing on it. And keep wraps localStorage so it never throws: in a private window or on a full disk it just forgets.
Sheets#
A sheet is the site’s only kind of dialog (games/static/games/house/sheet.js). The module keeps stack, the open sheets bottom first, and waiting, sheets that opened by themselves while another was up. Opening one builds its panel (optional kicker, title in the game’s display face, a hidden clock pill, ✕), body and foot, then calls show:
games/static/games/house/sheet.js sheet.const show = () => {
handle.waiting = false;
handle.open = true;
opener = document.activeElement;
opened = performance.now();
if (!stack.length) lockPage();
else stack.at(-1).el.inert = true;
stack.push(handle);
if (sheetClock) {
const draw = () => {
const left = sheetClock?.();
clock.hidden = left === null || left === undefined;
if (!clock.hidden) clock.textContent = left > 0 ? `${Math.ceil(left)}s left` : 'Time’s up';
};
draw();
ticking = setInterval(draw, 250);
}
document.body.append(handle.el);
const wanted = typeof opts.focus === 'function' ? opts.focus(handle) : opts.focus;
(wanted || firstFocus(handle.foot) || x || handle.box).focus({ preventScroll: true });
changed();
opts.onopen?.(handle);
};
The first sheet locks the page: it keeps the scrollbar’s gutter so nothing shifts, adds house-locked to <html> to stop scrolling, makes the app root inert, and adds one keyboard listener in the capture phase. Each later sheet makes the one below it inert instead.
games/static/games/house/sheet.js lockPage · lines 115–125function lockPage() {
const root = document.documentElement;
const gutter = (globalThis.innerWidth || 0) - (root.clientWidth || 0);
// The scrollbar's space is kept, so nothing behind shifts sideways.
if (gutter > 0) root.style.scrollbarGutter = 'stable';
root.classList.add('house-locked');
const app = appRoot();
appWasInert = !!app?.inert;
if (app) app.inert = true;
document.addEventListener('keydown', onkey, true);
}
games/static/games/house/sheet.js onkey · lines 88–107function onkey(e) {
const top = topSheet();
if (!top) return;
if (e.key === 'Escape') {
// Escape undoes the last layer, only that: what's over the sheets
// first (an announcement), else the top sheet.
e.preventDefault();
e.stopImmediatePropagation();
if ([...overSheets].reverse().some(fn => fn())) return;
if (top.dismissable) top.close('escape');
return;
}
if (e.key !== 'Tab') return;
const stops = tabStops(top.box);
if (!stops.length) { e.preventDefault(); top.box.focus(); return; }
const first = stops[0], last = stops.at(-1), at = document.activeElement;
if (!top.box.contains(at)) { e.preventDefault(); first.focus(); }
else if (e.shiftKey && (at === first || at === top.box)) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && at === last) { e.preventDefault(); first.focus(); }
}
Because that listener captures and calls stopImmediatePropagation(), Escape never reaches a game’s own keys while a sheet is up. What sits over the sheets is asked first (escapeOverSheets(fn)): an announcement showing over a sheet takes the press and goes, and the next Escape closes the sheet. Tab wraps within the top sheet’s tab stops, found fresh on every press (tabStops skips hidden, inert, disabled and aria-hidden branches).
handle.close(how) is safe to call twice, and just drops a sheet that’s still waiting. An open sheet leaves the stack, goes inert and fades out (house-going, motionMs('sheetOut'), 0 under reduced motion). The new top sheet is un-inerted or the page unlocked, focus is handed back, watchers are told, onclose(how) runs and handle.closed resolves. Focus returns to whatever had it when the sheet opened, if that’s still on the page, enabled and not inert; else to the caller’s refocus; else to the top sheet. Closing a sheet that isn’t on top leaves focus alone.
Stray clicks. For 400ms after a sheet opens, pointer clicks on its buttons, links and scrim are ignored, so the second click of the double-click that opened it can’t land on whatever appeared under the pointer. Keys (event.detail === 0) always go through, since a key is aimed.
Worked example: Escape with two sheets open#
A puzzle player opens Options, then Show me how on the Home Screen row. Options (bottom, inert) and “Add it to your Home Screen” (top) are open. They press Escape on an iPad keyboard.
- The capture listener hears it first. The top sheet can be dismissed, so Escape is stopped and
close('escape')runs. - The Home Screen sheet leaves the stack, goes inert and fades over 150ms. Options is on top again and stops being inert.
- Its opener, Show me how, is still there and enabled, so it gets focus back.
changed()tells everyonSheetswatcher one sheet is open, so the puzzle keeps its clock stopped and board hidden.- A second Escape closes Options. The stack is empty, so
unlockPage()restores the root’sinertand removeshouse-lockedand the key listener. Focus returns to the bar’s Options button. The watcher hears 0, and the puzzle shows its board, restarts its clock and focuses the board.
The game’s own shortcut listener heard neither press: the capture listener stopped them, and shortcuts() (below) ignores single keys while sheetsOpen() is above zero. tools/house/kit.test.mjs plays this sequence as its second test.
A sheet’s foot#
actionRow builds every row of actions in the house order: quiet ones far left, then secondaries (given “nearest the primary first”, so reversed), then the primary at the right. In a sheet, an action closes the sheet unless its onclick returns false or it’s keepOpen:
games/static/games/house/sheet.js actionRow · lines 140–170/**
* One row of actions, in the house order: quiet ones at the far left, then
* the secondaries, then the primary at the right end. Each action is
* { label, weight: 'primary' | 'secondary' | 'quiet', onclick(handle, e),
* href, danger, keepOpen, key, disabled, name }; secondaries are listed
* nearest the primary first. A sheet's action closes it after its onclick,
* unless it's `keepOpen` (Share result) or a link. Returns the row's
* element, with each button on `.buttons[name || label]`.
*/
export function actionRow(actions, handle = null, cls = 'house-foot') {
const list = actions.filter(Boolean).map((a, i) => ({ weight: 'secondary', ...a, i }));
// Primary last; secondaries reversed (the first named sits by the primary); quiet first, in order.
list.sort((a, b) => (RANK[a.weight] - RANK[b.weight]) || (a.weight === 'secondary' ? b.i - a.i : a.i - b.i));
const row = h(`div.${cls}`);
row.buttons = {};
for (const a of list) {
const weight = a.danger ? 'danger' : a.weight;
const el = a.href
? link(weight, a.label, a.href)
: button(weight, a.label, e => {
const keepOpen = a.onclick?.(handle, e) === false || a.keepOpen;
if (!keepOpen) handle?.close(a.name || 'action');
}, { disabled: !!a.disabled });
el.dataset.weight = a.weight;
if (a.danger) el.dataset.danger = '';
if (a.key) el.setAttribute('aria-keyshortcuts', a.key);
row.buttons[a.name || a.label] = el;
row.append(el);
}
return row;
}
If the primary is dangerous (“Close table”), focus starts on the safe secondary beside it, so a stray Enter can’t trigger it:
games/static/games/house/sheet.js firstFocus · lines 172–179/** Where focus starts in a row: the primary, or the safe button beside it
* when the primary is a danger (a stray Enter mustn't do it). */
function firstFocus(row) {
const kids = [...(row?.children || [])].filter(b => !b.disabled);
const primary = kids.find(b => b.dataset.weight === 'primary');
if (primary && !('danger' in primary.dataset)) return primary;
return kids.filter(b => b.dataset.weight === 'secondary' && !('danger' in b.dataset)).at(-1) || null;
}
house.css gives the first non-quiet button margin-left: auto, pushing quiet ones left. On a phone (560px and under) the foot is a full-width column-reverse, so the primary, last in the DOM, is on top.
Sheets that open by themselves. A sheet opened with auto: true (a hand’s scores, a result, How to play on a first visit) never stacks over one the player opened. It waits, and the oldest shows on the frame after the stack empties. Three more hooks: onSheets(fn), which a solo game uses to stop its clock while any sheet is up; setSheetClock(fn), which a table game uses to show the server’s clock in a turn-colored pill in every sheet (ticking every 250ms, since a server clock can’t be paused); and closeSheets(), which closes everything, waiting sheets too, and which the room calls whenever it leaves a screen.
The result sheet#
Every game ends on result() in games/static/games/house/end.js: a sheet of kind: 'result', with a lighter scrim, set at the bottom so the celebration stays in view. The game supplies the content (kicker, heading, emblem, facts, comparisons, the post line). The state fixes the actions and their order, so no game can reorder them:
games/static/games/house/end.js result · lines 72–211/**
* result(options) opens the result sheet (waiting, like any sheet that
* opens by itself, for the player's own sheets to close) and returns its
* handle, with post(text) to change the post line and `actions` (the row,
* without See the board, for the game's dock).
*
* options:
* - state: 'won' | 'lost' | 'stuck' | 'gaveup' | 'hand' | 'over'.
* - kicker, heading (the game's voice; a plain default by state).
* - emblem: the game's picture or celebration (a node, or a function).
* - celebrate: 'win' (a table game won), 'best' (a personal best), 'daily'
* (a daily solved), or null (celebrate.js). It plays behind the sheet as
* it opens; handle.celebrate(kind) plays one later (a rating high, once
* the post comes back). A table's game over that names none gets the
* room's (celebrateResults).
* - news: a game's own milestone in a word or two ("New best", "First
* solve"), a tag over the facts that pops in as the sheet opens; null
* for none. The compare lines still say it in full.
* - facts: [[label, value]] or nodes ("1:09" time, "84" moves).
* - compare: lines (strings or nodes): your best, your streak, rateLine().
* - body: anything else the game shows (a score chart), after compare.
* - post: the post line ("#3 of 20 today."), changed later with post().
* - primary: { label, onclick } (the default label: New {noun}, Next hand
* or Play again); null for none ("The host can start another game.").
* - noun: 'deal', 'puzzle', 'chart', 'case' …
* - share: a function (the game's own) or { text, url, title }.
* - leaderboard: its address, or a function.
* - restart, undo: functions, for lost and stuck.
* - back: { label, href } for a table's game over ("Back to Euchre").
* - extra: more secondaries, after the standard ones.
* - see: 'board' or 'table' (the quiet button's noun).
* - onsee(actions): put away (See the board, Escape, ✕ or the scrim); the
* game shows these actions in its dock.
* - onclose(how): after any close.
*/
export function result(opts = {}) {
const {
state = 'won', kicker = null, heading = HEADINGS[state] || HEADINGS.won, emblem = null, news = null, facts = [], compare = [], body = null,
post = '', noun = 'game', share = null, leaderboard = null, restart = null, undo = null, back = null, extra = [], see = 'board',
onsee = null, onclose = null,
} = opts;
if (!STATES.includes(state)) throw new Error(`No result state called ${state}`);
const defaultPrimary = state === 'hand' ? 'Next hand' : state === 'over' ? 'Play again' : `New ${noun}`;
const primary = opts.primary === null ? null : { label: defaultPrimary, ...(opts.primary || {}) };
const actions = [];
if (primary) actions.push({ ...primary, weight: 'primary', name: 'primary' });
// Secondaries, nearest the primary first.
const shareAction = share && {
label: 'Share result', name: 'share', keepOpen: true,
onclick: typeof share === 'function' ? share : () => { shareResult(share); },
};
if (state === 'won') {
if (shareAction) actions.push(shareAction);
// (#you: the board brings your row into view and lights it once, static/js/board.js)
if (leaderboard) actions.push(typeof leaderboard === 'string' ? { label: 'Leaderboard', href: leaderboard.includes('#') ? leaderboard : `${leaderboard}#you`, name: 'leaderboard' } : { label: 'Leaderboard', onclick: leaderboard, name: 'leaderboard' });
} else if (state === 'lost' || state === 'stuck') {
if (restart && primary?.label !== 'Restart') actions.push({ label: 'Restart', onclick: restart, name: 'restart' });
if (undo) actions.push({ label: 'Undo', onclick: undo, name: 'undo', key: 'Z' });
} else if (state === 'over') {
if (shareAction) actions.push(shareAction);
}
for (const a of extra) actions.push({ ...a, weight: 'secondary' });
// The quiet one, at the far left.
const seeLabel = `See the ${see}`;
if (state === 'over' && back) actions.push({ label: back.label, href: back.href, weight: 'quiet', name: 'back' });
else actions.push({ label: seeLabel, weight: 'quiet', name: 'see' });
const postLine = h('p.house-post', { role: 'status' }, post);
const content = h('div.house-result-body',
emblem ? h('div.house-emblem', typeof emblem === 'function' ? emblem() : emblem) : null,
// (a tag the eye lands on; the compare line under it is what's read out)
news ? h('p.house-news', { 'aria-hidden': 'true' }, h('span', news)) : null,
facts.length ? h('div.house-facts', facts.map(f => (f instanceof Node ? f : h('div.house-fact', h('b', f[1]), h('span', f[0]))))) : null,
compare.filter(Boolean).length ? h('div.house-compare', compare.filter(Boolean).map(c => (c instanceof Node ? c : h('p', c)))) : null,
body,
postLine);
const forDock = actions.filter(a => a.name !== 'see');
const said = 'celebrate' in opts ? opts.celebrate : overCheer(state);
let opening = said && typeof said === 'object' ? said.kind : said;
const onplay = said && typeof said === 'object' ? said.onplay : null;
let party = null;
const played = new Set(); // each kind plays once on a sheet (a best, then a rating high: once)
let deferred = null, onseen = null;
// (the sheet's own handle: onopen runs before sheet() has returned)
const play = (which, at, rising = false) => {
if (!CHEERS[which] || !at?.open || played.has(which)) return;
if (document.hidden) {
// A game over in a hidden tab: it plays when the tab's seen again, if
// the sheet's still open (the newest asked for, if several).
deferred = { which, rising };
if (!onseen) {
onseen = () => {
if (document.hidden) return;
document.removeEventListener('visibilitychange', onseen);
onseen = null;
const d = deferred;
deferred = null;
if (d && at.open) play(d.which, at, true);
};
document.addEventListener('visibilitychange', onseen);
}
return;
}
const c = cheer(which, { over: at.el, from: at.box, rising });
if (!c.live) return;
party = c;
played.add(which);
if (which === opening) onplay?.();
};
const handle = sheet(heading, content, {
kicker, actions, auto: opts.auto ?? true, kind: 'result',
onopen: at => play(opening, at, true),
className: `house-result house-${state}`,
onclose: how => {
party?.stop();
if (onseen) document.removeEventListener('visibilitychange', onseen);
onseen = null;
const putAway = !['primary', 'restart', 'undo', 'leaderboard', 'back', 'all'].includes(how) && !extra.some(a => (a.name || a.label) === how);
if (putAway) onsee?.(forDock);
// A finished game or puzzle, put away: the one moment it's polite to
// mention the Home Screen (install.js decides whether it's time).
if (putAway && (state === 'won' || state === 'over')) offerAfterResult();
onclose?.(how);
},
});
handle.post = text => { postLine.replaceChildren(...[text].flat().filter(x => x !== null && x !== undefined)); };
handle.actions = forDock;
handle.state = state;
// Later (a rating high, once the post comes back): a sheet still waiting
// its turn opens with it instead; a kind already played isn't played again.
handle.celebrate = which => {
if (handle.waiting) { if (CHEERS[which]) opening = which; return; }
play(which, handle);
};
return handle;
}
On screen, right to left:
state |
Primary (default label) | Secondaries, nearest the primary first | Quiet |
|---|---|---|---|
won |
New | Share result, Leaderboard | See the |
lost, stuck |
New | Restart (unless the primary already is), Undo | See the |
gaveup |
New | none | See the |
hand |
Next hand | none (a game adds Start without waiting through extra) |
See the |
over |
Play again | Share result | Back to {Game} if back, else See the |
A game’s extra actions follow the standard secondaries. primary: null leaves the primary out (game over at a table, for someone who isn’t the host). Share result is keepOpen; by default it opens the share sheet on a touch screen with navigator.share, and otherwise copies and toasts “Copied — paste it anywhere.”
What Share result says#
Every game’s Share result sends a few short lines a friend reads in a text thread, the way the daily word games do: the game’s emoji, its name and which one; a row of emoji that shows how it went without giving anything away; a line of facts; and the link, last, on its own line. A Codebreaker code broken in four guesses:
🔐 Codebreaker · No. ZQ34HP · Beginner · 4/10
⚪⚪⚫⚫
🔴⚪⚪⚫
🔴🔴⚪⚪
🔴🔴🔴🔴
⏱️ 1:12
https://unnecessaryobstacles.com/games/codebreaker/#/p/beginner/beginner/ZQ34HP
games/static/games/house/brag.js writes them, and only writes text, so every game’s share can be tested under Node. EMOJI gives each game on the shelf its own (a test fails for a game without one), and brag() puts the lines together:
games/static/games/house/brag.js brag · lines 53–58/** The message: "{emoji} {title} · {head}", then each line (empty ones
* dropped), then the link. */
export function brag({ game, title, head = '', lines = [], url = '' }) {
const top = `${EMOJI[game] ? `${EMOJI[game]} ` : ''}${title}${head ? ` · ${head}` : ''}`;
return [top, ...lines.flat().filter(Boolean), url].filter(Boolean).join('\n');
}
Its other parts are what a game builds its lines from: facts() joins the ones it has with ” · “, placeLine() says where you came in your own words (“I won 🏆”, “2nd of 4”), roundRow() draws a square a round, podium() the standings by medal, bar() a count out of a most.
shareResult() in end.js sends the text. The link goes inside the text, never as the share’s own url: a messaging app given both often sends one and drops the other, and a copy has only the text anyway. It’s called straight from the tap, with nothing awaited first, so the share sheet and the clipboard still count it as the player’s:
games/static/games/house/end.js shareResult · lines 29–44/** Share result: the game's few lines (brag.js) with the link last. On a
* phone with a share sheet it shares; elsewhere it copies and says so. The
* link goes inside the text, never as the share's own url: a text thread
* given both often sends one and drops the other, and a copy has only the
* text. Called straight from the tap, with nothing awaited first, so the
* share sheet (and the clipboard) still count it as the player's. */
export async function shareResult({ text, url = '', title = '' }) {
const said = url && !text.includes(url) ? `${text}\n${url}` : text;
const coarse = (() => { try { return matchMedia('(pointer: coarse)').matches; } catch { return false; } })();
if (coarse && navigator.share) {
try { await navigator.share({ title, text: said }); return 'shared'; } catch (err) { if (err?.name === 'AbortError') return 'cancelled'; }
}
const ok = await copy(said);
toast(ok ? 'Copied — paste it anywhere.' : 'Couldn’t copy it. Try again.');
return ok ? 'copied' : 'failed';
}
Pressing the primary, Restart, Undo, Leaderboard, Back or an extra is the player going somewhere. Any other close (See the board, Escape, ✕, the scrim) puts the sheet away: the game’s onsee(actions) gets the same actions, minus See the board, for its dock, and if the state was won or over, the Home Screen prompt may ask (offerAfterResult(), below).
post(text)fills in “#3 of 20 today.” when the server answers, after the sheet is open. The post line reserves two lines (min-height: 2.9em), so nothing moves.newsis a game’s milestone (“New best”, “First solve”): a tag over the facts, in the turn color (ink where there’s none), that pops in as the sheet opens. The compare line still says it in full for screen readers (ADR 0047). The puzzle kit, Hashi and Chess Puzzles pass it.- A string
leaderboardgets#youon its address (unless it has a#).static/js/board.jsthen scrolls to the player’s row, lights it once, and removes#youso a reload or Back doesn’t repeat it.
end.js also has record(key), a game’s lifetime win rate in this browser (“Won 12 of 40 · 30%”, counted once per game from its first move, keeping the last 200), and ratingLine(r), which writes the server’s rating as “Rating +12 → 1534.” (“1534?” while provisional, “…, no longer provisional.” when that ends, “…, your highest yet.” for a rating high, or the midnight line for a daily rated with its field: Ratings).
Celebrations#
A result sheet can name its celebration, result({ celebrate }): win, best, daily, or none. games/static/games/house/celebrate.js draws each kind on one canvas, no library, in the game’s colors (ADR 0051; docs/design-language.md, “Celebrations: a victory, by kind”):
games/static/games/house/celebrate.js CHEERS · lines 36–44/** How long each plays, in to out, how long its still picture stays, and
* (on a phone) how long it's seen before the sheet covers it: the first
* volley, the burst, the ring, the first two shells bursting. */
export const CHEERS = Object.freeze({
win: { ms: 3000, still: 1800, lead: 900 },
best: { ms: 2300, still: 1600, lead: 800 },
sweep: { ms: 5200, still: 2600, lead: 1500 },
daily: { ms: 1100, still: 1000, lead: 700 },
});
The canvas goes into the sheet’s scrim, before the sheet, so the sheet is drawn over it: it springs from the sheet’s top edge over the board and never covers the sheet’s words for long. A sheet that waits its turn (auto) celebrates when it opens (the sheet’s onopen), and handle.celebrate(kind) plays one later: the puzzle kit, Hashi and Chess Puzzles call it with best when a posted solve comes back a rating high. They pass best for a new best (a brainteaser’s, as few moves as it can be done in) and daily for a daily, else none. Each kind plays once on a sheet, so a new best’s best isn’t played again by the rating high that follows it; a daily’s daily still gives way to a best. A sheet still waiting its turn opens with the later one instead.
On a phone the sheet is the window’s width and nearly its height, so behind it nothing would be seen. covers() tells: a sheet at least 85% of its scrim’s width, or one leaving under a third of a short window above it (a phone on its side). There the result sheet (rising) waits below the window for its celebration’s lead (the first volley, the burst, the ring, the first two shells), then rises over it in 380ms (under reduced motion it waits unseen and fades in). A press while it waits brings it up at once, and the click that follows is swallowed so it doesn’t land on the scrim and put the sheet away. A celebration played on a sheet already up (a rating high) is copied onto a second canvas over the sheet for its lead, then fades from it in 300ms and plays on behind. On a phone it springs from a little way up the board (between 30% and 42% of the window), where it’s seen. A desktop’s sheet leaves the board in view round it, so there nothing changes:
games/static/games/house/celebrate.js covers · lines 50–56/** Whether a sheet (its box `r`) covers what's behind it in `box` (its
* scrim): a phone's, about as wide as the window, or one leaving under a
* third of a short window above it (a phone on its side). */
export function covers(r, box) {
if (!r?.width || !box?.width || !box?.height) return false;
return r.width >= box.width * 0.85 || (box.height < 520 && r.top - box.top < box.height * 0.3);
}
A table game’s game over names none, and gets the room’s: start() tells the result sheet, through celebrateResults(), what the table’s last snapshot says (gameOverCheer()): win when your place in standings is first, else best for a rating high, never for a hand over, a loss or a spectator, and once a game in this browser (uo:room:cheered, the last 20), so a reload that opens the sheet again doesn’t replay it, while the next game at the same table gets its own. It’s marked by its onplay, once it has played, not before: a game that ends in a hidden tab plays its celebration when the tab is seen again, if the sheet’s still open. A game that passes celebrate (null for none) decides for itself:
games/static/games/room/kit/room.js gameOverCheer · lines 135–153/** What a table's game over celebrates for this browser (house/end.js
* celebrateResults): `win` when your place is first, else `best` for a
* rating high, as { kind, onplay }; null for a hand over, a loss, a
* spectator, or a game already celebrated here. Once a game: onplay marks
* it, once it has played, so a reload doesn't replay it and a second game
* at the same table gets its own. */
export function gameOverCheer(state, t, { spectating = false, store = keep } = {}) {
if (state !== 'over' || spectating || !t || t.you === null || t.you === undefined) return null;
const kind = t.standings?.places?.[t.you] === 1 ? 'win' : t.rating?.high ? 'best' : null;
const once = `${t.code}:${t.game_no}`;
if (!kind || store.get(CHEERED, []).includes(once)) return null;
return {
kind,
onplay: () => {
const seen = store.get(CHEERED, []);
if (!seen.includes(once)) store.set(CHEERED, [...seen, once].slice(-20));
},
};
}
The colors are the tokens where the canvas sits: a game’s --house-cheer, a comma-separated list (the puzzle kit’s is its accent, green, ink and the accent washed pale), else its turn color, accent, focus and ink, each resolved by the canvas itself so a color-mix() works. The canvas is at most 2× the screen’s pixels; each frame clears and draws a few hundred pieces at most, with no blur or shadow. A press or key puts it away (a 150ms fade), a hidden tab stops it, and a new one stops the last. Under reduced motion still() draws one picture of the same moment, faded in and out in 150ms.
Toasts, banners, announcements and the splash#
Four kinds of news that don’t block (games/static/games/house/toast.js, games/static/games/house/announce.js):
games/static/games/house/toast.js toast · lines 39–54/**
* toast(text, { action, onaction, ms }) shows a short message and returns
* { dismiss }. `action` is one quiet button's label; pressing it runs
* onaction and puts the toast away. The same text already showing or
* waiting isn't said twice.
*/
export function toast(text, { action = null, onaction = null, ms = action ? MOTION.toastAction : MOTION.toast } = {}) {
const same = [showing, ...queue].find(t => t && t.text === text && !t.gone);
if (same) return same.handle;
const t = { text, action, onaction, ms, gone: false };
t.handle = { dismiss: () => end(t) };
queue.push(t);
if (!showing) next();
else hurry();
return t.handle;
}
Toasts queue and show one at a time in a single role="status" region. The region exists before anything is said, and text arrives on the next frame, because a live region created with text inside often isn’t announced. A toast stays 4.5 seconds, or 6 with an action (MOTION.toast, MOTION.toastAction), and is hurried off after 1,500ms when another waits. Repeated text isn’t said twice and only the action button takes clicks. A toast sits just under the page’s top bar, centered on it (a table’s, with the log pinned beside it), measured from the bar the way up (backArrow()) is in each time one shows. So it never lands on a banner, a prompt or an announcement, which play in the middle, or on the dock. A bar out of view, or a page without one, puts it at the window’s top, and so does a sheet being open (html.house-locked).
banner(text) is a game moment (“Euchred!”) over the table for two seconds, chained so banners play in order. It arrives with the hand intro’s pop (from 72%, a little past full size and back, over MOTION.pop) and wears a rule in the turn color. It’s aria-hidden, since the game log says the same in words. replace: true drops whatever’s showing or queued. While older moves are sent to their end at once (the room’s Cues catching up or too far behind, Table motion), and for 250ms after, or in a hidden tab, every banner replaces: so the four a Euchre catch-up raises show only the newest.
games/static/games/house/toast.js catchingUp · lines 144–150/** catchingUp(true) as older moves start going to their end at once (the
* room's Cues: catchUp(), a queue too far behind), catchingUp(false) once
* the queue's idle again; calls nest. */
export function catchingUp(on) {
if (on) catching++;
else if (catching > 0 && --catching === 0) catchTail = performance.now() + CATCH_TAIL_MS;
}
announce({ kicker, title, sub, art }, { over }) is for the moment everyone must look up for. A band in the turn color slices across over while everything dims, the title’s words pop in one after another (a *phrase* in asterisks, such as a painting’s name, is set in italics and read without them), art is slammed down, and the band slides on: MOTION.announce, two seconds, in to out. Announcements queue; with another waiting each holds only 1,100ms in all, and past 3 waiting the oldest are dropped. It sits over sheets and under toasts, takes no clicks, and any press puts it away after a 350ms grace; Escape puts it away at once, over a sheet too, before the sheet hears it (escapeOverSheets). replace: true drops every one showing or waiting. Its words are read once through a polite live region, unless read: false (the game’s log says it already). Under reduced motion it fades in and out where it stands:
games/static/games/house/announce.js announce · lines 46–72/**
* announce({ kicker, title, sub, art }, { over, ms, read, say, replace,
* className, onend }) and returns { skip, done } (done resolves with
* 'played', 'skipped' or 'dropped').
* - title: the news in a few words, the verb first where there is one; a
* *phrase in asterisks* (a painting's name) is set in italics
* ("Ana stole your card", "Basil bought Water Lilies"). kicker: a word over it
* ("Sold", "Streak"). sub: at most one short line under it.
* art: a node shown beside the title (a card, a seat's token).
* - over: the element it's centered on (the table); the window by default.
* - ms: how long, in to out (ANNOUNCE_MS; 3200 at most).
* - read: whether it's read out (true); say: the words read, if not these.
*/
export function announce({ kicker = '', title = '', sub = '', art = null } = {}, opts = {}) {
const { over = null, ms = ANNOUNCE_MS, read = true, say = null, replace = false, className = '', onend = null } = opts;
if (replace) clearAnnouncements();
let resolveDone;
const a = { kicker, title, sub, art, over, ms: Math.min(Math.max(ms, HURRY_MS), 3200), read, say, className, onend, gone: false };
a.done = new Promise(r => { resolveDone = r; });
a.resolve = how => { if (a.how) return; a.how = how; a.onend?.(how); resolveDone(how); };
a.handle = { skip: () => (showing === a ? a.skip?.() : drop(a)), done: a.done };
queue.push(a);
while (queue.length > MOST) drop(queue[0]);
if (!showing) next();
else hurry();
return a.handle;
}
splash(content, { over, into, once }) is a new hand’s card. It pops in, waits, then shrinks toward into (the title) and pulses it, so people learn where that news lives. It shrinks only into a place whose middle is in the window; toward one scrolled away or cut by the window’s edge it would end as a speck at the edge (a playtest saw “a small element pop up at the edge, then disappear”, 2026-10-29), so there it fades where it stands. The Your move prompt, below, is the same card. A click or Escape skips it, after a 350ms grace for a double-click. once, a key per hand, is remembered in uo:house:splashed (the last 20), so a reload doesn’t replay it.
Your move: prompt and beckon#
games/static/games/house/cue.js shows that something is yours to do, and where (Your move).
beckon(targets, { after }) rings what to press, in the turn color. It waits after ms (MOTION.beckonIdle, three seconds, on a routine turn, so a player who knows what to do never sees it), breathes three times (--m-beckon), then holds until a target is pressed or stop():
games/static/games/house/cue.js beckon · lines 41–67/**
* beckon(targets, { after }) rings each target (an element, a list or a
* NodeList) after `after` ms (MOTION.beckonIdle, a few seconds, by default;
* 0 at once), until stop() or a press on any of them. Returns { stop }.
* Beckoning the same targets again restarts the wait.
*/
export function beckon(targets, { after = MOTION.beckonIdle, untilPress = true } = {}) {
const els = list(targets);
if (!els.length || !cueOn()) return { stop() {} };
let timer = null, stopped = false;
const stop = () => {
if (stopped) return;
stopped = true;
clearTimeout(timer);
for (const el of els) {
el.classList.remove('house-beckon');
el.removeEventListener('pointerdown', stop);
}
};
const ring = () => { if (!stopped) for (const el of els) el.classList.add('house-beckon'); };
for (const el of els) {
el.classList.remove('house-beckon');
if (untilPress) el.addEventListener('pointerdown', stop, { once: true });
}
if (after > 0) timer = setTimeout(ring, after); else ring();
return { stop };
}
prompt({ title, sub }, { over, into, once, beckon }) is the splash’s card with “Your move” over a short title. It shrinks into into, the place you act, which then beckons. It plays for MOTION.prompt (2.6 seconds), takes no clicks, and is skipped by a click or Escape; once stops a reload replaying it.
The helper row cueOption() (“Show what to do”, uo:house:cue) turns both off site-wide. Under reduced motion the ring is still. The ring is a 3px !important outline whose offset breathes through a registered property (--house-breath, which animates even under !important), so it shows over whatever a control draws itself, such as a hand outlined in the turn color or an all: unset button. SVG doesn’t draw outlines reliably, so a board’s SVG spaces style .house-beckon themselves, in --house-turn.
The kits fit these to their games: the room’s and the puzzle kit’s.
Options#
openOptions(spec, { slug, store, onchange, onclose }) in games/static/games/house/options.js is every game’s Options sheet. spec has four lists of rows, helpers, playing, keys and look, always drawn in that order because the sheet walks SECTIONS, not the spec:
games/static/games/house/options.js openOptions · lines 133–146/**
* openOptions(spec, { slug, store, onchange, onclose }) opens the Options
* sheet: the sections in order, closed with Done. Without a store, choices
* are kept under `uo:<slug>:options`. Returns the sheet's handle.
*/
export function openOptions(spec, { slug = null, store = null, onchange = null, onclose = null } = {}) {
store ||= slug ? prefs(`uo:${slug}:options`) : null;
const rows = optionRows(spec, { store, onchange });
const home = homeScreenRow();
if (home) rows.push(home);
const feedback = feedbackRow();
if (feedback) rows.push(feedback);
return sheet('Options', h('div.house-options', rows), { onclose, className: 'house-options-sheet' });
}
A row is { id, label, hint, choices, default, under, get, set, onchange }. Without choices it’s an On/Off two-segment control, never a checkbox. Helpers default to on, each with its own opt-out (David’s standing rule). A row with get/set keeps its own value (the room’s Shortcut keys row stays in uo:cards:shortcuts, where people’s choice already was). Otherwise it’s in the store, by default prefs('uo:<slug>:options'), where a value not yet stored reads as its default, so a new setting needs no storage migration:
games/static/games/house/options.js prefs · lines 29–46/**
* prefs(key, defaults): a game's choices, kept together under one key in
* this browser ({ get(id), set(id, value), all() }). What isn't kept yet
* reads as its default; a browser that can't keep things just forgets.
*/
export function prefs(key, defaults = {}) {
const read = () => {
const kept = keep.get(key, {});
return kept && typeof kept === 'object' && !Array.isArray(kept) ? kept : {};
};
return {
key,
defaults,
get: id => { const kept = read(); return id in kept ? kept[id] : defaults[id]; },
set: (id, value) => keep.set(key, { ...read(), [id]: value }),
all: () => ({ ...defaults, ...read() }),
};
}
The segmented control, seg(), shows a pick as a tint, an inner ring and bold (Choosing isn’t doing), never the fill, and puts focus back on the button pressed. Each choice is sized by an invisible bold copy of its label (::after { content: attr(data-label); font-weight: var(--pick-weight); height: 0 }), so turning bold never nudges its neighbors. At 360px and under, choices share the row by their words (grid-auto-columns: auto) instead of equally. The small switch (.seg.small, a hand’s My order / Suit / Rank) is the same grid, its choices one width (“Equal choices are equal”, design language §9); on a phone (480px and under) it goes by its words and gives way, since a hand’s tool row has no room to spare (Contract Rummy’s at 375px). When a setting stacks (.house-under, or the puzzle home on a phone), its words keep their own height (flex: none), because their 12em flex basis would become a 192px gap in a column.
openOptions ends with the Home Screen row (phone or tablet, in a browser) and, for a signed-in player, feedbackRow(), which opens Feedback over Options; a game adds nothing for either. The standard Keys and sound rows are ROW.shortcuts(hint), ROW.chime(), ROW.clock() and ROW.awake().
Keys and key caps#
Every game’s single-key shortcuts go through shortcuts() in games/static/games/house/keys.js, which holds the site’s rules about keys:
games/static/games/house/keys.js shortcuts · lines 37–72/**
* shortcuts({ z: undo, '?': howTo, ... }, { on, dragging, target, mod })
* listens for single keys and returns a function that stops listening.
* - on(): whether Shortcut keys is on (single keys are dropped when it's off;
* the keys in `always` still work).
* - dragging(): true mid-drag (nothing fires).
* - mod: { z: undo, y: redo } for Ctrl/⌘ variants, which fire whatever the
* Shortcut keys option says (they're the browser's own habit).
* - always: { ArrowLeft: …, Enter: … } for keys that always work.
* Keys are matched case-insensitively for letters; a handler that returns
* false lets the key go on to the page.
*/
export function shortcuts(keys = {}, { on = () => true, dragging = () => false, target = document, mod = {}, always = {} } = {}) {
const lower = o => Object.fromEntries(Object.entries(o).map(([k, v]) => [k.length === 1 ? k.toLowerCase() : k, v]));
const single = lower(keys), withMod = lower(mod), fixed = lower(always);
const listener = e => {
if (e.defaultPrevented || e.repeat || typing(e) || sheetsOpen() || dragging()) return;
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
let fn;
if (e.ctrlKey || e.metaKey) {
if (e.altKey) return;
fn = withMod[key];
} else if (e.altKey) {
return;
} else if (fixed[key]) {
fn = fixed[key];
} else if (isSingleKey(e.key) && on()) {
fn = single[key];
}
if (!fn) return;
if (fn(e) === false) return;
e.preventDefault();
};
target.addEventListener('keydown', listener);
return () => target.removeEventListener('keydown', listener);
}
A single key (isSingleKey: one character, not an arrow, Enter, Space, Escape, Tab, Home, End or Page key) works only while Shortcut keys is on. That option starts on only with a fine pointer, since a phone has no real keyboard. Ctrl+Z works regardless, being the browser’s own habit.
A key cap is a <kbd class="house-key"> inside its button, added by keyCap(button, 'z'), which also sets aria-keyshortcuts. The cap is aria-hidden, so a screen reader hears “Undo”, not “Undo Z”. showKeyCaps(on) sets data-house-keys on <html>. Caps are visibility: hidden by default (keeping their space, so a button never changes width), visibility: inherit under [data-house-keys="on"], and display: none on a coarse pointer. Inherit, not visible: a cap set visible would outlive a button hidden in its space (a dock’s empty slot keeps its last label and cap), which is what David saw in Contract Rummy’s playtest (2026-10-29). check:house fails any stylesheet that gives a cap visibility: visible, and check:details reports a cap showing for a hidden control (stray). A cap is 12px in the button’s full ink (at 85% it fell under 4.5:1 on a colored button), 12px being the floor for words (design language §9, Readable), as are the room’s .tag and a seat’s .badge (which grows past its 64px for a longer word), the log’s hand headers, a puzzle’s folio on a phone, and a card’s corner suit (max(12px, .9em)).
Motion#
games/static/games/house/motion.js is a short list of named durations. Nothing in any game should move except by one of these:
games/static/games/house/motion.js MOTION · lines 12–47/** Milliseconds, by name. */
export const MOTION = Object.freeze({
press: 120, // a button going down
lift: 150, // hover or focus on a box or card
quick: 150, // the most a hover or press may take (house rule 11)
glide: 250, // your own move, place to place (ease-out)
stagger: 22, // between the cards of a run that glide together
reflow: 220, // the hand closing up
flight: 380, // someone else's (or a helper's) move across the table
flightLong: 580, // a long one
deal: 250, // each card from the dealer
dealGap: 40, // between dealt cards (1.5s in all at most)
flip: 240, // a card turning over, through its edge
sweepHold: 900, // a finished trick, its winner ringed
sweepHoldRelaxed: 1350,
sweep: 350, // then the trick slides to the winner
arrive: 350, // a card joining your hand
pop: 200, // a mark placed
nope: 360, // a refused move's shake
pulse: 1200, // a place that just changed, once
sheetIn: 150, // a sheet or toast appearing
sheetOut: 150, // and going
banner: 2000, // a game moment, fades included
announce: 2000, // a strong announcement (announce.js), in to out
toast: 4500, // how long a toast stays
toastAction: 6000, // one with an action
splash: 3400, // a new hand's splash, at most
prompt: 2600, // a "your move" card (cue.js), in to out
beckon: 1600, // one breath of the ring round what to press
beckonIdle: 3000, // how long a routine turn waits before the ring
celebrate: 1000, // a puzzle's settle when solved
hold: 1200, // someone else's move, held before the next one's motion starts
holdKey: 2000, // a deciding one (a sale, a ruling, a guess, a call)
catchUp: 1500, // your turn with more than this still to play: older moves jump to their end
emote: 3200, // someone's emote rising up the screen, fading as it goes (room/kit/talk.js)
});
house.css declares the same numbers as --m-* custom properties. A test in tools/house/kit.test.mjs reads the first :where(:root) block of house.css, camel-cases each name and checks it deep-equals MOTION, so the two can’t drift. Under reduced motion both go to zero for anything that travels:
games/static/games/house/house.css reduced-motion · lines 50–59/* Reduced motion: nothing travels, every change lands at once; fades of
150ms or less stay; a celebration is a still picture (celebrate.js). A game that times its motion
with these tokens gets this for nothing. */
@media (prefers-reduced-motion: reduce) {
:where(:root) {
--m-glide: 0ms; --m-stagger: 0ms; --m-reflow: 0ms; --m-flight: 0ms; --m-flight-long: 0ms;
--m-deal: 0ms; --m-deal-gap: 0ms; --m-flip: 0ms; --m-sweep: 0ms; --m-arrive: 0ms;
--m-pop: 0ms; --m-nope: 0ms; --m-celebrate: 0ms;
}
}
games/static/games/house/motion.js motionMs · lines 63–70/** How long a named motion takes here: its time, or 0 under reduced motion
* (except a fade short enough to keep). */
export function motionMs(name, { fade = false } = {}) {
const ms = typeof name === 'number' ? name : MOTION[name];
if (ms === undefined) throw new Error(`No motion called ${name}`);
if (!reducedMotion()) return ms;
return fade ? Math.min(ms, FADE_OK) : 0;
}
play(el, keyframes, name) runs a Web Animation for a named motion and resolves when it ends, or at once, doing nothing, when the motion is 0 or Web Animations are missing. The caller sets the end state either way. That contract is what makes reduced motion safe: motion only decorates a change that happens regardless.
dealDelays(n) spaces a deal: each card starts min(dealGap, (1500 − deal) / (n − 1)) ms after the last. A 20-card deal is 40ms apart and ends about a second in; a 52-card deal tightens to about 24.5ms, so it still lands within 1.5 seconds.
The way up#
The arrow at the top left of every bar is backArrow() in games/static/games/house/nav.js: same place, a 44px square, one drawing, always one level up (“More games” on a game’s home, “Back to {Game}” below it).
games/static/games/house/nav.js backArrow · lines 67–79/** backArrow({ href, label, onclick }): the arrow. With an `href` it's a
* link (so a middle-click or a long-press works, and the address shows);
* an `onclick` runs first and may stop the link (preventDefault) when a
* game has something to save or confirm before it leaves. Without an
* `href` it's a button. `label` is its name and its tooltip, "More games"
* on a home. */
export function backArrow({ href, label = 'More games', onclick, extra = {} } = {}) {
const attrs = { class: 'house-back', 'aria-label': label, title: label, ...extra };
const el = href === undefined ? h('button', { type: 'button', ...attrs }) : h('a', { href, ...attrs });
el.append(icon());
if (onclick) el.addEventListener('click', onclick);
return el;
}
With an href it’s a real link, so middle-click and long-press work. An onclick runs first and may cancel it; the room’s does, to go home without a page load. Django’s pages draw the same arrow from templates/house/back.html, and a test holds its path equal to BACK_PATH. The sheet’s ✕ is drawn too (closeIcon(), CLOSE_PATH), because a typed ✕ comes from whichever fallback font has it and sat a pixel or two high in some games (ADR 0033). guideLink(title), the last line of a game’s How to play, links to its chapter of this guide.
The Home Screen prompt#
games/static/games/house/install.js offers to add the site as an app on a phone or tablet (ADR 0029): a favor, never a gate. When to ask is a pure function, tested under Node:
games/static/games/house/install.js shouldOffer · lines 101–111/** Whether it's time to offer it by itself: a pure answer from the
* environment, what's kept and the clock, so it can be tested. */
export function shouldOffer(env, state, { now = Date.now(), prompt = !!deferred, shown = shownHere } = {}) {
if (env.standalone || env.os === 'other' || shown) return false;
if (state.never || state.installed || state.asked >= MAX_ASKS) return false;
if (state.visits < MIN_VISITS) return false;
// Android's Install button is the whole offer: with no prompt to wire it to, there's nothing to ask for.
if (env.os === 'android' && env.canAdd && !prompt) return false;
if (state.asked > 0 && (now - state.askedAt < SNOOZE_DAYS * DAY || state.visits - state.askedVisits < SNOOZE_VISITS)) return false;
return true;
}
Never in the app or on a computer; not before visit 2 (a visit is a browser session, marked in sessionStorage); at most 2 times; after “Not now”, not for 30 days and 5 more visits; on Android, not until beforeinstallprompt has fired (saved from page load), since there the Install button is the whole offer. It’s all kept in uo:install, in this browser only.
The only caller is the result sheet: when a finished game’s result is put away, offerAfterResult() waits 900ms, checks nothing else opened, and opens the sheet with auto: true. detect() picks the content: iOS’s three steps with its glyphs drawn inline, an in-app browser’s “open it in Safari” with Copy link, or Android’s Install button.
Feedback#
games/static/games/house/feedback.js lets a signed-in player write to the owner (ADR 0038; the server side is in Accounts). Every function asks feedbackUrl() first, which reads <meta name="uo-feedback">. Only a signed-in player’s page head has it (never a guide page, and not when there’s nowhere to send feedback), so for a guest nothing is drawn.
It’s offered after the copyright in every footer (footFeedback() in a game’s footer, footFeedbackHTML() in The Register’s markup, templates/house/feedback.html on Django’s pages, each a [data-feedback] button answered by listen()), and as the last section of Options:
games/static/games/house/options.js feedbackRow · lines 160–172/**
* The very last section of every game's Options, for a signed-in player
* only: Feedback, which opens the house kit's Feedback sheet over Options
* (feedback.js, docs/adr/0038). Like the Home Screen row, an action rather
* than a preference, and the kit's alone: a game adds nothing.
*/
export function feedbackRow() {
if (!feedbackUrl()) return null;
return h('section', { class: 'house-section', dataset: { section: 'feedback' } },
h('h3', 'Feedback'),
setting('Send feedback', 'To David, who makes these games. Only when you press Submit.',
button('secondary', 'Write', () => openFeedback(), { class: 'house-action', 'aria-haspopup': 'dialog' }), { id: 'feedback' }));
}
A staff account’s Django footers also link Analytics, the owner’s page (templates/house/admin.html, Accounts), drawn by the template from user.is_staff. A game’s own footer doesn’t offer it.
The sheet has a <textarea> (so a phone keyboard’s dictation works), a screenshot checkbox and Submit. Where the browser has SpeechRecognition or webkitSpeechRecognition, a Dictate button types what it hears. heard() rebuilds the text from every result each time, because WebKit repeats final results in later events and appending would type them twice. Submit keeps the sheet open while sending, then closes it with “Thanks — sent.” A refusal shows the server’s words under the box; an answer that isn’t the server’s JSON (a refused CSRF token, a stale page) asks for a reload; only no answer at all says to check the connection.
The screenshot is a redrawing of the page by html2canvas-pro (MIT, vendored in games/static/games/house/vendor/ with its license), loaded only when the box is first ticked. capture() draws the window behind the sheets, then shrinks and compresses it:
games/static/games/house/feedback.js capture · lines 242–310/** What's in the window now, without any sheet or toast, as a JPEG Blob
* under SHOT_BYTES; throws if it can't be drawn. */
export async function capture() {
const html2canvas = await loadCapture();
// A board hidden only because a sheet is up (sheet.js hideBoard) is drawn
// as it is behind it: shown for the moment its picture's styles are read,
// in the same task, so it's never painted.
const behind = [...document.querySelectorAll('[data-house-sheet-hides]')];
behind.forEach(el => el.classList.remove('hidden'));
let pictures;
try {
pictures = svgPictures();
} finally {
behind.forEach(el => el.classList.add('hidden'));
}
tagClipped();
let page;
try {
page = await html2canvas(document.body, {
x: globalThis.scrollX, y: globalThis.scrollY, width: globalThis.innerWidth, height: globalThis.innerHeight,
windowWidth: document.documentElement.clientWidth, windowHeight: document.documentElement.clientHeight,
scale: Math.min(globalThis.devicePixelRatio || 1, 2),
backgroundColor: getComputedStyle(document.body).backgroundColor || '#ffffff',
logging: false, useCORS: true,
// (the sheet and its scrim, and toasts: the picture is of the page behind)
// (and what only shows without script: the copy runs none)
ignoreElements: el => el.tagName === 'NOSCRIPT' || (!!el.classList && (el.classList.contains('house-scrim') || el.classList.contains('house-toasts'))),
onclone: async doc => {
// (the copy fetches the page's stylesheets again: drawn before they
// arrive, it's unstyled, so wait for each, a few seconds at most)
await Promise.all([...doc.querySelectorAll('link[rel="stylesheet"]')].filter(l => !l.sheet).map(l => new Promise(done => {
l.addEventListener('load', done, { once: true });
l.addEventListener('error', done, { once: true });
setTimeout(done, 4000);
})));
await Promise.race([doc.fonts?.ready, new Promise(done => setTimeout(done, 1500))]);
for (const el of doc.querySelectorAll('[data-uo-shot-hide]')) el.style.visibility = 'hidden';
for (const el of doc.querySelectorAll('[data-house-sheet-hides]')) el.classList.remove('hidden');
for (const svg of doc.querySelectorAll('svg[data-uo-shot]')) {
const p = pictures.get(svg.dataset.uoShot);
if (!p) continue;
const img = doc.createElement('img');
img.src = p.url;
img.className = svg.getAttribute('class') || '';
img.style.cssText = `display:${p.display === 'inline' ? 'inline-block' : p.display};width:${p.w}px;height:${p.h}px;max-width:none`;
svg.replaceWith(img);
}
},
});
} finally {
for (const svg of document.querySelectorAll('svg[data-uo-shot]')) delete svg.dataset.uoShot;
for (const el of document.querySelectorAll('[data-uo-shot-hide]')) delete el.dataset.uoShotHide;
}
let side = SHOT_SIDE;
for (let tries = 0; tries < 3; tries++, side = Math.round(side * 0.7)) {
const size = fit(page.width, page.height, side);
const out = document.createElement('canvas');
out.width = size.w;
out.height = size.h;
const g = out.getContext('2d');
g.imageSmoothingQuality = 'high';
g.drawImage(page, 0, 0, size.w, size.h);
for (const q of QUALITIES) {
const blob = await toBlob(out, q);
if (blob && blob.size <= SHOT_BYTES) return blob;
}
}
throw new Error('screenshot too big');
}
It fixes three things html2canvas gets wrong here:
- SVG colors. html2canvas ignores the page’s stylesheets inside SVG, but boards, chess pieces and box art are colored by class.
svgPictures()turns each SVG into a picture with its computed styles written in. It leaves gradients and patterns alone (a<use>colors them where it’s used) and brings along any shape a<use>borrows. - Screen-reader text is clipped to nothing, but html2canvas ignores
clip, so it’s hidden on the copy, with<noscript>. - Hidden boards. A solo game hides its board under a sheet. The puzzles, Hashi and Chess use
hideBoard(), which marks a board hidden only because of a sheet:
games/static/games/house/sheet.js hideBoard · lines 52–60/** A solo game's board, hidden while a sheet is up (a sheet is never free
* thinking time) or while paused: its `hidden` class. Hidden only for a
* sheet, it's marked so, and a Feedback screenshot (feedback.js) shows it
* as it is behind the sheet; paused, it stays hidden there too. */
export function hideBoard(el, { sheet = false, paused = false } = {}) {
if (!el) return;
el.classList.toggle('hidden', sheet || paused);
el.toggleAttribute('data-house-sheet-hides', sheet && !paused);
}
capture() shows a marked board only while reading its styles, in the same task, so it’s never painted. A paused board stays hidden, as its cover shows.
The picture is at most 1600px on its longest side, then a JPEG at falling quality until it’s under 1,572,864 bytes. If any of it fails, the words go anyway and the toast says so. npm run check:feedback:play (Testing) sends feedback from the hub, Star Battle, a Contract Rummy table and a phone.
Dailies and the Stats tab#
Two parts of the house kit serve the Dailies page and every game’s Stats tab (ADR 0034; the server side is Dailies).
games/static/games/house/dailies.js keeps the log of dailies solved in this browser (uo:dailies: by item and day, with time, board and a day’s first time, 800 days per item), and a run through today’s dailies that the Dailies page starts in sessionStorage. A result sheet asks it what’s next:
games/static/games/house/dailies.js nextDaily · lines 69–83/**
* What a result sheet offers after solving `item`'s daily of `day` during a
* run: { label, href } for the next one not yet solved (after this one in
* the run's order, coming round), or the way back to the Dailies page once
* every one is done; null without a run for that day (or one this item isn't in).
*/
export function nextDaily(item, day, { run = readRun(), log = readLog() } = {}) {
if (!run || run.day !== day || !Array.isArray(run.items)) return null;
const at = run.items.findIndex(i => i.item === item);
if (at < 0) return null;
const order = [...run.items.slice(at + 1), ...run.items.slice(0, at)];
const next = order.find(i => !doneOn(log, i.item, day));
if (!next) return { label: 'Back to your dailies', href: `${dailiesUrl()}?done=1`, name: 'dailies' };
return { label: `Next daily: ${next.name}`, href: next.url, name: 'nextdaily' };
}
So a puzzle solved during a run offers “Next daily: Chess Puzzles”, skipping what’s done, and “Back to your dailies” at the end. Its news() is what the Dailies page lights up once as new since this browser last looked, plus at most one cheer a day: the full sweep’s card (with a streak’s milestone the sweep reached on it), or a streak milestone’s announcement (Dailies; Celebrations). swept() says whether today’s is a full sweep (every daily solved, a chess solution shown not counting), and sweepText() is its share. stats-page.js does the same for a Stats tab.
Rated games without a daily have a Stats tab too. Solitaire (since ADR 0037) has losses, so it shows runs of wins (table()’s count). The Register (since ADR 0040) never loses, so it shows the days you solved a case and runs of them (streaks() over every solve’s day).
The Stats tab’s parts:
games/static/games/house/stats.js: pure arithmetic, tested intools/house/stats.test.mjs. Streaks, sweeps, times in at most six clock-friendly bins, a table game’s places.games/static/games/house/charts.js: two inline SVG charts, each with a table for screen readers. AResizeObserverredraws them at the size shown, so their text is never scaled.games/static/games/house/stats-page.js: builds the tab from the page’s data and the browser’s log, with the “?” beside Your rating (ratingHelp(), in the rating service’s words).games/static/games/house/stats.css: the tab’s styles, from the game’s tokens.
The streak rule is the one every game has always used: today’s streak survives until today is over.
games/static/games/house/stats.js streaks · lines 17–35/**
* Streaks in a set of days: the current one (ending today, or yesterday
* while today's isn't done yet, as every game's streak has always counted)
* and the best ever.
*/
export function streaks(days, today) {
const set = days instanceof Set ? days : new Set(days);
let current = 0;
let day = set.has(today) ? today : addDays(today, -1);
while (set.has(day)) { current++; day = addDays(day, -1); }
let best = 0;
for (const d of set) {
if (set.has(addDays(d, -1))) continue;
let n = 1;
while (set.has(addDays(d, n))) n++;
best = Math.max(best, n);
}
return { current, best: Math.max(best, current) };
}
Every call, at a glance#
What each piece does is in Components and patterns; these are the calls a game makes. From ui.js:
| Call | Does |
|---|---|
sheet(title, body, { kicker, actions, done, dismissable, auto, kind, wide, className, focus, refocus, onclose }) |
Opens a sheet; returns { el, box, body, foot, close(how), closed, open, waiting }. Without actions its foot is one primary Done. dismissable: false for one that must be answered (leaving a live table). auto: true for one that opens by itself. |
actionRow(actions) |
A row in the house order. An action is { label, weight: 'primary' \| 'secondary' \| 'quiet', onclick, href, danger, keepOpen, key, name }; secondaries are listed nearest the primary first. In a sheet, an action closes it unless keepOpen or its onclick returns false. |
sheetsOpen(), onSheets(fn) |
How many are open; told when that changes (a solo game’s clock stops while any is open). |
setSheetClock(fn) |
A table’s running clock, shown in every sheet (fn → seconds left or null). |
closeTop(), closeSheets() |
Escape’s job; closing everything (leaving a table), each sheet’s onclose run. |
toast(text, { action, onaction, ms }) |
Queued, polite, 4.5s (6s with an action); returns { dismiss }. |
banner(text, { layer, ms, replace }) |
A game moment over layer for 2s, queued (or, with replace, instead of whatever’s showing or queued), no clicks, not read out; a promise for when it’s gone. clearBanners() drops them all. A banner says only what’s true now: a game shows one only if the state hasn’t moved past it, and never from a reload or a reconnection’s catching up (Bowl: Table.resumed). |
splash(content, { over, into, once }) |
A new hand’s splash, shrinking into into; skippable; once (a key per hand) stops it replaying. |
announce({ kicker, title, sub, art }, { over, ms, read, say, replace }) |
The strong announcement (Signals): a band in the turn color across over (the table; the window by default), its words popping in, ~2s; queued; any press puts it away; read out politely unless read: false. Returns { skip, done }. clearAnnouncements() drops them all (leaving a table). |
celebrate(kind, { over, from, colors }) |
A celebration: win, best, sweep or daily (Celebrations), on a canvas in over, springing from from’s top edge. Returns { stop, done }. A result sheet plays its own (result({ celebrate })). |
prompt({ title, sub }, { over, into, once, beckon }) |
Your move: a card saying what’s yours to do, shrinking into into, which (or beckon, the targets) then rings; once per once key. Returns { skip, done, stop }. |
beckon(targets, { after }) |
Ring what to press after after ms (MOTION.beckonIdle by default; 0 at once) until stop() or a press on it. |
cueOption() |
The Helpers row “Show what to do” for both, site-wide. |
openOptions(spec, { slug, store, onchange, onclose }) |
The Options sheet. spec is { helpers, playing, keys, look }, each a list of rows { id, label, hint, choices, default, under, get, set, onchange } (On/Off unless choices; a helper defaults on); sections always come out in the house order. Kept under uo:<slug>:options, or a row’s own get/set. ROW.shortcuts(hint), ROW.chime(), ROW.clock(), ROW.awake() are the standard Keys and sound rows. |
optionRows(spec, opts), setting(label, hint, control, { under }), onOff(…), seg(label, choices, value, onpick), prefs(key, defaults) |
The same pieces, for a lobby’s settings or a game’s own rows. |
shortcuts(keys, { on, dragging, mod, always }) |
Single keys that never fire while typing, with a modifier, on a repeat, under a sheet or mid-drag, and are off when Shortcut keys is off (always keys, arrows and the like, still work; mod for Ctrl/⌘ variants). |
keyCap(button, key), showKeyCaps(on) |
A key cap in a button (and its aria-keyshortcuts); caps show only with shortcuts on and a fine pointer, and keep their space when hidden. |
MOTION, motionMs(name), play(el, frames, name), reducedMotion(), wait(), sweepTiming(), dealDelays(n) |
The motion vocabulary: a named motion’s time (0 under reduced motion, fades capped at 150ms); play resolves at once when nothing should move. |
backArrow({ href, label, onclick }), backArrowHTML(…) |
The way up (The top bar): a 44px arrow link, label its name and tooltip (“More games” on a home, “Back to {Game}” under it). An onclick runs first and may preventDefault() to stop the link. backArrowHTML is the same as a string, for pages built from markup (The Register). |
openInstall({ auto }), homeScreenButton(), shouldOffer(env, state), detect(…), environment(), isStandalone() |
The Home Screen prompt (The Home Screen prompt). Games call none of them: result() offers it after a finished game is put away, and openOptions() ends with its row. shouldOffer is the pure “is it time” and detect the pure “where is this open”, both tested under Node. |
hideBoard(el, { sheet, paused }) |
A solo game’s board hidden while a sheet is up or while paused (its hidden class); hidden only for a sheet, it’s marked, so a Feedback screenshot shows it as it is behind the sheet. |
footFeedback(), footFeedbackHTML(), feedbackLink(), openFeedback(), feedbackUrl() |
Feedback (ADR 0038): ” · Feedback” for a game’s own footer (null, or ‘’, for a guest), and the sheet itself. openOptions() ends with its row for a signed-in player; a game adds nothing else. |
h, keep, copy, button, link, handFocus |
Elements, browser storage that never throws, the clipboard, buttons in a weight, focus handed on safely. |
From house/end.js (re-exported by ui.js):
| Call | Does |
|---|---|
result({ state, kicker, heading, emblem, news, celebrate, facts, compare, body, post, primary, noun, share, leaderboard, restart, undo, back, extra, see, onsee, onclose }) |
The result sheet. celebrate names its celebration (handle.celebrate(kind) plays one later); a table’s game over that names none gets the room’s (celebrateResults(fn)). state is won, lost, stuck, gaveup, hand or over, and fixes the actions (The result sheet): a win is See the board · Leaderboard · Share result · Next {noun}. It opens by itself (waiting for the player’s sheets) over the lower part of the board. Returns the sheet’s handle plus post(text) and actions; See the board (or Escape, ✕) calls onsee(actions) so the game puts them in its dock. |
shareResult({ text, url }) |
Shares the text, the link last inside it, on a phone with a share sheet; elsewhere copies and toasts “Copied — paste it anywhere.” |
brag({ game, title, head, lines, url }) |
Share result’s words (brag.js): the game’s emoji and name, head, the lines, the link; with facts, placeLine, roundRow, podium, bar to build the lines. |
record(key), rateLine(won, played) |
A lifetime win rate per game in the browser, a game counted once from its first move; “Won 12 of 40 · 30%”. |
ratingLine(rating) |
The post line’s sentence for a rated result, from the server’s rating: “Rating +12 → 1534.” (“1534?” while provisional), the midnight line for a daily, ‘’ when it isn’t rated (Ratings). |
house.css: structure only#
games/static/games/house/house.css draws every piece above, with no color of its own except black shadows and scrims (npm run check:tokens fails it otherwise). It reads --house-* tokens, each falling back to the room’s name for the same job, into private --_* tokens on the kit’s elements:
games/static/games/house/house.css fallbacks · lines 131–149:where(.house-scrim, .house-toasts, .house-banner, .house-splash, .house-announce, .house-cheer, .house-options, .house-section, .house-setting, .house-seg) {
--_surface: var(--house-surface, var(--sheet));
--_ink: var(--house-ink, var(--ink));
--_soft: var(--house-soft, var(--soft));
--_edge: var(--house-edge, var(--rail));
--_radius: var(--house-radius, var(--rail-radius, 14px));
--_line: var(--house-line, color-mix(in srgb, var(--_ink) 14%, transparent));
--_field-edge: var(--house-field-edge, var(--field-edge, 1.5px solid color-mix(in srgb, currentColor 50%, transparent)));
--_btn-radius: var(--house-btn-radius, var(--btn-radius, 8px));
--_display: var(--house-display, var(--display));
--_display-weight: var(--house-display-weight, var(--display-weight, 400));
--_font: var(--house-font, var(--ui-font));
--_focus: var(--house-focus, var(--focus, var(--_ink)));
--_turn: var(--house-turn, var(--turn, var(--_ink)));
--_turn-ink: var(--house-turn-ink, var(--turn-ink, var(--_surface)));
--_toast-edge: var(--house-toast-edge, var(--chip-edge, var(--_edge)));
--_scrim: var(--house-scrim, rgba(0, 0, 0, .55));
--_shadow: var(--house-shadow, 0 24px 64px rgba(0, 0, 0, .45));
}
So a room game, which already declares --sheet, --ink, --soft and --rail, needs nothing new, while games/static/games/puzzles/kit/puzzles.css maps the paper (--house-surface: var(--paper-2), --house-edge: 1px solid var(--paper-edge) and so on). Because the private tokens sit on the kit’s elements, not :root, a look can re-ink just the kit: Retro PC’s light gray sheets set --house-ink: #000 on .house-scrim and its siblings. tools/house/tokens.mjs parses this block, following each var() chain, so check:tokens can name exactly which token a game is missing.
The token contract: a game re-skins, never resizes#
The page’s skeleton belongs to the house: how far in text starts, the bar’s height, the column’s width, each type role’s size, each button’s height. It lives in house.css as tokens (ADR 0026):
games/static/games/house/house.css scale · lines 70–126:where(:root) {
/* space: where the page's words start from the window's edge (the gutter),
the frame round a table (the felt's margin, inside the gutter), the bar's
top and the room under it, the column a home or lobby runs in */
--gutter: max(24px, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px));
--frame: 10px;
--bar-h: 44px;
--bar-top: 12px;
--bar-gap: 24px;
--page-max: 960px;
--page-max-wide: 1180px;
/* type: each role's size and line height (its face and color are the
game's; --t-display's size is too, for its one title) */
--t-title: 28px;
--t-bar: 17px;
--t-lead: 16px;
--t-tool: 15px;
--t-small: 14px;
--t-kicker: 12px;
--t-figure: 20px;
--lh-display: 1.1;
--lh-heading: 1.2;
--lh-body: 1.45;
--lh-note: 1.4;
--w-label: 600;
--w-tool: 700;
--w-figure: 800;
--kicker-spacing: .14em;
/* buttons: a move's height and side padding; a tool's (the bar's quiet
ones, a dock's small ones); a finger's on a touch screen */
--btn-h: 44px;
--btn-pad: 16px;
--tool-h: 36px;
--tool-pad: 10px;
/* panels and sheets */
--panel-pad: 16px;
--panel-gap: 16px;
--panel-radius: 12px;
--sheet-pad: 24px;
}
@media (max-width: 640px) {
:where(:root) { --gutter: max(16px, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px)); --frame: 0px; --bar-top: 8px; --bar-gap: 16px; --t-bar: 15px; --t-figure: 18px; --btn-pad: 12px; }
}
@media (max-width: 400px) {
:where(:root) { --tool-pad: 8px; }
}
/* (a phone on its side: the table needs every pixel of height, so the bar
is as short as its tools allow) */
@media (max-height: 520px) {
:where(:root) { --bar-h: 30px; }
}
@media (max-width: 560px) {
:where(:root) { --sheet-pad: 16px; }
}
@media (pointer: coarse) {
:where(:root) { --tool-h: 44px; }
}
Every block is :where(:root), with zero specificity, so any game’s :root { --btn-h: 40px } would win whatever the load order. That’s deliberate, so a game is never locked out by the cascade, and it’s why a check enforces the contract instead of CSS. The gutter is max(24px, env(safe-area-inset-left), env(safe-area-inset-right)), so everything drawn from it clears a phone’s notch when held sideways.
| A game’s own | The house’s |
|---|---|
Every color; its faces (--display, --ui-font, the --house-* faces) and --display-weight |
--gutter, --frame, --bar-h, --bar-top, --bar-gap, --page-max, --page-max-wide |
--t-display, the size of its one title |
--t-title, --t-bar, --t-lead, --t-tool, --t-small, --t-kicker, --t-figure and the line heights and weights |
Radii (--btn-radius, --rail-radius, --panel-radius), edges, textures |
--btn-h, --btn-pad, --tool-h, --tool-pad, --panel-pad, --panel-gap, --sheet-pad |
| Its own table between the bar and the dock | --t-heading 20, --t-body 16, --t-label 16, --t-note 14 and --s1…--s5 (4, 8, 16, 24, 40), which every root declares at exactly those values |
The last row is the older half of the contract (ADR 0012): every root (the hub’s, the card room’s, The Register’s, the puzzle kit’s, each room game’s) declares five of the six type roles (Display, Heading, Body, Label and Note; the sixth, the Figure, is declared once, in house.css, with the rest of the layout and type scale), five spaces and a pick (--pick-tint, --pick-ring, --pick-weight). npm run check:tokens (tools/tokens/check.mjs) fails a root missing one or sizing one differently; any stylesheet under games/static or static/ setting a layout token other than --panel-radius; a house-kit game whose page template (or one it extends) doesn’t load house.css; and soft ink under 4.5:1 on the sheets or any felt color, in any table style.
--t-figure (20px, 18 on a phone) and --w-figure (800) are the Figure, the sixth role (ADR 0054): the number a player hunts for at a glance, how near the end is or the score. .house-figure draws it in ink and tabular figures, whatever face it sits in, and .house-figure-noun names it beside it, small and soft; a game adds the class to its own count (Euchre’s tricks, Sixty-Six’s stock, Bullshit’s pile).
From the other side, the room’s buttons take every visual property from tokens and every size from the house’s scale:
games/static/games/room/room.css buttons · lines 29–47.btn {
appearance: none; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
background: var(--btn); color: var(--btn-ink); border: var(--btn-edge); border-radius: var(--btn-radius);
font-weight: var(--btn-weight); padding: 8px var(--btn-pad); min-height: var(--btn-h); line-height: 1.1; text-decoration: none;
transition: transform .08s;
}
.btn:active { transform: translateY(1px); }
/* Three weights (docs/design-system.md, house rule 2): the one primary is
filled, a secondary is outlined, a quiet one is underlined words. Danger
is a secondary in the bad color. */
.btn.alt { background: var(--btn2); color: var(--btn2-ink); border: var(--btn2-edge); }
.btn.danger { background: var(--btn2); color: var(--bad); border: var(--btn2-edge); border-color: var(--bad); }
.btn.quiet { background: transparent; color: var(--ink); border: 0; padding: 6px 10px; min-height: 36px; text-decoration: underline; text-underline-offset: 4px; }
.btn.quiet:hover { text-decoration-thickness: 2px; }
.btn.small { min-height: var(--tool-h); padding: 4px 12px; font-size: var(--t-small); }
.btn.wide { width: 100%; }
.btn.unseen { visibility: hidden; }
/* Disabled reads as "not yet", not as faded-out color that melts into the felt. */
.btn[disabled] { background: transparent; color: var(--soft); border: 2px dashed color-mix(in srgb, var(--soft) 70%, transparent); cursor: default; transform: none; }
Clubhouse makes .btn cream with green ink and 10px corners; Retro PC makes it square-cornered gray with an outset edge. Neither can make it shorter than --btn-h or pad it differently.
The room kit#
games/static/games/room/ is everything a table game needs except its board (ADR 0015). It re-exports the house kit’s sheets, toasts and Options rows through kit/dom.js and kit/options.js, so a table game imports from one place. kit/dom.js adds only a toast(text, ms) that still accepts a bare number of milliseconds, as older room calls do:
games/static/games/room/kit/dom.js toast · lines 20–25/** A short message over the middle of the table: never takes a click (only
* its one action, if it has one), goes by itself, queued behind any other.
* toast(text, ms) as before, or toast(text, { ms, action, onaction }). */
export function toast(text, opts) {
return houseToast(text, typeof opts === 'number' ? { ms: opts } : opts || {});
}
start() and its cfg#
A table game’s page extends games/templates/tables/play.html, which puts the addresses on <html> (data-api, the game’s /games/<slug>/tables/; data-base, its own address; data-table, a code from a shared link; and for a signed-in player data-user, data-csrf and their pages) and loads the game’s app.js. That entry usually only calls start(cfg):
games/static/games/contract-rummy/app.js lines 1–36// Contract Rummy: the family rules (docs/contract-rummy.md), played against
// bots or friends at a shared table. The lobby and waiting room are the room
// kit's (../room/kit/), the cards and table styles the card room's
// (../cards/kit/); this game brings its rules and its table.
import { start } from '../room/kit/room.js';
import { look } from '../cards/kit/looks.js';
import { rules } from './rules.js';
import { play, rummyOptions } from './table.js';
start({
game: 'contract-rummy',
title: 'Contract Rummy',
kicker: 'Card game · 3 to 8 players',
blurb: 'Seven hands, seven contracts, and the eternal question: who’s buying that?',
min: 3,
max: 8,
rules,
play,
look,
devices: 'desktop',
// How to play opens by itself once, on a first visit, before the deal.
firstRun: true,
cues: true,
progress: game => `Hand ${game.hand + 1} of ${game.of}`,
options: () => rummyOptions(),
settings: [{
key: 'buy_seconds', label: 'Time to decide on buying', value: 30,
hint: 'How long everyone gets to buy a discard.',
choices: [[10, '10s'], [20, '20s'], [30, '30s'], [45, '45s'], [60, '60s']],
}, {
key: 'bot_pace', label: 'Bot speed', value: 'brisk',
hint: 'Relaxed is easier to follow.',
choices: [['brisk', 'Brisk'], ['relaxed', 'Relaxed']],
}],
});
start() sets up the house kit, the look and the key caps, clears storage left by older versions, builds the Room, loads the player’s house rules, and opens the table in the address or the front door. It listens for popstate, so Back works between /t/<CODE>/ and the front door:
games/static/games/room/kit/room.js start · lines 156–187export function start(cfg) {
cfg.words = { ...WORDS, ...(cfg.words || {}) };
// The room's own table setting, after the game's: Chat (kit/talk.js).
if (cfg.chat !== false) cfg.settings = [...(cfg.settings || []), chatSetting()];
// The house kit draws every sheet, toast and Options row (ADR 0025): in
// the room's button classes, with the table behind made inert.
configure({ buttons: { primary: 'btn', secondary: 'btn alt', quiet: 'btn quiet', danger: 'btn danger' }, app: '#app' });
useLook(cfg.look);
useGame(cfg.game);
useCues(cfg.cues);
useShortcutHint(cfg.shortcuts);
showKeyCaps(shortcutsOn());
cfg.look?.apply?.();
dropOldGroups();
dropStale(cfg.game);
const page = document.documentElement.dataset;
const room = new Room(cfg, {
api: page.api, base: page.base, back: page.back || '/',
});
room.house.load();
// (Signing up says welcome on the way back here, once the screen's up.)
room.message = page.message || '';
const code = page.table || '';
if (code) room.open(code);
else room.home();
window.addEventListener('popstate', () => {
const m = location.pathname.match(/\/t\/([A-Za-z0-9]{6})\/?$/);
if (m) room.open(m[1]);
else room.home();
});
return room;
}
The cfg keys are documented at the top of games/static/games/room/kit/room.js. Every game gives game, title, blurb, min, max, rules() (How to play) and play(table, root, room), which draws the table and returns { update(snap), close() }. The rest are opt-ins:
cfg |
What it turns on |
|---|---|
settings |
The host’s table settings, as segmented pickers in the lobby and waiting room (when(seats) hides one for some sizes; more than three choices go under the words, more than five wrap into rows), or a control the game draws itself (draw({ value, set, editable, seats }), the whole width: Quiz Whiz’s library and its six, one string value; ADR 0058), and the player’s house rules (ADR 0021) |
options(), shortcuts, look |
Helpers and Playing rows, the Shortcut keys hint, a Table style row |
progress(game) |
“Hand 3 of 7” in Your tables and the watch screen |
firstRun |
How to play opens by itself on a first visit, before the deal, never over a clock: once per browser, or per player when signed in (games/static/games/house/howto.js, ADR 0039) |
cues |
The game says what’s yours to do (Your move at a table) |
deadline(snap) |
When this player’s clock runs out, shown in every sheet |
spectate |
play() can draw for spectators; otherwise they get the room’s own view |
neighbors, ownSeats, teams, sizes |
“You’ll sit between Ben and Cleo”; the game draws its own seat list; teams the service keeps; sizes the game can start at |
seatBarIn(root) |
A place of the game’s own for the seat bar (Michigan Rails: over its map’s foot); otherwise the room places it, over the dock or in the top bar |
seatNews: false, coverAfter, bots: false, fill, gather, art |
The game tells seat news itself; the bot offer after this many missed picks; people only (ADR 0031); Fill with bots; how people need to be together; a picture beside the table |
shareText(snap), words, devices |
Share result’s words, most often tableBrag(snap, { head, lines }) (Sharing); the game’s words for card-table phrases (“Deal me in”); “Made for a computer first” on phones |
Screens, and following a table#
The Room has a method per screen: homeScreen() (the front door: play bots, start a table for friends, join by code, Your tables), joinScreen() (from a shared link: take a seat, take yours over from another device, or arrive late and sit in or watch), waitingRoom(), watchScreen() and closedScreen(). These are the room’s foyer, drawn on .felt.foyer; the table itself is the game’s play(). In Your tables, a row’s status (“Hand 3 of 7 →”) wraps under the name when there’s no room, keeping its arrow with its last word, and its × is the house’s drawn closeIcon().
Each screen starts with leave(), which closes all sheets, the previous view, the table’s connection, the cover offer and the sheet clock, so nothing carries into the next screen.
Once you have a table, follow() runs the room: one snapshot listener that decides, on every update, which screen you should see.
games/static/games/room/kit/room.js Room./** Follow a table live: as your seat, or (`watching`) as a spectator. */
follow(table, { watching = false } = {}) {
this.leave();
this.table = table;
this.spectating = watching;
if (watching || !table.seated) table.spectate();
this.go(`${this.urls.base}t/${table.code}/`);
let shown = null;
table.onSnapshot(snap => {
// (A snapshot that lands after you've moved on is old news.)
if (table !== this.table) return;
const t = snap.table;
if (t.status === 'closed') {
forget(this.cfg.game, table.code);
if (!this.closing) return this.closedScreen(t);
toast('Table closed.');
return this.home();
}
if (t.you === null && t.moved && table.secret) {
// Taken on another device (a signed-in player's): this one watches.
toast('You took your seat on another device, so this one is just watching.', 6000);
this.spectating = true;
}
if (t.you === null && !t.reserved && !this.spectating) {
// This browser's seat is gone: removed by the host, or we left.
forget(this.cfg.game, table.code);
if (!this.leaving) toast('The host removed you from that table.');
this.leaving = false;
return this.home();
}
// Seats changing hands, told to everyone (unless the game does it);
// who hosts, in every game.
const said = this.cfg.seatNews !== false ? seatNews(this.seen, t, { quiet: this.removed }) : hostNews(this.seen, t);
if (said.length) toast(said.join(' '), 6000);
if (this.seen && t.seats.length !== this.seen.seats.length) this.removed = '';
this.seen = t;
this.offerCover(table, t);
// A place at the next game that's become a seat, or one given up.
this.spectating = t.you === null;
if (this.spectating && t.lost) table.unseat();
if (!this.spectating && !t.linked) this.keepGames(table, snap);
const mode = !this.spectating ? (t.status === 'lobby' ? 'lobby' : 'game')
: t.status !== 'lobby' && this.cfg.spectate ? 'game' : 'watch';
const key = `${mode}:${this.spectating}`;
if (key !== shown) {
this.view?.close?.();
shown = key;
this.view = mode === 'lobby' ? this.waitingRoom(table) : mode === 'watch' ? this.watchScreen(table) : this.cfg.play(table, this.root, this);
}
this.drawBar(snap);
this.view.update(snap);
this.drawSeatBar(snap);
this.talk?.place(snap);
this.sayMessage();
});
// A game's running clock shows in any sheet opened over it.
setSheetClock(() => {
const snap = table.last;
if (table !== this.table || this.spectating || !this.cfg.deadline || snap?.table.status !== 'playing') return null;
const due = this.cfg.deadline(snap);
return due === null || due === undefined ? null : Math.max(0, due - table.now());
});
// A game over celebrates your win at the table, or else a rating high,
// in every table game at once (house/end.js; a game that passes its own
// `celebrate` to result() decides for itself). Never for a spectator,
// and once a game: the sheet opened again (a reload) doesn't replay it.
// (Marked once it has played: a game over in a hidden tab plays when
// the tab is seen again, house/end.js.)
celebrateResults(state => (table === this.table ? gameOverCheer(state, table.last?.table, { spectating: this.spectating }) : null));
// Emotes and chat (kit/talk.js), listening before the stream opens.
this.talk = new Talk(this, table);
if (table.last) table.accept(table.last);
table.follow();
}
The order matters. A closed table sends you home or to the closed screen. you: null with moved means a signed-in player took this seat on another device, so this one becomes a spectator; you: null alone means you were removed. Then seat news is toasted, the cover offer updated, an unlinked seat linked to a signed-in account, and the mode (lobby, game or watch) worked out. The view is rebuilt only when the mode changes; otherwise it gets update(snap). So play() runs once per game, not per snapshot, and must patch its table in place.
Talking to the table#
games/static/games/room/kit/net.js is the client for the table service. Your seat is a secret the server gave this browser when you joined, kept in localStorage as uo:cards:<game>:<CODE> and sent as X-Seat. A spectator sends X-Watch, a random id made per page and kept nowhere, so the server can count watchers without knowing who they are:
games/static/games/room/kit/net.js who · lines 83–85/** The header that says who's asking: a seat (or a place at the next game)
* by its secret, else a spectator by the id its page made up. */
const who = (secret, watch) => (secret ? { 'X-Seat': secret } : watch ? { 'X-Watch': watch } : {});
Every call sends credentials: 'omit': the table API is cookie-free. Only link() and take() (linking a seat to a signed-in account, or taking it over on another device) and the house-rules call send the session cookie and CSRF token (ADR 0010, ADR 0021).
| Call | Request | Sent as |
|---|---|---|
Table.create(api, game, body) |
POST /games/<slug>/tables/ |
nobody yet |
join(name), reserve(name) |
POST …/<CODE>/join/, …/reserve/ |
nobody yet |
peek() |
GET …/<CODE>/ (a look before joining) |
X-Seat, if this browser has one |
ask(path) |
GET …/<CODE>/<path> (the log, Rummy’s arrange/) |
X-Seat or X-Watch |
act(action) |
POST …/<CODE>/act/ |
X-Seat |
host(op, args) |
POST …/<CODE>/host/ (start, add_bot, remove, arrange, settings, leave, close, cover, …) |
X-Seat or X-Watch |
link(), take() |
POST …/<CODE>/link/, …/take/ |
the session, with CSRF |
say(body) |
POST …/<CODE>/say/ (an emote or a line of chat) |
X-Seat |
follow() |
GET …/<CODE>/stream/?talk=n, else polling GET …/<CODE>/ (and talk/?after=n) |
X-Seat or X-Watch |
Every response carrying a table goes through accept(), which also estimates the server’s clock:
games/static/games/room/kit/net.js Table.accept(snap) {
if (!snap?.table) return snap;
this.offset = snap.now * 1000 - Date.now();
// Every seat's view of one version is the same moment: only redraw on news.
if (snap.table.version < this.version) return snap;
this.version = snap.table.version;
this.last = snap;
for (const fn of this.listeners) fn(snap);
return snap;
}
offset makes table.now() the server’s time; countdowns and reveals run on it. An older version is dropped, since a slow response can land after a newer stream event. The same version still passes, because linking a seat returns the same moment with linked changed.
The stream and its fallback. Live updates are server-sent events read with fetch(), because EventSource can’t send the X-Seat header:
games/static/games/room/kit/net.js Table./** One stream connection. True if it delivered anything before ending. */
async streamOnce() {
this.abort = new AbortController();
let got = false;
// If nothing arrives promptly, this server can't stream: fall back.
const stall = setTimeout(() => { if (!got) this.abort.abort(); }, 8000);
// A stream that's gone quiet (no news and no keep-alive, sent every 10s)
// has quietly died, as a phone's can when it sleeps: open a fresh one.
let heard = Date.now();
const quiet = setInterval(() => {
if (got && Date.now() - heard > QUIET_MS) {
this.kick = true;
this.abort.abort();
}
}, 5000);
try {
const r = await fetch(`${this.url}stream/?talk=${this.heard}`, { headers: who(this.secret, this.watch), signal: this.abort.signal, cache: 'no-store', credentials: 'omit' });
if (!r.ok || !r.body) return false;
const reader = r.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
heard = Date.now();
buf += value;
let cut;
while ((cut = buf.indexOf('\n\n')) >= 0) {
const chunk = buf.slice(0, cut);
buf = buf.slice(cut + 2);
const data = chunk.split('\n').filter(l => l.startsWith('data: ')).map(l => l.slice(6)).join('\n');
const first = !got;
got = true;
if (data && chunk.startsWith('event: talk')) {
try { this.hear(JSON.parse(data)); } catch (err) { console.error('Couldn’t show what was said:', err); }
} else if (data) {
// (A garbled event, or a game's view failing to draw it: the next
// one will do, but a failure is said, so it isn't silent.)
// The first thing a stream says is where the table is now, not
// news: `resumed` tells a game that what's new in it may be old.
this.resumed = first;
try { this.accept(JSON.parse(data)); } catch (err) { console.error('Couldn’t show a table update:', err); } finally { this.resumed = false; }
}
}
}
} catch {
/* dropped: reconnect */
} finally {
clearTimeout(stall);
clearInterval(quiet);
}
return got;
}
games/static/games/room/kit/net.js Table.async loop() {
let failures = 0;
while (!this.closed) {
const ok = await this.streamOnce();
if (this.closed) return;
if (this.kick) {
this.kick = false;
continue;
}
failures = ok ? 0 : failures + 1;
if (failures >= 3) return this.poll();
await new Promise(r => setTimeout(r, Math.min(8000, 600 * 2 ** failures)));
}
}
Three timers watch a connection:
- Nothing in 8 seconds: this server can’t stream (Django’s
runserver, say). That’s a failure. - Silence after data for 25,000ms, though the server says
: still hereevery 10 seconds: the stream died quietly, as a sleeping phone’s does. It’s “kicked”, reopened at once without counting as a failure. - A normal end: the server closes each stream after 15 minutes. One that delivered is reopened after 600ms.
A stream that delivered nothing is a failure. The loop waits 1.2s, then 2.4s, and after three in a row polls instead, every 1.2 seconds (5 when the tab is hidden), trying the stream again every 30 seconds:
games/static/games/room/kit/net.js Table./** Poll while streaming isn't working, and try the stream again now and
* then: one network blip shouldn't mean polling for the rest of the game. */
async poll() {
let since = Date.now();
let first = true; // (the first look after a gap isn't news)
while (!this.closed) {
let snap = null;
try { snap = await call(this.url, { secret: this.secret, watch: this.watch }); } catch { /* try again */ }
this.resumed = first;
first = false;
try { if (snap) this.accept(snap); } catch (err) { console.error('Couldn’t show a table update:', err); } finally { this.resumed = false; }
if (this.talkers.size) {
try { this.hear(await call(`${this.url}talk/?after=${this.heard}`, { secret: this.secret, watch: this.watch })); } catch { /* next time */ }
}
await new Promise(r => { this.wakePoll = r; this.pollTimer = setTimeout(r, document.hidden ? 5000 : 1200); });
this.wakePoll = null;
this.kick = false;
if (Date.now() - since > 30000 && !this.closed) {
since = Date.now();
if (await this.streamOnce()) return this.loop();
}
}
}
Worked example: a phone wakes up. A phone sleeps for five minutes mid-game; its stream is dead, but nothing told the page.
- On wake,
visibilitychangefires, andfollow()’s handler setskickand aborts the stream. streamOnce()’s read throws, is caught, and returns.loop()seeskickand goes straight round, without backing off.- The new stream’s first event is the table as it is now, accepted with
resumedtrue, so a game can skip banners for old news (Bowl’sTable.resumed). accept()sees a newer version and callsfollow()’s listener, and the table redraws.
The player sees the current table within one round trip.
Talk: emotes and chat#
Every table with other people has the room’s talk (games/static/games/room/kit/talk.js, ADR 0052), placed by the room after each redraw, so no game draws it: a smiley in the top bar, after the game’s tools (on a phone, where they’re behind ⋯, just before it), and, when the host has Chat on, a speech bubble with a count of new lines. The stream brings what’s said as an event: talk of its own, which net.js hands to onTalk listeners, each line once, by its number; it never goes through accept(), so an emote never redraws the table.
games/static/games/room/kit/net.js Table.hear(talk) {
if (!talk || typeof talk.n !== 'number') return;
const fresh = (talk.said || []).filter(s => s.n > this.heard);
this.heard = Math.max(this.heard, talk.n);
if (fresh.length) for (const fn of this.talkers) fn(fresh);
}
The smiley opens a tray of eight emotes under it, four across (players only; not at a table against bots, where no one would see). A sent emote floats up from the bottom left of every screen (clear of a phone’s notch on its side: its left is past env(safe-area-inset-left)) with the sender’s seat mark and name, over everything but taking no press (pointer-events: none), for --m-emote; with reduced motion it shows where it starts and fades. Your own floats when the server’s taken it; the stream’s copy of it is skipped. An emote heard while the tab is hidden doesn’t float (its timers would wait, and every one sent meanwhile would pop up at the edge at once on coming back); the screen reader isn’t told it either. Options’ Keys and sound has “Others’ emotes” (uo:cards:emotes) to hide other people’s.
games/static/games/room/kit/talk.js Talk./** An emote rises from the bottom of the screen (or, with less motion,
* shows where it starts and fades). */
float(line) {
if (!line || (line.seat !== this.t?.you && !emotesOn())) return;
// (not into a hidden tab: its timers wait, and coming back the emotes
// sent meanwhile would all pop up and vanish at the screen's edge at once)
if (document.hidden) return;
const el = h('div.emote',
h('span.emoteface', line.text),
h('span.emotewho', seatMark(line.seat, this.initial(line), { size: 18 }), h('span', line.name)));
// Somewhere along the bottom's left side, as on a call (clear of a
// phone's notch on its side: the left safe area).
const span = Math.max(40, Math.min(innerWidth * 0.4, 320) - 120);
el.style.left = `calc(${16 + Math.round(Math.random() * span)}px + env(safe-area-inset-left, 0px))`;
el.classList.toggle('still', reducedMotion());
while (this.layer.childElementCount >= SHOWN_AT_ONCE) this.layer.firstChild.remove();
this.layer.append(el);
setTimeout(() => el.remove(), MOTION.emote + 100);
this.say(`${line.name}: ${SAID[line.text] || line.text}`);
}
Chat is a host table setting the room adds after the game’s own (chatSetting(), unless the game says chat: false, as Bowl and Alphabet Roulette do), never offered at a table against bots. On, it shares the game log’s panel as a second tab, so a pinned log’s column stays one column; a screen with no log (Fictionary, the waiting room) gets the same panel as a drawer of its own, under the top bar and above the moves:
games/static/games/room/kit/talk.js Talk./** Chat goes in the game's log as a tab, or in a drawer of its own. */
placeChat() {
const log = this.chatOn ? shownLog() : null;
if (this.host && this.host !== log) {
this.host.removeTab('chat');
this.host = null;
}
if (log && !this.host) {
this.drawer?.remove();
this.drawer = null;
this.host = log;
log.addTab('chat', this.tabLabel, this.pane, shown => this.shown(shown));
}
if (!this.chatOn) {
this.drawer?.remove();
this.drawer = null;
}
this.drawBadge();
}
The log’s side of it is addTab, select and showing in log.js: setOpen(true) (Log, key L) opens the Log tab, the bubble the Chat tab. A spectator reads the chat with nowhere to type. Escape in Chat, typing too, puts it away (putAway()): the drawer closes, or the log over the table does (a pinned log goes back to its Log tab), and focus goes back to the button that opens Chat (on a phone, the smiley). The log and Chat’s drawer are opaque, the sheet’s own color, so what’s under them never shows through.
A phone’s bar has room for one more button, not two. At 640px and under, with both, the bubble goes and Chat is the tray’s last row, the smiley carrying the count; at 400px and under a title the talk crowds takes two lines rather than an ellipsis; and in the waiting room at 360px and under, with Chat on, How to play is a “?”. All three are CSS in room.css, keyed on the talk being in the bar (.bar:has(> .talk…)), so a game’s bar is as it was wherever there’s no talk.
Seating, watching and seats changing hands#
In the waiting room the host can drag a seat, tap ⇅ on two seats to swap them (by keyboard too), or Shuffle seats. Each sends the whole new order as host('arrange', { order }), built by pure functions in games/static/games/room/kit/seating.js (swapped, moved, and shuffled, which never returns the current order). The waiting room keeps one width from its first seat, with Deal and the host’s buttons above the seats, so arrivals move nothing.
A late arrival can watch and hold a place at the next game (ADR 0018). places(t) counts free seats (those a person can have, less those taken or held). reserve() holds one under a name and turns the spectator’s id into a seat secret that becomes a real seat at the next game. A game that draws for spectators places room.watchBar(): “2 watching” to players, “Watching · Sit in next game” to spectators.
Seat news comes from comparing snapshots in games/static/games/room/kit/news.js. seatNews(a, b) is pure and returns sentences like “Ada left. A bot is playing for them.”, so every game gets it whatever its own log says. Who hosts is hostNews(a, b) (“Ben is the host now.”), which seatNews ends with and the room toasts even in a game with seatNews: false: hosting can pass on its own (a host away a minute, the tables chapter), in the waiting room too, and the new host has to know. Mid-game each game’s table tells its new host itself, with what they can now do (“You’re the host now: you can end the game or deal on.”), so hostNews says “You’re the host now.” only in the waiting room and after a game, never twice. The Leave dialog names who’d host next from the host’s snapshot (heir), the server’s pick, not a guess. The host’s offer to hand a seat to a bot comes from the same file:
games/static/games/room/kit/news.js coverDue · lines 52–59/** The seat the host might hand to a bot: a person (not a bot already, not
* you) who's missed `after` picks in a row, and more since they were last
* asked about (`asked`: missed count when the host said Not now, by seat). */
export function coverDue(t, after = 3, asked = {}) {
if (!t?.host || t.status !== 'playing') return null;
return t.seats.find(s => s.person && !s.bot && s.index !== t.you
&& (s.missed || 0) >= after && (s.missed || 0) >= (asked[s.index] ?? 0) + after) || null;
}
After “Not now”, the host isn’t asked about that seat again until it misses coverAfter more picks.
What the room floats for the host or for a seat a bot plays, the offer and the seat bar, goes in one place (floating(), .roomfloat), placed by placeFloat() so it covers nothing of the game’s at any size. Under the top bar it covered the seat at the top of a ring, Euchre’s partner, Fictionary’s scores (round 1 of the 2026-10-29 review); in the dock’s flow it made the dock taller and pushed the board out of view (David’s playtest that day). So: Take my seat back lies over the dock, the dock’s own size, saying “A bot is playing for you.” beside the button (while a bot makes your moves the dock’s controls have nothing to do). The host’s Replace with bot and the offer sit in the top bar’s empty middle, between the title’s words and the tools, in the bar’s ink. Where the bar has no such room (a phone) they go over its title, one at a time, in a line. The room places it again on each draw and on a resize:
games/static/games/room/kit/room.js Room./** Where what floats goes, so it covers nothing of the game's at any
* size: Take my seat back over the dock (a bot is making your moves, so
* the dock's controls have nothing to do: it says so, and the way
* back); the host's tools (Replace with bot, the offer of a bot) in the
* top bar, in its empty middle, or where the bar has none (a phone) over
* its title, one at a time, in a line. */
placeFloat() {
const f = this.float;
if (!f?.isConnected || !f.firstElementChild) return;
const box = el => el?.getBoundingClientRect?.();
const put = (mode, at) => {
f.className = `roomfloat ${mode}`;
Object.assign(f.style, { left: '', top: '', width: '', height: '' }, Object.fromEntries(Object.entries(at).map(([k, v]) => [k, `${Math.round(v)}px`])));
};
const dock = box(this.root.querySelector('.dock'));
if (f.querySelector('.seatrow.mine') && dock?.width && dock.height >= 44) {
return put('ondock', { left: dock.left, top: dock.top, width: dock.width, height: dock.height });
}
const bar = this.root.querySelector('.bar');
const b = box(bar);
if (!b?.width) return put('inbar tight', { left: 8, top: 30, width: Math.min(innerWidth - 16, 360) });
// The bar's empty middle: after the way up and the title's words, before the tools.
const back = box(bar.querySelector('.house-back'));
const title = bar.querySelector('.title');
const tr = box(title);
const words = tr?.width ? tr.left + Math.min(tr.width, title.scrollWidth) : 0;
const from = Math.max(back?.right || b.left, words);
const tools = Math.min(b.right, ...[...bar.children].map(box).filter(r => r.width && r.left >= from - 1).map(r => r.left));
const middle = b.top + b.height / 2;
put('inbar', { left: -9999, top: middle });
const need = box(f).width;
if (tools - from - 32 >= need) return put('inbar', { left: (from + tools) / 2, top: middle });
// (no room beside the title: over it, from the way up to the tools)
const left = (back?.right || b.left) + 8;
put('inbar tight', { left, top: middle, width: Math.max(160, tools - 8 - left) });
}
The seat bar is the room’s, drawn alike in every room game. seatRows decides what it says: while a bot plays your seat, the way back; and for the host only, in every game, each person away or holding up the table, with the way to hand their seat to a bot (the service refuses anyone else’s cover):
games/static/games/room/kit/news.js seatRows · lines 61–72/** What a table's dock has to say about seats, as rows (the room's seat
* bar): a bot playing yours (the way back to it), and, to the host only
* (the server refuses anyone else), each person who's away or holding the
* table up, with the way to hand their seat to a bot. Nothing when you're
* not seated, the game's not on, or it has no bots to hand a seat to. */
export function seatRows(t, { bots = true } = {}) {
if (!t || t.status !== 'playing' || t.you === null || t.you === undefined) return [];
if (t.seats[t.you]?.bot) return [{ back: true }];
if (!bots || !t.host) return [];
return t.seats.filter(s => s.person && !s.bot && s.index !== t.you && (!s.online || s.holding))
.map(s => ({ seat: s.index, name: s.name, away: !s.online }));
}
drawSeatBar makes Take my seat back (host('reclaim')) and Replace with bot (host('cover', { seat, level: 'medium' })), each a full 44px, since a seat’s corner is too small to press on a phone. The room places the bar, as above, unless the game names a place of its own with seatBarIn(root) (it goes first there, and back there if the game redraws it). Michigan Rails, Auction House and Last One Standing (on a wide window) float it over the foot of their map, board or tray instead. With no rows there’s no bar, and spectators never see it. The host’s rows show only once each has stood 4,000ms (steadyRows), so a player gone a moment (a phone woken, a stream reconnecting) never flashes Replace with bot into the bar; Take my seat back shows at once.
Seat identity, tiles and the roster#
Each seat has a color, a shape and its player’s initial, the same in every game (games/static/games/room/kit/identity.js). Twelve colors and twelve shapes mean seats can be told apart in gray or by someone who can’t tell two colors apart. A look may set --seat-0 … --seat-11; seatColor(i) reads them once per look. Each shape says where its initial sits (y, the middle of its capitals) and how large it is in the mark’s 24 units: fs for one letter, fs2 for two (else .8 of fs). Each is as large as the shape holds every capital 1.5 units in from its edge in every face a look uses (W in Tahoma’s bold is the widest), two letters 1 unit in, up to 14 (the circle, square and shield; a letter that fills a shape crowds it). The triangle, star and plus hold no more than they had (10, 8.5, 9.5). So the initial grows with the mark: 12px and more on a circle drawn 22px, as large as the star allows on a small one. The dealer’s chip on a mark is a 15px coin with a 12px “D”.
Seat tiles (games/static/games/room/kit/seats.js) give each player a tile sized only by the player count and the window, never by its contents, so nothing moves as the table fills. The game picks from the grids allowed for n players:
games/static/games/room/kit/seats.js grids · lines 18–30/** The grids a table of n players may use, as [columns, rows]; the game
* picks whichever fits its contents best. */
export function grids(n) {
if (n <= 1) return [[1, 1]];
if (n === 2) return [[2, 1], [1, 2]];
if (n === 3) return [[3, 1], [2, 2]];
if (n === 4) return [[2, 2]];
if (n <= 6) return [[3, 2]];
if (n <= 8) return [[4, 2], [3, 3]];
if (n === 9) return [[3, 3], [5, 2]];
if (n === 10) return [[5, 2], [4, 3]];
return [[4, 3], [6, 2], [3, 4]];
}
Wherever a game draws the table, you sit at the bottom and play goes clockwise, so the player on your left is next (ADR 0053). Seats are never read like a book. Three pure functions say where each seat goes, as [row, col, span] in play order from you, and tools/room/kit.test.mjs checks each one goes round: every seat in its own cell, you on the bottom row, each next seat further round. ringSeats(n) is a table ring: you alone at the bottom middle, the others up the left, across the top and down the right, and a middle cell for the piles. Where a corner beside a tile is empty, the tile spans the row. Up to five players its middle column is narrow, only as wide as what’s in it:
games/static/games/room/kit/seats.js ringSeats · lines 32–54/** A table ring, for n players (2 to 8): you alone at the bottom middle,
* the others up the left, across the top and down the right, and the
* middle of the table (the piles) between you. { cols, rows, cells (each
* [row, col, span], in play order from you), middle ([row, col]), narrow:
* the middle column only as wide as what's in the middle }. Up to five
* players, no one sits in the middle column but whoever spans it, so it's
* narrow and the tiles either side of it get the room; past five, the
* three columns share it. Where the bottom corners are empty, your tile
* spans the bottom row. */
export function ringSeats(n) {
if (n <= 2) return { cols: 1, rows: 3, cells: [[2, 0, 1], [0, 0, 1]].slice(0, n), middle: [1, 0], narrow: false };
if (n === 3) return { cols: 3, rows: 2, cells: [[1, 0, 3], [0, 0, 1], [0, 2, 1]], middle: [0, 1], narrow: true };
const others = {
4: [[1, 0, 1], [0, 0, 3], [1, 2, 1]],
5: [[1, 0, 1], [0, 0, 1], [0, 2, 1], [1, 2, 1]],
6: [[1, 0, 1], [0, 0, 1], [0, 1, 1], [0, 2, 1], [1, 2, 1]],
// (Seven: no one straight across, so the corners beside you are filled.)
7: [[2, 0, 1], [1, 0, 1], [0, 0, 1], [0, 2, 1], [1, 2, 1], [2, 2, 1]],
8: [[2, 0, 1], [1, 0, 1], [0, 0, 1], [0, 1, 1], [0, 2, 1], [1, 2, 1], [2, 2, 1]],
}[Math.min(n, 8)];
const you = n <= 6 ? [2, 0, 3] : [2, 1, 1];
return { cols: 3, rows: 3, cells: [you, ...others], middle: [1, 1], narrow: n <= 5 };
}
loopSeats(n, cols, rows) reads a two-row grid round its edge, you on the bottom row with the player on your left beside you (Michigan Rummy, under its pile spread). horseshoe(k) puts everyone else in two rows round a gap where you sit (Bullshit, on a phone held upright). SeatTiles.place(cells) puts each tile in its cell. SeatTiles.middle(el, at) puts what’s in the middle of the table in its cell, as one more item in the players’ list (“The middle of the table”), where it stays when the seats are made afresh. On a phone, the rummies’ seat chips run from the player on your left round to you, and your own tile is last in the list, pinned at its foot over your hand.
The roster (games/static/games/room/kit/roster.js) is the compact choice when the table is a board: one strip of fixed cells, each a seat mark with one status spot (✓ picked, … choosing, ◌ away, ⚙ bot, ★ host). Its shape is a pure, tested function of seats and width. Twelve seats fit one row from 960px, and two rows of six on a 320px phone, since a cell never goes under 44px:
games/static/games/room/kit/roster.js shape · lines 22–32/** The grid for n seats in `width` px: one row from WIDE up (or when they
* fit); else as many to a row as fit at CELL, the rows evened out (twelve
* on a 320px phone: 6×2). Names only if a cell is NAMED wide. Pure. */
export function shape(n, width) {
if (n <= 0) return { cols: 0, rows: 0, names: false };
const fit = Math.max(1, Math.floor(width / CELL));
let cols = width >= WIDE || n <= fit ? n : fit;
const rows = Math.ceil(n / cols);
cols = Math.ceil(n / rows);
return { cols, rows, names: width / cols >= NAMED };
}
Names show under the marks only in cells 96px or wider; otherwise a mark says its name to screen readers and shows it when tapped. A third layout, ring.js, puts seats on an ellipse, seat 0 at the bottom, the rest clockwise.
The log, slots and the countdown#
The game log (games/static/games/room/kit/log.js) is a drawer the game places, which a wide window can pin beside the table (uo:cards:log-pinned). The server keeps the whole log; when a game starts or the page loads, the log asks log/ as this seat (so private lines reach only their seats), then adds each snapshot’s new events. A game’s describe(event, before, after, spoken) turns an event into a line. Each new line also goes once into a separate role="log" region, so a screen reader hears it with the drawer closed.
The log opens only from the top bar (Log, key L; on a phone, the first item under ⋯). The one-line ticker that used to sit under the status is gone (ADR 0046): once a game’s all: unset made it static, its reach took every press on a phone’s table. A press that does nothing now says why in the status line (each game’s hint()).
A slot (games/static/games/room/kit/slots.js) is a button that stays put. A dock’s move buttons are slots, set on every update (label, key, disabled, hidden), never rebuilt. A hidden slot keeps its space and last label, so nothing beside it moves. When its label changes it’s “armed”: a capture listener drops pointer clicks on it for 400ms, since a click just after a button changed meaning was meant for the old one. A move worth a second thought asks in place:
games/static/games/room/kit/slots.js Slot./** A click, or its shortcut key: do it, or ask first. */
press() {
if (this.busy || this.el.disabled) return;
if (this.confirm && !this.asking) return this.ask();
// (A key still down from asking isn't the answer.)
if (this.asking && performance.now() - this.askedAt < ANSWER_MS) return;
this.unask(false);
this.go?.();
}
The first press turns the slot the warning color and asks (“Discard 9♠ anyway?”); a second does it. The question clears after 6,000ms, when focus leaves, or when the slot changes. Never window.confirm().
The countdown (games/static/games/room/kit/clock.js) is a fixed-width bar beside the seconds, emptying by the server’s clock, read aloud once at 10 seconds.
Reveal scripts: play and end#
A reveal, as in Fictionary and Bullshit, is timed by the server (ADR 0017, tables/reveal.py): a list of beats, each with a cue, an at offset and a length, counted from a starts moment on the server’s clock, so everyone sees each beat at the same instant. games/static/games/room/kit/script.js plays one:
games/static/games/room/kit/script.js play · lines 42–102/** Play `reveal` against `now()` (server seconds; table.now). Returns
* { skip(), done } (done: a promise for when it's over), or null if this
* reveal has already been played here. */
export function play(reveal, now, cues, { onSay, still = reduced } = {}) {
if (!reveal?.beats) return null;
const key = `${reveal.id}:${reveal.starts}`;
if (played.has(key)) return null;
played.add(key);
const parts = c => (typeof c === 'function' ? { play: c } : c || {});
const queue = new Cues({ most: Infinity, gap: 0, budget: Infinity });
const stop = new AbortController();
const say = beat => { if (beat.say) onSay?.(beat.say, beat); };
// (Each beat's end runs once, however it's reached.)
const ended = new Set();
const finish = (beat, how = {}) => {
if (ended.has(beat)) return;
ended.add(beat);
parts(cues[beat.cue]).end?.(beat, { still: false, skipped: false, ...how });
};
let left = 0;
let resolve;
const done = new Promise(r => { resolve = r; });
const settle = () => { if (--left <= 0) resolve(); };
const steps = timeline(reveal, now());
left = steps.length;
if (!left) resolve();
// (Beats over before we got here end at once, in order, said quietly: only
// the last of them is read out, so a late arrival isn't read a backlog.)
const past = steps.filter(s => s.state === 'past');
for (const s of past) finish(s.beat, { skipped: true });
if (past.length) say(past.at(-1).beat);
past.forEach(settle);
for (const s of steps.filter(x => x.state !== 'past')) {
const { beat } = s;
queue.add({
ms: beat.ms,
play: async signal => {
// Wait for the beat's moment by the server's clock, not ours.
await wait(Math.max(0, (reveal.starts + beat.at / 1000 - now()) * 1000), signal);
if (signal.aborted || stop.signal.aborted) return;
say(beat);
const cue = parts(cues[beat.cue]);
if (still()) {
// No motion, but the beat's change still happens at its moment.
if (cue.still) cue.still(beat);
else finish(beat, { still: true });
return wait(beat.ms, signal);
}
return cue.play ? cue.play(beat, signal) : wait(beat.ms, signal);
},
end: () => { finish(beat, { skipped: stop.signal.aborted }); settle(); },
});
}
return {
skip() {
stop.abort();
queue.skip();
},
done,
};
}
The rule that makes reveals robust is in the module’s header: a cue’s play is only motion, and may never run; every change of state belongs in its end, which runs exactly once per beat. play is skipped under reduced motion, for a beat already over, in a hidden tab, and after Skip. end runs after play, or instead of it at the beat’s moment (how.still), or at once (how.skipped).
Worked example. Two beats: flip at 0ms for 600ms, then score at 800ms for 400ms. A page sees the reveal one second after starts.
timeline()puts the flip in the past (1000 ≥ 0 + 600) and the score “now”, 200ms in.- The flip’s
endruns at once, as skipped, and its line is read out. Only the last past beat is read, so a late arrival doesn’t hear a backlog. - The score waits zero ms, plays its motion, then its
endmoves the score.
Under reduced motion the score’s end would run at its moment with still: true, and its length would still be waited out, so the next beat keeps time. The same reveal in a later snapshot isn’t replayed: played remembers id:starts.
Table motion#
games/static/games/room/kit/motion.js moves things across the table with the house’s durations. A flight is a ghost laid over the page with position: fixed, so it never moves or resizes anything; the real element can hide while it travels. Moves queue in Cues and play one at a time, so a burst reads as a sequence:
games/static/games/room/kit/motion.js Cues.add(cue) {
this.queue.push(cue);
// (nobody's looking: nothing to play, everything to its end)
if (document.hidden) { this.skip(); return; }
if (this.queue.length > this.most || this.behind() > this.budget) this.endOlder(this.keepFrom(this.most));
if (!this.running && !this.timer) this.next();
}
Too far behind (more than most waiting, six by default, or more than budget still to show, five moves’ holds, 5 × MOTION.hold, by default) the older moves go to their end as they do on your turn, below: the last one still plays, and so does everything from the last cue marked hold, unless that keeps more than most. The budget used to be 2.5 seconds and skipped everything, the last move too: two moves held 1.2 seconds each were already past it. With the tab out of sight every move goes straight to its end, in order. Whenever moves go to their end at once, the queue tells the house kit (catchingUp()) until it’s idle again, so their banners show only the newest. When it becomes your turn a game calls catchUp() instead of skipping (ADR 0054): with more than MOTION.catchUp (1.5 seconds) still to show, the older moves jump to their end, but the last one still plays, and so does everything from the last cue marked hold (a trick’s sweep), so the move just before yours is never the one cut. A held cue that’s already playing finishes first:
games/static/games/room/kit/motion.js Cues./** Your turn came while others' moves are still playing: if more than
* `after` ms is left, the older ones go to their end state, but the last
* one still plays, and so does every cue from the last one marked `hold`
* (a trick's sweep, a claimed route), so the move just before yours is
* always seen (docs/design-language.md, "Pacing others' moves"). Only
* what's waiting is ended, in order, so the cues kept still follow it. */
catchUp(after = MOTION.catchUp) {
if (this.behind() <= after || !this.queue.length) return;
// (a kept cue already playing finishes first; then the rest catch up)
if (this.running?.hold) { this.pendingCatch = after; return; }
this.endOlder(this.keepFrom());
if (!this.running) {
clearTimeout(this.timer);
this.next();
}
}
The card games’ shared moves are here: deal(), sweep() (a trick ringed for 900ms, then gathered to its winner in 350ms) and flip() (240ms through the card’s edge). Under reduced motion deal() resolves at once, flip() swaps the face, and sweep() keeps the ring’s hold but skips the travel. The caller always applies the end state.
Since your turn coming is when what was done to you matters most, no card game clears toasts then. What someone plays on your melds is one sentence in every rummy, games/static/games/room/kit/onyours.js: playedOnYours(name, plays) says “Ben played the 7♥ and 8♥ on your run.” (your runs, your melds), and OnYours holds a seat’s plays until the game says its turn is over (take(over, name)), so a turn whose plays arrive in several updates is one toast. The card games’ banners go through games/static/games/cards/kit/banners.js: Banners.show(content, opts, fresh) queues one behind the last and checks fresh() as its turn comes, so a call from a hand gone by never plays over the next (the house’s banners wait as long as it takes, and a background tab doesn’t run their motion); done() lets the result sheet wait for them, and drop(), as it opens, puts away any still waiting. A result sheet’s kicker is kicker(parts) (games/static/games/cards/kit/words.js): its parts joined by ” · “, each part’s words kept together, so a narrow sheet never leaves “HANDS” on a line alone.
Press and hold#
A finger resting on something to see more of it goes through one helper, games/static/games/room/kit/hold.js, so a hold means the same at every table: Auction House’s spaces show their card while held and put it away on the lift; Last One Standing’s cards open bigger in a sheet that stays. It counts after 450ms still; a drift of 10px first is a scroll, not a hold. A mouse never holds (it has hover and right-click). A plain tap still taps, and the lift that ends a hold is no tap: its click is eaten, and the phone’s own long-press callout is kept away:
games/static/games/room/kit/hold.js hold · lines 20–68/**
* hold(el, { onhold, onrelease, ms }) and returns { held, cancel, off }:
* held(), whether the press now down (or the one just lifted) was a hold;
* cancel(), forget a hold not yet counted; off(), stop listening.
*/
export function hold(el, { onhold, onrelease = null, ms = HOLD_MS } = {}) {
let timer = null, from = null, held = false, down = false;
const cancel = () => { clearTimeout(timer); timer = null; };
const on = [];
const listen = (type, fn, opts) => { el.addEventListener(type, fn, opts); on.push([type, fn, opts]); };
listen('pointerdown', e => {
held = false;
if (e.pointerType === 'mouse') return;
down = true;
from = [e.clientX, e.clientY];
cancel();
timer = setTimeout(() => { timer = null; held = true; onhold?.(e); }, ms);
});
listen('pointermove', e => {
if (timer && from && Math.hypot(e.clientX - from[0], e.clientY - from[1]) >= DRIFT_PX) cancel();
});
// (leaving before it counts is a drift; after, the finger still holds it
// until it lifts)
listen('pointerleave', e => { if (e.pointerType !== 'mouse') cancel(); });
const lift = e => {
if (e.pointerType === 'mouse') return;
cancel();
if (down && held) onrelease?.(e);
down = false;
};
listen('pointerup', lift);
listen('pointercancel', lift);
listen('touchend', e => { if (held) e.preventDefault(); }, { passive: false });
listen('click', e => {
if (!held) return;
held = false;
e.preventDefault();
e.stopImmediatePropagation();
}, true);
listen('contextmenu', e => e.preventDefault());
el.style?.setProperty?.('-webkit-touch-callout', 'none');
return {
held: () => held,
cancel,
off: () => { cancel(); for (const [type, fn, opts] of on) el.removeEventListener(type, fn, opts); },
};
}
Your move at a table#
A table redraws often and plays news of its own (the hand intro, banners, moves sliding in), so games/static/games/room/kit/cue.js adapts the house’s prompt and beckon. A game makes one YourMove, whose ready() says when its own motion has finished (!this.cues.behind()). On every update it says what’s yours to do, under a key for that decision:
games/static/games/room/kit/cue.js YourMove./**
* now(key, what): what's yours to do, said on every draw. `key` names this
* one decision (a new key is a new decision; null, or no `what`: nothing's
* yours). what: { targets, prompt } where targets are what to press (an
* element, a list, or a function returning them, asked again as the table
* redraws) and prompt, for a kind worth a card the first time it's yours,
* is { once, title, sub, over, into } (into: the element the card shrinks
* into, or a function; the targets by default). routine: false where the
* table already marks the place on every turn (a hand outlined in the turn
* color): the prompt's ring, but no ring after idle.
*/
now(key, what = null) {
if (!what) key = null;
if (key === this.key) {
this.what = what || this.what;
// The table redrew under the ring: ring what's there now.
if (this.ringing && !this.quiet && list(this.what?.targets).some(el => !el.classList.contains('house-beckon'))) this.beckonNow();
return;
}
this.stop();
this.key = key;
this.what = what;
if (!key || !cueOn()) return;
const p = what.prompt;
if (p?.once && !prompted(p.once)) this.whenFree(() => this.playPrompt(p));
else if (what.routine !== false) this.timer = setTimeout(() => this.whenFree(() => this.beckonNow()), this.idle);
}
- The same key is the same decision redrawn. Nothing restarts, though elements that replaced the ringing ones ring.
- A new key stops the old ring.
- The first time a kind of decision is yours in a game, its prompt plays (
prompt.once: the table’s code, game number and kind, kept inuo:room:prompted, the last 60). - Otherwise the place rings after a few idle seconds, unless
routine: falsesays the table already marks it every turn.
A prompt checks every 300ms for its moment. It never plays in a hidden tab, under a sheet, over a splash, the hand intro or a banner, or before ready(). That includes a banner still queued (bannerQueued()): one raised in the same draw goes up a moment later, so Alphabet Roulette’s “Time!” and the review’s first prompt, from one snapshot, play one after the other, not on top of each other. A decision gone by then never gets its card. Pressing or typing into what’s ringing quiets it for that decision.
Each game picks its moments (its chapter says which): a prompt the first time a new kind of choice is yours (Euchre’s first bid, Contract Rummy’s first chance to go down, Fictionary’s first vote), a ring for a turn the whole table waits on (Bowl’s Start my turn, Alphabet Roulette’s die), and nothing over a short clock.
The room’s own screens ring too, without cards: Share the link for a host alone; Deal once someone has joined or the table is full (not while you add bots, and never to hurry you past your friends); Take my seat back while a bot plays your seat. These reach every table game; the one switch turns them off.
Sharing, and the room’s Options#
Share result on a table’s result sheet is room.shareAction(snap). Its words are tableBrag in games/static/games/room/kit/share.js, the house’s Share result words for a table: where you came, a square a hand for how each went for you (🟩 the hand’s best score, 🟥 its worst, 🟨 between, ⬜ everyone level; none for a game that’s a single game), the game’s own lines, the standings by medal, and a link. The link is the game’s kept result page when it was kept (someone at the table was signed in: table history), so a friend sees every hand; otherwise it’s the game. A game passes its own head and lines, and podium: false when it says its sides itself (Euchre’s “We won 10–9 🏆” and “🤝 Ana & Ben beat Cleo & Dot”):
games/static/games/room/kit/share.js tableBrag · lines 219–235/** A finished table's Share result text (house/brag.js): "{emoji} {title} ·
* {head}", a square a hand for how each went for you (games played in
* rounds; never one that's a single game), the game's own `lines`, the
* standings by medal (`podium: false` for a game that says its sides
* itself; `total(seat)` writes a score its way), and resultLink. `head`
* defaults to where you came ("I won 🏆", "2nd of 4"). */
export function tableBrag(snap, { game, title, head, lines = [], podium: standings = true, total } = {}) {
const t = snap.table, st = t.standings;
const names = t.seats.map(s => (s.bot && !s.person ? `${s.name} 🤖` : s.name));
const row = st && t.terms?.round !== 'game' ? roundRow({ rounds: st.rounds, you: t.you, lowWins: !!t.terms?.low_wins }) : '';
return brag({
game, title,
head: head ?? (st ? placeLine({ places: st.places, you: t.you }) : ''),
lines: [row, ...lines, standings && st ? podium({ names, places: st.places, totals: st.totals, you: t.you, ...(total ? { total } : {}) }) : []],
url: resultLink(t),
});
}
On a phone the text goes to the share sheet alone, straight from the tap; elsewhere it’s copied. Either way the button then gives way to Copy text, Copy image and Download image: the score card picture, which share.js draws as an SVG painted onto a canvas in the system font (an SVG on a canvas can’t load the page’s fonts). The picture used to go with the text on a phone, but drawing it first lost the tap (Safari then refused both the share sheet and the copy), and messaging apps often dropped the words beside a picture. Returning false keeps the sheet open:
games/static/games/room/kit/room.js Room./** Share result on the house's result sheet (result({ share })): the
* button gives way, in its place, to the picture's buttons once the
* text's shared or copied. */
shareAction(snap, opts) {
const { go, more } = this.sharing(snap, opts);
return (handle, e) => {
const btn = e?.currentTarget;
go().then(copied => {
if (!copied || !btn?.isConnected) return;
const next = more();
btn.replaceWith(...next);
next[0]?.focus();
});
return false;
};
}
The room’s Options are the house’s sheet with the game’s Helpers (ending with Show what to do for a game with cues) and Playing, the room’s Keys and sound (Shortcut keys, Chime on my turn, Others’ emotes), and the look’s Table style:
games/static/games/room/kit/options.js spec · lines 90–102/** The whole spec: the game's rows, the room's, the look's. */
function spec(own = {}, shown = look) {
// (An older game's rows came as a list of nodes: they're its Playing.)
const mine = Array.isArray(own) ? { playing: own } : own || {};
const helpers = mine.helpers || [];
return {
helpers: cues && !helpers.some(r => r.id === 'cue') ? [...helpers, cueOption()] : helpers,
playing: mine.playing || [],
keys: [...keyRows(), ...(mine.keys || [])],
look: shown?.rows ? shown.rows() : [],
helpersNote: mine.helpersNote,
};
}
The room’s own settings keep their uo:cards: keys (uo:cards:shortcuts, uo:cards:chime, uo:cards:emotes, uo:cards:theme, uo:cards:name). The prefix is older than the other table games, and renaming it would lose people’s settings and seats.
One switch is each game’s own: the intro a game plays over its table (room/kit/intro.js: the rummies’ Hand intro, Auction House’s Auction intro), whose Playing row is introOption(label, hint). It’s kept per game, uo:<slug>:intro, keyed by the game the room is playing (useGame), so turning off Auction intro leaves the rummies’ hand intros on. It used to be one switch for every game, uo:cards:intro; Contract Rummy and Michigan Rummy, whose it was, still read a choice made there until a player chooses again:
games/static/games/room/kit/intro.js introOn · lines 32–37/** The intro plays unless this browser turned it off in Options, for this
* game (the room's, unless named). */
export function introOn(game = currentGame()) {
const was = KEPT_SHARED.includes(game) ? keep.get(SHARED, true) : true;
return keep.get(introKey(game), was);
}
The card room#
games/static/games/cards/ is what standard-deck card games add on top of the room: table styles, card faces and the hand (ADR 0009). A game with its own deck or board never uses a table style; Get In Line borrows the faces and the hand, and the felt for its foyer.
Table styles. games/static/games/cards/themes.css is the card room’s root. Its :root declares the house tokens, and each style is a block of variables under [data-theme="…"], with Clubhouse also on :root as the default. A few per-style rules fix what variables can’t (Retro PC’s light gray sheets re-ink everything inside them). games/static/games/cards/kit/themes.js lists the styles and applies one:
games/static/games/cards/kit/themes.js apply · lines 27–31export function apply(id = current()) {
document.documentElement.dataset.theme = id;
const felt = THEMES.find(t => t.id === id)?.felt;
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', felt || '#1d4a3c');
}
It sets data-theme on <html>, and theme-color to the felt so a phone’s browser bar matches. The pick is kept in uo:cards:theme for every card game (Clubhouse removes the key), and a pick of a style that’s since been cut goes back to Clubhouse, the key removed:
games/static/games/cards/kit/themes.js current · lines 17–25/** The style in use: the one this browser picked, or Clubhouse. A pick of
* a style that's since been cut (Classic, Formica and Midnight, 2026-10-29:
* their indicators were hard to see) goes back to Clubhouse for good. */
export const current = () => {
const id = keep.get(KEY);
if (THEMES.some(t => t.id === id)) return id;
if (id !== null) keep.drop(KEY);
return 'clubhouse';
};
Every style must make every indicator a card game draws stand out: the turn ring (round a seat, the buy cue round the pile, a must-play’s ring), the turn color on a panel (the dock’s edge, a hot badge), a picked card’s mark beyond its lift, Your move’s ring at 75%, a hot badge’s words, the cards on the felt, and seat news’s words. tools/room/styles.mjs measures each from the style’s tokens, laid over every color of its felt: 3:1 for what you see (WCAG 1.4.11), 4.5:1 for words, and the turn color at least 0.12 apart (in OKLab) from the style’s own ink and edges, so a ring in it doesn’t read as one more outline. check:room fails a style that falls short. Classic, Formica and Midnight did, and were cut after the 2026-10-29 playtest rather than tuned (David’s call). games/static/games/cards/kit/looks.js wraps this as the look that start() takes: apply() before the first screen, and rows(), the Table style swatches in Options.
The felt foyer. A game whose board has its own look (Get In Line) puts data-foyer="felt" on its page and loads themes.css before its own tokens. The Clubhouse block’s extra selector, [data-foyer="felt"]:has(.felt.foyer), is more specific than the game’s :root, so while a foyer screen shows, its menu and waiting room look like every card game’s; at the table its own look returns (ADR 0036). check:tokens checks every style (Clubhouse overlaid with its block) for every house and room token, and for soft ink at 4.5:1 on its sheets and every felt color (a tiled pattern’s flecks aside).
Card faces. A card is a short string from the server: “QH0” is rank, suit and deck number; “JK1” a joker. cardEl() in games/static/games/cards/kit/cards.js draws it:
games/static/games/cards/kit/cards.js cardEl · lines 32–55/** A card element. opts: small, back, as (what a joker stands for, shown on
* the joker itself), selected. */
export function cardEl(c, opts = {}) {
if (opts.back || !c) {
return h('div.card.back', { class: opts.small ? 'small' : '', 'aria-label': 'Face-down card' });
}
const classes = [isJoker(c) ? 'joker' : suit(c).toLowerCase()];
if (opts.small) classes.push('small');
if (opts.selected) classes.push('sel');
const el = h('div.card', { class: classes.join(' '), dataset: { card: c }, 'aria-label': spoken(c) + (opts.as ? `, standing for ${opts.as}` : '') });
if (isJoker(c) && opts.as) {
el.classList.add('standing');
// What it stands for sits in the corner, which stays visible when cards overlap.
el.append(h('span.ix', { 'aria-hidden': 'true' }, '★', h('i.asis', opts.as)), h('span.pip', { 'aria-hidden': 'true' }, '★'));
} else if (isJoker(c)) {
el.append(h('span.ix', { 'aria-hidden': 'true' }, '★', h('i', 'JKR')), h('span.pip', { 'aria-hidden': 'true' }, '★'));
} else {
el.append(
h('span.ix', { 'aria-hidden': 'true' }, rankLabel(rank(c)), h('i', SUIT_GLYPH[suit(c)])),
h('span.pip', { 'aria-hidden': 'true' }, SUIT_GLYPH[suit(c)]),
);
}
return el;
}
- The corner index and pip are
aria-hidden; the name is thearia-label(“Queen of hearts”). - Suit glyphs carry U+FE0E, the text-presentation selector, so ♥ is never an emoji.
- A suit in a circle (a roundel, the trump’s mark) is drawn by
suitIcon()ingames/static/games/cards/kit/suits.js, because a glyph comes from whichever font has it, and Safari’s sat 2px below Chrome’s. - Each suit’s color is a token (
--suit-s,--suit-h…), which is how the Four-color style works. games/static/games/cards/cards.csssizes a card from--cw(about 76px wide at 1280×800, 103 at 1920×1080).
The hand. Every game where you hold cards uses Hand in games/static/games/cards/kit/hand.js. Its Suit and Rank orders are sortBy in games/static/games/cards/kit/cards.js, aces high (… Q K A, highRank: David’s call, 2026-10-29), the order shown only, never how a run is judged; Euchre and Sixty-Six sort by their own card strength, where aces are high already. sync() merges the server’s cards into what’s shown, keeping your order, adding new cards at the right (or in sorted place), and telling a new deal from a card or two picked up:
games/static/games/cards/kit/hand.js Hand./** Match the server's hand: keep your order, add new cards on the right
* (or into their sorted place). `deal` names the deal (the hand number):
* when it changes, the cards are a new hand, not cards picked up. */
sync(cards, deal = null) {
const have = new Set(cards);
const first = this.known === null;
const arrived = first ? [] : cards.filter(c => !this.known.has(c));
// Card names repeat between deals, so a new hand is told by its number,
// or by many cards arriving at once.
const dealt = !first && ((deal !== null && this.deal !== null && deal !== this.deal) || arrived.length > 3);
if (first && deal !== null && this.keptDeal !== deal) this.custom = [];
if (deal !== null) this.deal = deal;
this.arrived = new Set(dealt ? cards : arrived);
// A card or two picked up is news; a whole new hand dealt isn't.
if (arrived.length || dealt) this.fresh = new Set(dealt ? [] : arrived);
for (const c of [...this.fresh]) if (!have.has(c)) this.fresh.delete(c);
if (dealt) {
// A new hand starts in suit order, whatever order the last one was in.
this.custom = this.sorter(cards, 'suit');
this.selected.clear();
}
this.custom = this.custom.filter(c => have.has(c));
const placed = new Set(this.custom);
const added = cards.filter(c => !placed.has(c));
this.custom.push(...(this.custom.length ? added : this.sorter(added, 'suit')));
for (const c of [...this.selected]) if (!have.has(c)) this.selected.delete(c);
this.known = have;
keep.set(this.key, { deal: this.deal, cards: this.custom });
this.arrange();
}
Card names repeat between deals (every Rummy hand has a QH0), so a new hand is recognized by its deal number, or by more than three cards arriving at once. Your order is kept in this browser per table, with its deal. Cards always overlap in one fan, and the hand scrolls only when it can’t fit:
games/static/games/cards/kit/hand.js Hand./** Overlap the cards always (never more than MAX_STEP of a card showing),
* more as the hand grows to fit, but never show less than MIN_STRIP of a
* card: past that, the hand scrolls sideways. */
fit() {
const cards = [...this.el.querySelectorAll('.card')];
if (!cards.length) return;
const w = cards[0].getBoundingClientRect().width;
// (The hand's own padding, and a little to spare for the picked card's mark.)
const pad = getComputedStyle(this.el);
const room = this.el.clientWidth - parseFloat(pad.paddingLeft) - parseFloat(pad.paddingRight) - 8;
// Space for the next multiple of three cards, so one card in or out
// doesn't re-space the whole hand.
const n = Math.max(cards.length, Math.ceil(cards.length / 3) * 3);
const step = n > 1 ? Math.min(w * MAX_STEP, (room - w) / (n - 1)) : w;
// A long hand on a narrow screen may show a little less of each card
// (never under 20px) rather than scroll.
const all = cards.length > 1 ? (room - w) / (cards.length - 1) : w;
const overlap = Math.max(step - w, Math.min(MIN_STRIP, Math.max(20, all)) - w);
this.el.style.setProperty('--overlap', `${overlap}px`);
// Once the next card covers the corner pip, hide pips on all but the last.
this.el.classList.toggle('fanned', overlap < -w * 0.15);
this.edges();
}
With today’s constants, 11 cards 76px wide in 600px are spaced as if there were 12 (a multiple of three, so one card more or less doesn’t re-space the hand). The step is (600 − 76) / 11 ≈ 47.6px, under the cap of two-thirds of a card. That overlap, about 28px, is over 15% of a card, so the hand is fanned and pips show only on the last card. A card always shows at least 20px, and 28px where there’s room; only a hand that can’t fit at 20px a card scrolls, its edges fading.
The hand is one tab stop: arrows move, Space or Enter picks, Alt+arrows move a card. Cards are patched in place, never redrawn, so focus never jumps; a card that leaves hands focus to its replacement. Shifted cards slide (220ms, MOTION.reflow); a card picked up arrives (350ms) with a “New” tag until you next act.
The pencil-puzzle kit#
games/static/games/puzzles/ has two halves (ADR 0023; docs/puzzles.md is the reference): engine/, pieces every puzzle engine shares, which the referee also runs under Node (Referees and anti-cheat); and kit/, the interface every puzzle uses.
The engine pieces#
A puzzle is made from a code. Every engine seeds the kit’s random stream with a string naming everything that should change the puzzle, `${SLUG}|${ENGINE_VERSION}|${size}|${difficulty}|${code}` (or an earlier version, for an engine that still makes some: VERSIONS). So a new engine version makes new puzzles from the same codes, and the browser and the referee make the same puzzle from the same code:
games/static/games/puzzles/engine/rng.js stream · lines 24–53/** sfc32 seeded from a string: { next() in [0, 1), int(n), pick(list), chance(p), shuffle(list) }. */
export function stream(seed) {
let [a, b, c, d] = hash(String(seed));
const next = () => {
a |= 0; b |= 0; c |= 0; d |= 0;
const t = (((a + b) | 0) + d) | 0;
d = (d + 1) | 0;
a = b ^ (b >>> 9);
b = (c + (c << 3)) | 0;
c = (c << 21) | (c >>> 11);
c = (c + t) | 0;
return (t >>> 0) / 4294967296;
};
for (let i = 0; i < 15; i++) next();
const int = n => Math.floor(next() * n);
return {
next,
int,
pick: list => list[Math.floor(next() * list.length)],
chance: p => next() < p,
/** Shuffles `list` in place (Fisher–Yates) and returns it. */
shuffle(list) {
for (let i = list.length - 1; i > 0; i--) {
const j = int(i + 1);
[list[i], list[j]] = [list[j], list[i]];
}
return list;
},
};
}
It’s sfc32, seeded through a 128-bit string hash, using only integer math (Math.imul, shifts, | 0) that every JavaScript engine computes identically. It matches Hashi’s stream bit for bit, so Hashi can move onto the kit without changing a chart. Codes (games/static/games/puzzles/engine/code.js) are six characters from 23456789ABCDEFGHJKLMNPQRSTUVWXYZ, with no 0/O or 1/I to misread. An endless puzzle’s code comes from crypto.getRandomValues; a daily’s from the server (Dailies).
games/static/games/puzzles/engine/engines.js finds an engine by slug for the referee, the checks and the puzzle-making worker. It uses Object.hasOwn so a slug like constructor can’t find something on the prototype.
games/static/games/puzzles/engine/engines.js ENGINES · lines 14–24export const ENGINES = {
'aquarium': () => import('../../aquarium/engine/index.js'),
'chicago-traffic': () => import('../../chicago-traffic/engine/index.js'),
'codebreaker': () => import('../../codebreaker/engine/index.js'),
'fillomino': () => import('../../fillomino/engine/index.js'),
'galaxies': () => import('../../galaxies/engine/index.js'),
'griddles': () => import('../../griddles/engine/index.js'),
'low-poly': () => import('../../low-poly/engine/index.js'),
'star-battle': () => import('../../star-battle/engine/index.js'),
'yin-yang': () => import('../../yin-yang/engine/index.js'),
};
games/static/games/puzzles/engine/engines.js engine · lines 26–31/** The engine for a slug, or throws for one the kit doesn't have. */
export async function engine(slug) {
const load = Object.hasOwn(ENGINES, slug) && ENGINES[slug];
if (!load) throw new Error(`No puzzle called ${slug}.`);
return load();
}
Each engine’s index.js meets the engine contract: SLUG, ENGINE_VERSION, SIZES, DIFFICULTIES, ALPHABET, CODE_RE, newCode(), generate(code, size, difficulty), check(puzzle, answer), solution(puzzle), solve(puzzle, tier), rate(puzzle) and count(puzzle, limit). It may add VERSIONS, earlier versions it still makes, with generate(code, size, difficulty, stats, version); Star Battle does, so dailies made before its depth floor keep their puzzles.
generate is deterministic and bounded by counted attempts, not time, so a slow machine never makes a different puzzle. It keeps a puzzle only if rate says it’s exactly the difficulty asked for and count finds exactly one answer (except Fillomino, whose count is too slow at 10×10; check:puzzles runs count on every engine). If every size has its own difficulty, a size alone names a puzzle (“8×8”).
A judged engine exports JUDGED, like Chicago Traffic’s sliding-block puzzle (docs/chicago-traffic.md). It has no single answer and no ladder of techniques, so no solve or count: any legal answer solves it, movesOf(answer) counts its moves, and rate is the tier its fewest moves fall in, found by search. Its puzzles still come from codes, and its sizes are its four tiers.
How a puzzle plugs in, step by step#
Follow Star Battle’s daily 10×10 from the page to the win screen.
1. The page. games/templates/games/star-battle.html only extends games/templates/puzzles/play.html. That puts the puzzle’s addresses on <html> (data-daily, data-solves, data-archive, data-leaderboard, and data-csrf only for a signed-in player, so a guest gets no CSRF cookie). It loads house/paper.css (the paper’s colors, faces and grain, shared with the hub and this guide, ADR 0036), puzzles.css (the kit’s look on it), kit.css (the interface), star-battle.css (its accent and board), house.css, and the module star-battle/js/app.js.
2. The entry is the kit’s start with the puzzle’s module:
games/static/games/star-battle/js/app.js lines 1–6// Star Battle's front door: the kit does the rest (../../puzzles/kit/app.js).
import { start } from '../../puzzles/kit/app.js';
import star from './puzzle.js';
start(star);
The module (games/static/games/star-battle/js/puzzle.js) is all a puzzle brings: its words (title, noun, kicker, lead), its engine, sizeLabel(size) (“10×10 ★★”, stars drawn by the kit’s withStars()), its settings (those flagged assist are helpers), keysHint, rules(), thumb() for a daily’s card, mark() for its emblem, and mount(ctx), the board.
3. Start. start(game) in games/static/games/puzzles/kit/app.js configures the house kit with the puzzle kit’s button classes, makes the store (uo:star-battle:…, keeping saves of any version the engine still makes), and builds the hub (the server calls), the maker and the router. Addresses are hash routes, so the whole game is one page:
games/static/games/puzzles/kit/app.js start.async function route() {
const mine = ++ticket;
// (the puzzle lets go of the sheets' news first, then whatever is open, waiting ones too, shuts)
leave?.();
leave = null;
closeSheets();
const parts = location.hash.replace(/^#\/?/, '').split('/').filter(Boolean);
// (a game without dailies takes an old daily's address to its home, quietly: docs/adr/0043)
if (parts[0] === 'daily' && !hub.hasDailies) { location.replace('#/'); return; }
if (parts[0] === 'daily') {
waiting(`Opening today’s ${game.noun}…`);
let today;
try { today = await hub.todays(); } catch {
if (mine !== ticket) return;
toast(`Today’s ${game.noun} isn’t in yet. Try an endless one.`);
location.hash = '#/';
return;
}
if (mine !== ticket) return;
const p = today.puzzles.find(q => String(q.size) === parts[1]);
if (p) return open(p.size, p.difficulty, p.code, today.date, mine, null, p.engine);
// (never an echo of the address: a size we know of is named, anything else is just a bad address)
toast(Object.hasOwn(engine.SIZES, parts[1] || '') ? `Today’s ${game.noun} is a different size.` : 'That address doesn’t lead anywhere.');
location.hash = '#/';
return;
}
if (parts[0] === 'archive' && hub.hasArchive) {
if (isDay(parts[1])) return openPast(parts[1], mine);
if (!parts[1] || isMonth(parts[1])) {
archive(root, kit, parts[1] || '');
window.scrollTo(0, 0);
return;
}
toast('That address doesn’t lead anywhere.');
location.hash = '#/archive';
return;
}
if (parts[0] === 'p') {
const [, size, difficulty, code] = parts;
const made = Object.hasOwn(engine.SIZES, size) && engine.DIFFICULTIES.includes(difficulty) && (!bySize || levelOf(size) === difficulty);
if (made && engine.CODE_RE.test((code || '').toUpperCase())) {
return open(size, difficulty, code.toUpperCase(), null, mine);
}
toast('That address doesn’t lead anywhere.');
location.hash = '#/';
return;
}
home(root, kit);
window.scrollTo(0, 0);
// How to play opens by itself once per game per browser, here on the home before the first puzzle.
if (!seenHowTo(game)) howToPlay(game, { auto: true, options: store.options() });
}
ticket stops a slow route finishing after a newer one: every await is followed by if (mine !== ticket) return.
4. Making the puzzle. #/daily/10 asks hub.todays() (cached five minutes) for today’s code and the engine version the day was made under. versionOf uses the day’s version while the engine still makes it; an endless puzzle uses the version it was saved under, else the current one. A hard 10×10 can take most of a second, so it’s made in a Web Worker, which finds the engine through engines.js. If a module worker can’t run or dies, jobs run on the page’s thread. A made puzzle is kept for the visit, so the home’s thumbnail and the puzzle are made once:
games/static/games/puzzles/kit/maker.js createMaker · lines 6–65/**
* { make(code, size, difficulty, version) → Promise<puzzle> } for a puzzle
* module (its `engine` is the fallback). `version` is an earlier engine
* version the engine still makes, for a daily made under it; left out, the
* current one.
*/
export function createMaker(game) {
let worker = null, broken = false, seq = 0;
const jobs = new Map(); // id → { resolve, reject, args }
const made = new Map(); // "size/difficulty/code" → Promise<puzzle>
const here = ({ resolve, reject, args }) => setTimeout(() => {
// (an engine whose puzzles are files, Low Poly's, makes them asynchronously)
Promise.resolve().then(() => game.engine.generate(...args)).then(resolve, reject);
}, 0);
function fail() {
broken = true;
worker?.terminate();
worker = null;
const stranded = [...jobs.values()];
jobs.clear();
stranded.forEach(here);
}
function start() {
if (worker || broken) return;
try {
worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
worker.onmessage = ({ data: { id, puzzle, error } }) => {
const job = jobs.get(id);
if (!job) return;
jobs.delete(id);
if (error) job.reject(new Error(error)); else job.resolve(puzzle);
};
// The worker couldn't load or died: carry on without it.
worker.onerror = fail;
} catch { fail(); }
}
return {
make(code, size, difficulty, version = game.engine.ENGINE_VERSION) {
const key = `${size}/${difficulty}/${code}/${version}`;
if (made.has(key)) return made.get(key);
const p = new Promise((resolve, reject) => {
// (an engine that makes one version is never asked for one)
const earlier = version !== game.engine.ENGINE_VERSION;
const job = { resolve, reject, args: earlier ? [code, size, difficulty, {}, version] : [code, size, difficulty] };
start();
if (!worker) { here(job); return; }
const id = ++seq;
jobs.set(id, job);
worker.postMessage({ id, slug: game.slug, code, size, difficulty, version: earlier ? version : null });
});
made.set(key, p);
p.catch(() => made.delete(key));
return p;
},
};
}
games/static/games/puzzles/kit/worker.js lines 1–16// Makes puzzles off the page's thread, so a big one (a hard 10×10 can take
// most of a second) never stalls the interface. maker.js talks to this.
// It runs the puzzle's own engine, found by slug as the referee finds it.
import { engine } from '../engine/engines.js';
self.onmessage = async ({ data: { id, slug, code, size, difficulty, version = null } }) => {
try {
const game = await engine(slug);
// (an earlier version the engine still makes: a daily made under it)
const args = version === null ? [code, size, difficulty] : [code, size, difficulty, {}, version];
self.postMessage({ id, puzzle: await game.generate(...args) });
} catch (e) {
self.postMessage({ id, error: String(e?.message || e) });
}
};
5. The desk. play() in games/static/games/puzzles/kit/play.js loads the save (uo:star-battle:p:10/hard/<CODE>), ignoring one from a different version, since a code makes a different puzzle under each. Then it gives the board its context and calls game.mount(ctx):
games/static/games/puzzles/kit/play.js play.const ctx = {
puzzle,
engine: kit.engine,
saved: g.state ?? null,
options: () => store.options(),
/** Whether Shortcut keys is on: off turns off the board's single keys (a grid reads this itself). */
keys: () => store.options().keys,
/** The board changed in response to a player's move; `move` is what Undo and Redo replay. */
record(move) {
if (g.solved || paused) return;
g.moves.push(move);
g.redo = [];
after();
},
say: (text, spoken) => say(text, spoken),
announce,
locked: () => g.solved || paused || reading,
pinching: () => zoom.pinching(),
touch: coarse(),
// (a browser refuses, and says so in the console, before the page has had a tap: a first press-and-hold)
buzz: ms => { if (touching && navigator.userActivation?.hasBeenActive !== false) navigator.vibrate?.(ms); },
reduced: reducedMotion,
};
The board returns its element and sizing (el, aspect, fit(px)), state() to save, apply(move, undo), clear(), answer() (null until solved), status(), celebrate(animate) and a few more; docs/puzzles.md lists them all. The kit draws everything around it: the bar (the arrow, the clock, Pause, How to play, Options), the paper with its folio (“Daily · Mon, Oct 19”, “10×10 ★★”), the zoom strip, and the dock (a two-line status, then Undo · Redo · Restart). The paper keeps the house gutter at each side, 16px on a phone too, and the board is fitted inside it.
6. A move. The board owns its marks. On a tap it changes them and calls ctx.record(move), where a move is any JSON the board can replay (Star Battle’s is { c: [[cell, from, to], …] }). The kit pushes it for Undo, clears Redo, then calls board.refresh() and board.answer(). Undo and Redo are board.apply(move, true | false). Restart is board.clear() recorded as one more move, so Undo brings the board back.
7. Solved. When answer() returns something, the clock stops and the solve is kept locally (the tally, the streak, the dailies log), then posted:
games/static/games/puzzles/kit/play.js play.// ── solved ───────────────────────────────────────────────────────────
function solved(answer) {
if (g.solved) return;
g.elapsed = elapsedMs();
since = null;
g.solved = true;
g.seconds = Math.max(1, Math.round(g.elapsed / 1000));
g.solvedAt = Date.now();
if (scored) g.count = board.score();
const before = store.tally(puzzle.size, puzzle.difficulty);
g.pb = { first: before.solved === 0, beat: before.best, best: before.best === null || g.seconds < before.best };
store.countSolve(puzzle.size, puzzle.difficulty, g.seconds);
if (daily) store.days.add(daily);
if (day) store.dailies.add(day, puzzle.size, g.seconds);
// (the site's dailies: its streaks, and a run through today's from the Dailies page)
if (daily) { dailyDone(game.slug, daily, g.seconds, String(puzzle.size)); fromRun(); }
hub.track('puzzle_solved', { ...puzzle, daily });
finish(true, answer);
}
After the board’s celebration, finish() stamps “Solved 1:09” on the folio and swaps Pause for Share result in the same slot. Its words are the kit’s for every puzzle: the day (or the puzzle’s number) and size, the time, today’s place once posted, a new best, the streak, and any rows the puzzle draws (shareLine, Codebreaker’s key pegs; never a spoiler), with a brainteaser’s count (“🏁 18 moves · par 14”, or outOf’s “4/10”). The link opens this very puzzle, or today’s daily. It posts { code, size, difficulty, engine, answer, seconds }; the referee rebuilds the puzzle from its code and accepts only its one answer (Referees and anti-cheat). A guest’s solve waits in pending until they sign in (hub.flushPending()). Then the result sheet opens with state: 'won', the time, your best and the post line, and as its primary the next daily, the next in a Dailies-page run, or New puzzle. It celebrates a new best (or a brainteaser in as few moves as it can be done, or a rating high) with best, else a daily with daily (Celebrations).
The board’s surface#
Most boards are grids. gridSurface(ctx, opts) in games/static/games/puzzles/kit/grid.js is what every cell-grid board shares: an <svg> where cell (column, row) is the unit square at (column, row), with room for clues at negative coordinates, named layers, a hover cell and a keyboard cursor. It only reports gestures; the board owns the marks. Its callbacks are tap(i, info) (info.at says which side of a cell was meant), press(i) (right-click, Shift- or Alt-click, or a 420ms hold), stroke(i) (a painting drag, returning { over(j), end(), cancel() }, plus trace(x, y) for boards that draw along cell edges, like Galaxies), key(event, cell) and escape().
- A press becomes a drag only after leaving its first cell and moving 10px (less on small cells).
- Between
pointermoveevents the surface walks the line in quarter-cell steps, so a fast swipe visits every cell it crossed, in order. - With two fingers down (
ctx.pinching()) any gesture is let go, so a pinch never paints. - With Shortcut keys off, the surface drops letters and digits itself; arrows, Space, Enter, Escape and Backspace always work.
- A board whose drag selects (Fillomino) uses
cellSelection(), which holds the pick, restores it on cancel, and outlines it withselectionPath(). - A board whose second kind of mark paints too (Griddles’ crosses) passes
pressStrokes: a right- or Shift-drag, or a hold that drags, is astroke(i, info)withinfo.press, starting as the hold lands. A click or a still hold is apress.
Numbers and letters in a board’s SVG sit on their baseline, lifted by MID from games/static/games/puzzles/kit/ui.js, 0.35em (half a capital’s height). dominant-baseline: central isn’t used, because Safari places it differently and numbers sat high in their rings.
Zoom (games/static/games/puzzles/kit/zoom.js) first fits the board to its room, with cells up to 64px (84 on a very wide screen). Only if cells end up under 28px are − + and Fit offered, up to 52px cells. Pinch, trackpad pinch, Ctrl and the wheel, and a finger on the paper beside the grid all work. The board is only told its size in pixels per unit.
Sheets, saving and the hub#
How to play and Options are house sheets (games/static/games/puzzles/kit/sheets.js). How to play is the puzzle’s rules({ fingers, options }), then the kit’s Timing and zoom sections; options lets the words follow a setting (Aquarium’s follow Tap order). It opens by itself once per game per browser, on the home, before the first puzzle (uo:<slug>:howto). Options splits the puzzle’s settings into Helpers and Playing and adds the kit’s Keys and sound rows, stored where they always were:
games/static/games/puzzles/kit/sheets.js optionsSpec · lines 56–71/** The Options sheet's rows, from the puzzle's settings and the kit's, kept where they always were (store.options). */
export function optionsSpec(game, store) {
const own = game.settings || [];
const kept = (id, row) => ({ get: () => store.options()[id], set: v => store.setOption(id, v), ...row, id });
const row = o => kept(o.name, { label: o.title, hint: o.hint, choices: o.choices, default: o.default });
const keys = [KIT_KEYS, game.keysHint].filter(Boolean).join(', ');
return {
// (a puzzle that says its first move, game.firstMove, has the house's Show what to do too)
helpers: [...own.filter(o => o.assist).map(row), ...(game.firstMove ? [cueOption()] : [])],
playing: own.filter(o => !o.assist).map(row),
keys: [
kept('keys', ROW.shortcuts(`Single keys: ${keys}. Arrows, Space and Esc always work on the board.`)),
kept('timer', ROW.clock()),
],
};
}
Its Helpers end with Show what to do for a puzzle with a first move to show (below).
Any sheet but the result sheet stops the clock and hides the board (onSheets in play.js), because a sheet is never free thinking time. The clock also stops when the tab is hidden or the game is paused, and the time is saved every five seconds and on pagehide.
The store (games/static/games/puzzles/kit/store.js) keeps everything in localStorage under uo:<slug>::
| Key | What it holds |
|---|---|
p:<size>/<difficulty>/<code>, or for a daily p:<size>/<difficulty>/<code>/<day> |
Each puzzle opened: its marks, up to 600 moves for Undo, its time and engine version |
recent |
The 150 most recent puzzles; older ones are forgotten |
tally |
Solves and best times by size and difficulty |
options, choice, days, dailies, pending |
Settings, the last pick, days for the streak, dailies for the archive, solves waiting to post |
A daily is kept under its day, so it never opens a game played under the same code in Endless (Low Poly’s daily code is the picture’s own), or the reverse. A daily saved before dailies had their own keys, under its code and marked with its day, is still found:
games/static/games/puzzles/kit/store.js loadGame · lines 43–58/**
* A puzzle's saved state, or null. A puzzle saved under a version the
* engine no longer makes can't be drawn again, so it doesn't count; one
* saved under a version it still makes is opened at that version.
* A daily saved before dailies had keys of their own is under the
* puzzle's key, marked with its day: it's found there.
*/
loadGame(key) {
let game = read(`p:${key}`, null);
const [size, difficulty, code, day] = key.split('/');
if (!game && day) {
const before = read(`p:${size}/${difficulty}/${code}`, null);
if (before?.daily === day) game = before;
}
return versions.includes(game?.engine) ? game : null;
},
Progress never leaves the browser unless the player posts a solve (AGENTS.md rule 6). The hub (games/static/games/puzzles/kit/hub.js) is everything that talks to the server, and runs only when the page says data-hub="1": today’s lineup, the archive, posting, counting starts and solves, and flushing pending solves. Offline, 429 and 5xx get a Try again; a 422 (an answer the referee refused) doesn’t.
Your first move#
A puzzle module may bring firstMove({ fingers }), returning { title, sub, tray }: what a first move is, in a few words (“Mark your first cell”; “Tap a cell: a dot where no star can go, or a star”). On the first puzzle of that kind a player opens in this browser, while it’s empty, play() plays it as the house’s prompt once no sheet is up, so How to play comes first. It shrinks into the board, or into the tray for a puzzle played from one (Codebreaker’s pegs), which then rings until the first move.
It’s kept in uo:<slug>:first-move, so it’s once per kind, not per puzzle. The clock waits while it plays (running() is false while prompting), so a first daily’s time isn’t charged for it. A ring on the paper has no turn color, so kit.css gives .house-beckon the puzzle’s accent. Hashi and Chess Puzzles do the same in their own play.js: “Build your first bridge”, and “Find the best move” once the opponent has moved, saying which side you play.
The archive#
Every kit puzzle keeps its past dailies playable (ADR 0032), one a day from 2026-10-22 (ADR 0037). #/archive lists a month at a time, newest first, one card a day with no size picker. Each card says Solved in 2:31, In progress or Not started, from this browser’s dailies log, the puzzle’s save and, when signed in, the server’s record of your solves of those codes or on those days.
A past daily opens at #/archive/<date>/<size>, and its arrow goes up to its month. Solving one offers the newest earlier day you haven’t solved, whatever its size, in the loaded month:
games/static/games/puzzles/kit/archive.js nextFrom · lines 38–47/**
* After solving a past daily, the next one to play from a month of the
* archive: the newest earlier day not yet solved. { date, size, difficulty }
* or null.
*/
export function nextFrom(kit, data, date) {
const mine = solvedMap(data);
const d = data.days.find(d => { const st = d.date < date && standing(kit, d.date, d.puzzle, mine); return st && st.solved == null && !st.lost; });
return d ? { date: d.date, size: d.puzzle.size, difficulty: d.puzzle.difficulty } : null;
}
An archive solve is posted and refereed like any other, but never joins its day’s board or the streak (Ratings has how it rates).
Brainteasers#
Chicago Traffic and Codebreaker are puzzles played with pieces (ADR 0042). To a player they’re kit puzzles (the home, the desk, the dock, the sheets), with a few opt-in differences the kit reads from the module, the board and the page:
- A count. A board with
score()is scored on moves as well as time. The bar shows the count by the clock, out of the fewest when the module givespar(puzzle)(“3 / 14 moves”). The stamp and result sheet lead with it, a solve keeps it (g.count), and cards say “Solved in 14 moves, 1:02”.scoreNounnames it;fewest(puzzle, n)writes the result sheet’s line. - No taking back.
undo: falsedrops Undo, Redo, Restart, Z and Y (Codebreaker: a guess is final). - Giving up.
giveUp: trueadds a quiet Give up that asks in place (a second press within six seconds does it). It ends the game like a loss, withg.gaveUp, the house’sgaveupsheet and the module’sgaveUpText;reveal(animate, { gaveUp })shows the answer. A brainteaser’s “Pick up where you left off” row has Give up too (giveUpLaterinhome.js), marking the save without opening it (ADR 0043). - Losing. A board’s
lost()ends the game without a solve: the clock stops,reveal()plays, a sticker says the module’slostWord, and the sheet islost. Nothing is posted and no streak grows. The save keepsg.lost, so the home and archive say so and what’s next skips it. - No dailies (ADR 0043). The page has no
data-dailyordata-archive, sohasDailiesandhasArchiveare false: no Today’s card, archive or streak, and an old#/daily/address goes home. The server agrees (Kind.dailies): daily and archive calls answer 404, and the leaderboard has no Daily board. - Levels.
sizeTitle: 'Level'names the sizes on the Endless picker.
games/static/games/puzzles/kit/play.js lost · lines 324–339// Out of moves (Codebreaker's last row, guessed wrong), or given up (a
// brainteaser's Give up): over, not solved. Nothing is posted and no
// streak grows; the board shows how it ends (board.reveal).
function lost(gaveUp = false) {
if (g.solved) return;
g.elapsed = elapsedMs();
since = null;
g.solved = true;
g.lost = true;
g.gaveUp = gaveUp;
g.seconds = Math.max(1, Math.round(g.elapsed / 1000));
if (scored) g.count = board.score();
// (played from the Dailies page, it still goes on to the next game's daily)
if (daily) fromRun();
finish(true, null);
}
They use the toy box look instead of the paper. Their page extends brainteasers/play.html, which fills puzzles/play.html’s look block with games/static/games/brainteasers/toybox.css (the paper’s token names in molded-plastic colors, and Rubik), the kit’s look and interface, then games/static/games/brainteasers/brainteasers.css (rounder, firmer shapes). The token check holds the toy box to the paper’s rules (the token contract).
The rules the kits hold#
The design system’s rules (Principles) are held mostly by the kits being their only implementation, and partly by checks. Rules and checks lists every rule with the kit call and the check that holds it; Testing and checks has how each check works.
Changing the kits#
A change to a kit changes every game that uses it. That’s the point, and the risk. The usual changes:
- A house-kit behavior (sheets, keys, the result sheet): update its test in
tools/house/kit.test.mjs, runnpm run check:houseandnpm run check:tokens, thencheck:layoutandcheck:detailswith the site running. Changing the result sheet’s order means changingdocs/design-language.mdfirst. - A new motion: add it to
MOTIONand to the first:where(:root)block ofhouse.csswith the same number (the test fails otherwise), and to the reduced-motion block if it travels. - A new token
house.cssreads: add it to the fallbacks block as--house-x, falling back to the room’s name if there is one. Unless its chain ends in a literal default,check:tokensthen names every root that lacks it. - The room kit: run
npm run check:roomandnpm run check:tokens, and play a table in each look. The room’s storage keys stayuo:cards:…; newer ones, likeuo:room:prompted, are the room’s own. - A game’s Your move: a prompt for a new kind of choice, once per kind per game; a ring for a turn that’s easy to miss; nothing every turn, and nothing over a short clock. Play the moment at desktop and phone sizes, and with Show what to do off.
- A table style: add a block in
themes.cssand an entry inTHEMES;check:tokenschecks its tokens and contrast. - The puzzle kit’s engine pieces: run
npm run check:puzzles, and bumpENGINE_VERSIONin every engine whose puzzles would change. A change torng.jschanges them all.