Skip to content

Save & Exit brings you back to the same dungeon (§8) - #209

Merged
0xward merged 1 commit into
mainfrom
claude/nullstate-onchain-text-q6g2a3
Jul 31, 2026
Merged

Save & Exit brings you back to the same dungeon (§8)#209
0xward merged 1 commit into
mainfrom
claude/nullstate-onchain-text-q6g2a3

Conversation

@0xward

@0xward 0xward commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Closes the last known bug in GAME-DESIGN.md — §8 flips from [CHANGE] to [TODAY].

No smart contract, ABI, or contract address is touched.

The bug

Save & Exit on floor 3 → Continue → ensureFloor(3) found an empty cache and built a new floor. Different layout, monsters back on their feet, looted containers refilled, smashed props whole.

getSaveSnapshot() persisted 14 fields but never G.floors, and dungeon.js generated from bare Math.random() with no seed — so the same floor could not be reproduced at all.

At risk: XP, ordinary items (→ NullState Point via burn), and Glitch Shards, which are also sold at $1 per 5.
Never at risk: the weekly Paper and Golden Key — guarded both by the saved run caps and by the server's 1-per-wallet-per-week records.

The fix, in two halves

Either half alone fixes nothing.

1. The layout is a pure function of a seed. One runSeed chosen at newGame() and saved with the run; a floor's seed is hash(runSeed, cycle, act, abyss, depth). Same seed, same floor — the map costs zero bytes to persist.

2. What the player did to it is a delta. Which enemies died, which props broke, which containers were opened and what is still inside them, which rooms are lit, and where they stood. Indices into the generated arrays — sound only because of (1).

Measured: 2.6 KB for a whole five-floor bunker with every prop smashed and every container opened. Firestore's limit is 1 MB.

Why Math.random is swapped rather than threaded

Randomness for one floor is spread across four files — dungeon.js lays out rooms, game.js picks archetypes and elites, props.js places and rolls decor, entities.js jitters each enemy. The engine is plain <script> tags with no module system (rule 1 in §10), so there is no shared rng to import and threading one would mean touching every constructor. Generation is synchronous and finishes inside one call, so the global is swapped for exactly that window and a finally restores it.

Two guards, both of which earned their place

A snapshot does not always keep describing the run it was taken from. The shell rewrites campaignActIndex/depth on it when a bunker is cleared (so ENTER points at the next bunker) and again when a raid finishes. Replaying those floors into the new bunker would start it with the monsters already dead and the containers already emptied. The save now records a floorsKey — cycle, act, abyss, raid — and the delta is dropped unless it still matches.

Regeneration can legitimately differ. A Premium Sector cache appears only while that act's blueprint is owned, and ownership lives outside this save. Each floor stores the array lengths its indices came from; a mismatch drops the delta rather than killing the wrong monsters.

A pre-existing bug this surfaced

The snapshot written when a raid finishes was cached during the raid, so it still said raid: true. enterSavedSession() restores RAID_MODE from that flag — so the next Continue resumed as a raid, and clearing that bunker took the raid branch again, fired nullstate-raid-cleared, and advanced nothing.

The campaign silently stopped progressing after any raid. Fixed in the same place the campaign path already fixed up its own snapshot, for the same reason.

Dead code

Scanned all 14 engine files against each other and against the app for functions defined but never referenced. Exactly one: _wtShade(). Removed.

Verification

npm run test:seeded — 15 assertions, fails on the old engine (5 fail with seeding disabled):

Check
run seed survives the save
the floor regenerates IDENTICALLY
a fresh run gets a different floor
3 dead enemies stay dead · 3 smashed props stay smashed
the opened container stays open, holding exactly the loot left in it
a distant lit room stays lit
the player resumes where they stood
a delta that no longer fits is dropped, not misapplied
the NEXT bunker does not inherit the last one's floors, and gets its own seed
a fully-cleared bunker stays small enough to save (2.6 KB)

Full suite: test:seeded 15/15 · test:raid 17/17 · test:contracts 13/13 · test:floors all passed · test:inventory 7/7 · test:traversable 0 failures in 100 floors · tsc --noEmit clean · lint 34 errors (unchanged baseline) · audit · check:copy · check:market · check:cssvars · check:attribution all pass · build succeeds.

Docs: GAME-DESIGN.md §8 rewritten as shipped, build-order item 8 struck through, and game-mechanics.md's "Known limitation" note replaced with what now actually happens.


Generated by Claude Code

