Technical guide

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.

Checked against the code on 2026-10-29.

On this page
  1. The site’s day
  2. Codes from the key
    1. Seeds
    2. Worked example: Hashi on a day
    3. Kept once made
  3. Each game’s daily
    1. The Register, until 2026-10-24
    2. Hashi
    3. The pencil-puzzle kit
    4. Solitaire, which has none now
    5. Chess Puzzles
  4. The seeded stream
  5. One daily a game
  6. The week climbs
  7. When a solve counts as the day’s
  8. Streaks
    1. On the server
    2. In the browser
  9. The Dailies page
    1. What the server sends
    2. What the browser adds
    3. What’s new since you last looked
    4. A run through today’s
    5. On the hub: a streak at stake
  10. The archive of past dailies
  11. The Stats tab
  12. Changing it

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:

  1. 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.
  2. 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.

From a day to a puzzle On the server, the site's day and the game's slot go through HMAC-SHA256 keyed with the secret key; the first bytes become letters, and that code is kept for the day. From the code on, everything is public: the code, the game's slug, its engine version and the size make a seed string, which seeds a stream of random numbers, from which the game's engine makes the same puzzle in every browser and in the referee. On the server, with the secret key the site's day and the game's slot HMAC-SHA256 keyed with SECRET_KEY first bytes to 32 letters the code kept for the day In every browser, and in the referee: all public a seed string slug | version | size | code a seeded stream sfc32, integer math the game's engine the same puzzle, always The key only hides which code a day gets. Anyone holding a code can make its puzzle.
The key decides the code; the code decides the puzzle.

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.pytoday · lines 21–22
def 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–26
def _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.pyALPHABET · lines 17–18
# No 0/O or 1/I. 32 letters, so a byte maps evenly.
ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
obstacles/daily.pycode · lines 29–32
def 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.pyseed · lines 35–37
def 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).

  1. The slot. That day has one Hashi daily, at size hashi.daily.NORMAL, 10. The first time anyone asks, puzzles_for makes the row:
hashi/daily.pydaily_code · lines 29–31
def daily_code(date, size):
    """Hashi's puzzle code for the day: six letters."""
    return daily.code("hashi", date.isoformat(), str(size), length=6)
  1. The message. daily.code("hashi", "2026-10-20", "10", length=6) builds hashi|2026-10-20|10. All of it is public; only the key makes the result unguessable.
  2. The digest. _digest computes HMAC-SHA256 of the message under SECRET_KEY: 32 bytes.
  3. The letters. code maps the first six bytes to letters. Say (made up) the digest began 0xC7 0x1A …. 0xC7 is 199, and 199 mod 32 is 7, so the first letter is ALPHABET[7], a 9. 0x1A is 26, ALPHABET[26], a U. Four more bytes give four more letters.
  4. Kept. The row in hashi.DailyPuzzle (date, size, engine version, code) is stored and never made again.
  5. Public from here. The game asks /games/hashi/api/daily/ for the date, code and engine version. Its engine seeds a stream with hashi|<version>|10|<code> (ENGINE_VERSION is 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.jscaseRng · line 25
export 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.pyone · lines 19–21
def 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.pypuzzles_for · lines 34–46
def 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.pydaily_code · lines 32–36
def 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.pyKind.normal · lines 149–152
@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.pyKind.one · lines 154–167
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.pyKind.lineup · lines 169–173
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–81
def _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 key daily.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 where daily.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 by daily.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–101
def _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.pyKind.engine_of · lines 135–138
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.pyband_of · lines 54–67
def 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")
BandDays in a hundred
beginner5
easy25
medium40
hard25
expert5
Live from chesspuzzles/daily.py, BAND_WEIGHTS

The 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.pypick · lines 30–41
def 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.jsstream · 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.jsgenerate · 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.jsshuffled · 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.pyone_daily · lines 40–45
def 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.pythree_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:

LevelsMonTueWedThuFriSatSun
30011112
Live from obstacles/daily.py, WEEK
obstacles/daily.pyweek_level · lines 64–66
def 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.pystreak · lines 69–77
def 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.jsdailyDone · 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.jsstreaks · 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.jssweeps · 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–52
def _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.pyORDER · 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.pypage_data · lines 137–150
def 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.pypage · lines 11–21
def 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:

  1. a verified solve from the server, if signed in (with the browser’s time if it has one);
  2. the browser’s own save, or the uo:dailies log, for anything solved here;
  3. 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.jsswept · 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.jsnews · 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.jssweepText · 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.jsstartRun · 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.jsnextDaily · 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.pyarchive_start · lines 39–42
def 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.pyarchive · lines 127–138
def 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.pypayload · lines 122–150
def 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.jsmerged · 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 kit Kind, which does it for you), keep the day’s rows once made, and slot a posted solve only with date=today(). Give the Dailies page a maker in MAKERS (dailies/lineup.py) and lines in _solved_today and history, and the Stats tab a maker in payload. Call dailyDone and nextDaily from the game’s solve. A kit puzzle gets all of this from its Kind.
  • 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 VERSIONS where 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_FROM to 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’s moved, from tomorrow or later, never today, since a day’s daily mustn’t change under the people playing it. After that, the week decides: change WEEK (from a future day) or the lineup’s slots.
  • Moving the week’s start. Set UO_WEEK_RAMP_FROM to a Monday after the deploy reaches production, so no day already played changes level.
  • Moving the archive’s start. Set UO_ARCHIVE_FROM (or settings.ARCHIVE_FROM). It changes only what’s listed; codes, boards and results stay.