The site
Dailies, codes and seeds
How each day’s puzzle is chosen so it’s the same for everyone but can’t be seen early, and the Dailies page, streaks, archive and Stats tabs built on it.
On this page
A daily is the same puzzle for everyone all day, and nobody can play tomorrow’s today. The first promise needs the puzzle to come from something public; the second needs something only the server knows. So the work is split in two:
- On the server, with the secret key, a day and a game become a short code of six letters. Without the key, nobody can work out tomorrow’s.
- Everywhere else, in public, a code becomes the puzzle. Each game’s engine turns it into the same puzzle in every browser and in the server’s referee.
The secret guards only which code a day gets. Once you have today’s code you can make today’s puzzle yourself, which is the point: your browser has to.
Everything daily sits on one small module, obstacles/daily.py: the site’s day, codes and seeds, how many dailies a day has, and streaks. This chapter covers it, each game’s use of it, and what’s built on top: the Dailies page, streaks in the browser, the archive and the Stats tab.
The site’s day#
The site has one calendar. The day turns over at midnight in America/Chicago, the site’s TIME_ZONE, for every player wherever they are:
obstacles/daily.py today · lines 21–22def today() -> date:
return timezone.localdate()
timezone.localdate() reads the current UTC time (USE_TZ is on) as a date in the site’s time zone, the only one ever active. Daylight saving moves the turnover between 05:00 and 06:00 UTC. Every app’s today() (hashi.daily.today, puzzles.daily.today, …) just passes this on.
The browser never decides the day. Each game’s “today’s dailies” call answers with its date ("date": "2026-10-20"), and the game uses that for everything it keeps: which daily it solved, its streak, the Dailies page’s log. games/static/games/house/stats.js does day arithmetic on 'YYYY-MM-DD' strings as UTC dates, never the browser’s clock, so a device in the wrong time zone can’t move a player’s day.
Codes from the key#
A daily code is an HMAC: SHA-256, keyed with the site’s SECRET_KEY, over the parts that name the daily, joined with |.
obstacles/daily.py _digest · lines 25–26def _digest(parts) -> bytes:
return hmac.new(settings.SECRET_KEY.encode(), "|".join(str(p) for p in parts).encode(), hashlib.sha256).digest()
An HMAC is a keyed hash. The same key and message always give the same 32 bytes, so every web process agrees on a day’s code. Without the key the output looks random, so past codes say nothing about the next. All the secrecy rests on the key staying secret (AGENTS.md rule 9).
The digest’s first bytes become letters:
obstacles/daily.py ALPHABET · lines 17–18# No 0/O or 1/I. 32 letters, so a byte maps evenly.
ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
obstacles/daily.py code · lines 29–32def code(*parts, length: int) -> str:
"""A code of `length` letters (up to 32) for these parts: the same for
everyone, different for any other parts, unknowable without the key."""
return "".join(ALPHABET[b % len(ALPHABET)] for b in _digest(parts)[:length])
The alphabet drops characters people misread (0 and O, 1 and I) and keeps exactly 32. Since 256 is 8 × 32, b % 32 spreads byte values evenly, eight to each letter. A six-letter code is one of 32⁶, a little over a billion. The games use the same alphabet (ALPHABET in games/static/games/puzzles/engine/code.js, and Hashi’s and Solitaire’s engines), so an endless puzzle’s random code looks like a daily’s.
Seeds#
Some choices need a number, not letters: an order to deal pictures in, a band to pick. seed reads the first 8 bytes of the same HMAC as a 64-bit integer:
obstacles/daily.py seed · lines 35–37def seed(*parts) -> int:
"""A number for these parts, for a game that deals its daily from a seed."""
return int.from_bytes(_digest(parts)[:8], "big")
A seed is as secret as a code and never leaves the server. It picks Chess Puzzles’ band and puzzle order, and the order Low Poly’s and Griddles’ pictures come round.
Worked example: Hashi on a day#
Here’s Hashi’s daily on 20 October 2026, with the real functions but no real values (a real code comes from the key, so it doesn’t belong on a page).
- The slot. That day has one Hashi daily, at size
hashi.daily.NORMAL, 10. The first time anyone asks,puzzles_formakes the row:
hashi/daily.py daily_code · lines 29–31def daily_code(date, size):
"""Hashi's puzzle code for the day: six letters."""
return daily.code("hashi", date.isoformat(), str(size), length=6)
- The message.
daily.code("hashi", "2026-10-20", "10", length=6)buildshashi|2026-10-20|10. All of it is public; only the key makes the result unguessable. - The digest.
_digestcomputes HMAC-SHA256 of the message underSECRET_KEY: 32 bytes. - The letters.
codemaps the first six bytes to letters. Say (made up) the digest began0xC7 0x1A …. 0xC7 is 199, and 199 mod 32 is 7, so the first letter isALPHABET[7], a9. 0x1A is 26,ALPHABET[26], aU. Four more bytes give four more letters. - Kept. The row in
hashi.DailyPuzzle(date, size, engine version, code) is stored and never made again. - Public from here. The game asks
/games/hashi/api/daily/for the date, code and engine version. Its engine seeds a stream withhashi|<version>|10|<code>(ENGINE_VERSIONis 3) and grows the puzzle (the seeded stream). The referee runs the same engine on the same string to check a solve (Referees and anti-cheat).
Kept once made#
Every game stores a day’s dailies the first time they’re asked for: hashi.DailyPuzzle, puzzles.DailyPuzzle for every kit puzzle, and DailyChess. DailyDeal and DailyCase stay for results on Solitaire’s and The Register’s old dailies. Storing matters for two reasons. Some dailies are costly to make (Chess Puzzles’ and Griddles’ depend on earlier picks). And the inputs can change: rotate the key and every future code changes, but a stored day keeps its puzzle, board, solves and streaks.
That makes the formula a promise: an unopened day is still made from it, so it mustn’t drift. tests/test_daily.py rebuilds Hashi’s codes with HMAC the way the game did before obstacles/daily.py existed, for two keys over a hundred days, and checks they match byte for byte.
Each game’s daily#
| Game | What names the daily | The HMAC message | Kept in |
|---|---|---|---|
| Hashi | a 6-letter code | hashi|<date>|<size> |
hashi.DailyPuzzle |
| A kit puzzle | a 6-letter code | <slug>|<date>|<size>|<difficulty> |
puzzles.DailyPuzzle |
| Griddles, Low Poly | a code naming a picture, picked by a seed | seeds: see below | puzzles.DailyPuzzle |
| Chess Puzzles | a Lichess puzzle id from the bundle | seeds: chess|band|<date>, chess|<band> |
DailyChess |
The Register, until 2026-10-24#
The Register had the first daily, so its message began with daily, not its slug: daily|<date>|<mode>|<difficulty>, eight letters as XXXX-XXXX. Dailies are puzzles only now (ADR 0040), so it has no /api/daily/, day’s board or place on the Dailies page. Its DailyCase rows stay, with every score and rated day field. A case number still seeds the engine’s own stream, with the difficulty, mode and engine version in the seed string, so the same number is a different case at another difficulty or engine:
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}`); }
Hashi#
Hashi’s slots are sizes, since it has one difficulty (ADR 0024). It had three (DAILY), then NORMAL alone, and now the weekday’s size (The week climbs): 7×7 early in the week, 15×15 on Sunday.
hashi/daily.py one · lines 19–21def one(date):
"""The size of a day's one daily: the weekday's, from the day dailies follow the week."""
return DAILY[daily.week_level(date, len(DAILY))] if daily.follows_week(date) else NORMAL
hashi/daily.py puzzles_for · lines 34–46def puzzles_for(date):
"""The day's daily puzzles, created on first ask at the current engine
and fixed from then on (a new secret key mustn't change a day that's been
played). A daily made before a bump keeps its engine version while the
engine can still make it (VERSIONS), so the day's chart doesn't change
under anyone; one from a version it can't make any more stays as it was,
for the solves already posted to it, and the current engine's is made
beside it."""
out = []
for size in lineup(date):
kept = DailyPuzzle.objects.filter(date=date, size=size, engine__in=VERSIONS).order_by("-engine").first()
out.append(kept or DailyPuzzle.objects.get_or_create(date=date, size=size, engine=ENGINE, defaults={"code": daily_code(date, size)})[0])
return out
The engine version is in the seed string, so a code makes a different puzzle under each version. A day keeps the version it was made under while the engine can still make it (VERSIONS), so a bump mid-day leaves the day’s chart alone. A row from a version no longer made (engine 1’s, before ADR 0024) stays for its solves, and the current version’s daily is made beside it with its own board.
The pencil-puzzle kit#
Every kit puzzle (Star Battle, Fillomino, Yin-Yang, Galaxies, Aquarium, Griddles, Low Poly) makes dailies the same way, in puzzles/daily.py:
puzzles/daily.py daily_code · lines 32–36def daily_code(kind, date, size, difficulty=None):
# A puzzle with one difficulty (Hashi, docs/adr/0024) leaves it out of
# the code, so its dailies keep their codes when it moves onto the kit.
parts = (str(size), difficulty) if difficulty else (str(size),)
return daily.code(kind.slug, date.isoformat(), *parts, length=6)
A puzzle’s Kind (puzzles/registry.py) lists its slots in daily: its old lineup of three, gentlest first, one per size. The middle slot is its normal level:
puzzles/registry.py Kind.@property
def normal(self) -> tuple[str, str]:
"""The one daily's first slot (size, difficulty): the lineup's middle, its normal level."""
return self.daily[len(self.daily) // 2]
That was the one daily until the week took over (The week climbs), unless it moved to another slot from some day on. Galaxies’ did: 10×10 was a bit much, so from 2026-10-23 it’s 7×7 (ADR 0037). A move always starts at a future midnight, so a day’s daily never changes once played, and the new slot’s code is the one that slot always had.
puzzles/registry.py Kind.def one(self, day) -> tuple[str, str]:
"""The slot of a day's one daily: from the day dailies follow the
week, the weekday's level of the lineup (docs/adr/0041); before,
the normal one, or where it `moved`."""
from obstacles.daily import follows_week, week_level
if not self.dailies:
return self.normal
if follows_week(day):
return self.daily[week_level(day, len(self.daily))]
slot = self.normal
for first, moved_to in self.moved:
if day >= first:
slot = moved_to
return slot
puzzles/registry.py Kind.def lineup(self, day) -> tuple[tuple[str, str], ...]:
"""A day's dailies, gentlest first: the one daily alone from the
day there's one daily a game (docs/adr/0034), the whole lineup before."""
from obstacles.daily import one_daily
return (self.one(day),) if one_daily(day) else self.daily
Griddles and Low Poly don’t take dailies from a code. Their codes name pictures, which should come round in order, not at random. Both pick with Kind.pick(kind, day, size, difficulty, used), where used is every earlier daily’s picture at that slot, oldest first. Since each pick depends on the ones before, picked days are filled in date order (_fill), under a row lock so two requests can’t pick the same picture:
puzzles/daily.py _fill · lines 45–81def _fill(kind, start, through):
"""Make every daily from `start` through `through` that isn't kept yet,
in date order. Once made, a day's codes never change (a new secret key
mustn't change a day that's been played). A picked puzzle's are made
from its first day, whatever `start` is, so each day's pick follows the
day before's and none repeats."""
with transaction.atomic():
if kind.pick:
kept = DailyPuzzle.objects.filter(kind=kind.slug).order_by("date").values_list("date", flat=True).first()
start = min(d for d in (kind.since, kept, start) if d)
rows = DailyPuzzle.objects.filter(kind=kind.slug, date__gte=start)
if kind.pick:
# One filler at a time, so two can't pick the same picture.
rows = rows.select_for_update()
have = {(r.date, r.size, r.difficulty): r.picture or r.code for r in rows}
if kind.pick:
used = {slot: [p for (d, s, f), p in sorted(have.items()) if (s, f) == slot] for slot in kind.daily}
# (every slot's, oldest first: once the week moves the daily
# between sizes, a picture shouldn't come back at another soon)
anywhere = [p for _, p in sorted(have.items())]
new = []
day = start
while day <= through:
for size, difficulty in kind.lineup(day):
if (day, size, difficulty) in have:
continue
if kind.pick:
code = kind.pick(kind, day, size, difficulty, used[size, difficulty], anywhere=anywhere)
else:
code = daily_code(kind, day, size, difficulty)
picture = kind.picture_of(code, size) if kind.picture_of else ""
if kind.pick:
used[size, difficulty].append(picture or code)
anywhere.append(picture or code)
new.append(DailyPuzzle(kind=kind.slug, date=day, size=size, difficulty=difficulty, code=code, picture=picture, engine=kind.engine))
day += timedelta(days=1)
DailyPuzzle.objects.bulk_create(new, ignore_conflicts=True)
- Griddles (
puzzles/griddles.py) sorts each size’s pictures into a secret order (sort keydaily.seed(slug, "order", size, picture id)). Each day takes the first unused at that size, or, once all are used, the one used longest ago. The code names that picture’s place, salted in its upper bits from the day’s seed. - Low Poly (
puzzles/low_poly.py) dealt its three-a-day dailies from a public order, starting wheredaily.seed("low-poly", "daily order")picked; those days keep what they got. A public order let one day’s picture give away the next, so since one daily a game it takes the first unused picture in a secret order: the public order’s kinds, each shuffled bydaily.seed("low-poly", "secret order", code).
A Griddles code names a place in a list that a later version may change. So its Kind also records what a code shows (picture_of, kept as DailyPuzzle.picture) and how to point a code back at a picture (repoint). Every day served, today’s or the archive’s, goes through _as_kept, so it always opens as the picture it was made with:
puzzles/daily.py _as_kept · lines 84–101def _as_kept(kind, rows):
"""Kept dailies, each showing the picture it was made with: a day whose
picture a later library moved has its code pointed at the picture's
new place, once, so the archive, the boards and posting all agree on
it. (A picture the library no longer has can't be shown again: its day
keeps its code, which now shows another; docs/griddles.md.)"""
if not kind.repoint:
return rows
for r in rows:
if not r.picture:
continue
code = kind.repoint(r.code, r.picture, r.size)
if code is None:
log.warning("%s's daily of %s at %s shows %s, which its pictures no longer have", kind.slug, r.date, r.size, r.picture)
elif code != r.code:
r.code = code
r.save(update_fields=["code"])
return rows
Low Poly and Griddles have the details.
A day keeps its engine version. A kit daily records its version (puzzles.DailyPuzzle.engine) and is played at it while the engine can still make it, else at the current one:
puzzles/registry.py Kind.def engine_of(self, daily) -> int:
"""The engine version a daily is played at: the one it was made
under, while the game can still make it; else the current one."""
return daily.engine if daily.engine in self.versions else self.engine
Kind.versions is the engine’s VERSIONS (Star Battle’s: 2, and the 1 from before its depth floor), or the current version alone. The daily and archive calls name each puzzle’s engine, the browser makes it at that version, and the referee makes it at the version a post names, if still made. So an engine that keeps its predecessor in VERSIONS can be bumped without changing any day already made, today included. Rows from before the field existed are empty and play at the current engine, except Star Battle’s, all marked engine 1.
Solitaire, which has none now#
Solitaire had four dailies a day, each the first winnable of a run of candidate codes (ADR 0022). They ended on 2026-10-22 (ADR 0037); it keeps its endless deals and its rating. The DailyDeal rows stay, with every result on them. Solitaire has the rest.
Chess Puzzles#
Chess puzzles can’t be generated, so Chess Puzzles plays from a bundle of Lichess puzzles (ADR 0028), and its daily is a choice made with seeds.
First, the band. From the one-daily switch, the day’s seed picked a band, weighted toward the middle. Since the week took over, the weekday picks it from WEEK_BANDS: Easy early in the week, Hard on Sunday. Beginner and Expert stay in Endless.
chesspuzzles/daily.py band_of · lines 54–67def band_of(day):
"""The band of a day's one daily: the weekday's, from the day dailies
follow the week (docs/adr/0041). Before, by the site's seed for the day
(so it couldn't be worked out ahead and was the same for everyone),
weighted by BAND_WEIGHTS: the seed's place in a hundred falls in one
band's share."""
if daily.follows_week(day):
return WEEK_BANDS[daily.week_level(day, len(WEEK_BANDS))]
at = daily.seed("chess", "band", day.isoformat()) % sum(BAND_WEIGHTS.values())
for band in BANDS:
if at < BAND_WEIGHTS[band]:
return band
at -= BAND_WEIGHTS[band]
raise AssertionError("BAND_WEIGHTS covers every band")
| Band | Days in a hundred |
|---|---|
| beginner | 5 |
| easy | 25 |
| medium | 40 |
| hard | 25 |
| expert | 5 |
chesspuzzles/daily.py, BAND_WEIGHTSThe weights now matter only for the days between the switch and the week, which keep the bands they got.
Then, the puzzle. Each band’s pool is sorted by a hash of the band’s seed and the puzzle’s id, an order nobody can reproduce without the key. Day n (counted from EPOCH) takes the nth:
chesspuzzles/daily.py pick · lines 30–41def pick(band, day):
"""The puzzle a band's daily on `day` would be, given the dailies kept so far."""
order = _order(band, daily.seed("chess", band))
recent = set(DailyChess.objects.filter(band=band, date__gte=day - NO_REPEAT, date__lt=day + NO_REPEAT).exclude(date=day).values_list("puzzle", flat=True))
start = (day - EPOCH).days
# (a duplicate in the bundle, retired, is passed over like a recent one: the order itself never moves)
skip = recent | set(retired())
for k in range(len(order)):
candidate = order[(start + k) % len(order)]
if candidate not in skip:
return candidate
raise LookupError(f"Every {band} puzzle has been a daily lately")
recent keeps a puzzle from returning within 1095 days either side, so a remade bundle or new key can’t bring yesterday’s back next month. The pool (daily_pool in chesspuzzles/bundle.py) leaves out one-move puzzles. A puzzle retired as a duplicate (retired(); Content pipelines) is passed over like a recent one but stays in the pool, so the order never moves and only the days that would have landed on it change.
The seeded stream#
The second half, from code to puzzle, is public and runs in the browser. Every engine that makes things from a code uses the same kind of seeded random stream: a 128-bit hash of a string sets the state of sfc32, a small, fast generator, which runs a few times to mix. The kit’s is games/static/games/puzzles/engine/rng.js:
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 uses only Math.imul, shifts and | 0, integer operations every JavaScript engine does identically, and avoids floating-point math, whose last bits might differ. The only float is the last step, dividing a 32-bit integer by 2³², which is exact. So a phone’s Chrome, a laptop’s Safari and the server’s Node draw the same numbers from the same string and make the same puzzle.
The string is the puzzle’s identity: the game, engine version, size, difficulty and code. Hashi’s generate is typical:
games/static/games/hashi/engine/generate.js generate · lines 118–146/**
* The puzzle for a code: { engine, code, size, w, h, islands: [[x, y, n]] },
* made as engine `version` made it (one of VERSIONS; the latest unless
* asked). Throws if the code, size or version isn't one the game makes.
* `stats`, if given, is told how the making went (for tools/hashi/ and the
* guide): networks grown, those thrown away unrated, how the rest rated,
* and the medium ones too shallow to keep.
*/
export function generate(code, size, version = ENGINE_VERSION, stats = {}) {
size = Number(size);
version = Number(version);
if (!SIZES[size] || !CODE_RE.test(code) || !VERSIONS.includes(version)) throw new Error('Not a puzzle the game makes.');
const depth = version >= 3 ? DEPTH[size] : 0;
const r = stream(`hashi|${version}|${size}|${code}`);
Object.assign(stats, { attempts: 0, ungrown: 0, rated: {}, shallow: 0 });
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
stats.attempts++;
const grown = grow(r, size, size, SIZES[size].islands, { double: DOUBLE, loop: size <= 10 ? 0.05 : 0 });
if (!grown) { stats.ungrown++; continue; }
const puzzle = { engine: version, code, size, w: size, h: size, islands: grown.islands };
const shape = layout(puzzle);
const rated = rate(puzzle, shape);
stats.rated[rated] = (stats.rated[rated] || 0) + 1;
if (rated !== LEVEL) continue;
if (depth && solve(puzzle, 2, shape).used.connecting < depth) { stats.shallow++; continue; }
return puzzle;
}
throw new Error(`No ${size}×${size} puzzle came of ${code}.`);
}
An engine may draw many candidates before one passes (here, one its solver rates at the one difficulty). That’s still deterministic: the same code always fails the same attempts and keeps the same puzzle.
Engines keep their own copies of the stream. Hashi’s (games/static/games/hashi/engine/rng.js) matches the kit’s to the bit, so its codes will make the same charts when it moves onto the kit. Solitaire’s (games/static/games/solitaire/engine/rng.js) is a copy on purpose, so no change elsewhere can alter which cards a code deals:
games/static/games/solitaire/engine/cards.js shuffled · lines 29–38/** A shuffled deck for this game and code: the same every time, everywhere. */
export function shuffled(game, code) {
const r = stream(`solitaire|${game}|${code}`);
const deck = Array.from({ length: 52 }, (_, i) => i);
for (let i = 51; i > 0; i--) {
const j = r.int(i + 1);
[deck[i], deck[j]] = [deck[j], deck[i]];
}
return deck;
}
The Register’s (games/static/games/the-register/engine/rng.js) adds fork, a child stream per stage (setting, people, clues), so changing one stage’s draws doesn’t disturb the others.
An endless puzzle’s code is the one thing not seeded: newCode takes fresh bytes from crypto.getRandomValues (games/static/games/puzzles/engine/code.js). After that, it’s treated like a daily’s.
One daily a game#
Most games had three dailies a day, one per size or difficulty: some thirty across the site. David found that too many, and ADR 0034 cut it to one, at the normal level. The switch is by day, not by deploy:
obstacles/daily.py one_daily · lines 40–45def one_daily(day: date) -> bool:
"""Whether a site day has one daily per game (docs/adr/0034): from
settings.ONE_DAILY_FROM on. Days before it keep the lineups they had
(three sizes or difficulties), so their dailies, boards and archive
pages stay as they were played."""
return day >= settings.ONE_DAILY_FROM
settings.ONE_DAILY_FROM is 2026-10-20; UO_ONE_DAILY_FROM moves it, to try either side. Each lineup asks one_daily(day): leaderboard.daily.lineup, hashi.daily.lineup, Kind.lineup and chesspuzzles.daily.bands_for. Before that day a lineup is the old three; from it, the normal one alone (or a kit puzzle’s moved slot). Because it asks about the day, not the time the code runs:
- Old days keep their lineups. A board or stats line for a day before the switch still shows its three dailies.
- The one daily has the code its slot always had. It’s the old middle slot, by the same formula, so nothing about it changes and no engine version moves. The exception is Low Poly, whose old order was public (the pencil-puzzle kit).
- A day already kept stays kept. If a day’s three were made before a deploy reached it, they stay, and solving any of them counts as that day’s.
tests/lineups.py has decorators that move the switch far into the future (every day has three) or past (every day has one), so lineup tests pass on any date:
tests/lineups.py three_dailies · lines 14–15# Every day is before the switch: three dailies a game.
three_dailies = override_settings(ONE_DAILY_FROM=LATER, WEEK_RAMP_FROM=LATER)
The week climbs#
With one daily at the normal level, every day felt the same. David wanted the week to have a shape, the same in every game: an easy Monday, a hard Sunday (ADR 0041). From settings.WEEK_RAMP_FROM, 2026-10-26 (a Monday; UO_WEEK_RAMP_FROM moves it), a day’s daily is the level its weekday names in the game’s lineup, gentlest first:
| Levels | Mon | Tue | Wed | Thu | Fri | Sat | Sun |
|---|---|---|---|---|---|---|---|
| 3 | 0 | 0 | 1 | 1 | 1 | 1 | 2 |
obstacles/daily.py, WEEKobstacles/daily.py week_level · lines 64–66def week_level(day: date, levels: int) -> int:
"""The level (0 the gentlest) of a day's daily, for a game with `levels` of them."""
return WEEK[levels][day.weekday()]
With three levels, Monday and Tuesday are the gentlest, Wednesday to Saturday the normal one, and Sunday the hardest. Only puzzles have dailies; the brainteasers are endless (ADR 0043). Each game asks of its own lineup: Kind.one for kit puzzles, hashi.daily.one (7×7, 10×10, 15×15) and chesspuzzles.daily.band_of (Easy, Medium, Hard). A Kind whose lineup the week can’t climb is refused when declared.
Like the one-daily switch, it’s asked of the day, so the same promises hold: earlier days keep their dailies (Galaxies’ 7×7 included), and each day’s daily is the code its slot always had. The Dailies page, boards and Stats tab open on the day’s own slot. The tests’ week_dailies decorator (tests/lineups.py) puts every day after the switch; one_daily and three_dailies keep every day before it.
When a solve counts as the day’s#
The referee checks a posted solve, then it’s stored (Referees and anti-cheat). One rule in every game decides whether it’s on the day’s board: it must arrive on the daily’s own day. After the referee agrees, the view looks for a matching daily dated the server’s today() and links the solve only if one exists. In Hashi that’s DailyPuzzle.objects.filter(date=today(), code=code, size=size, engine=engine); the kit (DailyPuzzle) and Chess Puzzles (DailyChess) do the same. Solitaire and The Register no longer look.
A solve of yesterday’s daily posted after midnight finds no slot. It’s still kept, rated, and counted on the all-time board and in the solved count, but it’s not on yesterday’s board or in any streak. So “on the day” is decided by the server’s clock alone and can’t be posted.
The kit keeps a solve of a daily on its day separately from a solve of the same puzzle at another time. Low Poly’s daily code is the picture’s own, which you may have drawn in Endless before, and that mustn’t cost you the day.
Streaks#
A streak is days in a row with a daily solved on its day, ending today or yesterday, so it doesn’t break at midnight just because today’s isn’t done. The server keeps one and the browser another, by the same rule.
On the server#
obstacles/daily.py streak · lines 69–77def streak(days, on: date | None = None) -> int:
"""Consecutive days, ending today (`on`) or the day before, in `days`."""
on = today() if on is None else on
day = on if on in days else on - timedelta(days=1)
n = 0
while day in days:
n += 1
day -= timedelta(days=1)
return n
Each game’s streak(user) feeds it the days of the player’s daily-slotted solves (hashi.views.streak, leaderboard.views.streak, puzzles.views.streak, chesspuzzles.views.streaks). That’s the number on the result sheet (“5-day daily streak”), the boards and the account page. Since only on-the-day solves are slotted, it counts only those. obstacles/daily.py also has best_streak, which nothing on the server calls at the moment.
In the browser#
The Dailies page and Stats tabs need streaks for guests too, who post nothing. So the browser keeps its own log, uo:dailies in localStorage, written by every game the moment it solves a daily:
games/static/games/house/dailies.js dailyDone · lines 34–48/**
* A daily solved on its day: kept (its first time stands), for streaks, the
* Dailies page, a guest's Stats tab and a run. `board` is what its time is
* ranked on (a size, a game: "10", "klondike-3"), where the game has several.
*/
export function dailyDone(item, day, seconds = null, board = null, store = keep) {
if (!item || !/^\d{4}-\d{2}-\d{2}$/.test(day || '')) return;
const log = readLog(store);
const days = log.items[item] || (log.items[item] = {});
if (days[day]?.s != null) return;
days[day] = { s: Number.isFinite(seconds) ? Math.round(seconds) : null, b: board == null ? null : String(board) };
const keepDays = Object.keys(days).sort().slice(-KEEP);
log.items[item] = Object.fromEntries(keepDays.map(d => [d, days[d]]));
store.set(LOG, log);
}
The log is { items: { [item]: { [day]: { s, b } } } }: for each daily, each site day it was solved, with the time and the board it ranks on. An item is a game’s slug, or <slug>:<game> in a box of several games (as Solitaire’s four were; their days stay, unread). Days are the dates the game’s API gave. The kit calls dailyDone from games/static/games/puzzles/kit/play.js, Hashi and Chess Puzzles from their own play.js. The log keeps 800 days per item and is never sent anywhere (AGENTS.md rule 6).
games/static/games/house/stats.js applies the server’s rule in JavaScript, plus the best run:
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) };
}
It also counts full sweeps, days with every daily solved. Each daily counts from its first day (since), so a new puzzle doesn’t spoil the days before it existed:
games/static/games/house/stats.js sweeps · lines 37–51/**
* Days every daily was solved: `solved` is { [item]: Set of days }, `items`
* the dailies there are ([{ item, since }]: an item counts from its first
* day). A day with none of them due isn't one.
*/
export function sweeps(solved, items) {
const days = new Set();
for (const set of Object.values(solved)) for (const d of set) days.add(d);
let n = 0;
for (const d of days) {
const due = items.filter(i => !i.since || i.since <= d);
if (due.length && due.every(i => solved[i.item]?.has(d))) n++;
}
return n;
}
The Dailies page#
/dailies/ puts every game’s daily on one page, in the hub’s look: what each is today, how far you’ve got, streaks, and Play next. It stores nothing of its own (dailies/__init__.py). The dailies come from each game’s app, and a player’s record is what the games already keep.
What the server sends#
dailies/lineup.py builds the list. For each listed game with a daily, a maker turns the daily into an item:
dailies/lineup.py _hashi · lines 44–52def _hashi(game, day):
from hashi.daily import one, puzzles_for
from hashi.models import label
p = next(p for p in puzzles_for(day) if p.size == one(day))
return [{
"item": "hashi", "what": label(p.size), "url": _play("hashi", f"#/daily/{p.size}"),
"store": {"type": "hashi", "key": f"uo:hashi:p:{p.size}/{p.code}", "engine": p.engine}, "_daily": p,
}]
An item says what the daily is (“10×10”) and where it opens (the game’s address with #/daily/<size>). Its store says where the game keeps a player’s progress in the browser, with the engine version. A kit daily’s key ends in its day, uo:<slug>:p:<size>/<difficulty>/<code>/<day>, with before naming where an older save was kept. store lets the page say “In progress · 4:12” for a guest by reading the game’s own save, without the server ever knowing.
Kit puzzles share one maker (_kit, taking the day’s slot from Kind.one). Chess Puzzles’ item names the band and the puzzle’s rating. On a day before the switch, with three dailies, the page shows the normal one. A game without a daily (daily=False in the catalog: Solitaire, The Register, and the brainteasers, ADR 0043) isn’t listed.
The list follows the page’s own order, not the shelf’s: the pencil puzzles, then Chess Puzzles. A game the order doesn’t name goes last, in shelf order (the sort is stable). Play next and a result sheet’s Next daily follow the same order.
dailies/lineup.py ORDER · lines 71–74# The page's order (David, October 2026): the shelf's, the pencil puzzles
# and then Chess Puzzles. "Play next" and a result sheet's "Next daily"
# follow it. Dailies are puzzles only (docs/adr/0040).
ORDER = ("hashi", "star-battle", "fillomino", "yin-yang", "galaxies", "aquarium", "griddles", "low-poly", "chess")
For a signed-in player, page_data adds whether each of today’s was solved by a verified solve posted today (_solved_today), and history, every day of the last 400 each daily was solved on its day:
dailies/lineup.py page_data · lines 137–150def page_data(user, day=None):
"""What the Dailies page draws, and what its script reads (no model objects)."""
day = day or site_today()
rows = items(day)
signed_in = user.is_authenticated
for row in rows:
row["server"] = _solved_today(user, row) if signed_in else None
data = {
"today": day.isoformat(),
"signedIn": signed_in,
"items": [{k: v for k, v in r.items() if not k.startswith("_") and k != "thumbnail"} for r in rows],
"history": history(user, day) if signed_in else {},
}
return rows, data
The view renders the list as plain links, so the page works without script (Play next is then the first daily the server doesn’t know you’ve solved), and embeds data as JSON:
dailies/views.py page · lines 11–21def page(request):
"""Every game's daily on one page, in the hub's look (docs/adr/0034):
what each is today, how far you've got (your browser's progress, and
a signed-in player's verified solves), streaks, and Play next."""
day = today()
rows, data = lineup.page_data(request.user, day)
record(request, "visit", game="dailies")
# (without script, Play next is the first not solved, as far as the server knows)
left = [r for r in rows if r["server"] is None]
return render(request, "dailies/page.html", {"day": day, "rows": rows, "data": data, "next": left[0] if left else None,
"started": len(left) < len(rows), "done": request.GET.get("done") == "1"})
What the browser adds#
static/js/dailies.js reads each item’s store and asks the game’s save how far you’ve got: solved (and in what time), in progress, given up, solution shown, or not started. A save from another engine version is ignored, since it was a different puzzle. A kit save under before counts only if it’s marked as that day’s daily. It then takes, in order:
- a verified solve from the server, if signed in (with the browser’s time if it has one);
- the browser’s own save, or the
uo:dailieslog, for anything solved here; - otherwise, the save’s state.
Solves the log didn’t have yet are written into it, so the streaks see them next time. Streaks come from the log, the server’s history, and uo:<slug>:days (what each game kept before the shared log). The top shows done today, the all-dailies streak (days with at least one), its best, and full sweeps. Beside Play next, a status line says what’s at stake: that any one of today’s keeps a live streak going, that one left completes a full sweep, or that the sweep is done. A full sweep is every one of today’s dailies solved for real. A chess daily whose solution was shown is done with, so it’s never offered as Play next, but it isn’t solved. A day with nothing left to play and a solution shown says so (“8 dailies solved today, and Chess Puzzles with its solution shown, so no full sweep.”), with no card, no Share and no sweep counted:
games/static/games/house/dailies.js swept · lines 91–94/** A full sweep: every one of today's dailies solved, each for real (a
* chess daily whose solution was shown is done with, but not solved).
* `rows` are [{ state }], as the Dailies page reads them. */
export const swept = rows => rows.length > 0 && rows.every(r => r.state === 'solved');
What’s new since you last looked#
Returning after a solve should show what the solve did. The page keeps what it showed last (uo:dailies:seen, in this browser) and compares with news() from games/static/games/house/dailies.js:
games/static/games/house/dailies.js news · lines 96–131/**
* What the Dailies page shows as news (docs/design-language.md,
* "Celebrations"), from what it showed last time (`was`, kept under SEEN)
* and what it shows now: `now` is { day, done, streak, best, sweeps,
* solved: [items solved today], all: every one done }. Returns
* { changed: the numbers that went up, rows: the dailies solved since,
* cheer: null, or { kind: 'sweep' | 'streak', text } once per day at most,
* next: what to keep }. A first look has nothing to compare, so nothing
* lights up; a sweep or a milestone still gets its one cheer. A streak's
* milestone reached by the sweep itself goes with the sweep's cheer, as
* its `streak` ("7 days in a row"), so it's said too.
*/
export function news(was, now) {
const ok = was && typeof was === 'object' && typeof was.day === 'string';
const changed = [];
if (ok) {
if (now.done > (was.day === now.day ? was.done | 0 : 0)) changed.push('done');
for (const k of ['streak', 'best', 'sweeps']) if (now[k] > (was[k] | 0)) changed.push(k);
}
const before = new Set(ok && was.day === now.day && Array.isArray(was.solved) ? was.solved : []);
const rows = ok ? now.solved.filter(i => !before.has(i)) : [];
// One cheer a look, each at most once a day: the sweep, or else a streak
// reached with today's solve (a sweep later the same day still gets its own).
const milestone = now.done > 0 && MILESTONES.includes(now.streak) && now.streak > (ok ? was.streak | 0 : 0);
let cheer = null;
if (now.all && (!ok || was.swept !== now.day)) cheer = { kind: 'sweep', text: 'Full sweep', streak: milestone ? `${now.streak} days in a row` : null };
else if (!now.all && (!ok || was.cheered !== now.day) && milestone) {
cheer = { kind: 'streak', text: `${now.streak} days in a row` };
}
const next = {
day: now.day, done: now.done, streak: now.streak, best: now.best, sweeps: now.sweeps, solved: now.solved,
cheered: cheer ? now.day : ok ? was.cheered ?? null : null,
swept: cheer?.kind === 'sweep' ? now.day : ok ? was.swept ?? null : null,
};
return { changed, rows, cheer, next };
}
Each number that went up, and each row solved since, lights once with the house pulse (.house-pulse, the motion for “a place that just changed”). A first look, or one with nothing new, lights nothing.
A streak reaching a milestone (one of MILESTONES, from a week up to a thousand days) is the house’s strong announcement (“7 days in a row”, announce(), the front-end chapter), at most once a day. It takes no click, any press or key puts it away, and under reduced motion it fades in and out where it stands. The status line says it too (“That’s 7 days in a row.”), so the announcement is never the only place. A milestone the sweep itself reaches isn’t lost to the sweep’s cheer: it comes with it (cheer.streak), as a tag on the sweep’s card (“7 days in a row”, the result sheet’s .house-news), its line (“Every one of today’s 9, and that’s 7 days in a row.”) and the status line.
A full sweep comes first, and stays: a card (“Full sweep!”) lists each of today’s dailies with its tile’s color, its emoji and its time (a new best, a clean chess solve), then the streak and the sweeps so far, over the Full Sweep celebration, fireworks in each daily’s tile color under a night sky dark enough that pale sparks stand out on the paper (Celebrations; on a phone the card waits below the window until the first shells burst, then rises). It closes only when you close it. A new best is the game’s own pb kept with its save (the puzzle kit’s, Hashi’s and Chess Puzzles’), so a Hashi best is named too. Its Share sends the day in ADR 0050’s shape, read from where each game keeps its progress in this browser (rule 6: nothing is sent until you press it):
games/static/games/house/dailies.js sweepText · lines 148–168/**
* What a full sweep's Share sends (ADR 0050's shape, docs/adr/0051): the
* day and the sweep; a line per three dailies, each its emoji and how it
* went; the time in all and the streak; the link, last. `rows` are
* today's dailies in the page's order: { item, seconds, best, mistakes, hints }.
*
* 🗓️ Dailies · Thu, Oct 29 · 🧹 Full sweep
* 🌉 1:42 · ⭐ 3:10 🆕 · 🔢 2:05
* ☯️ 4:01 · 🌌 5:22 · 🐠 2:48
* 🔲 3:33 · 🔺 1:57 · ♟️ 0:45 🎯
* ⏱️ 25:25 in all · 🔥 12-day streak
* https://unnecessaryobstacles.com/dailies/
*/
export function sweepText({ day, rows, streak = 0, url = '' }) {
const lines = [];
for (let i = 0; i < rows.length; i += 3) lines.push(rows.slice(i, i + 3).map(sweepLine).join(' · '));
const timed = rows.filter(r => Number.isFinite(r.seconds));
const total = timed.length === rows.length && rows.length ? `⏱️ ${clock(timed.reduce((a, r) => a + r.seconds, 0))} in all` : null;
lines.push(facts(total, streak > 1 ? `🔥 ${streak}-day streak` : null));
return brag({ game: 'dailies', title: 'Dailies', head: `${shortDay(day)} · 🧹 Full sweep`, lines, url });
}
Once the card is put away, “Share your sweep” stays beside the status line for the rest of the day. This is the “News since you last looked” pattern in Components and patterns (ADR 0051).
A run through today’s#
Opening a daily from the page starts a run: that daily, then the rest unsolved, in page order, wrapping round. It lives in sessionStorage (uo:dailies:run), so it lasts as long as the tab:
games/static/games/house/dailies.js startRun · lines 61–64/** Start a run (the Dailies page): today's items still to do, in its order: [{ item, name, url }]. */
export function startRun(day, items, store = session) {
store.set({ day, items: items.map(({ item, name, url }) => ({ item, name, url })) });
}
When a game solves a daily, its result sheet asks for the 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' };
}
A run answers only for its own day and its own dailies, so a game opened from the shelf never shows “Next daily”. Once all are done, the sheet offers Back to your dailies, opening the page with ?done=1.
On the hub: a streak at stake#
The shelf’s Dailies card says nothing about today (ADR 0034) except one line (ADR 0047). The server renders it the same for everyone, adding only the site’s day (data-today). static/js/hub-dailies.js reads this browser’s days (uo:dailies, plus each game’s older uo:<slug>:days). With a streak alive and nothing solved today, the line becomes “Your 4-day streak is waiting: play one today.” and the card’s mark wears a dot. streaks() counts it, as on the Dailies page. A signed-in player’s verified solves aren’t read here; they join the log when the Dailies page opens.
The archive of past dailies#
ADR 0032 gave every kit puzzle an archive of past dailies, a month at a time, each playable any time. Since ADR 0037 it lists one daily a day, from settings.ARCHIVE_FROM, 2026-10-22 (UO_ARCHIVE_FROM moves it), but never before one daily a game or the puzzle’s own first day, up to yesterday:
puzzles/daily.py archive_start · lines 39–42def archive_start(kind):
"""The archive's first day: settings.ARCHIVE_FROM, never before there was
one daily a game, nor before the puzzle's own first day of dailies."""
return max(d for d in (settings.ARCHIVE_FROM, settings.ONE_DAILY_FROM, kind.since) if d)
Earlier three-a-day dailies aren’t listed, but their rows, codes, boards and solves stay, late solves are posted and rated as before, and they count in the Stats tab and ratings.
A day nobody opened has no row:
puzzles/daily.py archive · lines 127–138def archive(kind, year, month, on=None):
"""One month of the archive: each day's one daily from the archive's
first day to the day before `on` (today), newest first, as
[DailyPuzzle]. Days nobody opened are made now, so it has no gaps."""
on = on or today()
first = Date(year, month, 1)
lo, hi = max(first, archive_start(kind)), min(_next_month(first) - timedelta(days=1), on - timedelta(days=1))
if hi < lo:
return []
_fill(kind, lo, hi)
rows = DailyPuzzle.objects.filter(kind=kind.slug, date__gte=lo, date__lte=hi)
return _as_kept(kind, sorted((p for p in rows if (p.size, p.difficulty) == kind.lineup(p.date)[0]), key=lambda p: p.date, reverse=True))
So archive first fills the month’s missing days (_fill) by the usual rule, the key’s HMAC or the puzzle’s own pick. Picked days are filled in date order from the puzzle’s first day, so each pick follows the day before’s and none repeats. Each day lists its own daily (Galaxies’ is 10×10 on the 22nd and 7×7 from the 23rd), and a Griddles day keeps its picture (_as_kept).
The browser asks /games/<slug>/api/archive/?month=YYYY-MM (puzzles/api.py) for the month’s days, each with its puzzle. For a signed-in player it also says which are solved, on any device: a solve of the day’s code at its engine version, or one made on the day under whatever code it had then. A guest’s record is the browser’s uo:<slug>:dailies, by day and size, so it outlives the puzzle’s own save. There’s no size picker. After a solve, the next is the newest earlier unsolved day (nextFrom in games/static/games/puzzles/kit/archive.js). A day’s board offers “Play it from the archive” only for days the archive lists.
An archive solve is checked by the referee, counts on the all-time board and in the solved count, and is rated at half weight against par, like an endless puzzle. It’s never on its day’s board, in its day’s field or in a streak, because the day-slot lookup finds only today’s. Hashi has no archive yet; it gets one when it moves onto the kit.
The Stats tab#
Every game with its own pages and a daily, a table or a rating has a Stats tab at /games/<slug>/stats/, in its own look (AGENTS.md rule 3). games/static/games/house/stats-page.js draws it in the browser from data in the page, with numbers from games/static/games/house/stats.js, the Dailies page’s code.
dailies/stats.py gathers a signed-in player’s results into one shape: {d: site day, i: daily item, b: board, s: seconds or null, w: won, y: a daily solved on its day} (a table game’s are {d, place, seats, w}). A result’s day is its daily’s date, else the site day it was posted (timezone.localdate(created)), so a solve at 11:50 p.m. Central lands on the right day. Some games need care:
- Solitaire has losses, and a deal is played once, so its first result stands, as its rating does. With no daily, none of its results is “a daily on its day”, and its tab counts runs of wins instead of streaks of days.
- The Register has no daily (ADR 0040) and no losses, so a run of wins would just count solves. Its tab counts days you solved a case, and runs of them.
- Chess Puzzles counts a puzzle once: solved (and whether cleanly), or missed and never solved.
- Hashi’s Easy charts from its first engine have no ranking time (ADR 0024), and Low Poly ranks only daily times, so those carry
s: null.
dailies/stats.py payload · lines 122–150def payload(slug, user, today):
"""Everything the Stats tab's script needs, for this game and player."""
from puzzles.registry import KINDS
from ratings.explain import help_for
from ratings.sources import source
game = GAMES_BY_SLUG[slug]
kind = "table" if game.table else "timed"
signed_in = user.is_authenticated
if slug in KINDS:
maker = lambda u: _kit(slug, u) # noqa: E731
elif game.table:
maker = lambda u: _table(slug, u) # noqa: E731
else:
maker = {"hashi": _hashi, "the-register": _register, "solitaire": _solitaire, "chess": _chess}[slug]
# (a guest's results are their browser's: the page reads those itself)
meta, results = maker(user if signed_in else None)
rating = Rating.objects.filter(user=user, game=slug).first() if signed_in else None
return {
"game": slug, "title": game.title, "kind": kind, "daily": game.daily, "today": today.isoformat(),
"signedIn": signed_in, "rated": source(slug) is not None,
**meta, "boards": [{"key": k, "label": v} for k, v in meta["boards"]],
"results": results,
"ratings": ratings(user, slug) if signed_in else [],
"rating": {"label": rating.label, "rated": rating.rated, "provisional": rating.provisional} if rating and rating.rated else None,
"provisionalAfter": PROVISIONAL,
# (the "?" beside Your rating: what goes into it, from the code that rates it)
"ratingHelp": help_for(slug),
}
A guest’s tab has only the browser’s log, so merged joins the two: any daily the log has and the server doesn’t becomes a daily solve on its day.
games/static/games/house/stats.js merged · lines 125–140/**
* The server's results and this browser's log, together: a daily the log
* has that the server doesn't (a guest's, or one not posted) joins as a
* daily solve on its day.
*/
export function merged(results, log, items) {
const have = new Set(results.filter(r => r.y).map(r => `${r.i}|${r.d}`));
const out = [...results];
for (const item of items) {
for (const [d, v] of Object.entries(log.items?.[item] || {})) {
const s = typeof v === 'number' ? v : v?.s;
if (!have.has(`${item}|${d}`)) out.push({ d, i: item, b: v?.b ?? null, s: Number.isFinite(s) ? s : null, w: true, y: true, local: true });
}
}
return out.sort((a, b) => (a.d < b.d ? -1 : a.d > b.d ? 1 : 0));
}
From the joined results, timed works out played, solved or won, the daily streak and best, best and average time, and a spread of times in a few bins. The rating line is the rating service’s history, one point per rated result, provisional ones dashed (Ratings).
Beside “Your rating” is a small “?”, for guests too. It opens ratingHelp from the payload in a house sheet (ratingHelp() in games/static/games/house/stats-page.js): three to six plain points on what goes into this game’s rating, written by ratings/explain.py from the rating code itself (Ratings, “What players are told”). An unrated game has no “?”.
An empty panel ends with a link to the game from the page’s hidden data-play link (“Play today’s puzzle”, or “Play Euchre”). Like the Dailies page, the tab lights what’s new since this browser last showed it (uo:<slug>:stats:seen): a tile whose number went up, and the best time if it improved on the board shown. A first look lights nothing.
Changing it#
- Adding a game with a daily. Make the code with
daily.code(slug, date.isoformat(), …)(or a kitKind, which does it for you), keep the day’s rows once made, and slot a posted solve only withdate=today(). Give the Dailies page a maker inMAKERS(dailies/lineup.py) and lines in_solved_todayandhistory, and the Stats tab a maker inpayload. CalldailyDoneandnextDailyfrom the game’s solve. A kit puzzle gets all of this from itsKind. - Never change a game’s code formula. Unopened days are made from it, so a change rewrites the future and can collide with the past. If the inputs must change (as Hashi’s did when difficulty left), keep the old days’ rows and record it in an ADR.
- Bumping an engine changes what a code makes. For Hashi and the kit, keep the old version in
VERSIONSwhere it can still be made (a new filter switched on from the new version), and no day already made changes. Drop a version only knowing its days’ puzzles will change. - Rotating the secret key changes every code and seed not yet kept. Days already made stay; tomorrow’s change. Chess Puzzles’ three-year window keeps a new order from bringing back a recent puzzle.
- Moving the switch. Set
UO_ONE_DAILY_FROMto try a day either side; the lineups follow.npm run check:dailies:play(tools/dailies/play.mjs) plays the Dailies page through in headless Chrome. - Moving a kit puzzle’s daily to another slot. Before the week picks levels, add a line to its
Kind’smoved, from tomorrow or later, never today, since a day’s daily mustn’t change under the people playing it. After that, the week decides: changeWEEK(from a future day) or the lineup’s slots. - Moving the week’s start. Set
UO_WEEK_RAMP_FROMto a Monday after the deploy reaches production, so no day already played changes level. - Moving the archive’s start. Set
UO_ARCHIVE_FROM(orsettings.ARCHIVE_FROM). It changes only what’s listed; codes, boards and results stay.