NETWORKING ARCHITECTURE

Other Systems › Networking Architecture

How the client and the backend talk, what each side is allowed to decide, and what every call costs. Read this before changing anything in src/worker.js or any code path that makes a network call. The stack is Cloudflare Workers plus SQLite-backed Durable Objects. The older Save, Economy & Anti-Cheat page describes the retired Unity/UGS design and is kept for its doctrine only — its named services no longer apply.

Doctrine

RuleMeaning
Clients send events and requests, never stateThe save is a prediction cache. A server reconciliation overwrites it. Editing it should be inert.
Nothing happens offlineOffline play is banned outright. No queue, no outbox, no banking, no replay of work done while disconnected. On loss of connection autoplay pauses, the battle pauses, and a reconnecting window shows. Play resumes only when the connection returns.
Ownership is fenced by the server, not asserted by the clientAdopting the save mints an epoch; every later request carries it and the object refuses any instance holding a lower one. A check a client performs on itself is worth nothing once the client is the thing that is broken.
A dead writer is a dead channel“Offline” means the writer, not the socket. A window that can no longer save takes the same path as a lost connection — play pauses — because /api/battle is a separate writer and keeps crediting a window whose save has stopped moving. The one difference: a write halt is terminal, cleared only by a reload that re-adopts the server copy. Never by a client-side merge.
The server derives rewardsThe client reports what it did, never what it earned. XP, food and drops come from server tables, and the client applies what it is told.
Cost scales with battles, not with time connectedAn idle account must write nothing. If a player is doing nothing there is nothing to save.

⚠ Durable Object rules — a 1000× cost risk

Duration is billed in wall-clock time at the full 128 MB allocation whenever an object is active or idle but unable to hibernate. An idle object that qualifies for hibernation is not billed at all. Hibernation requires all of: no setTimeout/setInterval, no in-progress awaited fetch(), no standard WebSocket API, and no request still being processed. It then hibernates after 10s idle.

Nothing inside a Durable Object class may ever use setTimeout, setInterval, a plain WebSocket accept(), or an awaited fetch(). Any one of them makes the object permanently non-hibernateable. With a request arriving every few seconds the object then never leaves the billed idle state.

At 300k players × 24hDuration cost
Hibernateable (current code)~$250 / month
One blocker introduced~$101,000 / month

Throughput ceilings

LimitValueConsequence
Requests per single object~1,000/s soft limitPast it the object queues, then returns an overloaded error to the caller
blockConcurrencyWhile()~200 req/s at 5ms per callBlocks all concurrency unconditionally. Reserve for initialisation and migrations, never regular request handling
SQLite per object10 GBWrites fail with SQLITE_FULL

Cloudflare's own guidance is “do not create a single global Durable Object that handles all requests” — which is exactly what idFromName('main') is today. Their Queues team hit the same wall at ~400 msg/s and only got a 10× speedup by sharding into consumer-shard objects. Shared objects must therefore be sharded too: a single leaderboard object is the same mistake at a smaller scale.

Objects never call each other. The Worker orchestrates. A PlayerDO that awaits a fetch() to another object blocks hibernation for the whole round trip and bills for the wait — it is the same mistake as an open socket, wearing an internal address. When a battle needs both a player write and a leaderboard submit, the Worker calls each in turn.

Scheduled work is a Worker scheduled() cron that calls into the object. Never a DO alarm: setAlarm() is itself billed as a row written and keeps the object awake. Also standing: no unqualified DELETE or SELECT in a constructor, and one-off migrations claim their completion flag before doing the work.

Endpoints — who calls what