Save & Exit on floor 3 -> Continue -> ensureFloor(3) found an empty cache
and built a NEW floor. Different layout, monsters back on their feet,
looted containers refilled, smashed props whole. getSaveSnapshot()
persisted 14 fields but never G.floors, and dungeon.js generated from bare
Math.random() with no seed, so the same floor could not be reproduced at
all. At risk: XP, ordinary items (-> Point via burn) and Glitch Shards,
which are also sold at $1 per 5. Never at risk: the weekly Paper and Golden
Key, guarded by the saved run caps AND the server's per-week records.

TWO HALVES, EITHER ALONE FIXES NOTHING

1. The layout is a pure function of a seed. One runSeed chosen at newGame()
   and saved with the run; a floor's seed is hash(runSeed, cycle, act,
   abyss, depth). Same seed, same floor — the map costs zero bytes to save.

2. What the player did to it is a delta: which enemies died, which props
   broke, which containers were opened and what is still in them, which
   rooms are lit, and where they stood. Indices into the generated arrays,
   which is only sound BECAUSE of (1).

Measured at 2.6 KB for a whole five-floor bunker with every prop smashed
and every container opened. Firestore's limit is 1 MB.

Math.random is swapped for the seeded stream around generation rather than
threaded through, because randomness for one floor is spread across four
files and the engine has no module system to share an rng through (rule 1).
Generation is synchronous, so the swap is contained and a finally restores
it.

TWO GUARDS, BOTH OF WHICH EARNED THEIR PLACE

A snapshot does not always keep describing the run it was taken from: the
shell rewrites campaignActIndex/depth on it when a bunker is cleared and
again when a raid finishes. Replaying those floors into the NEXT bunker
would start it with the monsters already dead and the containers already
emptied. The save now records a floorsKey (cycle, act, abyss, raid) and the
delta is dropped unless it still matches.

Regeneration can also legitimately differ — a Premium Sector cache appears
only while that act's blueprint is owned, which lives outside this save. So
each floor stores the array lengths its indices came from, and a mismatch
drops the delta rather than killing the wrong monsters.

A PRE-EXISTING BUG THIS SURFACED

The snapshot written when a raid finishes was cached DURING the raid, so it
still said raid:true. enterSavedSession() restores RAID_MODE from that
flag, so the next Continue resumed as a raid — and clearing that bunker
took the raid branch again, fired nullstate-raid-cleared, and advanced
nothing. The campaign silently stopped progressing. Fixed in the same place
the campaign path already fixed up its own snapshot.

Also removes _wtShade(), the one function in the engine that was defined
and never called (found by scanning all 14 engine files against each other
and against the app).

npm run test:seeded — 15 assertions, fails on the old engine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017A764RdnwpyWnG7uCNhMiQ
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nullstate Ready Ready Preview Jul 31, 2026 6:03am

@0xward
0xward merged commit df67bf3 into main Jul 31, 2026
5 checks passed
0xward added a commit that referenced this pull request Jul 31, 2026
Daily Contracts answer "why play today". They do not answer "why open this at
all today", and those are different questions: a player with one spare minute
opens the app or does not, and if they do not, what breaks is the habit rather
than the session.

Seven escalating days, breaking it resets to 1, no claim step — opening the app
IS the event. Guests included, since a guest id keys the same records and is
exactly the player a retention mechanic is for.

  1: 80 Point   2: 2 t1   3: +1 energy   4: 3 t1
  5: 150 Point  6: 4 t1   7: 8 t1

Every rung is t1 shards, energy or Point on purpose: shard tier is act-gated,
so a t2 reward would hand a new player a currency they cannot spend. Day 7 is 8
t1 because the first weapon evolution costs exactly 8 — a full week is worth one
evolution, which is the ratchet §4 asks for and a prize a player can name.
Sized so a week of merely opening the app is worth about one day of playing it.

The whole decision (continuation, restart, or repeat?) happens inside one RTDB
transaction, so two tabs cannot both advance it or both be paid. A grant that
throws still counts the day: a re-claimable day is worse than one missed grant.

Also fixes a flaky test shipped in #209 — test-seeded-floors.js failed about one
run in four because a floor averages 1.6 lockable containers and a fixed depth
sometimes generated none. It now retries the mount rather than probing, since
every fresh mount draws a new RUN_SEED and the probe's floor is not the next
mount's floor.

npm run test:streak (31, stubbed RTDB) and test:streak-ui (16, real browser).
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.

1 participant