Brainteasers
The Register
A solo murder mystery where you strike out thousands of names, clue by clue, until only the killer is left.
On this page
The Register is a solo murder mystery. The suspects are a book of 26,000 to 52,000 names, and each clue is a true statement about the killer’s name or place in the book. You strike out everyone a clue clears with a highlighter, and accuse the one name left.
Everything follows from an eight-letter case number. The same number, difficulty, mode and engine version build the same book, clues and killer on any machine. So the browser builds the case, the server rebuilds it under Node to check a solve, and a shared link is just the number.
It was the site’s first game, a desktop app folded into this repo (ADR 0007), with a leaderboard and referee (ADR 0003, ADR 0006) and a rating. It has no daily: dailies are for puzzles only (ADR 0040). It’s a free fan game inspired by The Killer Isn’t Alice by Iris Starling, unofficial and unaffiliated.
At a glance#
| Part | Where |
|---|---|
| The case generator: the search, the validator | games/static/games/the-register/engine/generator.js |
| Tuning: sizes, bands, styles, balance, search effort | games/static/games/the-register/engine/config.js |
| The clue library: 82 families in 36 types | games/static/games/the-register/engine/rules.js |
| Settings (13), people, name banks (19) | games/static/games/the-register/engine/settings.js, games/static/games/the-register/engine/people.js, games/static/games/the-register/engine/names/ |
| Page layout (the book) | games/static/games/the-register/engine/book.js |
| The random stream | games/static/games/the-register/engine/rng.js |
| Story: prologue, epilogue, titles | games/static/games/the-register/engine/story.js |
| The interface: home, the book, pens, print, help | games/static/games/the-register/js/ and games/static/games/the-register/app.js |
| Its look | games/static/games/the-register/styles.css, static/the-register/pages.css |
| Its page | games/templates/games/the-register.html |
| The leaderboard, scoring, the referee’s Python | leaderboard/ |
| The referee’s Node script | referee/solve.mjs |
| Checks | tools/register/check.mjs, tools/register/variety.mjs, tests/test_leaderboard.py |
| Design notes | docs/the-register.md |
There’s no tables/ module and no server-side game state. The server stores the killers it has rebuilt (SolvedCase) and verified solves (Score), plus the old DailyCase rows, kept for the scores posted on them. The engine files are plain ES modules with no browser APIs, so the same code runs in a Web Worker (a background thread) and under Node.
The rules in code#
What a case is#
A case is a JSON object that generateCase returns and the browser stores whole:
games/static/games/the-register/engine/generator.js generateCase · lines 45–112/**
* Build a complete case from a code. Deterministic: same code + difficulty +
* mode + engine version (+ config) → identical setting, names, clues and solution.
*/
export function generateCase(code, difficulty = 'classic', mode = 'cold', onProgress = () => {}) {
if (typeof mode === 'function') { onProgress = mode; mode = 'cold'; }
const t0 = Date.now();
const diff = DIFFICULTIES[difficulty];
if (!diff) throw new Error(`Unknown difficulty ${difficulty}`);
if (!MODES[mode]) throw new Error(`Unknown mode ${mode}`);
const root = caseRng(code, difficulty, mode);
// The setting decides who these people are and how they're listed; the
// difficulty decides how many of them there are.
const setting = root.fork('setting').pick(SETTINGS);
const total = namesFor(difficulty);
const label = `Entering ${total.toLocaleString('en-US')} ${setting.people} into the ${setting.registerWord}`;
onProgress({ phase: 'names', label, p: 0 });
const names = generatePeople(root.fork('people'), setting, total);
onProgress({ phase: 'names', label, p: 1 });
const chapterPages = planChapters(root.fork('chapters'), names.length);
const book = layoutNovel(names, chapterPages, { fullNames: true });
onProgress({ phase: 'clues', label: mode === 'inquiry' ? 'Lining up the witnesses' : 'Gathering the evidence', p: 0 });
const rng = root.fork('clues');
const plan = casePlan(root.fork('plan'));
const build = mode === 'inquiry' ? tryBuildInquiry : tryBuildCase;
let solved = null, attempts = 0;
while (!solved && attempts < SEARCH.attempts) {
attempts++;
// If this case's mix of clue types keeps failing, let it draw on all of them.
solved = build(rng, book, difficulty, plan, attempts > SEARCH.attempts / 2);
onProgress({ phase: 'clues', label: mode === 'inquiry' ? 'Lining up the witnesses' : 'Gathering the evidence', p: Math.min(0.95, attempts / 12) });
}
if (!solved) throw new Error(`Could not construct a balanced case in ${SEARCH.attempts} attempts — loosen BALANCE or raise SEARCH in config.js`);
const { killer, victim, rules } = solved;
const story = generateStory(root.fork('story'), book.entries[victim], setting);
const ctx = { entries: book.entries, book, victim: book.entries[victim], registerWord: setting.registerWord };
const finalRules = rules.map(r => ({ ...r, ...describeRule(r, ctx) }));
onProgress({ phase: 'verify', label: 'Checking every alibi', p: 0.5 });
const caseData = {
engine: ENGINE_VERSION,
code,
difficulty,
mode,
setting: setting.id,
style: plan.style,
title: caseTitle(story),
story,
layout: 'novel',
fullNames: true,
names,
chapterPages,
victim,
rules: finalRules,
solution: seal(killer, code),
stats: { pageCount: book.pageCount, attempts, iterations: solved.iterations, ms: 0, sectionSurvivors: solved.sectionSurvivors },
};
const v = validateCase(caseData);
caseData.validation = { ok: v.ok, checks: v.checks };
caseData.metrics = v.metrics;
caseData.stats.ms = Date.now() - t0;
if (!caseData.validation.ok) throw new Error('Case failed validation: ' + caseData.validation.checks.filter(c => !c.ok).map(c => c.label).join('; '));
onProgress({ phase: 'done', label: 'The case is ready', p: 1 });
return caseData;
}
It picks the setting, deals the people into chapters and pages, searches for a killer, a victim and a chain of clues, adds a story, and checks the result with validateCase.
names is every full name in reading order, and chapterPages is each chapter’s page count. layoutNovel derives each name’s page, column and line from those two, so the layout is never stored. The killer is stored as solution, sealed (Secrets).
The book#
Names run down each of three columns in turn, then on to the next page. A chapter’s first page is shorter:
games/static/games/the-register/engine/book.js layoutNovel · lines 23–55/**
* Novel layout: names in the given (shuffled) reading order, three columns,
* chapters of `chapterPages[c]` pages each. Every chapter but the last fills
* its pages exactly; the book's final page splits its names evenly.
*/
export function layoutNovel(names, chapterPages, { fullNames = false } = {}) {
const entries = [], pages = [], chapterInfo = [];
let k = 0, pageNo = 0;
chapterPages.forEach((count, ci) => {
const firstPage = pageNo + 1, start = k;
for (let pg = 0; pg < count && k < names.length; pg++) {
const opener = pg === 0;
const rows = opener ? ROWS_OPENER : ROWS_FULL;
const onPage = Math.min(names.length - k, rows * NOVEL_COLS);
const perCol = onPage === rows * NOVEL_COLS ? [rows, rows, rows] : balance(onPage, NOVEL_COLS);
pageNo++;
const page = { page: pageNo, ci, opener, rows, cols: perCol.map(() => []) };
let s = 0;
perCol.forEach((n, c) => {
for (let line = 1; line <= n; line++, s++) {
const i = entries.length;
entries.push({ i, name: names[k + s], ci, page: pageNo, col: c + 1, line });
page.cols[c].push(i);
}
});
pages.push(page);
k += onPage;
}
chapterInfo.push({ ci, label: String(ci + 1), title: `Chapter ${numberWord(ci + 1)}`, word: numberWord(ci + 1), firstPage, lastPage: pageNo, count: k - start });
});
for (const e of entries) decorate(e);
return { entries, pages, chapters: chapterInfo, pageCount: pageNo, cols: NOVEL_COLS, style: 'novel', fullNames };
}
A column has 25 lines (15 on a chapter’s first page) and a page 3 columns, so a page holds 75 names and an opener 45. A chapter is 10 to 30 pages. planChapters draws 12 to 26 pages at a time and folds a too-short last chapter into the one before:
games/static/games/the-register/engine/book.js planChapters · lines 63–87/** Split the register's names into chapters of 10–30 pages (mostly 12–26), novel-style. */
export function planChapters(rng, total) {
const full = ROWS_FULL * NOVEL_COLS, open = ROWS_OPENER * NOVEL_COLS;
const cap = p => open + (p - 1) * full;
const pagesFor = n => 1 + Math.ceil(Math.max(0, n - open) / full);
const plan = [];
let left = total;
while (left > 0) {
const p = rng.range(12, 26);
if (left <= cap(p)) {
const need = pagesFor(left);
if (need >= CHAPTER_PAGES[0] || !plan.length) plan.push(need);
else {
// Too short for a chapter: fold it into the previous one, or split the two evenly.
const merged = plan.pop();
const namesInBoth = cap(merged) + left;
const both = pagesFor(namesInBoth) + 1; // two openers instead of one
if (pagesFor(namesInBoth) <= CHAPTER_PAGES[1]) plan.push(pagesFor(namesInBoth));
else { const a = Math.ceil(both / 2); plan.push(a, pagesFor(namesInBoth - cap(a))); }
}
left = 0;
} else { plan.push(p); left -= cap(p); }
}
return plan;
}
decorate gives every entry stats for its first name and its surname: the letters only (lower case, accents folded so É is E, apostrophes and hyphens dropped), the length, the vowel count (A, E, I, O, U, never Y), whether a letter repeats or is doubled, and a bitmask of the letters used. Name clues read these stats, never the raw string, so each clue has one definition:
games/static/games/the-register/engine/book.js stats · lines 129–141function stats(part) {
const n = letters(part);
let vowels = 0, mask = 0, repeat = false, dbl = false;
for (let j = 0; j < n.length; j++) {
const c = n[j];
if (VOWEL(c)) vowels++;
const bit = 1 << (c.charCodeAt(0) - 97);
if (mask & bit) repeat = true;
mask |= bit;
if (j && n[j - 1] === c) dbl = true;
}
return { name: part, lower: n, len: n.length, vowels, consonants: n.length - vowels, mask, repeat, double: dbl };
}
bookIndex builds the lookups that connection and section clues need, once per book: who has a given first name or surname, and each entry’s family group, the run of consecutive entries sharing a surname.
The clue library#
A clue is a family (a kind of statement, with a test and a sentence) plus params (the specifics). rules.js holds every family the game has ever dealt: 82, of which 62 are dealt today. Families are grouped into types (36, of which 32 are dealt), and a case holds at most one clue of each type. The types fall into four tiers:
| Tier | What it’s about | Dealt types |
|---|---|---|
record (sections) |
Stretches of the book, anchored to people in it | span, landmark, nearVictim, chapterCompany, nearPerson, chapterRelative |
name |
The name itself, or the first name set against the surname | initial, length, presence, position, ending, vowels, repetition, pairs, order, runs, initials, mirror |
connection |
Neighbours, the same line, the victim, family, namesakes | before, after, lineMates, neighbours, victimLetters, victimLength, pageCompany, household, namesakes, familySize |
reasoning |
Two conditions joined, or a name crossed with a number | numbers, ifThen, eitherOr, bothOrNeither |
Four more types (chapter, page, column, line) are legacy gates like “the killer’s page number is prime”, kept so cases from before engine 7 still work. DEALT_TYPES leaves them out, and validateCase fails a new case that has one.
The whole registry is one array:
games/static/games/the-register/engine/rules.js TYPES · lines 1014–1057// ── clue types: at most one clue of each type per case ───────────────────
// `broad` types are always present; they set the difficulty. Families listed
// under a type are interchangeable ways of saying something about one subject.
export const TYPES = [
// Engine 7 sections: stretches of the register, anchored to people in it.
{ id: 'span', tier: 'record', label: 'Between Two Names', families: ['span'] },
{ id: 'landmark', tier: 'record', label: 'A Landmark Name', families: ['landmark'] },
{ id: 'nearVictim', tier: 'record', label: 'Near the Victim', families: ['victimSpan'] },
{ id: 'chapterCompany', tier: 'record', label: 'The Chapter’s Company', families: ['chapterCompany'] },
{ id: 'nearPerson', tier: 'record', label: 'Near a Named Person', families: ['nearPerson'] },
{ id: 'chapterRelative', tier: 'record', label: 'A Named Person’s Chapter', families: ['chapterRelative'] },
// Before engine 7: simple structural gates, struck a page or column at a time. No longer dealt.
{ id: 'chapter', tier: 'registry', legacy: true, label: 'The Chapter', families: ['chapterParity', 'chapterPrime', 'chapterMultiple', 'chapterRange', 'chapterSpelled', 'pageInChapter'] },
{ id: 'page', tier: 'ledger', legacy: true, label: 'The Page Number', families: ['pagePrime', 'pageParity', 'pageMultiple', 'pageDigitSum', 'pageLastDigit', 'pageHasDigit', 'pageReversed'] },
{ id: 'column', tier: 'placement', legacy: true, label: 'The Column', families: ['column'] },
{ id: 'line', tier: 'placement', legacy: true, label: 'The Line Number', families: ['lineParity', 'lineHalf', 'lineMultiple', 'linePrime'] },
{ id: 'initial', tier: 'name', label: 'The Initial', families: ['word', 'half', 'straight', 'window', 'victimInitial'] },
{ id: 'length', tier: 'name', label: 'Length', families: ['lengthEq', 'lengthCmp', 'lengthParity'] },
{ id: 'presence', tier: 'name', label: 'Letters Present', families: ['contains', 'containsAny'] },
{ id: 'position', tier: 'name', label: 'Letter Positions', families: ['secondVowel', 'thirdVowel', 'secondHalf'] },
{ id: 'ending', tier: 'name', label: 'The Last Letter', families: ['endsVowel', 'lastHalf', 'endsIn'] },
{ id: 'vowels', tier: 'name', label: 'Vowels', families: ['vowelCount', 'balance'] },
{ id: 'repetition', tier: 'name', label: 'Repeated Letters', families: ['repeat', 'double'] },
{ id: 'pairs', tier: 'name', label: 'Letter Pairs', families: ['pair'] },
{ id: 'order', tier: 'name', label: 'Alphabetical Order', families: ['firstVsLast', 'secondVsFirst'] },
{ id: 'runs', tier: 'name', label: 'Vowel & Consonant Runs', families: ['vowelRun', 'consonantRun'] },
{ id: 'before', tier: 'connection', label: 'The Name Before', families: ['prevLength', 'prevLast'] },
{ id: 'after', tier: 'connection', label: 'The Name After', families: ['nextLast', 'nextLength'] },
{ id: 'lineMates', tier: 'connection', label: 'The Same Line', families: ['lineLength', 'lineLast', 'lineInitial'] },
{ id: 'neighbours', tier: 'connection', label: 'Both Neighbors', families: ['neighbourEnds', 'neighbourLength'] },
{ id: 'victimLetters', tier: 'connection', label: 'The Victim’s Letters', families: ['victimShare'] },
{ id: 'victimLength', tier: 'connection', label: 'The Victim’s Length', families: ['victimLength'] },
{ id: 'pageCompany', tier: 'connection', label: 'The Top of the Page', families: ['pageFirstSecond', 'pageFirstLength'] },
{ id: 'household', tier: 'connection', label: 'The Family', families: ['familyNeighbour', 'familyPosition'] },
{ id: 'namesakes', tier: 'connection', label: 'Namesakes', families: ['pageNamesake', 'columnNamesake'] },
// Engine 7: more ways to read a name, its family, and reasoning puzzles.
{ id: 'initials', tier: 'name', label: 'The Initials', families: ['initialsOrder', 'initialsKind', 'initialsWord'] },
{ id: 'mirror', tier: 'name', label: 'First Name & Surname', families: ['mirrorLength', 'mirrorShared', 'mirrorOrder', 'mirrorLink'] },
{ id: 'familySize', tier: 'connection', label: 'The Size of the Family', families: ['familySize'] },
{ id: 'numbers', tier: 'reasoning', label: 'Names & Numbers', families: ['lineVsLength', 'parityMatch'] },
{ id: 'ifThen', tier: 'reasoning', label: 'If… Then…', families: ['compound_implies'] },
{ id: 'eitherOr', tier: 'reasoning', label: 'Either… Or…', families: ['compound_xor', 'compound_or'] },
{ id: 'bothOrNeither', tier: 'reasoning', label: 'Both or Neither', families: ['compound_iff'] },
];
Each family supplies:
instances(ctx): every set of params, for families small enough to list. In a full-name book, a name family lists each twice, for first name and surname (part).sample(rng, ctx, killer, n): for the section and compound families, which have too many forms to list. Each attempt drawsninstances already true of the killer.test(entry, params, ctx): does the entry pass? A clue clears an entry that fails it, and the killer passes every clue.textandquote: the exact statement, which is the authority, and the witness’s flavor line.describeRulefills both in with the setting’s word (“Manifest”, “Census”); full-name books useFULL_TEXT, which says “first name”.
Section clues drive the early game. span (“the killer is, or is not, listed between A and B”) anchors on people whose full name appears exactly once, so the player can find them with Find:
games/static/games/the-register/engine/rules.js uniqueAnchors · lines 968–973/** Indices of people whose full name appears exactly once, in register order. */
function uniqueAnchors(book) {
let a = anchorCache.get(book);
if (!a) { a = []; for (const [, ids] of bookIndex(book).byFull) if (ids.length === 1) a.push(ids[0]); a.sort((x, y) => x - y); anchorCache.set(book, a); }
return a;
}
landmark (“after the first person whose surname is Radnai”) needs a surname occurring 3 to 15 times, and chapterCompany (“someone else in the killer’s chapter has the surname X”) 2 to 8. nearPerson and nearVictim count pages, and chapterRelative compares chapters:
games/static/games/the-register/engine/rules.js chapterRel · line 964const chapterRel = (e, a, rel) => rel === 'later' ? e.ci > a.ci : rel === 'earlier' ? e.ci < a.ci : (Math.abs(e.ci - a.ci) <= 1) === (rel === 'near');
Reasoning clues join two atoms, simple conditions like “the surname ends in a vowel” or “the killer is on an odd-numbered line”, with one of four operators. Every compound includes at least one name atom, so none can be struck by position alone:
games/static/games/the-register/engine/rules.js ATOMS · lines 980–992const ATOMS = [
{ id: 'start', name: true, kind: 'start', make: r => ({ part: r.pick(['first', 'last']), vowel: r.chance(0.5) }), test: (e, p) => isVowel(e[p.part].lower[0]) === p.vowel, say: p => `the killer’s ${PW[p.part]} begins with a ${p.vowel ? 'vowel' : 'consonant'}` },
{ id: 'end', name: true, kind: 'end', make: r => ({ part: r.pick(['first', 'last']), vowel: r.chance(0.5) }), test: (e, p) => isVowel(e[p.part].lower[e[p.part].len - 1]) === p.vowel, say: p => `the killer’s ${PW[p.part]} ends in a ${p.vowel ? 'vowel' : 'consonant'}` },
{ id: 'len', name: true, kind: 'len', make: r => { const part = r.pick(['first', 'last']); return { part, k: part === 'first' ? r.range(5, 7) : r.range(6, 8) }; }, test: (e, p) => e[p.part].len >= p.k, say: p => `the killer’s ${PW[p.part]} has ${NUM[p.k]} or more letters` },
{ id: 'has', name: true, kind: 'has', make: r => ({ part: r.pick(['first', 'last']), c: r.pick('aeinorlst'.split('')) }), test: (e, p) => e[p.part].lower.includes(p.c), say: p => `the killer’s ${PW[p.part]} contains the letter ${p.c.toUpperCase()}` },
{ id: 'dbl', name: true, kind: 'dbl', make: r => ({ part: r.pick(['first', 'last']) }), test: (e, p) => e[p.part].double, say: p => `the killer’s ${PW[p.part]} has a double letter (the same letter twice in a row)` },
{ id: 'half', name: true, kind: 'half', make: r => ({ part: r.pick(['first', 'last']) }), test: (e, p) => e[p.part].lower[0] < 'n', say: p => `the killer’s ${PW[p.part]} begins with a letter from A to M` },
{ id: 'longer', name: true, kind: 'longer', make: () => ({}), test: e => e.first.len > e.last.len, say: () => 'the killer’s first name is longer than their surname' },
{ id: 'odd', kind: 'odd', make: () => ({}), test: e => e.line % 2 === 1, say: () => 'the killer is on an odd-numbered line' },
{ id: 'top', kind: 'top', make: () => ({}), test: e => e.line <= 12, say: () => 'the killer is on one of lines 1 to 12' },
{ id: 'evenPage', kind: 'evenPage', make: () => ({}), test: e => e.page % 2 === 0, say: () => 'the killer’s page number is even' },
{ id: 'left', kind: 'left', make: () => ({}), test: e => e.col === 1, say: () => 'the killer is in the left-hand column of their page' },
];
games/static/games/the-register/engine/rules.js COMPOUND · line 1006const COMPOUND = { implies: (a, b) => !a || b, xor: (a, b) => a !== b, or: (a, b) => a || b, iff: (a, b) => a === b };
The operators are implies (if A then B), xor (exactly one), or (at least one) and iff (both or neither). The search combines the atoms’ cached bitmaps (one bit per entry; bitsFor), so a compound costs no more than a plain clue.
The statements spell out their edge cases. “The person listed before the killer” is false for the first person in the book. A “same line” clue is false for a name alone on its line. A span includes its two end names. “Family group of one” means both neighbours have other surnames.
Making a case#
Seeds#
A seeded random stream gives the same numbers every time for the same seed. A case’s seed is its number plus everything that changes what it means:
games/static/games/the-register/engine/generator.js caseRng · line 25export function caseRng(code, difficulty, mode = 'cold') { return new Rng(`${code}|${difficulty}|${mode}|v${ENGINE_VERSION}`); }
hash128 turns the string CODE|difficulty|mode|v8 into the state of an sfc32 generator, a small, fast random number generator. Rng.fork(label) makes an independent stream, and the book, chapters, clues, plan and story each get one, so changing how one is drawn doesn’t shift the others. Only newCaseCode calls Math.random, to mint eight characters without 0, O, 1 or I:
games/static/games/the-register/engine/rng.js newCaseCode · lines 60–64export function newCaseCode(entropy = Math.random) {
let s = '';
for (let i = 0; i < 8; i++) s += CODE_ALPHABET[Math.floor(entropy() * CODE_ALPHABET.length)];
return `${s.slice(0, 4)}-${s.slice(4)}`;
}
Because difficulty and mode are in the seed, the same number at another difficulty or mode is a different case. The engine version is in the seed too, so changing the generator makes a new family of cases instead of silently changing old ones. Old cases stay playable because each stored case remembers its engine. ENGINE_VERSION is 8. It must stay a one-line const, because the referee’s Python reads it with a regular expression.
Setting, people, chapters#
settingFor(code, difficulty, mode) picks one of 13 settings from the setting fork alone, which is cheap enough for the home screen to ask about thousands of codes. A setting is a place and time with its own vocabulary and its own mix of name banks:
| Setting | Register word | Name banks |
|---|---|---|
liner An ocean liner, 1907–13 |
Manifest | British Edwardian, Irish, Scandinavian, Italian, Ashkenazi, German, Levantine, Greek |
ellis Ellis Island, 1900–14 |
Register | Italian, Ashkenazi, Polish, German, Irish, Hungarian, Greek, Scandinavian |
andes A Peruvian mining town |
Census | Andean Peru |
ohio A Midwestern voter roll |
Roll | American 1950 |
festival A music festival |
Register | US Anglo, Latino, Black, Asian |
evacuees London evacuees, 1939 |
Register | British 1930s |
lewis A Hebridean island census |
Census | Gaelic Lewis |
expo Expo ‘70, Osaka |
Ledger | Japanese 1970 |
hotel A grand hotel’s guest book |
Guest Book | British Edwardian, German, Italian, Hungarian, Scandinavian, Ashkenazi, Greek, Levantine |
shelter A Tube shelter, 1940 |
Register | British 1930s, Irish |
shinkansen The first bullet trains |
Passenger List | Japanese 1970 |
motorcourt A Route 66 motor court |
Register | American 1950 |
conference A tech conference |
Badge List | US Anglo, Asian, Latino, Black |
Each setting weights its banks (cultures). festival, motorcourt and conference group people as parties; the rest as households. A setting also has place(rng) (which ship, which island, which census year), titles, a premise, and lists of roles, scenes, weapons, nights, inspectors and motives for the story. Story lines are pronoun-neutral on purpose: a resident is a name first.
Each of the 19 name banks under games/static/games/the-register/engine/names/ has 91 to 168 female and male first names and 74 to 219 surnames, most common first, with a zipf exponent for each list. people.js draws from them by rank:
games/static/games/the-register/engine/people.js zipfPicker · lines 21–32/** Popularity-weighted picker over a most-common-first list (Zipf by rank). */
function zipfPicker(list, s) {
const cum = [];
let t = 0;
list.forEach((_, r) => { t += 1 / Math.pow(r + 1, s); cum.push(t); });
return rng => {
const x = rng.next() * t;
let lo = 0, hi = cum.length - 1;
while (lo < hi) { const mid = (lo + hi) >> 1; if (cum[mid] < x) lo = mid + 1; else hi = mid; }
return list[lo];
};
}
A name at rank r has weight 1/r^s, so common names repeat as in real records. The banks are short, so two multipliers soften the exponent; otherwise one surname would cover 15 to 30 percent of a town. Lewis’s surname exponent is 1.3 on purpose: that island is MacLeods all the way down.
games/static/games/the-register/engine/people.js SURNAME_SKEW · lines 11–14// Name banks hold ~100–250 names each, far fewer than a real population, so
// their popularity skew is softened: otherwise one surname would cover 15–30%
// of a town. Lewis still ends up dominated by MacLeods — as it really was.
const SURNAME_SKEW = 0.6, FIRST_SKEW = 0.9;
A household draws one surname and one culture. A party (friends at a festival) mixes cultures and, with the setting’s sharedSurname chance, repeats the previous surname for a couple or siblings:
games/static/games/the-register/engine/people.js generatePeople · lines 57–89/** Returns `total` full names ("First Surname") in register order. */
export function generatePeople(rng, setting, total = 26000) {
const cultureOf = () => rng.weighted(setting.cultures, ([, w]) => w)[0];
const sizeOf = () => rng.weighted(setting.grouping.sizes, ([, w]) => w)[0];
const out = [];
while (out.length < total) {
const size = Math.min(sizeOf(), total - out.length);
if (setting.grouping.kind === 'households') {
// A household: one surname, one culture.
const c = culture(cultureOf());
let surname, members = [];
for (let t = 0; t < 10 && members.length < size; t++) {
surname = c.surname(rng);
members = [];
const taken = new Set();
for (let m = 0; m < size; m++) { const f = firstName(rng, c, surname, taken); if (f) { taken.add(f); members.push(`${f} ${surname}`); } }
}
out.push(...members);
} else {
// A party of friends: mixed backgrounds; couples and siblings sometimes share a surname.
let shared = null;
for (let m = 0; m < size; m++) {
const c = culture(cultureOf());
const surname = shared && rng.chance(setting.grouping.sharedSurname) ? shared : c.surname(rng);
const f = firstName(rng, c, surname);
if (!f) continue;
out.push(`${f} ${surname}`);
shared = surname;
}
}
}
return out.slice(0, total);
}
A full name is at most 20 characters, so it fits a column on one line (the sum is in styles.css beside .cols.c3.full). No two children in a household share a first name, a first name can’t be the surname, and no name is on the AVOID list of real names that read as jokes. Household and party sizes come from each setting’s weighted sizes.
Households go into the register in the order drawn, as a clerk would have entered them. That’s why a family group means something, and why the register is not alphabetical.
Difficulty#
Difficulty sets three numbers:
games/static/games/the-register/engine/config.js DIFFICULTIES · lines 9–24/**
* Difficulty sets three things:
* names — how many people are in the register (26,000 for Cozy, rising to
* 52,000 for Noir, whose id is still 'hardboiled');
* band — how many suspects survive the "section" clues (the ones about
* stretches of the register, struck in runs), i.e. how much
* name-by-name checking the reader does;
* clues — the [min, max] clue count.
* There is at most one clue per type (see TYPES in rules.js), and in Cold Case
* every clue must still be necessary.
*/
export const DIFFICULTIES = {
cozy: { label: 'Cozy', blurb: 'A Sunday-afternoon mystery. A smaller register, and few suspects left once the sections are struck.', names: 26000, band: [300, 600], clues: [9, 10] },
classic: { label: 'Classic', blurb: 'The intended experience: a few evenings with the Register.', names: 39000, band: [700, 1400], clues: [10, 12] },
hardboiled: { label: 'Noir', blurb: 'Fifty-two thousand names, and thousands still standing after the sections. Bring coffee.', names: 52000, band: [1500, 3000], clues: [12, 15] },
};
names is the register’s size, band how many suspects may survive the section clues (how much name-by-name checking is left), and clues the clue count. The id hardboiled is Noir. Cases before engine 7 always had 26,000 names, which namesFor(difficulty, engine) remembers. Every case has at least 7 clues.
Case styles and the type plan#
So no two cases feel alike, four styles set how many of the fine clues (everything but the sections) come from each tier:
games/static/games/the-register/engine/config.js STYLES · lines 57–69/**
* Case styles: every case leans a different way, so no two feel alike. Each
* style sets the tier quotas for the fine clues — name (the name itself),
* connection (neighbours, family, the victim) and reasoning (clues built from
* two conditions, or that cross a name with its position) — and how many fine
* types it may draw from at all. The reader sees the style in the case report.
*/
export const STYLES = {
wordsmith: { label: 'The Wordsmith', blurb: 'Most of the evidence is about the names themselves.', quota: { name: [4, 9], connection: [1, 3], reasoning: [0, 2] } },
gossip: { label: 'The Gossip', blurb: 'Most of the evidence is about who sits near whom, and family.', quota: { name: [2, 5], connection: [3, 6], reasoning: [0, 2] } },
logician: { label: 'The Logician', blurb: 'Expect evidence that hangs on “if”, “either” and “both”.', quota: { name: [2, 5], connection: [1, 3], reasoning: [2, 4] } },
generalist: { label: 'The Generalist', blurb: 'A little of everything.', quota: { name: [2, 6], connection: [2, 4], reasoning: [1, 3] } },
};
Each case also sets aside a share of the fine types it could draw, TYPE_DROPOUT = 0.4, but never so many that a tier can’t meet its style’s maximum plus two:
games/static/games/the-register/engine/generator.js casePlan · lines 158–173/**
* A case's style (which tiers its fine clues lean on) and the fine clue types
* it sets aside, so no two cases draw on quite the same mix.
*/
function casePlan(rng) {
const style = rng.pick(Object.keys(STYLES));
const dropped = new Set();
for (const tier of FINE_TIERS) {
const types = DEALT_TYPES.filter(t => t.tier === tier).map(t => t.id);
// Keep enough of each tier to meet the style's quota, with room to spare.
const keep = Math.min(types.length, STYLES[style].quota[tier][1] + 2);
let left = types.length;
for (const t of rng.shuffle(types)) if (left > keep && rng.chance(TYPE_DROPOUT)) { dropped.add(t); left--; }
}
return { style, dropped };
}
The plan has its own fork, so the style and the dropped types depend on the number alone. If the search keeps failing, its second half (after attempts / 2) ignores the dropped types.
The killer, the victim and the pool#
Each attempt starts with pickPair: a random killer, and a random victim in a different chapter. cluePool then works out every clue instance’s verdict for every entry, as a bitmap cached per book. It keeps only clues that:
- are true of the killer, since a clue can’t clear its own killer;
- clear between
minClearShareandmaxClearShareof the suspects (everyone but killer and victim), so no clue does nothing and none does the whole job.
games/static/games/the-register/engine/config.js BALANCE · lines 73–115/**
* Balance thresholds. "Suspects" means everyone except the killer and the
* victim (25,998 names). A clue "clears" a name when the name breaks it.
*/
export const BALANCE = {
// (2) No single clue may clear more than this share of suspects — no clue
// does the whole job. Also a floor, so no clue is a near no-op.
maxClearShare: 0.80,
minClearShare: 0.12,
// Tighter caps for clue types that are applied in bulk with a few clicks.
// A chapter clue struck with "Strike chapter" shouldn't wipe out half the
// town — it should trim the book, leaving the real work to the other clues.
// (Engine 7 has no chapter clues; chapterCompany is the nearest relative.)
maxClearShareByType: { chapter: 0.35, chapterCompany: 0.6 },
// (1) Overlap: at most `maxShare` of suspects may be cleared by `clues` or
// more clues.
//
// Measured on typical cases, the share of suspects cleared by at least k clues:
// k: 2 4 6 8 10 12
// 99.7% 95.5% 78% 46% 14% 1.7%
// "Cleared by 2+" is pinned near 99.7% by arithmetic: every suspect must be
// cleared, and the only names cleared by exactly one clue are the near-misses
// that make each clue necessary — with one solution allowed there can only be
// a few dozen. So the literal { clues: 2 } can only be set to ~0.999 and
// barely bites. The default caps pile-ups instead: no more than 22% of the
// town may be struck by 10 or more clues.
overlap: { clues: 10, maxShare: 0.22 },
// Every clue must be the *only* clue clearing at least this many names.
// 1 = every clue is strictly necessary. Higher = each clue does more
// unique work (and the case is more forgiving of a misread clue elsewhere).
minSoloClears: 1,
// Read in Casebook order, every clue must newly clear at least this share
// of the suspects still standing when the reader reaches it. Keeps late
// clues from feeling like busywork.
minMarginalShare: 0.08,
// No two clues may clear nearly the same names: if the smaller of two clues'
// cleared sets lies this much inside the other's, they read as a repeat.
maxPairOverlap: 0.85,
};
A type can have its own ceiling (maxClearShareByType; chapterCompany’s is 60%), so a clue that strikes in bulk can’t wipe out half the book.
bitsFor asks a clue about one part of a name once per distinct name, since every entry with that name gets the same verdict. Clues that don’t depend on the victim are cached per book across attempts.
Cold Case: the search#
Cold Case gives the player every clue at once, so the generator must hit several targets together. The search is tryBuildCase. A failed attempt fails fast, and generateCase tries a fresh killer.
- Section clues. It draws 2 to 4 section types by
SECTION_WEIGHT(spanandnearVictim, the strongest, weighted down so they don’t open every case). Each is chosen so the survivors land in the difficulty’s band, each step taking an even share of the cut on a log scale. Two that clear nearly the same names are refused. They stay fixed for the attempt. - Fine slots. It splits the clue total by the style’s tier quotas (say 4 name, 2 connection, 1 reasoning) and fills each slot with a random clue from that tier.
- Local search: small changes, keeping improvements. For each name it tracks how many clues clear it (
fail) and the XOR of their slot numbers, so for a name cleared by one clue, the XOR is that clue. The cost is
4 × (names besides the killer that nobody clears) + 2 × (clues short of a name only they clear) + overlap excess
A step swaps a slot for a clue that clears a surviving name, or loosens a clue so a weak one gains a name of its own. It scores up to 32 moves, takes the best, and sometimes accepts a worse one (uphill) to escape a dead end. Only names cleared by two clues or fewer can change in one swap, so only those are rescored.
4. At zero cost it checks the overlap limits, then shuffles the Casebook into an order where each clue, read in turn, newly clears at least minMarginalShare of those standing. After thirty failed shuffles it puts sections first, then the biggest bites. Working out where to start is part of the puzzle.
games/static/games/the-register/engine/config.js SEARCH · lines 117–131/** Search effort. Raise if cases start failing to generate; lower for speed. */
export const SEARCH = {
attempts: 300, // fresh killer/victim picks before giving up (a failed
// attempt is cheap; most fail fast on an awkward killer)
iterations: 1500, // local-search steps per attempt (more rarely helps:
// a stuck attempt is usually a hard killer — re-pick)
candidates: 32, // moves scored per step
uphill: 0.04, // chance of accepting a worse move (escapes dead ends)
perType: 6, // candidate moves per clue type per step, so a type with
// hundreds of forms is no likelier to be tried than one with two
sample: 40, // clues drawn per attempt for each sampled family (the
// section clues and compound clues have too many
// possible forms to enumerate, so each attempt draws
// fresh ones that fit its killer)
};
What each constraint buys the player:
| Constraint | Why |
|---|---|
| Only the killer survives | The case has one answer. |
Every clue is the only clue clearing at least one name (minSoloClears) |
No clue is redundant; reading each one matters. |
| No clue clears more than 80% or fewer than 12% | No single clue does the job; none is a no-op. |
| At most 22% of suspects cleared by 10 or more clues | The work isn’t piled onto a few names. |
| Read in Casebook order, each clue newly clears at least 8% of those standing | Late clues aren’t busywork. |
No two clues clear nearly the same names (maxPairOverlap, 85% of the smaller set) |
Two clues never read as a repeat. |
games/static/games/the-register/engine/generator.js pairOverlap · lines 520–532/** Largest overlap between two clues' cleared sets, as a share of the smaller set. */
function pairOverlap(bitsList, killer, victim, N) {
const cleared = bitsList.map(b => { const out = []; for (let i = 0; i < N; i++) if (!b[i] && i !== killer && i !== victim) out.push(i); return out; });
let max = 0, pair = null;
for (let a = 0; a < cleared.length; a++) for (let b = a + 1; b < cleared.length; b++) {
const [small, bigBits] = cleared[a].length <= cleared[b].length ? [cleared[a], bitsList[b]] : [cleared[b], bitsList[a]];
let both = 0;
for (const i of small) if (!bigBits[i]) both++;
const share = small.length ? both / small.length : 0;
if (share > max) { max = share; pair = [a, b]; }
}
return { max, pair };
}
A hard case can be slow: in one batch a Noir case took 23 attempts and 12.7 seconds in Node, a Cozy one a single attempt and a second. searchLog records why attempts die (“section band missed”, “search did not converge”, “two clues too alike”), and check.mjs --stats prints the tally.
Live Investigation: the sequence#
Live Investigation reveals witnesses one at a time, each only once every name the evidence so far clears has been struck. So tryBuildInquiry builds the case forward:
- Each step aims to clear
1 − (1/standing)^(1/left)of those standing, an even share on a log scale, so the field thins steadily. - Phase 1 is section clues, down to the band’s maximum. Phase 2 is name clues, alternating tiers (repeating the last tier is weighted 0.55) and leaning to the style’s tiers by
sqrt(mid/mean). - A candidate scores
pace / (0.04 + |share − aim|). The top six are shuffled with a jitter, and the first not too like an earlier witness wins. - The second-to-last witness must leave a group some unused type clears entirely, and the last clears everyone left, so the case ends on a clean line-up.
A witness is judged by what they newly clear, not by what only they clear. So check.mjs reports minSolo 0 for Live Investigation, and validateCase skips the solo and overlap checks for it.
Validation#
validateCase doesn’t trust the search. It recomputes everything from the clue definitions, with caseMetrics, before a case is released:
games/static/games/the-register/engine/generator.js validateCase · lines 602–679/** Independent re-check of a stored case: names, layout, uniqueness, balance. */
export function validateCase(caseData) {
const checks = [];
const add = (label, ok, detail = '') => checks.push({ label, ok: !!ok, detail });
const inquiry = caseData.mode === 'inquiry';
const engine = caseData.engine || 0, legacy = engine < 7;
const expected = namesFor(caseData.difficulty, engine), many = expected.toLocaleString('en-US');
if (caseData.layout === 'novel') {
const nv = validateNameList(caseData.names, caseData.fullNames, expected);
add(caseData.fullNames ? `${many} well-formed full names` : `${many} names, all unique and well formed`, nv.ok, nv.ok ? `${nv.unique.toLocaleString('en-US')} distinct ${caseData.fullNames ? 'full names (repeats are realistic)' : 'names'}` : nv.problems.join('; '));
} else {
const nv = validateNames(caseData.chapters);
add('26,000 names, all unique, correctly filed and sorted', nv.ok, nv.ok ? `${nv.unique.toLocaleString('en-US')} unique names` : nv.problems.join('; '));
}
const book = layoutCase(caseData);
const pagesOk = book.pages.every((p, i) => p.page === i + 1) && book.entries.length === expected;
add('Every name has exactly one page, column and line', pagesOk, `${book.pageCount} pages · ${book.cols} columns`);
if (book.style === 'novel') {
const lens = book.chapters.map(c => c.lastPage - c.firstPage + 1);
add(`Every chapter is ${CHAPTER_PAGES[0]}–${CHAPTER_PAGES[1]} pages long`, lens.every(n => n >= CHAPTER_PAGES[0] && n <= CHAPTER_PAGES[1]), `${book.chapters.length} chapters of ${Math.min(...lens)}–${Math.max(...lens)} pages`);
}
const rules = caseData.rules;
const killer = unseal(caseData.solution);
const m = caseMetrics(book, rules, caseData.victim, killer);
add('Exactly one name satisfies every clue', m.survivors.length === 1, `${m.survivors.length} survivor(s)`);
add('That name is the sealed solution', m.survivors.length === 1 && m.survivors[0] === killer);
add('The victim is not the solution', caseData.victim !== killer);
const [lo, hi] = clueRange(caseData.difficulty);
add(`Between ${lo} and ${hi} clues`, rules.length >= lo && rules.length <= hi, `${rules.length} clues`);
const types = rules.map(r => FAMILY[r.family].type);
const dupes = types.filter((t, k) => t && types.indexOf(t) !== k);
add('No two clues of the same type', !dupes.length, dupes.length ? `repeated: ${[...new Set(dupes)].map(t => TYPE[t]?.label || t).join(', ')}` : `${new Set(types).size} distinct types`);
const tierCount = tier => types.filter(t => TYPE[t]?.tier === tier).length;
if (legacy && !inquiry) {
const missingBroad = LEGACY_BROAD.filter(t => !types.includes(t));
add('Chapter, page number, column and line are all covered', !missingBroad.length, missingBroad.map(t => TYPE[t].label).join(', '));
const quotaOk = Object.entries(TIER_QUOTA).every(([tier, [a, b]]) => tierCount(tier) >= a && tierCount(tier) <= b);
add('Name and connection clues within their quotas', quotaOk, `${tierCount('name')} about the name · ${tierCount('connection')} about connections`);
}
if (!legacy) {
const retired = rules.filter(r => FAMILY[r.family].retired || FAMILY[r.family].legacy);
add('No simple page, column, line or chapter gates', !retired.length, retired.length ? `retired: ${retired.map(r => r.typeLabel).join(', ')}` : '');
if (!inquiry) {
// Engine 7 read its 2–3 section clues first; from engine 8 the Casebook is shuffled.
const [sLo, sHi] = engine < 8 ? [2, 3] : SECTION_CLUES;
const sec = types.filter(t => TYPE[t]?.tier === 'record').length;
const secFirst = engine >= 8 || types.slice(0, sec).every(t => TYPE[t]?.tier === 'record');
add(`${sLo}–${sHi} clues about sections of the register${engine < 8 ? ', listed first' : ''}`, sec >= sLo && sec <= sHi && secFirst, `${sec} section clues`);
const st = STYLES[caseData.style];
const quotaOk = !!st && FINE_TIERS.every(tier => tierCount(tier) >= st.quota[tier][0] && tierCount(tier) <= st.quota[tier][1]);
add(`Clue mix fits the case’s style${st ? ` (${st.label})` : ''}`, quotaOk, `${tierCount('name')} about the name · ${tierCount('connection')} about connections · ${tierCount('reasoning')} reasoning`);
}
}
const tooBig = m.perClue.map((c, k) => c.clearShare > maxClearFor(FAMILY[rules[k].family]?.type) + 1e-9 ? k + 1 : 0).filter(Boolean);
const tooSmall = m.perClue.map((c, k) => c.clearShare < BALANCE.minClearShare - 1e-9 ? k + 1 : 0).filter(Boolean);
const shares = m.perClue.map(c => c.clearShare);
const typeCaps = Object.entries(BALANCE.maxClearShareByType || {}).map(([t, v]) => `${TYPE[t]?.label.replace(/^The /, '').toLowerCase() || t} clues ${pct(v)}`).join(', ');
add(`No clue clears more than ${pct(BALANCE.maxClearShare)}${typeCaps ? ` (${typeCaps})` : ''} or less than ${pct(BALANCE.minClearShare)} of suspects`, !tooBig.length && !tooSmall.length,
tooBig.length || tooSmall.length ? `out of range: clue ${[...tooBig, ...tooSmall].join(', ')}` : `range ${pct(Math.min(...shares))} – ${pct(Math.max(...shares))}`);
if (!inquiry) {
add(`At most ${pct(BALANCE.overlap.maxShare)} of suspects cleared by ${BALANCE.overlap.clues}+ clues`, m.overlapShare <= BALANCE.overlap.maxShare + 1e-9,
`${pct(m.overlapShare)} · on average each suspect is cleared by ${m.meanClears.toFixed(1)} clues`);
const minSolo = Math.min(...m.perClue.map(c => c.solo));
add(`Every clue is the only clue clearing at least ${BALANCE.minSoloClears} name${BALANCE.minSoloClears > 1 ? 's' : ''} (so every clue is necessary)`, minSolo >= BALANCE.minSoloClears, `fewest: ${minSolo}`);
}
const minMarg = Math.min(...m.perClue.map(c => c.marginalShare));
const minCleared = Math.min(...m.perClue.map(c => c.marginal));
add(inquiry ? `Every witness, in turn, clears at least ${pct(BALANCE.minMarginalShare)} of those still standing` : `Read in order, every clue clears at least ${pct(BALANCE.minMarginalShare)} of those still standing`,
minMarg >= BALANCE.minMarginalShare - 1e-9 && minCleared >= 1, `weakest: ${pct(minMarg)}`);
add(`No two clues clear nearly the same names (at most ${pct(BALANCE.maxPairOverlap)} overlap)`, m.pairOverlap <= BALANCE.maxPairOverlap + 1e-9,
`closest pair: clues ${m.pairOverlapClues ? m.pairOverlapClues.join(' & ') : '—'} at ${pct(m.pairOverlap)}`);
add('Clue statements are present and distinct', new Set(rules.map(r => r.text)).size === rules.length);
return { ok: checks.every(c => c.ok), checks, metrics: { ...m, survivors: undefined } };
}
Among other things it checks the names and their layout, that there’s exactly one survivor and it’s the sealed solution, the clue count with no repeated type or legacy gate, and every balance limit above. The report is stored on the case (validation, metrics) and shown to staff as “How this case was checked”. createCase in js/cases.js refuses to store a case that failed.
The story#
generateStory picks the setting’s place, a role, a scene, a weapon, a night, an inspector, a motive and a clincher from a shared list. It fills prologue (the “The Case” page) and epilogue (on the closing sheet). It draws from its own fork after the search, so the story never changes which case a number makes.
Secrets#
Every browser knows everything. The browser must tell the player when an accusation is wrong, so it has to hold the killer. The case stores it as solution, sealed:
games/static/games/the-register/engine/generator.js seal · lines 21–22/** Seal the solution so a casual peek at the save file doesn't spoil it. */
export const seal = (i, code) => btoa(String((i + 7919) * 31) + '|' + code.split('').reverse().join(''));
The seal only stops a casual peek at the save file. Anyone who reads the engine can unseal it, and anyone can rebuild a case from its number. ADR 0003 accepts that.
The server doesn’t trust “I solved it”. It rebuilds the case itself, so a wrong killer, a case that doesn’t exist and an impossible time are refused. A player who read the source can still post a real solve of a case they cheated on; Referees and anti-cheat has the boundary.
In Live Investigation, later witnesses are hidden on screen, not in the data. The case holds them all, and g.revealed says how many have been heard.
Posting and the referee#
scoreFor in js/hub.js builds a solve as { code, difficulty, mode, engine, accused, seconds, wrong, hints }. It posts to /api/scores/ only when the player presses Post to the leaderboard. Signed out, it waits in localStorage (uo:pending) until they sign in (flushPending, run before any case opens).
Referees and anti-cheat covers the view, the Node run and the warm-ahead queue. The Register’s own parts:
referee/solve.mjsimports the same engine files the players run and prints{engine, killer, title, names}. It fails if the case doesn’t validate.- The Python reads
ENGINE_VERSIONfromgenerator.jswith a regular expression. A post from another engine version is refused (“made by a different version of the game”) rather than checked against the wrong book. - The server takes on trust
seconds(checking only that it’s between 60 seconds and 60 days) andwrongandhints(each between 0 and 999). A player who undercounts their own mistakes only cheats themselves. - There’s one score per player per case (a unique constraint on
Score). A second post gets the first back withalready: true. - The answer carries the time, points, all-time place and rating, with no rank or streak (ADR 0040).
Scoring is in leaderboard/scoring.py:
leaderboard/scoring.py final_seconds · lines 19–20def final_seconds(seconds, wrong, hints):
return seconds + wrong * WRONG_PENALTY + hints * HINT_PENALTY
leaderboard/scoring.py points · lines 23–25def points(difficulty, mode, wrong, hints):
base = BASE_POINTS[difficulty] * MODE_FACTOR[mode]
return round(base * max(FLOOR, 1 - wrong * WRONG_COST - hints * HINT_COST))
| Wrong accusation | +120 s on the time, −15% of the points |
| Hint | +300 s, −10% |
| Points are never below | 25% of the base |
| Difficulty | Cold Case | Live Investigation |
|---|---|---|
| cozy | 100 | 125 |
| classic | 200 | 250 |
| hardboiled | 400 | 500 |
guide/worked/the_register.py, CLEAN_POINTSSo a Classic Live Investigation solved in 1:30:00, with one wrong accusation and two hints, is ranked on 1:42:00: 12:00 of penalties, 2 minutes plus two 5-minute hints. It earns 162 of 250 points. It takes 8 hints to reach the floor.
A hint is each question put to the Sergeant (two options), and each “Show me the clue” after a wrong accusation. Wrong accusations are counted from the saved game’s accusation list.
The daily case, until 2026-10-24#
The Register had a daily Live Investigation case from the start (ADR 0006; Dailies) until dailies became puzzles only (ADR 0040). What’s left:
- an old daily’s link (
#/open/<code>/<difficulty>/<mode>/daily) opens that case like any other; - an old
?board=dailyaddress opens the all-time board; - the
DailyCaserows stay with every score on them, and the days already rated stay rated.
The leaderboard is now the all-time board (points summed per player, best times shown, filterable by mode and difficulty) and Ratings.
Rating#
The Register is rated as a timed puzzle (Ratings), through a Register source on the shared Timed base in ratings/sources.py. What’s particular to it:
- A board is a (mode, difficulty) pair, so there are six. Par on a board is the median
final_secondsof the last 99 earlier verified solves there, by anyone. - The time that counts is
final_seconds, penalties included, so a wrong accusation costs rating as well as points. - Every solve is rated against par as it’s posted, at half weight. It needs a par, so a board needs a minimum number of earlier solves. The days that had dailies were rated as fields of everyone who solved that day’s case, and stay that way.
- Only posted solves are rated, and the player chooses whether to post.
Worked example. A settled detective at 1550 ± 80 solves in 1:30:00 with one wrong accusation and two hints, so the time that counts is 1:42:00. The board’s par is 2:00:00:
score = time_score(final, par) = 0.581
p = expected vs. par seat = 0.531
change = (σ²/c)·(score − p)·½ = +0.40
With no mistakes (a score of 0.640) they’d have moved +0.88. A brand-new detective at 1500 ± 350 making the same solve would gain +9.4. Timed puzzles move ratings slowly, because a time against par is a partial result and par itself is uncertain (against par).
The interface#
Screens#
The page is one <main id="app"> that app.js fills by route:
| Route | Screen |
|---|---|
#/ |
Home (js/home.js) |
#/case/<id> |
The Game (js/game.js) |
#/open/<code>/<difficulty>/<mode> |
Opens a shared case |
#/new |
Home, with the New case sheet up |
#/print-test/<caseId> |
Hidden: print pages for the layout checks |
Home has Continue, New case, Your cases, Open a case by number, Back up and Restore.
- New case asks for a mode and a difficulty.
freshCodemakes the number, trying up to 2,000 untilsettingForgives a setting you haven’t met (or, once you’ve met them all, one from the quarter you met longest ago). The number still decides everything, so it can be shared. - A spare case: 1.5 s after Home mounts,
prepareSparebuilds one Classic Cold Case ahead, so New case is instant. The engine runs in a worker (engine/worker.js), so building 52,000 names never freezes the page. - Case file shows the story and your progress, and to staff alone “How this case was checked”.
- Back up downloads every game and case as one JSON file (
kind: the-register/backup). Restore adds back what’s missing or older, and never rolls progress back.
The Game is a two-page spread on a wide window and one reflowed page on a phone (COMPACT_QUERY: ≤ 720 px wide, or short landscape). The front matter is real pages (model.js): title, “The Case”, the evidence and contents, with a blank page if needed so page 1 falls on the right. The evidence is paginated by an estimate calibrated against rendered height, so print and screen agree.
Marking#
A mark is a pen number, one byte per entry in a Uint8Array, saved base64-encoded. 0 is none, 1…N is the clue that struck the name, and 250 is pencil (“struck, reason unrecorded”). The mark stores the pen, not the colour, so recolouring a clue’s pen recolours every name it struck. The palette has 13 colours.
| Input | Does |
|---|---|
| Click / drag a name | Strike with the active pen; click again with the same pen to clear, with another to re-tag |
1–0, ⇧1–⇧7 |
Choose clue I–X, XI–XVII |
` or . |
Pencil |
⇧-click, right-click or long-press |
Start/finish a run of names across pages; per-name menu |
P |
Strike by position: pages, lines and columns, with a live count of what matches |
| Bar under a page / thumb tab | Strike or clear a page; strike a chapter |
⌘Z, ⇧⌘Z |
Undo (300 deep), redo; also buttons |
/, ⌘F |
Find: first name, surname or both, accents ignored |
← →, [ ], G, C, V, +/-, Z |
Pages, chapters, go to page, Casebook, one/two pages, zoom |
The Sergeant (a hints dialog) answers “have I struck the killer by mistake?” and “check my marks against the clues I tagged them with”, which outlines names struck with a clue that doesn’t clear them (pencil isn’t judged). Each question is a hint. When one name is left, a toast offers to accuse them.
Accuse: type a name and pick its page and line (names repeat), or pick from a list once 40 or fewer stand. A wrong one gets a random alibi and the offer of the clue that clears them, as a hint, never spoiling an unheard witness.
In Live Investigation, checkWitnesses runs after every change. When outstanding() (names the heard witnesses clear that aren’t struck) reaches zero, the next witness comes forward; several can arrive at once if the next clears nothing new. The first is there from the start (revealed: 1), and closing the case reveals all.
The clock and saving#
The clock ticks every 5 seconds while the tab is visible and the player has moved, clicked, pressed a key or scrolled in the last 3 minutes. timePlayed is that count, posted as seconds.
The game saves after every change (waiting for a 600 ms pause) and on leaving, to IndexedDB (js/store.js: stores cases, caseIndex, games, deleted) mirrored to localStorage (register:game:<id>), so a crash costs nothing. The newer copy wins on load. Save versioning stops a stale second window from overwriting newer progress (saveConflict in js/records.js: “Stale save”, “Saved elsewhere”), and a deleted game never comes back. The first save asks the browser once to persist storage.
A new game’s record:
games/static/games/the-register/js/cases.js newGameFor · lines 56–68/** A fresh game on a case. `extra` adds fields of its own. */
export async function newGameFor(caseRec, extra = {}) {
const now = Date.now();
const g = {
id: uid(), caseId: caseRec.id, title: caseRec.title, code: caseRec.code, difficulty: caseRec.difficulty,
mode: caseRec.mode || 'cold', setting: caseRec.setting || null, revealed: caseRec.mode === 'inquiry' ? 1 : undefined,
victim: caseRec.story?.victim ?? caseRec.victim, createdAt: now, updatedAt: now, ...extra,
total: caseRec.names?.length || 26000,
marks: '', remaining: (caseRec.names?.length || 26000) - 1, struck: 0, timePlayed: 0, solved: null, accusations: [], hints: 0,
};
await api.putGame(g);
return g;
}
Closing sheet, print and help#
Case closed is the house result sheet (Front end), with the killer, the facts and the epilogue. Its main action is Post to the leaderboard until the time is posted, then New case. A case’s own link, Share case, is on its Case file.
Print renders the screen’s pages at true Letter size (the whole book, the evidence, a range or the open pages, with or without highlights). Witnesses not yet heard print sealed. PAGE_W and PAGE_H (816 × 1056 CSS px) are US Letter at 96 dpi. Names are set in IBM Plex Mono so letters can be counted by eye.
How to play opens with ?, its button, or by itself on the first case: once per browser (uo:the-register:welcomed) and once per signed-in account (ADR 0039).
Look#
“A walnut desk, a cream book, a box of highlighters”, as the stylesheet puts it; its colors and faces are in Its look. Dialogs, toasts and the result sheet are the house kit’s (ADR 0025), as is the footer’s Feedback link (ADR 0038).
The margins and type size are computed so a 20-character name fits a column on one line (the sum is in a comment beside .cols.c3.full). If you change MAX_FULL_NAME, the page width or the type size, that sum must still hold.
Content#
The name banks are the game’s only content. Each file is {id, label, zipf, female, male, surnames}, a hand-made list of period names whose first comment line says whom it describes (“Italian emigrants, c. 1900–1913”). They aren’t imported datasets, and there’s no build step: names/index.js registers them for the settings. Clue text, witnesses’ professions and quotes are in rules.js, and story material in settings.js. See also Content pipelines.
Its look#
The Register’s personality, in the design system’s personality sheet. It’s a brainteaser in a look of its own (Brainteasers); everything else is the house kit’s.
| Family | Brainteasers, on the house kit, in its own look: a walnut desk, a cream book, brass and oxblood |
| Palette | The desk is #17120e and its panels #241c16, inked #efe5d3 with soft words #ad9f88. The book’s pages are cream #fbf6e9, inked #2b2219, soft #75685a; faint #a99a83 is for rules and dots only. Brass #d1ab62 is what’s picked (the pick ring, the active clue, the Casebook’s open tab), the suspects count and the killer’s name. The accent #b8432f is the one filled button. Oxblood #8e2a1e is printed in the book: chapter letters, clue numbers, the victim and the ring round the killer. Good #8fcf8a and bad #ff8f7d are set to read on the dark desk. The highlighters are 13 pen colors of their own, and the Sergeant’s flag on a name is a dashed red of its own, #c0392b, not a mistake. Under a light color scheme the desk, panels, their ink and the brass turn light. |
| Faces | Its own: Baskerville for display, Iowan Old Style for the book, the system sans for the interface, a handwriting face for notes, and IBM Plex Mono (self-hosted) for the names, so their letters can be counted. |
| Corners and edges | Its own: sheets at 14px corners, buttons at 8px, a page with a faint warm vignette, and highlighter strokes skewed with uneven corners. |
| Board | A two-page spread of the register on a wide window, one reflowed page on a phone, with the Casebook beside it: the clues, each with its pen’s swatch, the active one edged in brass. A register page has a running head, chapter openers with a large oxblood letter, and names in three columns of Plex Mono with line numbers. A struck name wears its clue’s highlighter. The victim is struck through in oxblood, a name the Sergeant flags gets a dashed red outline, and the killer’s name is ringed in oxblood. |
| Emblem and art | Its home’s masthead; the result sheet’s emblem is the killer’s name. Its box: its ledger page, on its walnut desk. |
| Voice | A case, clues, witnesses, suspects and the Sergeant; Accuse, and “Case closed”. |
| Payoff | Case closed, the house’s result sheet, with the killer’s name in brass display type as its emblem and the solution beneath. |
| The glance | The bar: the case’s title, the save state (a dot: green saved, brass saving, red offline or in conflict) and “N suspects remain” in brass figures. No visible clock: the time played counts unseen. |
| Exceptions | The home’s masthead is 84px (56px under 860px, 48px under 720px), the one title off the type scale. The clue strip shows two lines of a clue and opens to all of them when pressed. A long case title gives way first in the bar, at 1280px and under. The clue chip in use lifts 2px. On a phone the bar keeps Find, Undo, Redo and ⋯ beside Accuse, so its icon buttons are 32–36px wide (44 tall). |
Testing#
| What | Proves |
|---|---|
npm run check:register (in CI) |
Builds a batch of cases in both modes at every difficulty, twice each, and compares (determinism), and fails if any case fails validateCase. --stats prints balance numbers, --clues a Casebook, --seed=WORD fixes the batch, --mode= limits it. |
npm run variety:register -- N |
Not pass/fail: how often each type appears, what opens the Casebook, which section recipes recur, and how alike two cases’ clue types are. Run it after tuning. |
tests/test_leaderboard.py |
The daily is gone. Posting: sign-in, scoring, refusing the wrong person, a too-fast time, a bad post or another engine; one score per case; 202 when busy. The all-time board. The real Node engine rebuilding a case, when Node is installed. |
tests/test_dailies.py |
Old daily solves stay off the Dailies page. |
There’s no browser test of its own. The site-wide layout and details checks cover the interface.
Changing it#
| To… | You need |
|---|---|
Change anything that alters which case a number produces (config numbers, a clue’s instances order, a name bank, a setting, how the stream is drawn) |
Bump ENGINE_VERSION so old numbers keep their old cases, and run check:register and variety:register. Stored cases keep working, since each remembers its engine. |
| Tune difficulty | config.js only; run node tools/register/check.mjs 30 --stats. Generation time matters: the referee rebuilds within the hub’s request budget. |
| Add a clue family | A family in some type’s families in rules.js, with instances or sample, text and quote, and a FULL_TEXT entry if it speaks of “the name”. Bump the engine. |
| Add a setting | An entry in SETTINGS. It changes pick(SETTINGS) for every number, so bump the engine. |
| Add a name bank | A file under names/ and a line in names/index.js. Bump the engine. |
| Change scoring | leaderboard/scoring.py. Old scores keep their stored points and final_seconds. |
Known issues#
names.jsandcorpus.jsare dead code except for the legacy validator. They made the 26,000 alphabetical one-word names of cases before engine 7. Nothing callsgenerateNamesnow; onlyvalidateNamesandLETTERSare imported. They could go once nobody needs to validate a pre-engine-7 case, a question for David inknown-issues.md.- A very slow case can hold up one post for a minute or more. The first post of an unchecked Noir case may answer 202 “try again” while the single background thread builds it. Starting a case queues it ahead, which usually prevents this, and the queue and rate are limited (Referees).
- The name banks have no recorded provenance. Each says whom it describes, not where its popularity order came from. “Real names, weighted by real popularity” (the design notes) is the intent, not something the repo can show.
- Share links need the same engine. A number from an older engine opens as “an older edition of that case”, not the case the sender had, and there’s no way to ask for the old one.
Known limitations#
- Seconds, wrong accusations and hints are the browser’s word. Only their bounds are checked, so anyone who edits their save can post a better time on a case they really solved. This is the accepted boundary (ADR 0003), and it means the leaderboard is for friends.
- Ties. The all-time board breaks equal points by best time. Ratings treat equal times as a tie.
- A wrong accusation costs time, points and rating. How to play and the accuse sheet (“it goes on the record”) both say so.