Every route below is real and reachable today. The Caller column is the discipline that matters: a route nothing calls is either a door for tooling or a door that is built and not yet wired, and the two are not the same thing. Shell = play/index.html, Game = play/app.js. Everything under /api/_internal/* is Worker→object plumbing and answers 403 from outside — roughly thirty routes, deliberately not enumerated here because nothing outside src/worker.js may call one.

The hot path — what a playing account actually calls

EndpointCallerTriggerWrites
/api/meShellConnection check: 60 s heartbeat (skipped when another /api/ call answered ok inside the minute, never while paused), on online, on tab return, and instantly whenever any API call failsnone
/api/save GETShellBoot, once. Mints the ownership epoch — see the fence. The server copy wins unconditionally, not by timestamp1 row (the epoch)
/api/save POSTGameDebounced action flush, and forced boundaries1 coalesced row
/api/battleGameOnce per battle. Returns rewards and the replacement spawn1 coalesced row
/api/floor/enterGameFloor entry. Returns the derived pool and accrued refill1 row (cursor)
/api/grants/pending GETGameThe drain. Asks what the server says it owes and takes delivery; the gid is acked inside the save, so a rollback re-owes the grant rather than losing itnone
/api/name/claimGameImmediate, never debounced — cross-player uniqueness1 row
/api/feedback POSTGameIn-game bug report. Logs capped at 30,000 — rejected, never truncated. Screenshots were removed 2026-08-05 (D order): a shot field is ignored, never stored1 row
/api/logoutShellSign-out. Exempt from the fence1 row

Money and inventory

EndpointCallerTriggerWrites
/api/shop/catalogue GETGameShop open. Resolved from premium_shop.csv in the Worker; 503 shop_data_unavailable rather than an invented price if the pinned codex has no such filenone
/api/shop/buy POSTGamePurchase, keyed on a client idem. Returns the new ledger balanceledger + entitlement
/api/wallet/spend POSTGameInventory and soul slot blocks. The client names a kind, never a cost — the server resolves cap and STEP from inventory_slots.csv / soul_slots.csv, checks the ledger, debits and raises the cap in one transactionledger + slot_caps
/api/entitlements/pending— (nothing calls it)Built for a player disputing a missing purchase. Live and correct; simply unwirednone

Chance — the roll doors

EndpointCallerTriggerState
/api/roll POST—Upgrade attempts, soul grading, breeding economicsBuilt, probe-verified, not wired — the client still rolls these locally
/api/hatch POST—Egg hatching outcomeBuilt, probe-verified, not wired
/api/breed/claim POST—Breeding resultBuilt, probe-verified, not wired
/api/transfer/pubkey GET · /api/transfer/dungeon POST · /api/transfer/sent GETShell + Game (lane 2 of T48; the Dungeon pins the pubkey)Parked for a future expansion (D 2026-11-02): every door answers 404 transfer_parked unless the Worker var TRANSFER_OPEN is 1 (absent on both live workers; spec § 0). As built: creature transfer to Herumon Dungeon (T48, docs/creature-transfer-2026-11.md). pubkey is public and answers the Ed25519 public half of the Worker secret TRANSFER_SIGN_KEY (503 transfer_unconfigured without it). dungeon {uid}: the object judges its own items row (cat Monster, not gone - the uid is a hint), marks it sent/sent_src without spending it (a COPY), signs the v1 record (uid, species, line_id, stage, level, grade, mutant, sex, origin tower, acct hash, ts) over its canonical JSON and keeps it in transfers; a repeat answers 409 already_sent with the same record and signature, a foreign uid 404 transfer_unknown. sent lists the account's sent uids for the badge and the sheet lineBuilt, probe-verified (tools/probe_t48_transfer_srv.py 9/9, 2026-11-02); shell write and Dungeon import are the later lanes
/api/evolve POSTevolveDoor (the advancement arrow)A creature advances a stage: the uid, the form it leaves, the form it reaches. The worker stamps the step off creatures.csv; the object judges the stored record's form and level, writes adv_ledger (src door) and renames the items row. 403 evolve_short, 409 evolve_form, 404 evolve_unknown; idempotent on (uid, to)Built, wired, probe-verified (T19 part 2, 2026-10-22)
Wild battle drops are the one chance outcome that is wired, and it does not have its own endpoint: /api/battle returns a resolved drops array and the client grants exactly that, having had its own gear, enhancement and egg rolls deleted rather than mirrored. Until the three doors above are wired they are backend that protects nothing in practice — the exploit stays open on the client.

The unlock ladder — doors that refuse below the rung (T6 part 2, 2026-10-22)

The ladder is codex/unlocks.csv (see index.html, "System unlocks"), read through the pinned codex like every authored table and judged on the account's best floor - the server's progress ratchet (PlayerDO.progressOf), never the current floor. The Worker stamps the forwarded request (x-lock: system:floor, always reset first so a client-sent header never reaches the object) and the player's object answers 403 {error:'locked', system, floor, maxf}; the world doors and the trader refuse in the Worker, whose hop already carries the ratchet. A missing table fails open with one UNLOCKS_OPEN ops line.

EndpointGated formRungStays open
/api/hatch POSTthe batch form (eggs[], Hatch All)hatch_all 8the single-egg form
/api/dismantle POSTtwo or more rows (Dismantle All, multi-select)dismantle_all 8one row
/api/roll POSTsoul_grade / enchant / grade on a non-Gear category / any grade with all (the ALL-IN ticket)souls 12 / scrolls 30 / grading 40 / upgrade_all 40the Gear grading step (taught in the scripted opening), egg, sex, breed_rate
/api/devour POSTevery calldevour 12—
/api/apvp/queue, /api/apvp/finish POSTthe mint and the settle (Worker-side, the ratchet rides the myteam hop)auto_pvp 15tickets, the defense log
/api/unlock POSTkind pen / kind classbreeding 25 / classes 50—
/api/shop/trader GET, /api/shop/trade POSTevery call (Worker-side, the T5 floor read)trader 40—
/api/pvp/mm/queue POSTthe queue (Worker-side, the ratchet rides the abyss/busy hop)competitive_pvp 60poll / ready, so a queued ticket can finish
/api/save POSTa soul equipped below 12, a pen in use below 25: flagged unlock_claim_bad (class system), the save is keptsouls 12 / breeding 25the save itself - detection only
The ratchet reaches the client as maxf on the boot GET /api/save (index.html sets window.__HT_MAXF before app.js reads it), on /api/me, on /api/floor/enter and on the battle credit, so the client's padlock and the door's refusal agree and an unlock shows the moment the floor is credited. Gate: tools/probe_t6b_unlock_doors.py.

The scripted opening — guaranteed drops (T6 part 4, 2026-10-22)

B1 (D 2026-09-09): "the guaranteed items exist so the lesson never waits on RNG". Drops are server-rolled (D 2026-07-31: hardcoded never, client authority never), so a guaranteed drop is a server rule from a codex row. codex/opening.csv (see index.html, "The opening") is read like unlocks.csv - the pinned ref, an hourly cache, fails open with one OPENING_OPEN ops line per ten minutes (no table = no guaranteed drop, never a failed battle) - and rides the battle event into the object as _opening, because the object never fetches. A row with a Selection is a guaranteed drop for the clear of its Floor: the object's only floor-clear signal is the progress ratchet, so the drop is minted on the account's first credited non-boss victory with the ratchet at Floor+1 or past it, and only while the ratchet is still inside the opening (the table's last Floor + 1) - a veteran never collects the kit. Each beat is claimed first in kv (opening:<realm>:<beat>), once per account, then minted through the first-win row shapes (fixed / random_family / lowest_rarity_tie_random) plus class_weapon (the stored save's charSoul names the class line; the Worker's resolveDrop picks a uniform weapon of that family, tier and slot), with a server uid in items (origin drop), into the same drops array as every other drop tagged op:<beat>, recorded in drops_granted with the event so a replay keeps it. Selection currency (the floor-2 materials beat, T42) is a wallet credit, not an item: owed on the account's first clear of its Floor (the server's own clear record, kv clear:<realm>) or once the ratchet is past it, for exactly one first upgrade attempt of the weapon_drop weapon - grading.csv's Gear row at that beat's Tier from its Grade, the price the roll door charges (today 1 Scrap) - booked as wallet_ledger (source opening, ref opening:<realm>:<beat>:<cur>) and answered as opCredit for the client to adopt. Any other unbuilt Selection is flagged opening_sel_unbuilt once and left unclaimed. Gate: tools/probe_t42_materials.py. Gate: tools/probe_t6d_opening.py.

Identity

EndpointCallerTriggerNotes
/api/auth/steam, /api/auth/steam/callbackShellSign-inSteam OpenID check_authentication round trip, then a signed session cookie. Rate-capped per IP before a session exists. Planned (T22, dfmc-client steam/INTEGRATIONS.md): POST /api/auth/steam/ticket, a Steam session ticket checked with AuthenticateUserTicket, minting the same session for the same steamid - the shell’s door, the web keeps this one
/api/auth/discord, /google, /apple + /callbackShellSign-in, or Link from Settings (?link=1)Discord: OAuth code + PKCE S256, scope identify, identity = /users/@me read with our own token. Google: OIDC code + PKCE + nonce, id_token RS256 checked against Google’s JWKS (iss, aud, exp, nonce). Apple: form_post, ES256 client-secret JWT, id_token against Apple’s JWKS, no scope (only the stable sub). State, nonce and verifier are held server-side and taken once. A first sign-in makes a new account (19 digits led by 9 — never a Steam64); a linked identity opens the account it is linked to. No credentials → the button is hidden and the door answers 503 provider_unconfigured
/api/auth/providersShellSign-in screenWhich provider doors this deployment has credentials for; Steam always
/api/auth/dev, /api/auth/dev_identityHarnessLocal wrangler onlyDead code unless DEV_LOGIN=1, which deployed vars never set. dev_identity mints a verified-identity ticket without a provider, for the link/merge probes; OAUTH_MOCK_BASE (the probes’ stub provider) is honoured under the same lock only
/api/account/resolve GETSign-in flowprovider+uid → account id, before a session existsOps-key gated. It was briefly unauthenticated and was an enumeration oracle — it told any caller whether a given Google/Apple id had an account. Resolution belongs inside a signed platform token exchange; do not re-open a public one
/api/account/link, /api/account/identitiesGameAttach another platform loginTakes a ticket, never an identity. A provider callback in link mode leaves a server-side ticket (15 min, bound to the session that started the link); a client-sent provider_uid answers 400 verified_identity_required, a foreign ticket 403. Link answers 409 account_conflict carrying a summary of BOTH sides when the other login already has progress — including a legacy Steam account, which holds no index row
/api/account/merge POSTGameChoose which progress survivesNever auto-merges, and only between two verified identities — this session’s and the ticket’s, which the merge spends. Progress is deleted, currency is not — the loser's Wraithgold transfers as an audited merge_out/merge_in pair. A losing account holding paid-provenance units refuses outright
/api/account/reset POSTGamePlayer-initiated realm wipeAdvances the realm generation; since T61 (2026-11-02) it clears everything the account holds — the wallet ledger (all but a Wraithgold row with paid provenance), slot caps, entitlements, trades, saved builds, and on the World the name, badges, board rows, ratings, matches and season rows. Only the login (accounts / identity / sessions) and the account history (T39) stay.

Provider setup (D; the secrets are not in the repo and never in wrangler.jsonc). Redirect URIs are https://<host>/api/auth/<p>/callback for release and https://<host>/dev/api/auth/<p>/callback for dev (<p> = discord, google, apple). Discord: Developer Portal → New Application → OAuth2 → add both redirects; wrangler secret put DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET. Google: Cloud Console → APIs & Services → OAuth consent screen (External, scope openid) → Credentials → OAuth client ID, type Web application, both redirects; GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET. Apple (paid Developer account): an App ID with Sign in with Apple, a Services ID (its identifier is the client id) with the host as domain and both redirects as return URLs, and a Sign in with Apple key (.p8); APPLE_CLIENT_ID (the Services ID), APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY (the .p8 contents). A provider with any of its secrets missing stays hidden. The dev allowlist (DEV_ALLOWED_STEAMIDS) also bounds these logins on /dev: a linked identity of a listed account gets in, a new account is never made there.

Cross-player

EndpointCallerTriggerWrites
/api/pvp/finishGameImmediate — another player is waitingshared object
/api/db/*GameIdentity-bound PvP key/value transportshared object
/api/lb/board/* GETGameBoard view. Serves the daily lb_snap; see the caching note below — there is no edge cachenone (snapshot day roll excepted)
/api/lb/meGameOwn rank. /api/lb/submit is retired: it answers 410 (scores are server-derived since ANTICHEAT 2) and the client calls it nowhereshared object

Tooling and ops — key-gated, never called by the client

EndpointLockWhat it is for
/api/backup/exportFEEDBACK_KEYDefault reads the R2 mirror (source:"r2-mirror", sweep-time copy, env-scoped); live=1 reads the player object's actual SQL (source:"live") — the only truth read. One diagnosis session was built on mirrors mistaken for live saves; every export now names its source
/api/backup/history, /api/backup/factsFEEDBACK_KEYThe version ring and the server-built ratchet/ledger. These are the diagnosis doors — the admin console's per-player routes read the WorldDO and answer empty
/api/backup/dropsFEEDBACK_KEYRead-only view of drops_granted, the server's own record of what it handed out per battle event. Never pruned since 2026-10-23 (T39: account history is kept forever); reports oldest_retained. There is deliberately no restore side
/api/backup/restore, /api/backup/recover, /api/backup/grantFEEDBACK_KEYThe one legitimate door for a rich save. Restore refuses an older payload over a newer live save with 409 unless force=1; since 2026-11-02 (T68) it also refuses a payload holding more souls or items than the server slot cap (409 over_cap, both counts listed) unless force=1, which stores as given and flags ops_restore_over_cap — see admin-backend
/api/backup/reverseFEEDBACK_KEYSHUT — 501 refunds_not_wired. The machinery beneath it is correct and is what a store webhook will call, keyed on that store's transaction id. It previously trusted a caller-supplied amount, which is a delayed-fuse bug
/api/backup/sweep, /api/lb/rank/sweepFEEDBACK_KEYManual triggers for the two cron passes. Both return their result
/api/feedback/exportFEEDBACK_KEYBug reports. The param is k, not key. POST with {ids:[..],status:"processed"} closes them
/api/usage, /api/ledger/reconcile, /api/lb/purge, /api/lb/backfillFEEDBACK_KEYSpend dashboard, ledger reconciliation, destructive board maintenance
/api/admin/killswitchFEEDBACK_KEY (header) only — an admin session without the key is refusedThe tiered kill-switch (see Kill-switch below). GET is the status: tier, auto tier, any forced tier, the signals and the thresholds in force. POST {op:"force", tier:0-3, minutes?, why?}, {op:"clear"}, {op:"config", cfg:{...}}, {op:"config_reset"}; every action is an audit row
/api/admin/*ADMIN_STEAMIDS + sessionTraffic queue, budgets, flags, feedback triage, wipe. Known broken: the per-player routes (flags, save-history, rollback) are routed to the WorldDO while player data lives in the PlayerDO, so they are blind. Use the /api/backup/ doors instead
/api/health, /__buildopenUptime probe and this deployment's client-file fingerprint. /__build is per environment; the /api paths are shared

Board caching, corrected 2026-07-31 — there is no edge cache and there never was. A Worker on the route preempts the CDN cache, so the s-maxage=1800 this page used to claim was decorative and the max-age beside it governed browsers, which serve fetch() from cache without revalidating until expiry. A 30-minute browser hold is how D stared at a one-row board while the origin was serving six. The header is now private, max-age=120. What actually protects the object is the daily lb_snap snapshot, which was doing that job the whole time — the edge was never part of it. Revisit the two minutes at player scale.

The write path — only two writers

There is no periodic autosave. A player sitting in a menu costs nothing.

WriterCadenceNotes
/api/battleOne per battleIrreducible — it is the game. Capped by the spawner at 60/hour/floor.
Action flush10 s debounce, reset per action; as built, autoplay's battle-triggered flush is lazy (5 min) and any manual action tightens it back to 10 sA whole loadout rework becomes one call; an idle autoplay hour becomes ~12.

Safe to debounce

SystemWhy
Equip, unequip, swapRearranging owned items; only the resting state matters
Socket and unsocket gems, runes, soulsIntermediate states are noise
Inventory sort, party composition, pen assignmentPure arrangement
Settings, autoplay configNo server arbitration
DismantleOwn items, own scrap. A lost call leaves the item; the next flush heals it

Never debounced — send immediately

SystemWhy
Purchases and entitlement acksReal money gets zero-loss durability regardless of cadence cost. The ack lives in the save, never the ledger — a ledger-side ack survives a restore and silently loses the purchase
Name claimCross-player uniqueness needs arbitration
PvP actionsAnother player is blocked on it
Boss key consumptionGates zone progression; a lost call duplicates the key

Forced flush boundaries

A debounce may only ever be pending while the player is fiddling in menus — never while the server is being asked to judge something. Flush immediately on: battle entry, floor entry, boss entry, any purchase, pagehide, and on reconnect. Otherwise the server validates a battle against a stale loadout.

How a sync is queued

Release status. Everything in this section is live on release 0.25 and the three client changes once marked DEV below have been LIVE on release since 2026-08-03 (0.26): DEV below: the hidden coalesce, the non-OK re-arm, and (in the next section) the shell writing both run keys. The backend half is identical on both branches — src/worker.js is byte-for-byte the same file on main and dev.

There is no queue in the durable sense and there must never be one: the doctrine bans banking work done offline. What follows is a deadline and a dirty flag, both of which live in the page and die with it. Nothing is replayed from a previous session, and a re-arm always resends current state, never a recorded intent.

MechanismAs built
The deadline takes the minimumqueueFlush(ms) sets _srvFlushAt to min(existing, now + ms) and marks _srvDirty. So a lazy 5-minute autoplay deadline is tightened to 10 s by the next manual action rather than replaced by whichever call happened last.
The heartbeat fires itmaybeFlush() runs on the rAF ticker and compares against Date.now() explicitly — the ticker passes performance.now(), a different clock domain, and comparing the two never fires at all.
One write in flight at a timeA flush issued while another is unsettled is chained behind it and re-runs once the first has landed and refreshed _srvVer. Without this, two overlapping writes from the same session carry the same base_ver; before 2026-08-20 the second was refused 409, and the client — correctly following its own rule — concluded another device owned the run and rebooted onto a pre-ascend copy (reported live as “kicked down a floor when ascending”). Today an epoch-bearing session's mismatch is accepted and flagged save_conflict; only an epoch-less session is still refused.
Unload writes cannot chainA pagehide or keepalive write cannot wait on a promise the page will not live to settle, so it re-arms the dirty flag instead of queueing behind the in-flight one.
Nothing to save is nothing to senddebounce, hidden and pagehide all return early when _srvDirty is false. Before this, a session the player never touched uploaded its whole state the moment a tab was backgrounded.
hidden is coalesced, pagehide never is DEV visibilitychange→hidden fires on every screen-off, notification pull and app switch, which on a phone is constant, and each one used to spend a token from the save bucket. Throttled to one flush per HIDE_COALESCE_MS (15 s, matching the 429 retry_ms), with _srvDirty deliberately left set when a flush is skipped so nothing is dropped and the heartbeat carries it. Backgrounding is recoverable because the page survives; a close is the last chance there will ever be.
Re-arm rules, by answer DEV2xx — clean, adopt srv{}. 409 carrying data (a version conflict) — never re-arm; this window is stale and resending is precisely the wrong move, so it reboots onto server truth through onSaveConflict. 409 {error:'stale'} with server_ts (the wall-clock ordering gate, #150/#151) — heal, not reboot: window.__HT_TSFLOOR is raised to server_ts + 1, and the write re-arms at 250 ms, up to 4 times; past four heals it falls into the fail ladder below. Any other non-OK (429, data-less 409, 5xx) — re-arm and honour retry_ms when the server gives one, else 10 s; the sixth failure in a row (SRV_FAIL_HALT) halts writes. 413 and 403 halt writes at once. Network throw — re-arm at 10 s and fire the connection check immediately.
Why not sendBeaconIt cannot set custom headers, so it cannot carry x-ht-epoch — and the fence treats a missing epoch against a non-null session epoch as a mismatch. Exempting beacons would reopen exactly the bypass the fence exists to close. fetch(keepalive:true) can set headers, which is why it is used.
Closed 2026-08-03: the keepalive cap vs the save ceiling. A keepalive request body is capped at 64 KiB (measured in Chromium 2026-08-03) while a save is allowed up to 409,600 bytes. The client therefore gzips any body over 4 KB with CompressionStream (buffered, never streamed — a keepalive stream body is refused) and sends it with x-ht-enc: gzip; a real ~131 KB save goes out at ~24 KB. keepalive is set only when the bytes on the wire are at most KEEPALIVE_MAX (60,000); a larger body is sent as an ordinary fetch. Where CompressionStream is missing the body goes as plain JSON.

Trust boundary

FactWho decides
What spawned on a floorServer — derived, see below
Whether a battle could be enteredServer — it holds the spawn cursor
XP, food, drops, class XP, creature XPServer — derived from its own tables and returned to the client
Player decisions during a fightClient — the only thing it genuinely owns
Fight simulationClient, everywhere. Guarded by a power-plausibility gate — an unwinnable matchup is refused at the request, not flagged for later review — plus cross-player statistical anomaly detection

Re-simulation: considered and rejected (D ruling)

Deterministic replay does account for player choices — the choices are the inputs, and for autoplay there are none to send because the heuristic is deterministic. It was rejected anyway, because it requires the battle engine to run server-side, which is the engine-extraction job already ruled out. Do not re-propose it as a cheap addition; it is that job wearing a different name.

What the power gate does not catch, accepted knowingly: a forged win in a fight that was plausibly winnable, and an understated turns count (which feeds the rate cap). The exposure is bounded because the server already owns what spawned, whether the battle could be entered, and what the rewards are — and the spawner caps a player at 60 battles an hour. The most a forger converts is some losses into wins, at a rate they were already limited to, visibly out of line with their power in the statistics.

Blanket server-side resolution of every battle is deliberately not planned — extracting the engine into a module both sides import is the largest job on the table. The documented industry line is the split above: full re-simulation for the competitive surface, plausibility gates and sampled auditing for grind. The idle RPG Firestone runs this exact model — 99% server-side with the main battle left client-side because the calculation volume does not justify it — and catches the residual with statistical flagging, reporting roughly 1% of new players banned daily at fewer than 1 wrong call in 500 reports. Note the corollary: flags feed a human queue, they do not auto-ban.

Floor spawns are derived, never stored

ElementRule
IdentityHMAC(serverSecret, account + floor + DATA_PIN + spawnIndex). The secret never leaves the server, so there is no lookahead: a player cannot compute future spawns
RefillLazy on access: min(elapsed_minutes, 10 − current), overflow discarded, frozen at cap. Only spawns whose 60s has already elapsed are ever sent
ConsistencyIndex N always resolves to the same creature, so a spawn keeps its identity and roll until killed, and re-entry never rerolls
Stored{lastAccessTs, liveIndices[], cursor, pin} per floor, ~50 bytes. The save stores nothing about pools
DeliveryFloor entry, and on the /api/battle response. Respawns cost no extra request
Data changes — adding creatures (D ruling)Version per floor, not by DATA_PIN: a spawnTableVersion advances only when that floor's pool actually changes, so adding creatures to floor 40 never re-rolls floor 3. Nothing on a floor is ownable (creatures come from eggs and breeding; floor spawns cannot be captured), so re-deriving a changed floor costs a player nothing but a different opponent. A battle in flight is the only hazard: /api/battle carries the version it started under, and the server resolves under the old table
Structural changesAnything changing the derivation function, the secret, or the table format takes a maintenance window — the flag exists in the worker (MAINTENANCE=1, bring-up is two edits per OPS). A window is required whenever the same data would be valid under two rulesets at once; routine content patches do not create that, data-shape changes do
The server secret is load-bearing. Rotating it rerolls the world for everyone. Treat it like DATA_PIN: set once, never rotated casually.

Save authority — which copy wins on login

The server save is authoritative. If the server holds a save for a realm, that save wins on login, unconditionally. The local copy is used only when the server has nothing for that realm, which is a first login on a browser that already carries progress; that one copy is pushed up and becomes the server's.

Server has…Local has…Result
a saveanything, any ageServer save adopted into localStorage. If the local copy carried later work, that work is discarded and the in-progress battle snapshot is dropped with it — a battle snapshot belongs to the save it was taken from.
nothinga save at the current SAVE_EPOCHLocal kept and posted to the server.
nothingnothing, or a pre-epoch saveCharacter creation.

Why not the newest copy. Adoption used to pick whichever save carried the later ts. That stamp records when a browser last wrote, not how far the save got, so a browser left on old progress won every conflict simply by being opened last — and then posted its old state back up. It also put the decision in the hands of the client clock, which is arbitrary: a machine with a skewed clock would win every conflict forever, or lose every one. No timestamp comparison between two clients can be trusted, so none is made.

Two server-side gates remain behind this and are unchanged: a write whose ts precedes the stored one is rejected 409 stale, and a write whose playerXp is under half the stored value is no longer refused (2026-10-01, xp409): it lands, the stored XP is kept by the grow-only rule and handed back in srv, and xp_regress_halved is flagged. maxFloor is grow-only at the save door (maxfloor_regress, below), and a floorNum that moves down in the same write as a regressed maxFloor is held at the stored position (floornum_stale_writer, #114); the login rule above keeps a stale client from proposing either in the first place.

The save door — what the server checks before it stores anything

A save is a proposal, not a fact. The client posts its state; the server decides what the account becomes. Every rule below runs on every write, and none of them can move a player backwards — that was the defect they exist to kill. The door order is: the size ceiling (413), the floor and XP plausibility checks (400 implausible floor / implausible xp), the version handshake, then the generation check, then the content rules.

Scrap, Ink, Dust and Essence are the server’s from the wipe (T19a, 2026-10-22). Every earn and every spend of the four materials is a wallet-ledger row before the client can show it, so the door compares the save’s wallet with the ledger: a claim above it (beyond a small grace) is clamped to the ledger and handed back in srv.wallet, which the client adopts; a claim below it is kept and catches up on the next save. A save built a moment before an upgrade, a class purchase or a trade landed is clamped silently; anything else is flagged wallet_over_ledger. Buying a class, trading with the Shadow Trader and every upgrade attempt are refused insufficient on the ledger, never on the number on screen. Ichor keeps its own ceiling and Wraithgold its entitlement ledger.

RuleWhat it doesFlag
GenerationA save from a generation older than the account's is answered {wiped:true} and the client reboots fresh. Runs after the size ceiling, the plausibility checks and the version handshake, and before any content rule — so a stale-generation save over 409,600 B is answered 413, not {wiped:true}.—
Version handshakeIf the client sends base_ver and it is not the version the server holds: a session with an epoch has already proven ownership at the epoch fence, so the write is accepted (every rule below still runs, the version comes from the server's own counter) and flagged — save_retry when the client declares a retry and is exactly one step behind, else save_conflict (same owner — accepted) (since 2026-08-20). The gap is not bounded: an epoch session's write is stored whatever version it echoes; the flag is the only record of the shape. A session without an epoch is refused 409 with the authoritative {ver, ts, data}; it adopts that copy and reboots, and nothing of its own is stored. No longer optional (2026-08-02): optionality was the migration ramp, and an omitted base_ver is an omitted check. Once a session has an epoch it is by definition running a build that sends both, so from that point an absent base_ver against a save the server holds is answered with the ordinary conflict payload. Pre-epoch sessions keep the old behaviour until they reload, and never after.save_conflict, save_retry, save_no_base_ver
Two size ceilingsThe device-only fields are stripped, then what remains is measured: over 409,600 B is refused 413 (and over 262,144 B is flagged save_large). A second, looser guard refuses a serialised save over 900,000 B after every other rule has run. Accepted writes feed a time-spaced save_hist ring per realm, which is what /api/backup/history reads: a save enters it only if it lands at least WR_HIST_MS (60 s) after the last history row, and the ring keeps the 20 most recent rows — not the last 20 versions (write-cost step 9, 2026-08-06).save_oversize
GenesisAn account with no server save may only create a newborn one. Rich state enters through the key-gated ops restore, never this door.save_genesis
Floor ceilingmaxFloor and floorNum are capped at max(ratchet + 1, what the account already held). The second term matters: when the ratchet stalls, the cap can no longer confiscate floors the server itself previously stored.maxfloor_over, floornum_over
Grow-only progressmaxFloor, playerXp and classXp never decrease. A stale device, an old tab or a re-adopted local slot simply loses the merge. Both level readouts derive from those two xp fields, which is why a stale write used to read as "I lost twenty levels".maxfloor_regress, xp_regress_merged
Grow-only earned stateEarned scalars (wildWins, invCap, soulCap, pensUnlocked) keep the higher value; earned maps (zoneWins, zoneKills, crXp, bossDown, hatchLv) merge per key; cleared, codex and clsOwned merge by union — a floor once cleared and a creature once seen are never un-seen.grow_only, grow_only_map
Credible countersKill and win counters are capped by what the server actually credited. Every accepted save stamps _ledK/_ledB — the ledger's kill and battle counts at that moment — so the next save may only claim the delta the server credited since, plus a small burst for battles in flight. A blob claiming two thousand kills against sixty credited ones is contradicting the server's own record.kills_over_ledger, wins_over_ledger
Ledger ceilingsplayerXp and wallet.Ichor are measured against a delta ceiling: the credited part of the previous stored value (the stored value minus the server-written excess _ovX / _ovF, what the ledger never credited) plus what the ledger credited since the last accepted save's stamp (_ledX / _ledF) plus grace (ledger_grace: 2,500 XP, 300 Ichor). A save with no stamp gets the flat form max(previous stored value, ledger + grace) once. The Ichor ceiling also adds the verified dismantle allowance (and any unconsumed trade allowance). XP has no kept band (2026-10-01, xp409): any playerXp claim over the ceiling is clamped to it (never below the value already stored) and reported in srv; a claim under the credited total is lifted to it. An Ichor claim over the ceiling but within the clamp band (food_clamp 5,000 Ichor) is kept and flagged, and its excess is stamped, so the next save is not measured from it: the band is one band in total, never one per save. Beyond the band it is forged-scale, clamped to the ceiling (never below the value already stored) and reported back in srv.xp_over_ledger (clamped), ichor_over_ledger (kept), xp_forged, ichor_forged (clamped)
Class XP ceilingclassXp is hard-capped at class_max_xp (18,464,128, the level-100 cumulative total; classxp_over_cap), then held to the same delta ceiling against the ledger's class credit (_ledC, grace 2,500; excess _ovC, as above). Any claim over the ceiling is clamped and reported back in srv, and a claim under the credited total is lifted to it (shipped 2026-08-01; the cls_clamp kept band removed 2026-10-01, xp409).classxp_over_cap, classxp_over_ledger (clamped), classxp_forged (clamped)
Wraithgold is overwritten, not clampedChanged 2026-08-02 — the clamp is deleted. wgCeil and the wraithgold_over_grant flag no longer exist. A ceiling on a client-written number stopped minting but had no idea what was spent, which is exactly why a displayed balance could disagree with the ledger. The door now overwrites wallet.Wraithgold from wgBalance() — SUM(delta) over the wallet ledger and nothing else — and reports the correction as srv.wg. A client asserting 999,999 stores as its real balance. This was only safe once all three sinks (item block, soul block, shop) were on the ledger; deleting it earlier would have silently refunded every block anyone had bought. The account seed (WG_SEED 100, WG_ALPHA_GRANT is the off switch) is a flat ledger entry posted once per account per realm, idempotent on (source,ref) = (migration, seed:<realm>) — never read from the save.wallet_drift
Slot caps are overwritten tooinvCap and soulCap come from the server-owned slot_caps row, seeded from the save exactly once (the save is the only record of blocks already bought, so nobody loses any) and owned by the server after that. The client earns a cap only through /api/wallet/spend, so whatever the blob claims is advisory. Note for live accounts: from first touch the server's number wins, so a blob holding a cap the server never recorded is corrected downward. The soul bag is counted against that cap (2026-10-01, soulcap): soul-category rows in bag+bagAdd, minus hatched-hidden eggs, plus one class soul per owned class. A save may not hold more souls than the server cap, or, on an account already over it, more than it already held; new souls past that are stripped newest first. The breed-egg roll on a full bag mints nothing (409 soul_bag_full).reported in srv{} (soulStripped); soul_over_cap
One line per buildA repeated imgId across equipOv is flagged. Visibility only, deliberately not a refusal — live saves already hold the violation and rejecting would lock those accounts out of their own progress to punish them for a hole the client left open. The client now refuses to seat a duplicate; this measures the back-population. Species-level only: a Stage 1 beside its own Stage 3 needs Line_ID server-side, which the creatures loader does not keep today.party_line_dupe
Wallet ratesTwo tiers. Crossing the soft line only reports; the hard line clamps, and it sits above the worst legitimate income in the data. The hard burst is a token bucket: the part of a balance no allowance explained is stamped (_ovW) and taken off the next save's base, so the burst refills at wallet_caps_per_min instead of being re-earned by every save. Scrap, Dust, Essence and Ink have no balance ceiling (no refusal on balance during BOOK); a claim beyond what the server booked plus the soft burst is watch-only: wallet_over_ledger. The save's ts that clocks this guard is bounded to the server's clock plus 2 minutes.wallet_rate, wallet_rate_hard, wallet_over_ledger (watch)

Why the seed moved server-side (2026-08-02). It used to be minted by the CLIENT: on load, an account with no wgSeed flag set its own wallet to WG_SEED and claimed the flag in the same write. That is correct shape for a client-side migration and wrong for money. The ceiling above is held = previous stored Wraithgold, so for any account that ALREADY had a save when the seed shipped (2026-07-27) held was 0, the claimed 100 was clamped straight back to 0 and flagged wraithgold_over_grant — and the client never retried, because it had already written wgSeed:1. Permanent zero, reachable only by the OLDEST accounts, which is why it surfaced as a single developer report against three healthy player accounts. The rule to take from it: a client-side one-off cannot mint anything the server ratchets. If the server owns the ceiling, the server owns the grant.

An entitlement row was the other candidate shape and was rejected: nothing on the client applies pending entitlements yet, so the credit would have lifted the ceiling and never reached the wallet.

ONE WRITE IN FLIGHT AT A TIME. base_ver is optimistic concurrency: a write lands only if the version the client echoes is still the one the server holds. It follows that two overlapping writes from the SAME session are indistinguishable from a second device — both carry the same base_ver, the first bumps the version, and the second is refused 409. The client then does exactly what it is told to do with a 409: concludes it is the loser, stops saving, adopts the server copy and reloads. That copy predates whatever the losing write was carrying, so the player is thrown backwards.

Reported live 2026-08-02 on a single device: “frequently getting this error and getting kicked down a floor when trying to ascend a floor.” The ascend is precisely where it bites — preloadFloor() forces a floor-entry flush, and the rAF heartbeat can fire the 10s debounce flush in the same breath. Writes are now serialized: a flush issued while another is in flight is CHAINED behind it and re-runs once the first has landed and refreshed the version, so it never sends a stale base_ver. Unload writes (pagehide, keepalive) cannot wait for a promise the page will not live to settle, so they re-arm the dirty flag instead of chaining.

Since 2026-08-20 the server no longer refuses the self-race. A base_ver mismatch from a session that holds an epoch is accepted and flagged (save_conflict same owner — accepted, or save_retry for a declared one-step retry): the epoch fence already proved no other window claimed the run. Only an epoch-less session still gets the 409 and the reboot. Serializing writes stays, because it keeps the flag quiet. An ops write to the save (restore, rollback, a migration, a repair) is therefore not left to base_ver: since 2026-09-30 (F380) it rotates the session epoch like a new tab’s claim, and the open window is fenced and reloads onto it.

The general point: a concurrency primitive that cannot tell your own second write from someone else’s turns every self-race into a false conflict — and a false conflict here is indistinguishable, to the player, from data loss.

The server says what it changed. An accepted write answers with srv:{} naming every field the server overrode, and the client adopts those values. A clamp that returns 200 OK and quietly eats progress is data loss with a success message on top; that is exactly how a stalled ratchet cost two players sixteen and ten floors and hours of xp.

Why it is built this way. Every shipped save service resolves the same problem with a version token the client echoes, and refuses the stale writer rather than merging or clamping it: PlayFab (ETag + If-Match → 412), Unity Cloud Save (write locks), Nakama (object versions), DynamoDB (conditional writes), Google Play Games (both conflicting snapshots returned, resolved by highest progress). None of them resolve conflicts by client clock, and none silently reduce a submitted value. Earned progress is a grow-only counter — it merges by max, so a late arrival from a stale device can never subtract.

Server-owned outcomes — the roll door

Chance belongs to the server. Deterministic work belongs to the client. That split is the ruling (2026-07-31): drops, upgrade attempts, egg hatching and breeding results are told to the client and never reported by it; equipping, unequipping and dismantling are deterministic, so the client performs them and they are validated in the next exchange by the save-door rules above.

POST /api/roll — session-gated. The Worker resolves the authored row from the codex CSVs (grading.csv, soul_grading.csv, breeding.csv; pinned to DATA_PIN on release and to main on dev, module-cached) and hands it to the player's object, exactly the way /api/floor/enter hands over its floor table — a Durable Object may never await a fetch. The object draws with HMAC-SHA256(SPAWN_SECRET, "roll|sid|realm|kind|idem"), the same keyed generator the spawn table uses, records the outcome and answers it.

PropertyMechanic
IdempotentThe outcome is keyed on the client's idem string. A retry, a reconnect or a double-tap replays the stored result: it can never re-roll and never double-charge. This is what makes it safe to ask again after losing the answer.
UnpredictableThe draw is keyed on the server secret. A client cannot compute it in advance and cannot reproduce it afterwards.
Authored onlyA from/to/tier step the CSV does not contain is refused 400. No inventing a cheaper ladder.
Rate ceilingCounted in SQL, not memory — an in-memory throttle dies with the isolate.
Attempt ordinal
(ON DEV, not on release)
The row above is true of a RETRY and was never true of a SECOND ATTEMPT. Because idem is minted by the client fresh on every click, a reload before the spend reached the server bought a different key and therefore a different draw. For the kinds where the server answering is the cost — grade, soul_grade and, since 2026-08-14, enchant — the draw key is server-derived instead: srv|<kind>|<realm>|<iid>|<n>, where iid is the target instance the client names and n is an ordinal the object holds. The ordinal advances only once a save lands on a version higher than the one current when the roll was answered — that is what "the spend settled" means, and it is the cheapest honest evidence available because the client debits in the same tick it applies. Until then the same attempt is replayed, flagged replay + unsettled. A client must never sell an unsettled answer: it decides nothing and costs nothing, or it charges twice for one draw (#135). egg and sex are deliberately excluded — both already derive from stable server-side identities, so a fresh key cannot buy them a new draw. Additive: a client sending no iid falls through to the old client-keyed behaviour, so one backend serves release and dev at once.

Answered today: grade and soul_grade (success, authored odds, cost, the on-failure rule) and breed_rate (Ichor per minute, minutes per egg, Ichor per egg, dismantle value). Now server-owned and WIRED: wild battle drops — /api/battle returns a resolved drops array and the client grants exactly that, having had its own gear, enhancement and egg rolls deleted. Server-owned but not yet wired: egg hatching results and upgrade attempts — the doors exist and are probe-verified, but the client still rolls those locally until their batches land. Since moved to the server as well: boss guaranteed loot and zone keys (2026-08-08) and the first-win ladder (2026-09-28), which now ride the same drops array.

How a wired outcome reaches the screen. Deterministic rewards (XP, Ichor) are computed and shown the moment the battle ends, because nothing about them is chance. The chance-bearing part waits for the server: the client posts the battle, and the resolved drops are granted when the response lands, inside the victory bar’s own fill animation. Space is checked through the same bag guard as any other grant, so a full bag loses the drop rather than overflowing. If the player has already left the victory screen the items still land in the bag. Nothing is banked offline: a failed post grants nothing and forces the connection check, which pauses play.

The grant lane — retired 2026-08-05

The drain is off. Everything below describes how the lane worked until v0.34 and is kept as the record of why it was switched off, not as current behaviour. Drops arrive inline at the end of each battle; there is no catch-up on load. The lane itself disappears in the server-authority rework, when the server holds the bag and there is nothing to catch up on.

Why it was switched off

Two faults made it do more harm than good. The owed list was computed from the stored save’s grantAck, and that field was written to the save but never read back on load — so every page load began with an empty acknowledgement, the first save afterwards overwrote the stored history, and the server re-owed everything it had already handed over. Players came back to bags past their cap. And the drain filtered the offered items on having a name, which eggs do not carry: it found nothing to place, and acknowledged the grant anyway. Ninety-three eggs were destroyed that way and 1,357 more were queued to follow; 785 of those were formally revoked on 2026-08-05 and the remainder sit dormant.

How it worked, for the record

The rule. pendingGrants(steamid, realm) = every row in item_grants that is minted, not revoked, and whose gid is not listed in the STORED save’s grantAck. That is the whole definition. There is no server-side “delivered” column, deliberately: the ack lives in the save so that a restore to an earlier point rolls the ack back with the items, and the grant becomes owed again rather than the item vanishing. A server-side ack loses the item silently in exactly that case.

The order of a delivery. (1) The server mints a grant, one row per battle event, keyed drop:<eventId>, holding raw descriptors — lane, grade, tier and the draw seeds. (2) The Worker resolves those descriptors against items.csv on the way out, because the Durable Object may never hold the item table. (3) The client applies the items through invRoomFor, the same guard every other path that hands the player an item uses, and appends the gid to grantAck. (4) The next save carries that ack, and the grant stops being pending. A full bag pauses the drain and reports how many items are still waiting; it never eats them.

grantAck must survive a page load, and until 2026-08-05 it did not. loadRun restores a fixed list of keys from the stored save. grantAck was written to the save and concatenated in two places but was not in that list, so every page load began with an empty ack. The first save afterwards then overwrote the stored history with only what that session had drained, the server was owed every forgotten grant again, and the drain handed the player items they already had. Measured on a seeded account: 40 acks stored, client boots with 0, inventory 28 → 68 within thirty seconds. Live, on real accounts: 134 → 105 → 0 acks across one session, with the save shrinking 64,386 → 62,469 bytes and nothing else changed; another account holding 1,048 grants against 0 acks with a bag at 258 of 160.

The ack has no floor, and that is the sharp edge. A save carrying fewer acks is accepted over a stored save carrying more, and every gid dropped in that write is owed again on the next drain. This is the same class of hole the rollback work closed for maxFloor, playerXp and classXp by making them grow-only — and grantAck is the one field where walking backwards mints items. Re-acking is idempotent and never produces a duplicate grant, which is what makes recovery from a wipe safe.

Failure modes to keep in mind. A grant whose items all fail to resolve is still offered, with an empty item list. The ack list grows with every delivery and is not pruned against the grant lane’s own retention, so acks for long-expired grants stay in the save as dead weight — on a heavy account that is roughly 19 KB against a 184 KB save, under the 256 KiB ceiling but above the 128 KiB warn line. Nothing here accrues offline: a grant is only ever minted by a battle the player actually fought while connected. The grant rows themselves are also never pruned, so the cost of reading them used to grow with the account’s whole history; since 2026-09-17 the save door reads only the outstanding ones, which bounds that read by what is actually owed rather than by everything ever given.

What the save contains

Out of the saveWhere it lives instead
Battle turn logsClient-local, never uploaded. The device adventure log they fed was retired 2026-10-22 (ADVLOG-OUT, D T31-2); the server keeps only the ~185-byte battle metadata row
Adventure log, chapters and eventsRetired 2026-10-22 (ADVLOG-OUT, D T31-2): no player-side adventure log, no extraction doors. The player action record is server-side only (battle rows, ledgers)
Floor spawn poolsDerived, see above
Item and equipment display fieldsLooked up from items.csv by id. The save keeps id, quantity and instance data only

Measured against a real save: 655 KB → ~12 KB. Turn logs alone were 68% of it.

What lives on the device

Local storage is a cache of server truth plus three things the server deliberately never sees. It is not a queue, not an outbox and not a second source of progress. Every key below is namespaced by environment and account, because /dev/ shares the origin playdfmc.com with release — an unnamespaced key means one blob shared between two environments and between every account that has signed in on that browser.

KeyHoldsNotes
herumon_run_v2:<env>:<sid>
…:<sid>_hc
The run save, per realm This is the key the game reads. env is dev or rel; sid is the steamid, or anon before /api/me answers
herumon_run_rel_v1
herumon_run_rel_v1_hc
The same adopted save, written in parallel by the shell LIVE on release since 2026-08-03 (0.26). Legacy, and it must keep being written. It feeds the first-boot migration for a device that has never played. The two formulas are duplicated in play/index.html and play/app.js and must stay identical — tools/probe_runkey.py is the gate. See the root-cause section below for what happens when they drift
herumon_battle_v2:<env>:<sid> In-progress battle snapshot Stamped {ep, gen} at persist. Resume drops any snapshot that is unstamped, from another epoch or generation, parked before the save the session adopted (its wts older than __HT_SAVETS, #122), or whose floor exceeds the loaded save's maxFloor (the maxFloor+1 check only decides while the save has no maxFloor yet; the unit guard ahead of it refuses anything above maxFloor). The unnamespaced herumon_battle_v1 is deleted on sight — it once teleported a reborn account to floor 9
herumon_chain_standard
herumon_chain_removed-mode
Battle-event hash-chain head Cleared on wipe-kick and sign-out. Server-side chains are steamid-keyed, so a foreign head is benign
herumon_mode, herumon_pid, herumon_pvp_net, herumon_loop_db Realm selection, PvP peer id, PvP transport setting, loopback PvP bus Device preferences and the two-tab PvP harness. None of it is progress
<key>_bak, <key>_preserved_<ts> Rescue copies _bak is the previous local write. _preserved_ is a local save the shell refused to upload into an empty account — preserved aside, never destroyed, installable through the key-locked ops restore if it is genuinely that player's
sessionStorage.ht_conflict Why the last reboot happened Survives the reload, so the reason is still readable afterwards by a probe or by anyone reading the console

The three fields that never travel

advBattles, advChapters and floors are stripped by the client and by the save door — turn logs and floor pools are device data, pools are derived and logs are prose. advArc, the adventure-log archive, joined them on 2026-08-01 after it walked live accounts into the save ceiling. On login the shell splices all four back onto the adopted server copy, gated on the local slot carrying the same steamid and the same generation — an unstamped or foreign local loses its device log once and nothing else. For the first three the server never holds a value, so the splice is uncontested; for advArc the local copy wins outright, because a stored save written before the strip still carries an archive and that copy is by definition the stale one.

The save ceiling — 409,600 bytes, and what happens when a save reaches it

POST /api/save strips the device-only fields, then measures what remains. Anything over 409,600 B (400 KiB) is refused 413 save too large and flagged save_oversize; over 262,144 B is accepted but flagged save_large.

The ceiling does not control the save size — the CAPS do. The ceiling only decides whether the player is refused. Measured on a real maxed account 2026-08-08: a soul costs ~401 B (most of it the origin birth record) and an item ~177 B. At the authored caps of 600 souls + 160 items a fully legitimate save projects to ~321,700 B. Any ceiling below that refuses saves the game's own caps permit, so every player who fills their collection is locked out — which is exactly what happened twice. Do not lower this below the projection without lowering the caps first.
A 413 is not just a lost write — it stops the player entering any fight. serverFlush returns false on a 413, and enterBattle awaits that flush and refuses to proceed when it fails (the Stage 5 boundary, so the server never judges a fight against a stale loadout). Floor entry (preloadFloor) and boss entry (enterBossRoom) fire the same flush without awaiting it and refuse only while play is paused (see Stage 5 is a gate below). An oversize save therefore presents to the player as "the game will not start fights", not as a save error. FIXED 2026-08-08 — LIVE on release since 2026-08-08 (0.42). A refused save now PAUSES play and files an automatic bug report. Two classes, because they are not the same failure: terminal (413 too large, 403 refused) halts on the FIRST refusal — a retry resends the same body, so it can never succeed; persistent (429, 5xx) is allowed its retries and halts once the writer has failed SRV_FAIL_HALT = 6 times running, roughly a minute of a dead write path. Counting attempts rather than trusting the status is what catches the case nobody predicted. It calls haltWrites() and not __setPaused() deliberately: haltWrites latches __HT_HALTED, which is what stops __connCheck un-pausing the window the instant /api/me answers 200 — and during a write halt it always will, because the connection was never the problem. The report fires BEFORE the halt, or the re-render costs it its log, and it uses its own save bucket so it cannot eat the boot budget or file one per battle during an outage.
FieldTravels?Why
advBattles, advChapters, floorsNo — stripped by serverSnap() on the client and by the save doorTurn logs and floor pools are device data. Pools are derived; logs are prose
advArc (adventure-log archive)No, since 2026-08-01 — same two stripsIt grows forever by design (compress-not-discard) and is the field that walked live accounts into the ceiling
Everything elseYesGame state the server validates and stores

Why this matters, measured on a live account. advArc was added on 2026-07-28 and was never added to the strip list, so it rode every upload. On one player's save it was 68,560 B of 127,115 B — 54%, leaving 3,957 B of headroom. Once an account reaches the cap every save is refused, and the client's serverFlush re-arms only on a network throw: a 413 returns false silently, with no retry and no warning. The player keeps playing, /api/battle keeps crediting the ledger, and the next login adopts the stale stored copy — because the server save is authoritative on login. That is the rollback. One account went thirteen hours and 1,908 credited kills between two accepted saves; three of five active accounts were at or against the cap, and every healthy account was far below it.

The archive lives on the device only. It is spliced back into the adopted save by syncSaves() on login, and for this one field local wins — unlike the three fields above it still exists inside every save written before the strip, and that stored copy is by definition the stale one. A device with no archive of its own keeps the server's last upload rather than nothing. Without the splice, every login would blank the player's log. No migration is needed: the first post-fix save is under the cap, is accepted, and the stored blob sheds its archive on that write.

Diagnosing a stalled account. The admin console's per-player routes read the WorldDO and answer empty, so use the key-gated doors: /api/backup/history?sid=&realm= for the version ring (a version number that has not advanced while play continued is the symptom) and /api/backup/facts?sid= for the ratchet and ledger the server built from credited battles — the only honest record of what a rolled-back player actually earned.

Known and not yet fixed: a refused save is still silent to the player — any non-OK, non-409 answer should warn and pause, since a save being refused is functionally offline. The archive also packs one event per block, so gzip+base64 inflates it (40,912 B of text stored as 68,560 B, against 7,856 B if packed as designed); now that it stays on the device this is a localStorage-quota problem rather than a save-ceiling one. And 275 of 405 archived battle rows carry a blank timestamp.

Retired 2026-10-22: the adventure log (device archive and the planned server rows alike) is gone (ADVLOG-OUT, D T31-2). The archive once rode the save; it then lived on the device only; now neither exists, and the player action record is what the server already holds from the battle path (power, result, loot).

Inventory — synced every save, validated in the Worker

The bag stays inside the save. On every sync the Worker checks each row against items.csv and creatures.csv and removes anything that could never have existed, then forwards a clean body to the object. It runs in the Worker and not the object for the same reason the drop tables do: items.csv is 208 KB, a save fires every 10–60 s, and handing that table to a Durable Object per request is the cost trap the whole drop design was shaped to avoid.

RejectedTest
Unauthored basethe name is not in items.csv
Wrong categorythe row's category contradicts the authored one
Off-ladder gradenot one of the ten authored grades
Wrong tieronly for bases that HAVE a tier — see below
Invented speciesa monster naming nothing in creatures.csv

Quantity is clamped, not rejected (nothing stacks, D 2026-08-01). Any row whose qty is not 1 — above or below — is kept with qty set to 1, and the change is recorded as qty:N (nothing stacks).

Eggs have grades, not tiers. items.csv authors them with a blank Tier and the client writes tier: 0. A validator that compared the two would call every egg in the game a mismatch — 60 across the live accounts, 54 on one of them. Untiered bases skip the tier test entirely.

Legality is not provenance. The checks above ask whether an item could exist, which needs no history. Whether a legal item was ever actually granted is a different question, and the server's grant record only begins the day it shipped — so every item every live account holds predates it. Unrecognised items are therefore FLAGGED, never removed, until granted items are born carrying a server-issued id. The server keeps one row per item so it can answer that question later; the first sync adopts what an account already holds, once.

Nothing is stripped silently: every removal is flagged and counted back to the client in srv{}, and the validator fails open — if the codex cannot be read the save still lands untouched.

Sharding

ObjectHolds
PlayerDO — idFromName(steamid)saves, save_hist, ledger, chains, floor cursors, adventure-log rows, entitlements
WorldDO — sharedlb, lb_snap, names (uniqueness), anomalies and the traffic-review queue, game-wide rate counters, matches, pidowner, ratings

Two separate classes, not two names on one class. It keeps the per-player object small and lets the build guard check each explicitly. Shard WorldDO in turn if leaderboards get hot — a single global leaderboard object is the same mistake at a smaller scale, and it is the one Cloudflare's Queues team hit at ~400 msg/s.

A single global object caps at ~1,200 players on save size alone, against a 10 GB per-object SQLite limit. Per-player never needs rebalancing; a fixed shard pool does, every time it grows.

Cost model

Superseded by measurement (2026-07-29): the numbers below were the design estimate; the implemented handlers were then counted statement-by-statement. As shipped (with the battle response carrying the pool, the 60 s heartbeat, and the lazy autoplay flush): ~142 requests and ~336 rows written per player-hour of sustained autoplay. At 300k players × literal 24/7 that is ≈ $88k/month (rows written carry 82% of it); at a realistic 8 h/day, ≈ $29k/month. At alpha scale everything sits inside included tiers: $5/month flat to roughly 2–3k sustained players. The two backlog levers above are worth a further ~$27k/month at full scale. Nothing offline means autoplay needs a live connected tab — literal 24/7 for all players is a planning ceiling, not a forecast.

Rates, verified against Cloudflare's pricing docs 2026-07-29: DO requests $0.15/M after 1M; duration $12.50/M GB-s after 400k; rows written $1.00/M after 50M; rows read $0.001/M after 25B; SQLite storage 5 GB included then $0.20/GB-month. R2 $0.015/GB-month, $4.50/M Class A. Fine print that bites: setAlarm() is billed as a row written, deletes count as rows written, the KV-style get/put/delete/list methods are billed as row operations, and every write stores nominal metadata that counts toward billable storage.

Line300k × 24/7300k, 30% × 8h
Rows written$13,990$1,354
Worker requests$4,860$483
DO requests$2,430$243
Worker CPU$324$32
DO duration$250$20
Storage (3.6 GB)$0$0
R2 backups$45$45
Total~$21,900~$2,180

Rows written dominates at every scale, and it is bought down by cadence, not by save size — a row is a row whether it holds 12 KB or 655 KB. Storage size matters for the 10 GB ceiling and for bandwidth, not for this line.

Cutover order

Each step depends on the one before it. The whole set lands in the wipe window, because that is the only time step 2 is free — afterwards it means moving live saves between objects while players are connected.

#StepWhy here
0Build guard + budget alertShips first, before the object is touched. It is the only thing standing between a slip and the six-figure duration bill, and it must exist while the rewrite is happening, not after
1Validate the session in the WorkerToday nothing knows who is calling until the request is already inside the object. Routing by account is impossible without it, and it is also the abuse fix — unauthenticated traffic gets a 401 from the Worker instead of costing a DO request
2Route by account — idFromName(steamid)Objects are created lazily on first request, so there is nothing to provision. This is the ceiling being lifted; everything else is optimisation
3Split WorldDO outCross-player state has nowhere to live once players are islands
4Save format — logs to rows, pools derived, items by idChanges what lives in the object, so it follows the object move. 655 KB → ~12 KB
5Write path — delete the periodic save, add the debounced flushDepends on the format. This is the cost fix: rows written is ~95% of the bill
6Backup sweep — Worker cron, fan out across accountsBUILT 2026-07-29 — see “As built”: world account registry, cursored pages, five-slot daily ring, day gate, budget-asked, manual trigger
7Rate caps, traffic-review queue, then leaderboards back onEverything above must be stable first

If time runs short, cut 7 and the mirror's version retention. Steps 0–5 move together; half of them shipped is worse than none, because the format and the routing each invalidate the old backup path.

As built — 2026-07-29

All seven stages are implemented, probed and deployed on the dev environment. Branch dev; every stage carries a full-stack probe under tools/ (real Worker, real objects, real client, real battles). What follows is where the implementation refined the contract above — read the contract for the why, this for the how.

AreaAs implemented
Sessionsv2.<steamid>.<exp>.<nonce>.<HMAC-SHA256>, minted only by the Steam callback after a server-side check_authentication round-trip; verified in the Worker before any object wakes. One active session per account. Revocation stays a row in the owner's PlayerDO.
RoutingPLAYER.idFromName(steamid) per player; WorldDO at idFromName('world') for boards, names, PvP KV, feedback, admin, usage, and the cross-player account registry. World trusts x-sid set only by the Worker after verification. The old 'main' object is bound but fully unrouted.
Spawns in playAs specified — plus one refinement: the battle response carries the floor's refreshed pool (derived from state already loaded, zero extra reads), so the client never makes a separate post-battle /api/floor/enter. That endpoint serves boot, travel and refill-due only. The event carries its pool slot; the primary kill's identity is dictated by the derivation, extras are clamped, dead slots 409.
Write pathTwo writers, as ruled — with autoplay refined: a battle response arms a lazy 5-minute flush (the battle event is the authoritative reward record; the save copy may lag). Any manual action tightens the deadline back to 10 s; every forced boundary (battle entry, floor travel, boss entry, hidden/pagehide with keepalive, reconnect, signout) still flushes instantly. The connection poll is a 60 s heartbeat; any failed request checks instantly, so pause latency is one round-trip.
Save dietMeasured on a real save: 670,388 B local → 20,875 B stored (32×). Battle logs and adventure history never leave the device; the shell splices local-only fields back on adoption so logs survive login. 400 KiB post-strip ceiling → 413 + flag (raised from 128 KB via 256 KiB; see changelog).
Rate capsToken buckets in Worker isolate memory, keyed on the verified steamid, checked before any object wakes. Doubled 2026-08-06 after ordinary play locked a live account out (see the changelog entry): battle 24 burst / 4 per min, floor 30/8, save 20/12, save_read 60/60, me 60/60, db 80/40, other 40/24, wallet 24/40; battle is set from the spawn budget, not from “parity”. tables.json spawn_budget accrues one credit per 60,000 ms, cap 10, and incense halves per_ms — so an incense player legitimately accrues 2/min and the old 1/min cap could not be reached without breaching it. 4/min is 2× the fastest legitimate accrual. The bucket is a COST limit, not an economy one: the spawn budget is server-side and authoritative and binds earnings whatever this number is. Consequence to hold in mind — a wider /api/battle surface makes the Phase 2b kill-claim cross-check more important, not less; name 3/hr and auth 10/10 are UNCHANGED — they are per-IP abuse limits, not play limits, and no real player has breached either; auth caps per IP before a session exists; a blanket ip_all 600 burst / 360 per min ceilings one address across everything (raised from 240/120 after the 2026-08-08 CGNAT incident: one carrier-grade NAT pools a whole evening crowd into one counter). It runs after the session gate — verifySession, the soft-block check and the per-account bucket all run first, so a request later refused by ip_all has already spent its account token. Three more classes route out of other: roll 300/240 (/api/roll, /api/hatch, /api/breed/claim — the chance doors, split out 2026-08-08), mm 60/45 (/api/pvp/mm/, the matchmaking poller) and pvpm 120/90 (/api/db/m/). Over cap = 429 plus a breach report to the world's traffic table (throttled 60 s per sid|class) — never a silent drop.

Reads are a separate class, and they are exempt (2026-08-02). rateClass takes the HTTP method, so GET /api/save spends save_read tokens rather than write tokens. Membership of RATE_READ_ONLY means two things at the gate: never soft-blocked, and never escalated to the traffic queue. /api/me joined the set on 2026-08-06, and the reason is the class, not the block. other is a junk drawer: it held /api/me — the one endpoint the shell uses to learn whether it is signed in — beside /api/hatch, /api/roll, /api/breed/claim and /api/account/reset, so tripping the bucket on a hatching spree took the login door with it and a player holding a valid 90-day cookie was shown a Steam gate. /api/me measures 4 rows read and ZERO written, mints nothing and mutates nothing, so it satisfies the rule this set is governed by on its own terms. It still spends a token and still answers 429 when the bucket is dry: what was removed is the LOCKOUT, not the limit, and the soft block is unchanged in force on every endpoint that mints. Reading your own save grants nothing, so it cannot be an abuse vector — and because the shell retries that read on failure, the escalation was being fed by the retry loop itself. Nothing may be added to this set that can mutate state or mint a value.
Traffic reviewTRAFFIC_BLOCK_N breaches in 10 min (50 in the deployed wrangler.jsonc vars since the roll-bucket change; the code default of 3 applies only where the var is unset) soft-block that sid for that endpoint class only — play elsewhere continues, nothing automatic ever bans. The Worker checks a soft-block cache (SB_TTL_MS, 60 s) before any object wakes. Admin queue (ADMIN_STEAMIDS, session-checked): GET /api/admin/traffic, POST /api/admin/traffic/clear (audited with the clearing admin's sid), POST /api/admin/traffic/block.

Blocks now expire (2026-08-02), and this is the incident that forced it. The three numbers were a trap: save is cap 10 / 6 per min, reportBreach fires at most once a minute, and three breaches inside ten minutes blocks the class — so roughly three minutes of sustained 429s permanently disabled an account's save endpoint, which ordinary autoplay reaches. Nothing in the codebase ever cleared a block, and the flag's “queued for review” described a queue that did not exist. Worse, a soft block is per (account, class): with save blocked, /api/me kept answering 200 (class other) while GET /api/save answered 403 forever, so the shell fell back to the sign-in gate and told the player to do the one thing that could not help. It was reported as “player can't log in” and cost an alpha account seven hours; two more accounts had been blocked for three days and nobody knew. Blocks now age out after SB_EXPIRE_MS (code default 1 h; 0 restores permanent). Set to 5 min in wrangler.jsonc on 2026-08-06, because an hour of total lockout was never proportionate to three 429s that ordinary play produces; a genuine abuser is simply re-blocked by the next ten-minute window. The row survives with blocked=0, so /api/admin/traffic history and the audit table are untouched and a real abuser is re-blocked by the next window.
BudgetsCeilings on everything the code itself can spend: BUDGET kinds (r2_write 800k/mo, rank_write 2M/mo), counted in the world's usage ledger. A wmeta override per kind beats the constant — adjustable from the console: GET /api/admin/budget, POST /api/admin/budget/set (0 pauses the kind; every change audited by direct insert, bypassing the flag throttle). Refusal pauses the caller for the month with a budget_exhausted flag. What budgets cannot cap: raw Worker-request billing under a flood — that is the WAF rate rule at the edge, plus MAINTENANCE=1 as the universal breaker. Durable Object row writes are watched (not capped) by the write breaker — see Write accounting.
Kill-switchSteps the game down as spend climbs, from a signal or an operator, never from a code edit or a deploy. The World object holds a tier 0-3. It moves on its own from signals the World already measures: the highest BUDGET used/cap and the number of soft-blocked accounts. The thresholds sit in src/killswitch.json, and POST /api/admin/killswitch {op:"config"} overrides any of them at run time. A tier is reached when any of its signals meets its threshold. It steps back down only once every signal is under threshold × recover_ratio (0.9). Default thresholds: tier 1 at 75% of a budget or 10 blocked accounts, tier 2 at 90% or 25, tier 3 never on its own. An operator can force any tier, with an optional expiry, and clear it. Tier 1: an audit row, and the non-essential doors (/api/lb/*, /api/me/history, /api/feedback, /api/name/*) are slowed: 6 a minute per account, then 429 ks_slow. Tier 2: the non-play doors are also read-only (feedback, name claims, account doors, the board maintenance passes: a write answers 503 read_only), and diagnostic flag rows are shed. Saves, battles, floors, rolls, grants, the wallet and PvP are never touched at tiers 1-2. Tier 3: the maintenance notice, the same page MAINTENANCE=1 shows, for everything except health, the ops doors, the budget console and this door. Each isolate re-reads the tier every 30 s, which costs one World read and no writes unless the tier moves. /api/usage carries the tier. What the player sees (2026-10-23): a 429 ks_slow or 503 read_only answer shows one small line at the top of the screen (“Some features are slowed / read-only for a moment”) that never blocks a tap; the next normal answer from the same door, or any normal answer once the answer’s retry_ms (30 s at least) has passed, clears it. Nothing is retried. A 503 maintenance reloads the page once, so the maintenance notice above shows; a second one within 5 minutes keeps a line on screen instead of reloading again.
Backup sweepWorker cron, every minute, never a DO alarm. waccounts in the world (written on every login) is the only cross-player census; wmeta holds the cursor; 250 accounts a page (~750 subrequests; 300k ≈ 20 h a cycle). Mirrors to R2 as {env}saves/{sid}/{realm}/slotN.json — env-prefixed since 2026-07-30: the dev deployment writes under dev/, release stays unprefixed (existing-data compat), because both deployments share ONE bucket and unprefixed dev sweeps were rotating over release backups. The deployment learns which environment it is from ENV_NAME (deploy-time var, authoritative) or from its own live traffic (per-deployment stable), and the sweep skips the minute rather than guess when cold. N = utc_day % 5 — five generations always exist, the oldest is overwritten, zero list or delete calls. Day gate: one full cycle per UTC day — without it a small account list wraps instantly and the cron re-mirrors unchanged saves forever (measured ~$390/mo of waste at 1,000 accounts). The sweep asks the r2_write budget before any put. Tooling, each route carrying its own key lock: /api/backup/export (newest of the five slots via heads), /api/backup/list, POST /api/backup/sweep (manual, returns its result).
Exact ranksBuilt — see the Open table below for the full design (keep-all, daily keyset rank pass, /api/lb/me, pending_cut). Manual trigger POST /api/lb/rank/sweep, double-locked.
EnvironmentsThe request's own path decides the environment: /dev/* is dev (the zone route's contract), everything else is production. Dev reads MAINTENANCE_DEV, production reads MAINTENANCE — a dev→main fast-forward can never open production by accident. The shell installs a fetch/sendBeacon shim before any script runs: under /dev/, every same-origin /api/ call rewrites to /dev/api/ (nothing else ever prefixed API calls — before the environments diverged, all /dev/ API testing silently ran against production). The Steam redirect's return_to carries /dev home. Dev login allowlist: DEV_ALLOWED_STEAMIDS admits only listed accounts, checked after Steam proves the identity (403 CLOSED ALPHA page); empty = open; production never consults it. When any environment goes down, restore "run_worker_first": true so the notice page beats the static asset layer — and remove it again on bring-up.
Test-only varsDEV_LOGIN=1 (mints sessions without Steam, local wrangler only) and RATE_OFF=1 (bypasses buckets for harnesses — born of the harness being 429'd at login #11, the blanket doing its job). Neither is ever set in deployed vars.
Ops lock ruleRecorded after a live near-miss: every spend-capable or data-bearing tooling route carries its own lock, never a lock inherited from a gate another change can route around. (The maintenance exception once routed backup paths around the session gate that held the key check — a wrong key ran the sweep until sealed.)

Backlog (assessed, deliberately not taken)

ItemAssessment
4a — merge the chain head into the event rowLow risk, ~$13k/mo at 300k×24/7. Swaps the chains lookup for a latest-event-by-chain read (needs an index) and changes the events prune to “expired, except each chain's newest row” or heads vanish and chains falsely reset. Operates inside the anti-forgery core — wants a settled playtest before and after. Backlogged 2026-07-29 (D).
4b — defer the ledger writeRefused for now. Holds earned rewards in DO memory, which dies with the isolate; grace is xp 500 / food 300, and a batch window of a few battles can exceed it at higher floors — an eviction would clamp a legitimate player's save: silent progress loss, the exact failure the durability rule forbids. The escape hatches cost what they save (a safety alarm bills a row per battle; wider grace widens cheat headroom identically). Worth ~$14k/mo at 300k×24/7 and $0 at alpha. Revisit at 50k+ real players with telemetry and a grace audit. Backlogged 2026-07-29 (D).

Precedent

Where each decision on this page has been done before, so it is not re-litigated from scratch.

DecisionPrecedent
Rewards server-derived, combat client-sideFirestone (Holyday Studios): 99% server-side, main battle client-side because the calculation volume does not justify moving it. Residual handled by statistical flagging
Replays as inputs, not recordings — noted, not adoptedClash of Clans recreates a match from recorded taps rather than storing video. DFMC keeps turn logs client-side instead, because it does not run the engine server-side and so cannot recreate anything
Server-verified leaderboard runs — considered, rejectedAntGame.io controls the seed, takes the client's run, and re-simulates it, so no submission can be forged. Rejected here on cost: it needs the engine server-side
HMAC commit-reveal so the client cannot look aheadThe provably-fair construction: publish the hash of a secret server seed first, compute outcomes as HMAC(server_seed, client_seed:nonce), reveal the seed only afterwards. Works only while the seed stays secret until after resolution
Never expose the seed to the clientDiablo II: Resurrected — a deterministic PRNG with client-visible state was fully reverse-engineered into known-seed farming routes with guaranteed drops
No periodic autosave; event-driven writesNakama's own guidance: avoid background jobs in favour of an event-driven route so work is only performed for players actually present
Offline progression banned outrightOffline income is normally a delta-time calculation against the device clock, and is therefore trivially exploited — Melvor Idle and Territory Idle both have documented clock-skip exploits. Games that keep it either make time server-authoritative or convert offline time into a capped resource spent while online
Economic caps rather than anti-automationIdle players broadly treat auto-clickers as legitimate optimisation, so the defence is the value ceiling per real second — here, the 60 spawns/hour spawner — not client-side automation detection
Staying custom rather than moving to a BaaSPlatforms solve auth, IAP receipt validation, leaderboards and sharded storage; they never solve game-rule validation or anti-cheat. PlayFab cut its free tier in March 2026 from 100,000 to 1,000 lifetime players per title, with the replacement tier limited to studios shipping on Xbox — the lock-in risk in practice
Per-player actor isolationMicrosoft Orleans has run Halo backend services since 2011. The model is proven; a published cost postmortem for a live-service game specifically on Durable Objects does not yet exist, so expect undocumented operational edges

Open

ItemState
Backup sweepRESOLVED — it has been scheduled since 2026-07-29, and this row was stale. wrangler.jsonc carries crons: ["* * * * *"], the Worker's scheduled() runs backupSweep then rankSweep, and mirrors exist in R2 for every live account. The real defect, found by checking rather than by repeating the claim, was narrower and is also fixed (2026-08-02): envPrefix() read a module global set inside fetch(), and a scheduled() invocation can run in an isolate that has never served a request — it returned null and the sweep bailed with paused:'env-unknown'. That was the right call (writing into the other environment's keys would be worse) but it meant the sweep could sit out indefinitely on a quiet worker. Measured on live: the newest mirror for an active account was two days old while every forced sweep, which runs on a request-served isolate, worked perfectly. The environment is now stamped durably in the world object the first time each isolate serves a request; ENV_NAME still wins if set, and a world that has genuinely never seen traffic still pauses rather than guessing. Original finding, kept for the record: the bucket existed and the binding was live with nothing scheduling the sweep
Mirror version retentionSOLVED 2026-07-29 by construction: the five-slot daily ring (slot = utc_day % 5) always holds five generations; the oldest is overwritten, nothing is ever listed or deleted
Save trustCLOSED 2026-07-29 (D ruling: state never creates progress). An account with no server save may only create a newborn one (genesis caps on maxFloor, floorNum, classXp, wallet, bagAdd, invCap, crXp; violation = 403 + flag). maxFloor now lives in a server-owned ratchet advanced solely by credited battle events: floor entry gates on the ratchet and every save's claim is clamped to ratchet+1 — the old circle (the gate reading a number the client wrote) is broken. Accounts holding a server save before the rule grandfather once. The shell never uploads a local into an empty account (it preserves it aside); the one legitimate door for a rich save is the key-locked POST /api/backup/restore, which installs verbatim, seeds the ratchet, and audits. Original finding, kept for the record: /api/save clamps only playerXp and food. classXp, creature XP, items, equipment, wallet, maxFloor and invCap are accepted verbatim; floorNum was range-checked 1–100 only until 2026-07-30 — it now clamps to ratchet+1 like maxFloor (flag floornum_over)
Exact ranksBUILT 2026-07-29. Keep-all: submit stores one row per (realm, board, account) forever — the top-200 prune is gone, the table is bounded by the player count (~60 B a row). A daily rank pass rides the Worker cron beside the backup sweep: one keyset page a minute (score DESC, at ASC, acct ASC, never OFFSET), cursor in wmeta, one full cycle per UTC day, the rank_write budget (default 2M rows/mo, admin-adjustable) asked before every page’s writes, and a poison page skipped after ten failures with a flag — never a re-arming loop. Ranks land in lb_ranks; board totals publish to wmeta at completion so /api/lb/me answers rank, score, total and pending_cut from three point reads, privately cached 15 minutes. Board reads are untouched: the daily top-100 snapshot, served under a two-minute BROWSER cache — there is no edge cache on this route, see the caching note above. A run submitted after the cut shows pending_cut until the next pass — rank freshness equals board freshness by design. Cost at alpha: $0 (deep inside included tiers); at 300k players the pass is ~27M rows/mo, and the default budget refuses long before that — raising it is a deliberate admin act. Residual: rows of departed players linger (storage pennies; archival someday)
Build guardBUILT: tools/do_guard.py scans every *DO class for the banned constructs and is mandatory before every deploy. Budget alert set in the dashboard (alerts notify; the breakers are code + MAINTENANCE=1)
Single global objectRESOLVED 2026-07-29 (per-player objects + WorldDO; the old 'main' is bound but unrouted, its data untouched until the wipe). Original risk, kept for the record: idFromName('main') routes every player through one object — the thing Cloudflare's own docs say not to build. It is the ceiling, not an optimisation: ~1,200 players on save size and ~1,000 req/s on throughput
Unauthenticated trafficRESOLVED 2026-07-29 (signed sessions verified pre-object; blanket per-IP bucket first; auth per-IP before a session exists; WAF rate rule at the edge remains the dashboard step). Original finding: Reaches the object before any session check, so it costs DO requests and duration and can starve one global object. Step 1. MEASURED AGAINST PRODUCTION 2026-07-28: /api/me, /api/db/queue.json and a nonsense path all answered 401 from the object. A tested implementation is parked at tools/reference/edge_gate.js in dfmc-client and tools/probe_edgegate.mjs is its acceptance test — 11 of 18 rows fail today, each one a request a stranger can make that wakes the object. Note the parked version only checks that a cookie is present; step 2 needs the Worker to resolve it to a steamid, so the sessions table must become readable from the Worker (cache/KV, or a sessions WorldDO)
Board read is not actually cachedRESOLVED 2026-07-31, and the “fix” recorded here in July was itself wrong. There is no edge cache and there cannot be one on this route: a Worker preempts the CDN. Reads serve the daily lb_snap under private, max-age=120 — a browser cache. The previous 30-minute browser hold was mistaken for an edge hold and is how D stared at a one-row board while the origin served six. Original finding: Original finding: /api/lb/board/* takes no auth by design and its response carries s-maxage=1800 with a comment saying the edge serves it. Measured against production 2026-07-28: cf-cache-status is absent entirely — every read hits the object. A Worker response is not zone-cached unless the Worker puts it there, and even then a unique ?bust= mints a fresh entry per request. Cache in the Worker on a query-stripped key, and shape-check the realm/board segments at the edge so a scanner never reaches the object
Request body is paid for before it is judgedLargely closed by the Stage 1 session gate: the per-account bucket and the soft-block check now run in the Worker, before any object wakes, so an unauthenticated or over-cap post is refused without the body ever reaching the object. What remains is narrower — an authenticated, within-cap post is still parsed in full inside the object before its size is judged, and there is still no content-length gate. Original finding: /api/feedback await req.json()s before its rate limit, so an oversized post is parsed in full and then counted. A content-length gate belongs in the Worker with step 1. Sizing: a report is 220,000 shot + 24,000 logs + 4,000 body; the largest save alive today is 872 KB and the median 433 KB across all 10 accounts, so a 4 MB general ceiling is ~4.5× headroom — a tripwire, not a working limit
Bug-report screenshots live in the objectCLOSED 2026-08-05 — screenshots were removed (D order). The client sends an empty shot, and any shot that does arrive is ignored, never stored (the column stays, always empty), and /api/feedback/export no longer selects it. Original finding: base64 JPEGs in the same SQLite as every save, capped at 240,000 chars and rate-limited 20/hour/account plus 300/hour globally.
Where feedback lives after the shardUnassigned. Rows are per-account but the export and the global hourly ceiling are cross-player, so it is either PlayerDO rows with a WorldDO counter, or a WorldDO table outright. Needs a decision with step 3
/api/entitlements/pending answered 500 to everyoneCLOSED. Fixed 2026-07-28. It sat above const ses = this.session(req) and referenced it — temporal dead zone. Confirmed live before the fix. Nothing in the client calls it, so it was dormant, but it is the endpoint for a player disputing a missing purchase. Carry the hoist through the class split
FEEDBACK_KEY is a var, not a secretSTILL OPEN — verified 2026-08-03, and the blast radius has grown. Plaintext in wrangler.jsonc and therefore in the repo. It now gates every /api/backup/* door including restore, recover, grant and reverse, plus /api/account/resolve, /api/ledger/reconcile (writes), /api/lb/purge (destructive) and both manual sweeps. Original finding: it gates /api/backup/export (every save of every player), /api/ledger/reconcile and /api/lb/purge. Should be a Wrangler secret and should be rotated. Security, not cost — but the cutover is the natural moment
The chance doors are built and not wired/api/roll, /api/hatch and /api/breed/claim all exist, are session-gated, are idempotent on the client's idem and pass their probes — and nothing calls them. The client still rolls upgrades, hatches and breeding results locally, so the exploit those doors close is open in practice. Drops are the one wired case. Until a batch lands, this page describes protection the build does not yet have; treat every unwired row above as backend, not as defence
The admin console cannot see player data/api/admin/* is routed to the WorldDO while saves, flags and history live in the PlayerDO, so flags, save-history and rollback answer empty. Not a data loss — a blind console. The key-gated /api/backup/history and /api/backup/facts are the working substitutes and are what any diagnosis should use
The WORLD object is not fencedIt authenticates from the x-sid header set by the Worker and holds no session table, so it has no epoch to compare: /api/db, /api/pvp/*, /api/lb/* and /api/admin/* are unfenced. Safe by construction today — _trustHeaderSid returns no epoch and the gate skips — but that is an accident of shape, not a decision. Lower stakes than the save path; recorded rather than fixed
Vestigial second seed pathCosmetic, no player impact, but it should not survive a reading. After the ledger overwrite the save door still runs the old __wg_seed kv block, which raises the blob to max(before, WG_SEED). It is a no-op because wgEnsureSeed has already posted the same 100 to the ledger immediately above it — but it is a second seeding path pointed at the blob rather than the ledger, and the whole point of 2026-08-02 was that there is exactly one balance. Delete on the next pass through that handler
keepalive body cap vs the save ceilingCLOSED 2026-08-03. Measured at 64 KiB in Chromium; the client gzips the body (x-ht-enc: gzip) and sets keepalive only when the wire size is at most KEEPALIVE_MAX (60,000). See the queueing section

When the write path dies

There are two writers and only one of them can be latched off. _staleInst (another window took the localStorage slot, or the slot was cleared underneath this one) and _loadBroken (the save would not parse) both stop saveLocal and serverFlush. Neither stops /api/battle. A window in that state keeps being credited — the ledger and the floor ratchet advance normally — while the save blob stands still. On the next login the server copy wins unconditionally, which is correct, and everything that lived only in the blob is gone with it: wallet, inventory, creature levels, eggs, pens.

This cost a player ten hours on 2026-08-01. His last stored save was ver 874 at 10:43 UTC; the next was ver 876 at 21:11 carrying playerXp 478,119 and maxFloor 69 from the server's own ledger and ratchet, beside the Ichor and the 66-item inventory of 10:43. The only warning had been a toast, and a toast does not stop autoplay.

RuleAs built 2026-08-02
A latch pauses playhaltWrites() calls the shell's window.__HT_HALT, which enters the same paused state as a lost connection: overlay up, __HT_PAUSED set, so enterBattle, lockIn and challengeBoss all refuse. Autoplay stops at the next battle boundary.
A halt outranks the poller__connCheck clears the pause the moment /api/me answers 200 — and during a write halt it always will, because the connection was never the problem. __setPaused refuses to un-pause while __HT_HALTED is set. The overlay reads NOT SAVING and offers only a reload.
Stage 5 is a gate, not a hopeBattle entry awaits serverFlush('battle-entry') and refuses the fight if it did not land, re-taking the phase guards after the round trip. Floor and boss entry refuse while paused. Previously the flush was fire-and-forget, so a dead writer returned false instantly and the fight was entered anyway — the server then judged it against a loadout it had never been sent, which is the exact thing the boundary exists to prevent.
The server derives the paralysisA paralysed client cannot report itself, so /api/battle compares the ledger's battle count against _ledB, the count the server itself stamped into the last accepted save. Past STALE_WRITER_N (25) it returns _stale and flags stale_writer. One indexed row read via json_extract, no JS parse of a 128 KB blob, inside a call that already exists — no extra write, so cost still scales with battles, not with time connected. Fail-open: a diagnostic may never refuse a battle. STALE_WRITER_N=0 disables it.
The client tries the writer before it diesOn _stale the client flushes first. A healthy client simply closes the gap and plays on; a latched one gets false back immediately and halts. A false positive costs one save, not a session.

What is deliberately absent. No outbox, no replay, no client-side merge of the two copies. The ten hours are gone and stay gone. What survived — the XP, the floor, the kill count — survived because the server derived it from its own tables, which is the doctrine working exactly as intended.

Travelling while the line is down — the veil watchdog

LIVE on release since 2026-09-02 (0.68). The floor-transit veil (ASCENDING / DESCENDING) is an input-blocking overlay held up until the next floor is real: the server's answer for that floor, then its plate and every spawn image decoded. Reported 2026-08-04 from a metro tunnel: the veil read ASCENDING · FLOOR 23 and never lifted, and it stayed up after the connection came back. Only a reload cleared it. Reproduced headless before anything was changed.

RuleAs built 2026-08-04 (dev)
The refusal comes before the veiltravel() and travelTo() refuse outright while __HT_PAUSED is set, the way enterBossRoom() always has. Previously the veil went up first and the refusal happened one call later, inside preloadFloor. The page above has claimed since 2026-08-02 that “floor and boss entry refuse while paused”; that was true of the intent and of boss entry, and one call too late for floors.
A refusal never leaves a veil behindpreloadFloor still refuses while paused, and now clears any transit it is declining to drive — the case where the pause lands between raising the veil and the call, and the re-entry from syncFloorSrv's callback.
The watchdog belongs to whoever raises the veilEvery anti-softlock timer used to be armed inside preloadFloor (two of them, both 30 s), so a veil raised by a path that never reached it was watched by nothing. Each trip now carries a token and veilWatch(tok, target) is armed by travel/travelTo at 35 s — longer than both inner timers, so it only ever fires when they were never armed. It cancels the trip and shows FLOOR n UNREACHABLE. Keyed on the token, not on floor numbers, because travelTo may legally target the current floor.
Resume finishes the triponConnectionChange(false) re-drives a pending transit through preloadFloor(target). It previously only reset the pacing counters, so a trip caught in flight had nothing to restart it — and travel/travelTo/enterBossRoom all refuse while a transit is set, so the player could not restart it either.
Nothing is credited for the attemptThe watchdog cancels; it does not retry and does not advance the floor. A refused or abandoned trip leaves floorNum, the streak and the server untouched — the doctrine is unchanged, only the exit is.

Why it was near-certain rather than rare. Autoplay's auto-advance branch calls travel('up') on a 2.6 s cadence and had no pause check of its own, so any autoplaying account that lost its line on an open floor raised a veil nothing could clear. The refusal in travel() covers that branch and the manual button and the floor map in one place.

Measured: tools/probe_veil.py — normal travel lands; a paused travel raises no veil; an orphaned veil is cleared; a trip caught by a pause completes on resume; a veil nothing drives clears itself within 35 s and hands control back. BEFORE 1/6, AFTER 6/6 against the real client.

The fence — who owns the save right now

A login already fences other devices: /api/_internal/session deletes every previous token for the steamid, so the old device's next request is simply unauthenticated. It does not fence other tabs, because two tabs share one cookie, one token and one session row — and that is the case that cost ten hours on 2026-08-01.

So the token is per instance, not per login. GET /api/save is the moment a tab adopts the save, so it mints a fresh epoch on the session row and returns it. Every later request from that tab carries x-ht-epoch. The object compares and refuses 409 stale_session on any mismatch, flagging stale_instance. This is Kleppmann's fencing token: the guarantee only exists because the resource rejects the stale number — a client that checks itself is worth nothing once the client is what has failed.

DetailWhy
A missing header is refused tooOnce a session has an epoch, omitting the header is a mismatch. Otherwise the bypass is simply not to send it. The same reasoning closed base_ver's optionality on the save door: once a session has an epoch it is by definition running a build that sends both, so an absent base_ver against a save the server holds is answered with the ordinary conflict payload and the client reboots onto server truth. Pre-epoch sessions keep the old behaviour until they reload, and never after.
Enforced only once the session HAS an epochA tab open across the deploy keeps working until it reloads. After that every session has one and the gate is absolute. This is a migration window, not a permanent exemption.
Exempt: /api/me, /api/logout, GET /api/save The first two bootstrap and tear down; the third is the claim.
The claim rides an existing requestNo extra round trip. One row written per boot, against the seven this account already writes per battle.
One client wrapper, not N call sitesThe shell wraps fetch once to attach the header and to recognise stale_session. app.js issues fetches from dozens of places and global-regexing a call site there is forbidden.
A second tab is stopped before it can claimThe epoch catches a second tab after it has taken the save. The Web Locks API stops it taking one at all, which matters because GET /api/save mints the epoch — a loser tab that booted normally would fence out the legitimate owner. The lock is claimed with ifAvailable before /api/me, held for the tab's lifetime, and handed on by the browser when the holder closes: no heartbeat, no timer, no server cost. The loser gets ALREADY OPEN and a play here instead button that steals the lock; the steal rejects the holder's request, which is how the retired tab learns to halt. Scoped per environment, or opening /dev/ would evict a production tab on the same origin. Where the API is missing the tab proceeds and the epoch remains the guarantee — this layer is prevention, not the fence. A signed-out gate lets go (#992). A tab that answers 401 at /api/me owns no climb and can mint no epoch, so it releases the lock the moment it shows the Steam button; the window the Steam return lands in claims cleanly, announces itself on a BroadcastChannel, and the gate it left behind stands down with a quiet signed in in another window notice.
Known gap: the WORLD object is not fencedIt authenticates from the x-sid header and holds no session table, so it has no epoch to compare. PvP, leaderboard and admin paths are therefore unfenced. Lower stakes, recorded rather than fixed.

The drop grant log is the restoration source, and it expires. drops_granted(ev, realm, ts, items) is the server's own record of what it handed out per battle event, and since 2026-10-23 (T39) it is never pruned — account history is kept forever (events_seen, the battle replay cache, still prunes at 7 days). A save-loss restoration is a REPLAY of fact for every drop the table holds; before its first row, it is an estimate. Read-only, ops-key gated: GET /api/backup/drops?key=&sid=&realm=&from=&to=, which also reports oldest_retained so you can see how much of the window is left. There is deliberately no restore side — granting is a separate decision from knowing.

The background flush — a 429 is not a throw

The client pushes on visibilitychange → hidden and on pagehide, both with fetch(…, {keepalive:true}), guarded by _srvDirty so an untouched session sends nothing. Two defects sat on that path, both fixed 2026-08-03.

1. A rate-limited save was dropped silently. serverFlush clears _srvDirty before the fetch, and the .catch() that re-arms it only runs on a network throw. A 429 resolves — it fell through to return !!(r&&r.ok) and nothing re-armed, so the client went on believing it was clean while the write never landed. The save class is cap 10 / 6-per-minute, and this is the one path where the page may not survive to retry. Any non-OK response now re-arms and honours the server's retry_ms.

A 409 carrying data is the deliberate exception and must never re-arm. A conflict means this window is stale; resending is precisely the wrong move. It returns through onSaveConflict above, which reboots onto server truth. A 409 {error:'stale'} with server_ts (the ts-ordering gate) is healed instead: __HT_TSFLOOR rises to server_ts + 1 and the write re-arms at 250 ms, up to 4 times, before it joins the fail ladder; any other data-less 409 is treated like a 429.

2. Backgrounding burned the rate bucket. hidden and pagehide bypassed the debounce, and on a phone hidden fires on every screen-off, notification pull and app switch. Each one spent a token; three breaches inside the window soft-block the account, which is the trap that once made a signed-in account look logged-out for seven hours. hidden is now throttled to one flush per HIDE_COALESCE_MS (15 s, matching the 429 retry_ms); _srvDirty is deliberately left set when a flush is skipped, so nothing is dropped and the debounce heartbeat sends it.

pagehide is never coalesced. Backgrounding is recoverable because the page survives; a close is the last chance there will ever be.

Why not sendBeacon: it cannot set custom headers, so it cannot carry x-ht-epoch. The fence treats a missing epoch against a non-null session epoch as a mismatch, so a beacon write would be refused — and exempting it would reopen the exact bypass the fence exists to close. fetch(keepalive:true) can set headers, which is why it is used.

CLOSED 2026-08-03, the keepalive cap vs the save ceiling: a keepalive request body is capped at 64 KiB (measured in Chromium), while a save is allowed up to 409,600 bytes. The client gzips any body over 4 KB (x-ht-enc: gzip; ~131 KB goes out as ~24 KB) and sets keepalive only when the wire size is at most KEEPALIVE_MAX (60,000).

Client: serverFlush(), HIDE_COALESCE_MS in play/app.js. Probe: tools/probe_saveflush.py (8/8).

The shell adopted server truth into a key the app never read

This is the root cause of the multi-device rollback, and it is two constants that drifted apart.

The only bridge between them was the legacy migration in app.js, and it is gated on have == null — by design, "a player who already has a namespaced run keeps it and the shared blob is simply dropped." That is correct for a one-time migration and fatal as a permanent bridge: on any device that had played before, the freshly-adopted server save was written to the legacy key, skipped by the migration, and then deleted unread.

Why every other guard was silent. The app then booted on its own stale state holding a legitimately minted epoch and a current base_ver — so by every check the server makes, the write that followed was valid. Proven separately: tools/probe_rollback.py drives a real worker and the server refuses every stale combination there is (old epoch, old base_ver, borrowed epoch, omitted headers). The fence was never the problem. "Server wins on login" was never the problem either — it won, into a slot nothing loads from.

It also explains the damage pattern. playerXp, classXp and maxFloor survived because they are ledger-backed and grow-only server-side, so a stale blob could not lower them. Everything held only in the save — wallet, bag, equipOv (including creature XP), soulOv, sockOv — rolled straight back. And a refresh never healed it, because the migration is skipped on every boot once the namespaced key exists.

Fix: the shell now writes the adopted save to both keys. The legacy slot still feeds the first-boot migration for a device that has never played; the namespaced slot is what loadRunInner opens. The key formula is duplicated in the shell and must stay identical to runKeyBase() — tools/probe_runkey.py is the gate, and its control (empty namespaced key) matters as much as its failure case, because a probe that only shows the bug cannot tell a real defect from a broken harness.

Shell: runKeyFor(), syncSaves() in play/index.html. Client: runKeyBase(), loadRunInner() in play/app.js. Probes: probe_runkey.py (3/3), probe_rollback.py (server-side matrix).

The save cadence is a coalescer, not a heartbeat — and it stops during a fight

queueFlush() is a trailing debounce armed by an action, not a periodic tick: 25 saveRun() call sites arm it, _srvFlushAt keeps the earliest pending deadline, and the rAF heartbeat only checks whether that deadline has passed. An idle, clean session sends nothing. Its own comment states the intent — "10s debounce that resets per action, so a whole loadout rework becomes ONE server row."

Inside a battle that guarantee inverted. State changes every turn, so the save re-dirtied immediately and the coalescer degenerated into a true ten-second heartbeat for the fight's entire duration. Those are the least valuable saves in the game: /api/battle has already credited the ledger server-side, and enterBattle is a hard gate that awaits a flush and refuses the fight if it does not land — so the previous battle is already stored. The client was re-uploading, at full save size, a battle the server already knew about.

And that traffic alone consumed the entire rate budget. A save every ten seconds is 6/minute; the save bucket is cap 10 refilling at exactly 6/minute. Zero headroom — so every one of the seven event-driven flushes (battle-entry, floor-entry, boss-entry, hidden, pagehide, signout, stale-writer) was an overdraft against a 10-token buffer, and three breaches inside ten minutes soft-block the account. That is the mechanism behind an account that looks logged-out while it is signed in.

The fix is to stop ticking while a round is in progress. FIGHT_PHASES is bintro, intro, select, target, confirm, resolving — deliberately narrower than gearBonus's inBattle list, which also contains victory, powerup, evolution and defeat because it answers a different question ("are stats locked"). _srvDirty is left set while suppressed, so the pending flush fires on the first tick after the fight ends. Leaving the fight is therefore the victory flush, for free — no second trigger to keep in step, and victory previously had no flush of its own at all. A close mid-fight is still safe, because pagehide flushes unconditionally.

Measured, not reasoned (tools/probe_flushrate.py, 4/4, counting real POSTs while re-dirtying every 400 ms as a fight does): 0 saves across a 25-second fight, against 2 in the same window on the floor, which is the control proving coalescing still works. The held flush lands on the transition to victory.

Why this rather than lengthening the interval. Tuning 10s to 20s would have halved a number that should be zero. The interval was only long enough to matter because we were saving something the server already had.

Client: queueFlush(), maybeFlush(), FIGHT_PHASES in play/app.js. Probe: tools/probe_flushrate.py.

Codex data loading — both sides fail CLOSED (the client failed open until 2026-08-04)

The game's tables (creatures.csv, floors.csv, floor_creature_rarity.csv, moves.csv, items.csv, classes.csv and a dozen more) are fetched from the codex at boot — live main on dev, the immutable DATA_PIN snapshot on release. On release they come from the game’s own worker first (/api/codex/<DATA_PIN>/, the bytes baked into its bundle, 2026-10-23), with jsDelivr and raw as the fallback.

The server treats a missing table as fatal: /api/floor/enter answers 503 spawn_data_unavailable rather than build a pool it cannot vouch for — no pool without truth. Today the client halts too: if any core table (floors, creatures, moves, items, classes) is missing, the loader calls haltData and play does not start (the ruling below). Until 2026-08-04 the client did the opposite. A failed fetch resolves to null, the loader emitted Core CSVs missing — game data incomplete, and play continued. Worse, the loader then aborted at its first unguarded dereference of that null (crs.forEach), so every structure it would have built after that point was missing too — the client was left partially initialised, with no single flag saying so.

This is the data side of report #20 (“sumiu td” — the screen vanished). The CSVs were never wrong; they were absent. With no creature table, every spawn descriptor the server sent resolved to nothing and warned Spawn "?" has no Line_ID in creatures.csv. That nameless unit is what threw inside renderVals, and because renderVals is all-or-nothing, one exception blanked every binding on screen at once. Reproduced by blocking exactly one cross-origin CSV: the client boots, is playable, and holds no creature data at all.

Mitigated (2026-08-04): core tables now retry a transient failure — three attempts with backoff, network errors and 5xx only. A 4xx is not retried, because a file legitimately absent from an older DATA_PIN must fail fast rather than stall the boot three times over. Measured: two 503s followed by a success now loads all 300 creature rows with no warning at all, where before a single 503 lost the table for the whole session.

Ruled 2026-08-04 (owner): if it cannot load, it cannot play. When the retries are exhausted the client now fails closed and matches the server. It stays on the loading screen, names the file that did not arrive, and offers RELOAD; save writes are stopped so a half-initialised session can never overwrite real progress. It also files its own bug report with the log attached, because the player cannot describe this failure and the log is the only thing that identifies which file died. One report per boot, and a failure to report never becomes a second error.

Implementation note worth keeping: the halt screen (#__pause, z-index 99991) sits below the loading screen (#__load, z-index 99998). If the boot never finished, the loader is still up and the halt message renders completely invisible underneath it — every computed style reads visible, the text is in the DOM, and the player sees a logo and nothing else. A DOM text assertion passes. This was caught by screenshotting the result, not by querying it. The message is therefore drawn on the loader, which is also the honest behaviour: the game never started, so it never leaves the loading screen.

The battle snapshot — one slot per kind

A battle in progress is held on the device, never on the server: persistBattle() writes {units, round, turns, floorNum, engagedIdx, inBossFight, streak, pvp} plus the seed and the move log into localStorage, and tryResumeBattle() reads it back. The server holds nothing mid-fight — a spawn slot is marked dead only when a kill is credited — so a paused enemy is still standing on the floor when the player comes back.

There are now two slots, named by kind (LIVE on release since 2026-09-02 (0.68)). There was one: herumon_battle_v2:<env>:<sid>, with no kind in it, and persistBattle writes s.pvp into that same blob. A PvP match joined out of a PvE battle therefore overwrote the paused PvE fight. PvE keeps the historic key so a snapshot written by an older build still resumes across the update; PvP is …:<sid>:pvp. The kind is carried by the key and never inferred from the blob — a snapshot is only ever read out of the slot that owns it.

And the resume path had never once run. The reconcile guard that drops a foreign or poisoned snapshot called .filter on snap.units — which is an object keyed a0/a1/a2/e0/e1/e2, not an array. It threw on every boot, the surrounding catch called clearBattleSave(), and the snapshot was deleted instead of restored. Measured on the pristine build rather than read: persist a real fight, reload, and the client lands on the floor screen with no resume note and an empty slot. Fixed by iterating the object's values; the same guard's floor test read d.floor and d.floorNum, neither of which exists at the top level of a snapshot, so it evaluated to 0 and never fired either. After the fix a reload returns to select with all six units and the resume note. The fix (commit 26367ed, 2026-08-04) is on release too.

The single write path — one facade, one watermark

LIVE on release since 2026-09-02 (0.68). The save is being split into seven keys by write frequency (run, party, pens, inv, sock, codex, pvp) held in save_parts beside the authoritative saves blob. An audit on 2026-08-04 found that six code paths write saves and exactly one of them wrote the parts — the save door. The five that did not: the ops restore door, the R2 backup restore, bulk dismantle, the ledger-reconcile sweep, and admin rollback. They were found by measuring, not by reading, which is the reason to assume there will one day be a seventh.

Every writer now goes through writeSave(). It writes saves, splits and writes the changed parts, and stamps a watermark, all in one transaction. This is not the dual-write anti-pattern: that anti-pattern is about crossing a transaction boundary between two independent systems, and saves and save_parts are two tables in ONE SQLite database inside ONE single-threaded object. What three separate save platforms advertise as a hard-won feature — atomic writes across records — is free here. (research.html entry 4.)

PieceWhat it guarantees
writeSave(sid, realm, d, ver, ts, raw)The only place saves is written. Splits and stamps in the same transaction. d may be null when a caller holds only a string it could not parse — the save still lands and the watermark is cleared, so the parts are known-stale rather than silently wrong.
save_parts_markOne row per (steamid, realm) recording the saves.ver the parts were last reconciled against, and the wipe generation they belong to.
partsFresh(sid, realm)The stage-3 primitive. Parts are trusted only while the watermark equals saves.ver and the generation still matches. Reported by the parts door now, so the rate is known before anything trusts it.
Monotonic verAn invariant, not a convention. A writer asking for a version at or below the stored one is bumped forward and flagged save_ver_nonmono — otherwise a restore that lowered the version would make stale parts read fresh.

Why both a facade and a watermark, when either alone looks sufficient. The facade is the fix: it makes the five bypassing writers correct. The watermark is what survives the writer nobody remembered — a raw write anywhere leaves the mark behind saves.ver, and the read path rebuilds instead of serving the old projection. A guarantee that depends on remembering to call a function is exactly what just failed.

The watermark is stored, never inferred. writeSaveParts skips any key whose content hash did not move, so max(save_parts.ver) lags saves.ver whenever an accepted save changed nothing in a key. Measured on a live account: saves.ver 3430 against a highest part version of 3429. Inferring the watermark would have read that account as fresh.

A realm reset now clears the parts. resetRealm deleted saves, save_hist, chains, ledger, progress and more — and there was no DELETE FROM save_parts anywhere in the Worker. Inert while saves is authoritative; at read cutover the wiped run would have loaded straight back out of the projection, so the reset would silently not have happened. Both defences are in: the generation stamped on the watermark makes stale parts unreadable, and the rows are deleted as well, because ignoring data is not reclaiming it.

When the battle credit cannot be delivered

LIVE on release since 2026-09-02 (0.68). The server's figure is the only number a battle pays (see system-xp.html), so the question of what happens when that answer does not arrive stopped being cosmetic. Owner ruling: hold the player for a couple more tries; local authority is never an option; if it is an outage the player gets the reconnecting window, not a fabricated battle end; and a failure files an automatic report.

AnswerWhat happens
Network throw, or 5xxRetried. The request may never have arrived. Three attempts total, backing off 600 ms then 1800 ms with jitter.
429Retried, but never sooner than the server's own retry_ms. Retrying inside the window is what turns a rate limit into a soft block, and a soft block on an honest player is how the seven-hour login incident happened.
Any other 4xxNot retried. A 400/403/409 is a decision, not an outage; hammering it spends the bucket and files an honest player into the traffic table. The body still heals the floor pool exactly as before.
All attempts exhaustedThat is the outage. Play pauses into the reconnecting window, nothing is credited, nothing is banked, and one automatic report is filed.

Retrying is safe by construction, and it was checked rather than assumed. /api/battle is deduplicated on the battle event id, so a repeat credits once.

But the dedup branch used to answer nothing, and that was a hole the moment the client stopped crediting itself. It returned {ok, dedup} with no figures, so a retry after a lost response — the request landed, the answer did not — paid the player zero for a battle the server really had credited. The event row now stores the answer it gave and the dedup branch replays it. It costs nothing extra: the credited figures are all known where the row is already written, and rows written is most of the bill. Drops are deliberately not replayed inline — they were minted into the pending lane, and draining that lane exactly once is the whole point of it. Events recorded before this shipped have no stored answer and replay as a bare dedup, which is the old behaviour rather than a new hole.

An exhausted credit pauses immediately, and does not wait for the poll. The connection check normally requires two consecutive misses — one blip is not an outage. A caller that has already failed three attempts with backoff has proved the point far better than one poll miss, so it asks to pause at once. Waiting for a second miss let the player fight another battle that could not be credited either, which is precisely what “nothing happens offline” forbids. Incidental failures and the 60 s poll keep the two-miss rule.

The report is budgeted and bounded. /api/feedback shares the other rate class with the shop catalogue and grants (hatching and breeding moved to roll on 2026-08-08), so during an outage an autoplaying account would otherwise file one report per battle and rate-limit itself — reporting a failure must never become the second failure. Battle-credit failures get two reports per session with a sixty-second cooldown; the boot bucket keeps its separate one-per-boot budget. The log payload is capped per line, truncated to the last 16 KB (the failure is at the end, not the start) and stripped of C0 control bytes. That is a size and hygiene bound, not a security boundary: a report is text a browser wrote, and whatever reads it must still treat it as untrusted and never interpolate it as markup.

The battle chain, and why it kept “breaking”

LIVE on release since 2026-09-02 (0.68). Every battle event carries the chain it belongs to and the head it follows — a hash of the previous event. The server compares the submitted head against the one it holds and flags chain_break on a mismatch. The flag fired 19 times on the owner's own account, which is exactly why it was never enforced.

The cause was an ordering difference, not an attack. The client advanced its head when it built the event, before the request was sent. The server advances its head only after the event passes validation. Every path that returns before that write therefore left the client one ahead:

It self-corrects after exactly one flag, which is why the pattern looked odd: the mismatching battle still advances the server's head, so the battle after it lines up again. Ten flags on one chain id over a day and a half is ten rejected or lost battles, not a sustained break.

The head now advances on confirmation. An answer without an error — credited, accepted-but-uncredited, or a dedup replay of an event the server already holds — means the server's head is now this event's. An error means it rejected the event and its head did not move, so the client's must not either; the retry and the pause cover the no-answer case, where it also did not move. The two heads now agree by construction.

It is still not enforced, and that is deliberate. Making the flag trustworthy is a separate decision from acting on it. A correction of the record belongs here too: the first diagnosis of this said the server does not advance on an uncredited battle. It does — the chains write is unconditional, and a rate-capped battle advances it. That conclusion came from a probe stub rather than the worker, and the real cause is the list above.

Write accounting

Rows written is the dominant Cloudflare cost line at every population size — Durable Objects bill $1.00 per million rows written against $0.001 per million read, a thousand to one — and until 2026-08-06 nothing in the build recorded which system produced them. The billing telemetry cannot answer it either: durableObjectsPeriodicGroups resolves to object and day, with no route dimension anywhere in the dataset. Research: research.html entries 6 and 7.

Where the wrap livesONE wrap of sql.exec in the MainDO constructor, not at the 313 call sites. Most SQL in this build is issued from helpers on a caller's behalf, so a per-call-site tag would miss the majority of statements and could drift; a constructor wrap catches all of them by construction.
What identifies a systemA request-scoped tag set at the top of MainDO.fetch from the path: battle, save, floor, breeding, hatching, grants, wallet, leaderboard, pvp, session, feedback, roll, ops. Everything beneath the tag inherits it. A Durable Object serves one request at a time, so the tag cannot interleave.
What it costsZero database writes. One console.log per request, and observability is already on. A per-write audit row would be paid for in the exact currency being measured — it would double the dimension under investigation — and the existing spend() helper is worse than a bare insert at two reads and a write per call.
Why cursors are summed at the END of the requestrowsRead is only final once the caller has consumed the cursor, and callers spread it after exec() returns. Reading the counter inside the wrapper would undercount every SELECT in the build. The cursors are held for the life of one request (capped at 4,000) and summed in the finally.
HibernationNo timer, no fetch, no socket. do_guard green.
The write breakerwrBreaker reads the same per-request row count and alerts; it never refuses an authoritative write. Layer 1: a request over 2,000 rows (WR_REQ_MAX) files wr_request_runaway. Layer 2: rows per object per hour against 20,000 (WR_HOUR_MAX), flagged once each at 70/90/100% as wr_budget, wr_budget_90 and wr_budget_shed (one kind per step since 2026-09-30, so the one-a-minute flag throttle can no longer swallow the 100% row). At 100% the object sheds diagnostic writes only: flag() stops inserting anomalies rows until the hour turns; saves, battle credits, grants and wallet moves are untouched. Layer 3: the hour total is held in memory and flushed to wr_hours (168 hours kept) on the next hour's first request, where the closed hour is compared to 10× the p90 of the retained active hours (at least 12 needed) — wr_rate_anomaly. An evicted object under-counts its hour. Full account: admin-backend, “The write breaker”.

First measurements (tools/probe_wrcost.py 13/13, real workerd, 2026-08-06 — LIVE on release since 2026-08-08 (0.42).: /api/me on a warm object is 2 statements, 2 rows read and 0 written; the same call on a COLD object writes 2 rows to kv, once, on the first request after WIPE_EPOCH moves. GET /api/save writes 1 row despite being a read path. And a rejected save (409 stale_session) still costs 2–9 rows with flag_rept as its top table — rejections are not free, and the flag path is what makes them expensive. None of these are yet a ranking: research entry 7 is explicit that lever value is unmeasured on this build until a full collection day has run.

Alt-tabbing must not cost a battle

Two things go wrong at once when the tab is hidden, and only together do they produce COULD NOT REACH THE SERVER — THIS BATTLE WAS NOT COUNTED:

An attempt made while the document is hidden no longer advances the attempt counter. A hidden tab is not evidence of an outage. A wall-clock bound still applies, so a player who never comes back cannot retry forever, and the three-attempt rule is untouched once the tab is visible — which is the case that rule was written for.

And the two keepalive bodies stop competing at all: the hide flush yields while a credit is in flight. The save is the cheap one to defer — the battle event is the authoritative reward record, and the credit's own success already queues a flush — so the save still lands at the next boundary. Nothing is skipped, only ordered.

Nothing about this banks a result offline. A credit that genuinely cannot be delivered still credits nothing and still pauses play; what changed is that a tab switch is no longer mistaken for that.

Client: the _battlePost retry and the visibilitychange handler in play/app.js. Probe: tools/probe_hidecredit.py — BEFORE 6/6 (asserting the retry has no notion of a hidden tab), AFTER 7/7.

There are two spawn limiters, and the player sees the smaller one

Section rewritten 2026-08-09 (0.45). The heading this replaced — “the spawn budget is one pool” — is the sentence that let a real bug survive a fix. It described the layer on top and denied the existence of the layer underneath, and a probe written to it spent credits without ever marking a slot dead: it proved the pool was shared across floors while never touching the counter that was actually binding.

Two counters decide how many spawns a floor shows, and the player sees the minimum of them.

LimiterKeyed byRate and cap
The account budget — table spawn_budgetRealm only. One pool, spent wherever you fightTABLES.spawn_budget: per_ms 60,000, cap 10. Incense halves per_ms — 2/min, cap untouched
The floor's own memory — floors_srv.dead[]Realm and floor. One row per floor, each running its own clock from its own last drainrefillSrv(), cap 10, the same rate off the same boon expiry

/api/floor/enter builds the pool out of that floor's own dead[] and then caps it to the account budget. What the player is shown is therefore min(this floor's live slots, your budget), and the countdown quotes whichever of the two is actually binding.

How they drifted — reports #85 and #87

refillSrv hardcoded one slot per 60,000 ms and took only the floor state, so it had no steamid, no realm and no way to know a boon existed. Under Incense the account accrued 2/min while every floor still refilled 1/min. The floor just farmed fell behind the budget and looked empty; an untouched floor, whose slots had never been drained, showed the full amount. D's own report is the shape exactly: floor 49 empty, floor 48 full.

The repair is arithmetic, not a rate swap. refillSrv(st, boonUntil) now runs the budget's own accrue(), and advances its clock with accrueAt() — the exact inverse, such that accrue(from, accrueAt(from, n, bu), bu) === n by construction. A plain 60000→30000 swap would still have been wrong across the instant a boon expires, because that window has to split at the boundary. A third hardcoded 60,000 was found in the same expression while fixing it: the floor-bound branch of nextInMs told a player under Incense to wait twice as long as the floor actually needed.

Known and deliberate: the client's own refillPool() still re-asks the server on a hardcoded 60,000 ms, so under Incense the on-screen countdown can lag by up to 30 s. It is cosmetic — the client never invents a spawn and the number shown comes from the server's nextInMs — and it was left out so the fix stayed worker-only.

What was already true and still is

Why over-showing is worse than it looks. An over-budget kill is not merely unpaid: the server sets credited = false and raises a spawn_budget flag against the account. A player shown a full map they cannot be paid for therefore accrues anti-cheat signal for doing exactly what the screen invited. Under-showing is self-correcting; over-showing is not.

Travel requires the server answer for every floor, cached or not. That follows the offline ruling — a dead connection must pause rather than serve stale content — and the existing 30 s FLOOR N UNREACHABLE watchdog covers the failure.

Reviving a slot from the budget alone was considered and refused. st.seq[i] is bumped in exactly two places — refillSrv and a floor re-roll — and deriveSlot derives creature identity from it, so a slot revived without a serial bump comes back byte-identical and the same creature can be refought for its drops. The dead-slot 409 in /api/battle is also the only thing stopping a client refighting one slot, and it calls flag(): honest players would have tripped it. That is a change to creature identity and to the anti-cheat surface, not a bug fix.

Server: spawn_budget, accrue(), accrueAt(), refillSrv(), floorStateOf(), capPoolToBudget(), peekSpawnCredits() in src/worker.js. Client: preloadFloor(), capPoolLocal(), refillPool() in play/app.js. Probes: tools/probe_floorclock_boon.mjs (9/9 after, 3/9 on the pre-fix tree, with T6 and T8 passing on both as controls), tools/probe_spawnshare.mjs (9/9, real SQLite, methods lifted from the shipped worker) and tools/probe_spawncap.py (BEFORE 3/3, AFTER 6/6).

Server-side rolls: the chance is the server's, always

LIVE on release since 2026-08-08 (0.42). D's order, 2026-08-08, verbatim: “server side for all chance outcomes”. An audit of play/app.js found 61 Math.random() calls; most are cosmetic (spawn positions, animation jitter, AI targeting, shuffles, uids, commit salt). Seven decide something a player owns and are the migration list, in D's order:

#RollStatus
1item + class-soul grading successDONE, on dev
4enchant rollnext
2bred egg gradepending
3breeding parent line 50/50pending
5drop grade bandpending
6boss key drop chancepending
7creature sexpending — already server-side on the HATCH path (/api/hatch returns g); breeding and the ensureGenders backfill remain
8wild spawn tier / quality / mutantHELD — needs D's ruling

The door already existed, and it had never worked

POST /api/roll was built on 2026-07-31 under the rollback/authority batch and accepts grade, soul_grade and breed_rate. No client ever called it. Wiring the forge to it exposed a defect that had been shipped and unreachable for eight days: the worker matched a row with String(r.Category) === cat, but soul_grading.csv authors neither a Category nor a Tier column, so that comparison tested the literal string "undefined" against a real category and no soul_grade request could ever resolve a row. Both keys are now column-tolerant: a column the CSV does not author is an unconstrained key, not a mismatch. The lesson is the standing one — an endpoint nothing calls is not tested by its own existence.

How the roll is drawn

The worker resolves the authored row (a Durable Object may never await fetch() — the hibernation rule) and hands it to the player's own object. The object draws HMAC-SHA256(SPAWN_SECRET, "roll|steamid|realm|kind|idem"), the same keyed generator the spawn table uses: the client cannot predict it and cannot reproduce it. The result is stored under the client's idem key, so a retry, a reconnect or a double-tap always answers the same outcome — that idempotency is what makes it safe for the client to ask again when it loses the answer.

Nothing is spent until the server answers

The forge used to debit the bundle synchronously and then roll. It now creates the ticket, shows the attempt animation, and debits in the same tick that applies the outcome. Three consequences, all deliberate:

The 620 ms wait is now an animation floor, not the roll. If the answer beats it, the result waits for the animation; if the animation beats the answer, the result waits for the server, up to UPG_ROLL_MS (12 s).

Write cost. A roll writes one row to a rolls table and sweeps rows older than seven days. Against the measured 27.4 rows per save this is noise, and the record only has to outlive a session. The abuse ceiling is counted in the same table (120/min) because an in-memory throttle dies with the isolate.

Probe. tools/probe_upghang.py — BEFORE 6/14, AFTER 14/14 across fourteen consecutive runs. It stubs /api/roll on a narrow single-path route (never a catch-all, which serves every request 200 and leaves dc-runtime.js unexecuted while looking like a broken build). Test 9 aborts the route outright to prove the offline rule. Three harness races were found and fixed rather than tolerated, and each is a general trap: the wallet is not a stable observable (the breeding pens both accrue and consume Ichor every frame, so a “wallet unchanged” assertion drifts in both directions); a bench reset's setState had not committed after a fixed sleep, so a cost was read off the previous item and a single correct charge was reported as bundle 10, charged 30; and settling was raced with a sleep instead of waited for as a condition.

The spawn pool is ONE pool, and the client may never guess at it

LIVE on release since 2026-08-08 (0.42). D restated the rule on 2026-08-08 because the build was not honouring it: “Only 10 creatures can be spawned at most in any floor. If I kill one on floor 27 and now have 9 creatures spawned, all floors that I go to now have 9 until it refills. Refills are server side and the rate is increased by having incense applied.” That is the same rule this page and Premium Shop already author — one pool, cap ten, one spawn per 60 s while below cap, 30 s under Incense, the cap itself unchanged. It is not a pool per floor.

Read this with the two-limiter section above. D’s rule is about the budget, and the budget is one pool. It is not the only counter: each floor also keeps its own dead[], refilled on its own clock, and the player sees the smaller of the two. A rate change that moves only the budget leaves the floors behind — reports #85 and #87.

The server was already right, and this was measured rather than assumed. Against a real Worker and real Durable Object SQLite: with the pool drained on one floor, an untouched floor whose own ten slots were all occupied answered sb=0 and ten nulls. One kill anywhere takes every floor to nine. /api/battle carries the refreshed pool and the new budget back in slot{pool, sb, sbNextMs}. Spawn identity is HMAC(SPAWN_SECRET, account+floor+ver+index) — seed-based, unchanged.

The client was the liar

syncFloorSrv ended with if(!j||!j.ok) return;. A 429 from the floor rate cap (30 burst / 8 per min) landed on that line and returned in silence — no retry, no pause, no connection check — and spawnSb kept whatever it last held. capPoolLocal then capped the floor against a stale budget. Measured: the client painted ten spawns while the server would have shown two.

This is not cosmetic. An over-budget kill is not merely uncredited — it sets credited=false and raises a spawn_budget flag against the account. An honest player accrues anti-cheat signal for fighting exactly what the screen invited them to fight. It is the same failure class as the battle-credit reports (#71/#72/#73): a refused or dropped request that nothing surfaces.

The rule now

The full-stack harness exists now, and OPS had been recording it as missing. npx wrangler dev --local --var DEV_LOGIN:1 serves the real Worker and play/ as assets, so localhost:8787 is the real client on real object storage. Two traps cost a run each: ALLOWED_ORIGINS whitelists the hostname localhost only, so a browser POST from 127.0.0.1 is 403 bad origin; and the Genesis rule refuses a fresh account claiming progress, so a seed save must be clamped to maxFloor≤2, classXp≤300, 15 bagAdd, invCap≤80 and a capped wallet. tools/probe_spawnbudget.py: BEFORE 5/7, AFTER 7/7. tools/repro_spawnbudget.py holds the diagnosis.

Server-side rolls, item 4: the scroll enchantment

LIVE on release since 2026-08-08 (0.42). D, 2026-08-08: “Enchant roll needs to be server side as well because it changes the item in the database (scrolls are only overwritable not removable).” That last clause is the whole design. A scroll applied over another destroys the one underneath and there is no way back, so the outcome has to exist before anything is consumed. The client asks and is told; if no answer comes, the scroll is still in the bag and the socket is untouched. There is no refund path because nothing is ever spent speculatively.

Gems and runes carry no chance at all — their value is read straight from items.csv — so they are untouched and still apply in the same tick. Only the scroll waits.

The balance file that nothing read

codex/enchant_base.csv authors the magnitude table: Const, Per_Grade, Per_Tier, GF_Base, GF_Step and a Mode of multiply or add. Nothing had ever read it. The client carried 3.6, 5.6, 1.70, 0.56, 12.0, 0.90 and 0.50 as literals in enchBase(), so the CSV and the game could drift apart in silence and the balance file was decoration. enchBase() is deleted and the server reads the CSV — D, 2026-07-31: “The csvs need to be the source of all truth so we can balance. Hardcoded NEVER. Client authority NEVER.”

The draw takes two HMAC lanes from the same keyed generator as the grade roll: lane 0 picks the enchantment against the authored Weight_Pct, lane 1 picks the magnitude inside the row's Roll_Min_Mult–Roll_Max_Mult band. Lane 0 is byte-identical to the previous single-lane call, so every grade roll already recorded is unaffected. The roll can only take the stat the host item carries — a Defense item never rolls Attack — and the eligible pool is filtered server-side, not trusted from the request.

Probe. tools/probe_enchant.py — BEFORE 2/9, AFTER 9/9. Test 7 aborts /api/roll and proves the scroll survives. Two of its own assertions were wrong first and were fixed rather than relaxed. Test 8 searched the served bundle for enchBase( and matched the comment recording the deletion — the probe failed on its own tombstone; it matches the definitions now. And test 5 judged the class mix on eighteen draws, because /api/roll has its own rate class and answers 429 with retry_ms 15,000: 200 requests yield about 64 draws however they are paced, and waiting out every refusal would take half an hour for a distribution check. A flat ±0.10 was both too tight for n=64 and worthless as evidence, so the bound is now three standard deviations of a binomial at the observed n, with a sample floor of 50 so it cannot pass on a handful.

The whole encounter is derived, not just the one you clicked

LIVE on release since 2026-08-08 (0.42). D, 2026-08-08: “All three mons in battle need to come from the server together with the battle xp and loot. Rethink this system as it's being done very stupid.”

What was wrong

A wild battle has three enemies. Only e2 — the spawn you clicked — was ever derived. e0 and e1 were conjured by the client with Math.random(): tier, species, level, quality and mutant, all client-side, then sent back in the kills array for the server to price.

The defence was a clamp — an extra's tier pulled down to the primary's, its level pinned to floor ±3, its species accepted only if the floor's own table authored that creature. That bounds the damage and it is still the client deciding what two thirds of every fight are, and account XP, class XP, food and the egg lane all read off those claims.

The fix is not a better clamp

An extra is a property of the slot. It comes off the same keyed stream on its own lane suffix — deriveExtras() calls deriveSlot() with lane = 'x0' | 'x1' — and it ships with the pool the client already asks for.

Six client rollers deleted

rollSpawns, rollWeightedRow, buildSpawn, spawnOne, conjureExtra, ensureFloorPool. Between them they rolled encounter tier, species, level offset, wild quality and mutant. Only conjureExtra was still reachable — the rest sat one call away from being wired back up, which is exactly how the boss-loot roller survived four months after the wild lanes moved. The cutout fallback no longer re-rolls either: a bad crop is an art problem and must never silently become a different creature. wildTags() stays — deterministic in floor, quality and grades, and it reads no RNG.

Verified. Slot 0 on floor 2 issues primary DFMC-076 (UC) with extras DFMC-010 (C) and DFMC-256 (R); re-entering the floor returns the identical encounter; the battle credits 21 XP / 11 Ichor / 21 class from three server-derived kills with an empty client kills array. Full regression, all green: probe_spawnbudget 7/7, probe_bossloot 9/9, probe_bosskey 5/5, probe_enchant 9/9, probe_eggroll 8/8, probe_sexroll 8/8, probe_upghang 14/14.

Spawn credits and boss floors — one flag was doing two jobs

Build 0.47. bossEdge gated two unrelated rules at once, and the two rules do not want the same test.

RuleWhat it needs to knowWhy
The spawn-credit skip“Is this a boss floor?” Nothing else.A boss floor has no wild slots, so there is nothing for a credit to buy. Charging one is charging for a spawn that does not exist.
The rate-cap overdraft“Is this the first clear of this boss?” — floor === _mfB + 1.The _mfB ratchet is monotonic, so that equality can be true at most once per boss per account. That is what bounds the overdraft to one; without it the allowance would be renewable by re-fighting.

bossEdge carried the ratchet clause, and the credit skip was reading it. Every re-fight of an already-cleared boss therefore spent one of the account's ten spawn credits — the ratchet had already advanced past that floor, so the equality was false, so the skip did not fire, so a floor with no wild slots on it debited the wild-spawn budget. The first clear of each boss was free and every clear after it was charged.

The flag is now split, and the ratchet clause lives on exactly one of them:

Both anticheat tests remain on the skip. Neither was relaxed to make the split work: the floor must be authored as a boss floor in floors.csv, and the submitted event must satisfy the UNCLAMPED +e.floor === floor equality. The equality is deliberately taken before any clamp, so a client cannot claim a boss floor by submitting a number that the server would have pulled into range.

The spawn countdown was showing 0s while the player waited on the account budget

Build 0.47. Read this beside “There are two spawn limiters, and the player sees the smaller one” above — the countdown is where that section's arithmetic becomes a number on screen, and it was quoting the wrong limiter.

nextInMs chose which clock to quote with credits < occ. That test cannot fire in the case that matters:

The countdown is now max(floorClock, credits > 0 ? 0 : budgetClock). The reasoning is the spawn rule itself: a spawn needs BOTH a refilled floor slot and a credit to spend, so the honest answer is the later of the two clocks, and a credit already in hand imposes no wait at all — which is why the budget term collapses to 0 rather than to the accrual clock whenever credits > 0.

There are two limiters and the screen shows the binding one. The account budget accrues one credit per 60,000 ms with cap 10; the floor's own refill runs its own clock off its own last drain. The countdown now names whichever of the two the player is actually waiting on, which is the same rule the pool size already obeys.

A leaderboard name froze until the player's SCORE moved

Build 0.47. lbUpsert is the only writer of lb.name, and its two early returns fire before the name-resolution block:

ModeEarly return when
maxthe incoming score is lower than the stored one
setthe incoming score is equal to the stored one

Both are correct as score logic and both skip the name. The consequence is exact: a displayed name is frozen at whatever it was the last time that player's score moved. A renamed player whose score is stable never appears under the new name, however many writes go past. /api/name/claim wrote only the names registry, so nothing else was going to correct it either.

The fix is on the rename, and NOT in lbUpsert — this is a cost decision

Hoisting name resolution above the early returns would put SELECT name FROM names WHERE steamid = ? on every quiet fold write — the writes that currently return early precisely because they have nothing to do. And names is PRIMARY KEY(name_lc): steamid is not indexed. That query is an un-indexed full table scan, placed on the hot path, which is the same pattern already identified on this page as the cause of rising read costs. It is the wrong repair even though it is the obvious one.

The rename does the work instead, once, at the moment the name actually changes:

The snapshot bust is throttled to once per account per 60 s, held in memory on the Durable Object. Without it, a player alternating between two names would force a global board recut per request — the bust is cheap to issue and expensive to serve, which is the shape that has to be rate-bound rather than merely correct. In-memory is sufficient because the object serves one request at a time and losing the throttle on eviction only costs one extra recut. /api/name/claim sits behind the existing rate limiter as well; measured, it answers 429 with retry_ms 15000.

Why a bust is needed at all. lb_snap is recut only when the snapshot passes 24 h. Without an explicit delete, a rename would be invisible on the board for up to a day even after lb itself carried the new name.

A stale device could teleport the player backwards — report #114

Hotfix on top of build 0.47, release commit 208bb79. Already live.

Symptom. The player was on floor 93 on his PC. He logged into his phone briefly, returned to the PC, and landed on floor 57.

Mechanism — two thirds of the doctrine were already there

/api/save already merges two classes of stale-device damage back up, and both fired correctly on this write:

FieldRuleFlag
maxFloorGrow-only — the stored value is kept when the claim is lower.maxfloor_regress
playerXp, classXpGrow-only. The rule's own comment names this exact report class: “logged in on my phone and lost 20 levels is exactly this write”.xp_regress_merged
floorNumDeliberately excluded — stored verbatim in the downward direction.—

The exclusion is not an oversight and it is documented in the maxFloor rule's own comment: “floorNum is a POSITION, not progress — the player walks down floors legitimately — so it is ceilinged but never floored.”

That reasoning is correct about a player and wrong about a stale device. The chain, in order:

The fix

When maxFloor had to be merged back UP — the regression case, specifically — and the claimed floorNum is below the stored one, the stored position is kept. Like every other server correction it is reported back in srv, so the client adopts the server's answer rather than being quietly overruled. New flag: floornum_stale_writer.

Why the signal is maxFloor REGRESSING and not the XP merge

This is the part a future session would get wrong, so it is recorded as reasoning and not just as a rule.

The XP merge is not a staleness signal. The server credits XP from its own ledger, so a perfectly current client routinely saves a slightly lower XP number simply because it has not yet seen the last credit. Keying this rule on xp_regress_merged would hold the position of ordinary players during ordinary play — a worse bug than the one being fixed, and one that would look like the game refusing to let people walk.

maxFloor is unambiguous. It is grow-only and the server hands its value back in srv, so a current device always carries at least what the account holds. A claim below it can only come from a blob that predates the account's own progress. That is the definition of a stale writer, established without a clock.

The flag is set on the REGRESSION specifically. The over-claim ceiling clamp writes the same _srvFix.maxFloor key and is not a staleness signal — it is an honest client being capped. Testing for the presence of the key rather than for the regression would collapse the two cases.

Direction, failure mode and what is deliberately untouched

Gate: tools/probe_floorpos.mjs — 14/14. It covers the reported case, the legitimate walk-down, a current device climbing, the pre-existing ceiling rule, the XP-lag case in both directions, and a sweep over all 120 claimable floors proving a stale writer can never lower a stored position.

A paused fight is device-local, and it must not outlive the device (report #122)

Status: on release. D: “Logged into my phone and was at level 56. When I logged out I was at level 93 on my pc.”

Two attempts at this were aimed at the save door and both missed. His own account's fault log says why: across two hundred recorded anomalies there is no deepest-floor regression, no stale writer and no missing version check. The server never did anything wrong. The phone adopted floor 93 correctly — and then the resume of a PAUSED FIGHT put him back on 56, because a paused fight lives in the device's own storage, survives adoption, and restores the floor number from its own snapshot.

Every guard on that path looks upward. A paused fight from a floor the account has never reached is thrown away — the anti-teleport rule. Nothing looks downward, because a lower floor is perfectly legitimate for a player who walked down. A floor-56 snapshot on a deepest-floor-95 account passes all of them. And the save that follows is entirely legal: current version, no regression, nothing to flag — which is exactly the shape of the evidence, and why no server-side change could ever have fixed it.

D's ruling, 2026-08-12. Resuming stays LOCAL — a server-held fight was considered and rejected on write cost. “If you change devices then the fight is dropped; if you come back to an old fight it just doesn't start, it goes directly to the current floor that you were on on the last device.” That is what ships: the snapshot now records the moment it was parked, and a boot discards it if the account has been played anywhere else since. The player lands on the floor the last device left them on, which is the floor the adopted save already carries. Zero server writes — the whole rule is a comparison of two timestamps the client already holds.

It costs the player nothing. A spawn credit is spent, and a slot marked dead, only on a CREDITED KILL — parking a fight does neither, so the creature is still standing on the floor when the player returns. The one recorded refusal on D's account is the opposite order: a fight parked on a slot, that slot later killed for real, and the stale fight then resolving against a corpse — refused with no reward, which is the correct outcome for a fight that should not have resumed at all.

Snapshots written before this stamp existed carry no parked-at time and are judged by the existing rules exactly as before, so nothing breaks mid-rollout. Reproduced on D's own save before fixing: a floor-56 parked fight moved a deepest-floor-95 account to floor 56; after the fix it stays put, and an honest paused fight on the same device still resumes.

The dismantle claim is verified against the server's own previous save

A dismantle pays Ichor, and the claim rides the next save as a queue of dismantled rows. The worker prices each row; the player's object then verifies each one against its own previous save: the uid must have been held (bag, added items, the two party slots, or a socketed soul's source), must be gone now, and its category, grade, tier and quantity must match what was held. A row that fails any of these earns nothing — the dismantle itself is never refused, only its Ichor allowance. This guard is watch-only and one flag per account per kind per day.

2026-09-02 (report #703, LIVE on release since 2026-09-02 (0.68)): an unpriced claim is PENDING, never zero. When the Worker cannot read the codex tables (the dismantle and breeding CSVs) it hands the claim to the object unpriced. The object used to verify and spend every row anyway and pay zero — the reply read ok 60, bad 0, allow 0, the client took the 200 as a receipt and withdrew the rows, and the income was unbooked forever: the honest wallet then tripped the Ichor ceiling on its next save (D's own account, five clamps in two minutes; reproduced on his seed: 60 creatures, 8,505 Ichor, one save). Now an unpriced claim is left exactly where it is — nothing verified, nothing spent, nothing booked — the reply carries dism.pending so the client keeps carrying the rows to the next flush, one flag (dism_unpriced) names the outage, and the Ichor ceiling is not measured on that save (the claim is pending, not forged; the next priced save settles it). Gate tools/probe_dismoutage.mjs.

2026-08-20 (the Ichor-clamp batch, RELEASED IN 0.56): the grant-record fallback now accepts every origin the server itself stamps — seed, craft, drop, breed, bred, boss, hatch and grant — not just the four that existed when #153 shipped; unsourced stays refused because it honestly means nobody knows. The two refusal reasons are distinct (absent-no-grant for no record at all, origin-<stamp> for a record whose stamp is not accepted), so a missing record can never again be conflated with a rejected one — the conflation is what let honest hatch-and-dismantle and boss-egg dismantles clamp four accounts at the Ichor ceiling. The declaration queue caps at 800 rows on both ends: the authored caps are 600 souls + 160 items, so the largest legal single-window purge fits whole, where the old 400 clipped honest Adam-and-Eve runs and the dropped rows had paid the client with nothing declared behind them (the 08-15 clamp shape: 40,132 cut beside a working 71,955 allowance). A list arriving at the cap raises the watch-only dism_queue_full flag. food_clamp resized 2,500 → 5,000 in gen_tables.py — honest kept-overs had reached 2,450 of the old band; 5,000 is six best-case fights. Admin grants are sourced from birth under the same batch (see admin-backend). Gates: probe_dismorigin 9/9 (red 4/8), probe_dismqcap 8/8 (red on 400), probe_grantbook 9/9; dismwindow 10/10, ledgerrace 13/13, rollbook 5/5, breedbook 5/5 re-run.

2026-08-20, later the same day (RELEASED IN 0.56): the dismantle CALL. POST /api/dismantle is the "send immediately" tier made real: the Worker prices the claimed rows exactly as it prices the declaration queue (client price stamps overwritten, never merged; fails closed, unlike the save door — an unpriceable dismantle has simply not happened yet and the client keeps the item), and the player’s object verifies each uid by the queue’s own two-path rule: held in the stored save with identity matched, or absent but backed by an unspent grant with an accepted origin. Acceptance spends the record (gone, with gone_src=call as the channel marker) on both paths, books every currency on the queue’s exact keys (source dism, ref uid:cur) so the two doors are structurally unable to pay one uid twice, and the Ichor slice lands in ledger.food_total the way battle income does — the ceiling covers the wallet with no envelope at all. Replay by idem answers the stored result verbatim (the hatch pattern). The client fires the call after its local flow, fire-and-forget, keeps the queue append as the fallback carrier ("a lost call leaves the item; the next flush heals it"), and withdraws accepted uids from the queue on the answer; a stray re-declaration of a call-paid uid settles quietly at the save door — ok, unbooked, unflagged — while a queue-spent grant replayed at the queue still flags grant-spent as shipped. Gate: probe_dismcall 11/11 end-to-end; dismwindow 10/10 twice over, ledgerrace 13/13, rollbook 5/5, breedbook 5/5, dismorigin 9/9 re-run.

2026-08-13 (report #133, ON DEV): the flag used to say only "4 of 32 unverifiable" — a count with no WHICH and no WHY, and the count alone ended two diagnosis theories in retraction. It now names the first bad row's uid prefix and its reason (no-uid / absent-from-prev / still-held / a field mismatch with both sides' values), which is enough to pull the save pair and settle the cause. Nothing else changed: same single flag write, still gates nothing.

Changelog