Skip to content

The Daily Rift — seeded runs, one a day, with streaks and sharing - #24

Merged
FiddlyDigital merged 2 commits into
mainfrom
daily-rift
Jul 31, 2026
Merged

The Daily Rift — seeded runs, one a day, with streaks and sharing#24
FiddlyDigital merged 2 commits into
mainfrom
daily-rift

Conversation

@FiddlyDigital

Copy link
Copy Markdown
Owner

The retention hook: a single seeded run per day against the same dungeon as every other player, with a streak to keep and a result worth sharing.

Two commits — the seedable simulation that makes it possible, then the feature itself.

1/2 — Seedable simulation (47fc7f5)

rng: () => number becomes a third injected port alongside now and stash:

new Game(cb, { seed: 20260731 })   // reproducible
new Game(cb)                       // unseeded, as before

All ~59 simulation-side Math.random() calls route through it:

  • game.ts and the composed modules use this.rng() / this.game.rng()
  • systems that already receive game use game.rng(); CombatSystem's private rollDie/resolveCombatRoll take an explicit rng parameter
  • dataLoader's pure content pickers (shuffleInPlace, buildOffer, tierForFloor, Boon/Brand.pickThree, FloorEvent/Npc/Omen.random) take an optional rng defaulting to Math.random, threaded from the call sites

Cosmetic randomness in renderer.ts/particles.ts deliberately stays on Math.random — it never affects outcomes.

Two subtleties worth reviewing:

  • Stream position survives a save. A PRNG's position isn't captured by a state snapshot, so resuming a saved daily would silently become a different dungeon. Game counts its draws (rngCalls, serialized) and restoreRngPosition() fast-forwards a restored run to the same point. rng/rngBase join SAVE_SKIP as live closures; seed/rngCalls are saved as run data.
  • Unseeded runs resolve Math.random lazily rather than capturing the reference, so tests and hosts that stub the global still take effect on an already-constructed Game. (Eight existing tests do exactly this.)

New src/rng.ts: mulberry32 (makeRng), a stable FNV-1a hashSeed, and dailySeedString (UTC day).

2/2 — The Daily Rift (92f7d27)

  • Start-screen button showing today's date and current streak; disables itself once the day is spent.
  • Same dungeon for everyone, keyed off the UTC calendar date.
  • Locked to default difficulty with no NG+ heat so scores are comparable — but class and curse stay player choices. The dungeon is what's shared, not the loadout.
  • One attempt per day: the first result is banked; replaying neither double-counts the streak nor overwrites the record.
  • Streaks advance on consecutive UTC days, reset after a gap, and keep a best-ever. Month boundaries handled by date arithmetic, not string maths.
  • Shareable summary — the existing share box becomes a multi-line daily result (date, outcome, XP, kills/lines/combo, streak); normal runs keep the old one-liner.

launchWithModifier gained fixedDifficulty/skipHeat options rather than a parallel code path, so the daily reuses the whole existing setup flow.

Design note

I let players pick class and curse on dailies rather than fixing the entire loadout. Fully fixing it would make scores more strictly comparable, but removes the decision-making that makes a run interesting. Easy to tighten later if leaderboard purity matters more than variety.

Verification

npm run verify green: 456/456 tests (10 new seed/rng cases, 6 new streak cases covering gaps, month rollover and same-day replay), coverage ~86.1% stmts / 78.4% branches / 85.4% funcs / 88.9% lines, lint + validate-data + headless + build + smoke all pass.

Verified live in-browser: the Daily button renders today's date, and starting a daily correctly skips the difficulty picker straight to class selection, with no page errors.

🤖 Generated with Claude Code


Generated by Claude Code

claude added 2 commits July 31, 2026 17:30
Groundwork for the Daily Rift: a run can now be reproduced exactly from a
seed. Adds `rng: () => number` as a third injected port alongside `now` and
`stash`, defaulting to `Math.random`:

  new Game(cb, { seed: 20260731 })   // reproducible
  new Game(cb)                        // unseeded, as before

All ~59 simulation-side `Math.random()` calls now route through it:

- game.ts and the composed modules use `this.rng()` / `this.game.rng()`
- systems that already receive `game` use `game.rng()`; CombatSystem's
  private rollDie/resolveCombatRoll take an explicit rng parameter
- dataLoader's pure content pickers (shuffleInPlace, buildOffer,
  tierForFloor, Boon/Brand.pickThree, FloorEvent/Npc/Omen.random) take an
  optional `rng` defaulting to Math.random, threaded from the call sites

Cosmetic randomness in renderer.ts and particles.ts is deliberately left on
Math.random — it never affects outcomes, and seeding it would make replays
look eerily identical without being any fairer.

Resuming a seeded run would otherwise silently become a *different* dungeon,
because a PRNG's stream position isn't captured by a state snapshot. Game
therefore counts its draws (`rngCalls`, serialized) and `restoreRngPosition`
fast-forwards a restored run to the same point. `rng`/`rngBase` join
SAVE_SKIP as live closures; `seed`/`rngCalls` are saved as run data.

Unseeded runs resolve `Math.random` lazily at call time rather than
capturing the reference, so tests and hosts can still stub the global.

src/rng.ts adds mulberry32 (`makeRng`), a stable FNV-1a `hashSeed` for
turning a date or share-code into a seed, and `dailySeedString` (UTC day, so
every player gets the same rift on the same date).

Tests: same seed → byte-identical run fingerprint; different seeds diverge;
unseeded stays random; stream position survives a restore; helper coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
…sharing

Builds the retention hook on top of the seeded RNG: a single run per day
against the same dungeon as every other player, keyed off the UTC calendar
date (`hashSeed(dailySeedString())`).

- Start screen gains a Daily Rift button showing today's date and the
  current streak; it disables itself once the day is spent.
- Daily runs are rebuilt from scratch with the seed (the page-load Game was
  generated unseeded) and locked to the default difficulty with no NG+ heat,
  so scores are actually comparable. Class and curse stay player choices —
  the dungeon is what's shared, not the loadout.
- One attempt per day: the first result is the one banked, and replaying
  neither double-counts the streak nor overwrites the record.
- Streaks advance on consecutive UTC days, reset after a gap, and keep a
  best-ever. Month boundaries handled by date arithmetic, not string maths.
- The death/victory screen's existing share box becomes a multi-line daily
  summary (date, outcome, XP, kills/lines/combo, streak) when the run was a
  daily; normal runs keep the old one-liner.

launchWithModifier grew `fixedDifficulty`/`skipHeat` options rather than a
parallel code path, so the daily reuses the whole existing setup flow.

- types.ts: DailyResult / DailyState
- storage.ts: loadDaily / dailyPlayed / recordDaily (streak logic)
- start-modal, game-over-modal, ui.ts: daily card and share payload
- main.ts: beginDaily, dailyStartInfo, finishDaily
- tests: 6 streak cases incl. gaps, month rollover, and same-day replay
- README: Core-systems row; ports list now names the seed

Verified live: the button renders today's date, and starting a daily skips
the difficulty picker straight to class selection, with no page errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6kP5vUAcNbxLK5EnPKAhv
@FiddlyDigital
FiddlyDigital merged commit 01df015 into main Jul 31, 2026
1 check passed
@FiddlyDigital
FiddlyDigital deleted the daily-rift branch July 31, 2026 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants