Ranked 3v3 (avatar + 2 creatures per side), best of 3, team locked at match start, double-blind simultaneous move selection. This page defines the PvP screen, the move timers, how battles survive crashes and disconnects, removed-mode quit-out rules, and the leaderboards.
Entered from the main menu (PVP ARENA). The arena opens at floor 15 and Competitive matchmaking at floor 60 (unlocks.csv). One screen, two queue cards side by side (#989, #1017) that fill it (#1046: the cards stretch to the window, the crest takes the leftover, every font scales with the window height - 1.5x at 1080p, which is also what a phone lays out at):
| Region | Contents |
|---|---|
| Auto PvP card (left) | Tickets, the queue door and the defense-snapshot status for asynchronous Auto PvP — rules on Auto PvP. |
| Competitive card (right) | The rank card and the MATCHMAKING plate are one unit (board 6.3): current rank and rating number above one large MATCHMAKING plate (it read FIND MATCH, then QUEUE, until #1046); while queuing it becomes a cancel with elapsed time. Matchmaking pairs by rating, band widening the longer the wait. Ranks reuse the game’s own ladder language: Worn → Void — the ten grade names as ten rank tiers, top of ladder is Void. |
| The team | There is no team-lock socket row: the roster the match takes is the party currently equipped (avatar + 2 creatures), sent once when both players are ready and locked for the whole Bo3 — no sideboarding between games. |
| Boards | Not on this screen: the four boards live in the RANKINGS window (#733). The arena has no History tab, and a finished match’s replay has no way in from the UI today (the history rows are still recorded). |
Both sides pick all their moves for the round simultaneously; timers exist to bound the round, not each unit. The design follows the banked chess-clock model competitive simulators converged on, sized to our 10–16-turn fights.
| Timer | Value | Rule |
|---|---|---|
| Round timer (PvP) | 30 s | Both players pick within it. Resolution/animation time does not count. |
| Personal bank (PvP) | 90 s | Drawn from only after the 30 s round timer is spent, chess-clock style. Never refills during a game; resets between games of the Bo3. |
| On expiry | — | The autoplay AI picks for you — the same AI that runs PvE grinding, not “first move in the list.” Losing time costs quality, not the game outright. |
| Damage ramp (PvP) | ×1.5 @ 20 → ×3 @ 40+ | Replaces the 30-round hard cap and its tiebreak (2026-08-16). From round 20 every hit and every lingering damage tick, both sides, is multiplied: ×1.5 at 20, ×2 at 30, ×2.5 at 35, ×3 from 40 on and holding. A game ends only on a wipe — the ramp guarantees one, so no tiebreak is needed or exists. A hot ×-chip beside the round counter shows the live multiplier from round 20. PvE never ramps. Replays of Competitive games ramp identically — playback must re-produce the recorded fight. |
| PvE | No timer | Move timers are a PvP-integrity tool; against the AI there is nothing to protect and autoplay already exists for players who want speed. PvE battles instead get full crash-safe resume (below). |
Timers: dfmc-client src/tables.json pvp_live (read by src/worker.js MM_ROUND_MS / MM_BANK_MS); ramp: src/battle_round.js rampMult. Generated by tools/codex_tables.py.
Every battle is a deterministic replay: initial state + per-round RNG seed + committed move log. That single decision buys crash recovery, reconnects, replays, and removed-mode integrity all at once.
| Rule | Detail |
|---|---|
| Save cadence | Once per round: both sides' committed choices plus the round's RNG seed are persisted before the resolution is shown. One small write per round. |
| PvE crash | Relaunch resumes the battle at the top of the current round — same state, same seed. Nothing is lost and nothing rerolls. |
| Anti-scum | Because the seed is committed before results render, killing the game after seeing a bad roll and reloading replays the identical roll. There is no outcome to reroll, ever. |
| PvP authority | The Cloudflare Worker (see Networking Architecture; the Unity Relay / UGS design is retired) is a packet relay, not a simulating server — both clients run the deterministic sim in lockstep, kept honest by the Competitive-integrity layers below. Reconnecting replays the committed log to the current round. |
| Case | Rule |
|---|---|
| PvP disconnect | The match does not pause and timers keep burning — pausing on disconnect is a documented exploit vector (players firewall-drop to buy free thinking time). When your round timer + bank expire, the autoplay AI plays your side, exactly as on a timeout. |
| Reconnect window | Reconnect at any point and control returns at the next round: the round the AI already played for you stays as it was played (the server refuses your orders for it). There is no forfeit cap (Tower rulings T29): the AI plays your side to the end of the match unless you return, and the result counts and pays rating like any other. The round clock and the bank are server values (pvp_live: 30 s a round, a 90 s bank per game). |
| PvE crash / quit | Identical outcomes: resume at the current round on next launch. A battle in progress can be resumed but never abandoned. |
The classic failure mode is the logout escape: kill the client the instant a fight goes wrong. Turn-based rounds close that door structurally.
| Rule | Why it holds |
|---|---|
| Battles are resume-only | Alt+F4, crash, and power loss are indistinguishable and all lead to the same place: the battle resumes at the top of the current round with the committed seed. Quitting never exits a battle. |
| Committed rounds stand | Once both sides' moves are locked, resolution is deterministic and already persisted. If the round that kills the character has committed, the death happens on resume — watching it or not changes nothing. |
| No mid-round escape value | Killing the client before committing a move just returns the player to the same decision with the same information. In a turn-based game there is no "leave the server before the hit lands" — nothing advances without a committed round. |
Since 2026-10-23 (A17) the server owns the live round — the round number, its deadline and each side’s time bank sit on the match, and a round closes when both sides have locked in or when a side’s clock runs out (the autoplay AI then plays that side’s round). What the two clients still exchange between themselves is the commitment and reveal of each round (see What the clients still exchange). Integrity comes from the layers below. Retexted 2026-10-22 (F203) to what exists: layers 1, 2 and 3 are built and server-side, layer 4 is not built, layer 5 is partly built. The planned-design text that stood here until then is in the page history.
| Layer | Mechanism |
|---|---|
| 1 · Team legality | The server builds the team; the client never names it. POST /api/pvp/mm/team and the Auto PVP mint discard whatever team the client posts: the Worker builds the slice from the account's stored save with src/team.js buildTeamSlice (the same code as the power sheet), so every stat, move, gear package and max HP is server-derived. Rule checks against codex data are the formula's own (a class-locked or wrong-slot piece counts 0). The client stopped writing its own pvpSnap slice into the save on 2026-10-22; nothing reads it. |
| 2 · Provenance | The ledger says what the account owns. Before a slice is built — at mm/team, at the Auto PVP mint and in the daily defense fold — PlayerDO.pvpProvenance checks every companion (equipOv 8/9) and every soul source against the account's live items rows in one batched read: a creature whose uid the ledger never issued, or has already spent, is no unit (the seat is empty, the soul unsocketed) and the account is flagged pvp_team_illegal_uid; a save with no class soul is refused 409 team_illegal. A ledger with no creature row at all (an ops restore before the account's first save) judges nothing. Gear pieces carry no uid in the save and are not ledger-checked yet (F255 residual). |
| 3 · Commit–reveal rounds | Double-blind between peers requires it: each round both clients send H(move + nonce), and reveal only after both commits arrive. A reveal that fails its commit is an instant forfeit. The round's RNG seed is H(revealA ‖ revealB ‖ roundN) — derived from the reveals (nonces included), never from the commits. Seeding from commits is grindable: whoever commits second knows the opponent's commit and can brute-force nonces until the resulting seed rolls in their favour. Seeding from reveals closes it — the second committer cannot compute the seed without the opponent's still-secret nonce. Residual edge — refusing to reveal after seeing the opponent's reveal — is priced out: a player who commits but fails to reveal within the round window forfeits that round's actions entirely, and the round counts toward the 5-AI-round forfeit counter. Dodging one bad roll costs a whole silent round. One mechanism provides blindness and unriggable randomness. |
| 4 · Per-round checksum | NOT BUILT. The clients exchange no state checksum; the round protocol is the commit–reveal above and the server's round arbiter (deadlines, auto rulings). A checksum belongs with the server-owned round (A17). |
| 5 · Replay adjudication | Partly. The finish door (POST /api/pvp/finish) verifies every revealed commitment against its hash (a mismatch flags pvp_commit_mismatch and refuses), settles on two agreeing reports (a conflict flags pvp_report_conflict), and settles a self-reported FF loss alone. Since 2026-10-22 (T29) a lone report whose opponent the arbiter ruled absent (auto on the latest round recorded) is settled by the server's own fight from the two slices pinned at match start (src/apvp_sim.js apvpAutoFight, both seats on the module's autoplay, seeded by the match id): the sim's winner is paid like the dual-report settle and the claim is only recorded (pvp_lone_settled). A report with no match row is 404. A replay of the played rounds is not built (A17). |
Per-round input validation runs on both sides regardless: the chosen move is one of the unit's four, its cooldown is legal, the target is legal. Malformed actions die before resolution.
ON RELEASE from build 0.36 (2026-08-06). This section replaces the peer-discovery
queue entirely. A player presses FIND MATCH and the client posts a
ticket to POST /api/pvp/mm/queue. From then on it polls /api/pvp/mm/poll
and renders whatever status comes back. The client does not read the pool, score candidates, choose an
opponent, create a match, or hold an identity. There is no decision left on the client to get wrong.
The queue used to be peer discovery: every client wrote its own node with ts = its own
Date.now(), read everyone else's, and judged freshness with myClock − theirClock.
On 2026-08-06 a player's PC ran 3m 40s fast (measured on the ops door as age_ms -220103).
His client therefore judged every legitimate match to be 220 s stale, discarded it, wiped its own pairing
marker and kept queueing in silence, while his opponent sat out the 28 s team wait and was told the
opponent never readied. Which of the two symptoms appeared depended on which side held the lower player id.
A first fix stamped timestamps server-side and let the client measure its own drift; that worked, but it left
the client correcting itself, so a missing header would silently restore the bug. The authority was in
the wrong place, and that is what this rewrite moves.
acctKey(steamid), derived from the Steam session the Worker has
already signature-verified. The old random localStorage player id is deleted — a player
can no longer name themselves to the matchmaker or write into another player's slot.ratings table; 1000 with no row). It used
to be a number the client put in its queue node, and then the player's ladder row — which the client writes
— so either way a matchmaking input a player must never supply about themselves (review F200, 2026-09-30).names table, else the account's own row in the SQL
lb table (server-written), else “Rival”. It briefly fell back to a client-supplied
string when no ladder row existed, and until review lane X (2026-09-30) to the client-written kv ladder row;
either would have let a player queue under someone else's name.Every deadline the server sends is a duration remaining (closesInMs), computed
against the object's own clock. Nothing in the payload is an absolute timestamp, so there is no comparison left
for a wrong clock to poison — the whole failure class is gone by construction rather than bounded.
Liveness is a server-held lease, not a timestamp the client re-stamps: the ticket carries
lease_ms, renewed only once it is in its second half.
One indexed read of the pool ordered by rating, then a single sweep pairing nearest neighbours oldest-first.
The band opens with the ticket's server-measured age — 25 points, widening by 25 every two
seconds, capped at 5000. A match is created by inserting a row whose pair_key carries a
UNIQUE constraint: the constraint is the arbiter, replacing the old
“lower player id creates the match” convention, which was a rule two clients agreed on with nothing
enforcing it.
| phase | what has to happen | if it does not |
|---|---|---|
| ready | both sides press JOIN MATCH within 20 s | the match is cancelled; whoever readied goes straight back into the pool, the other is dropped |
| teams | both teams posted within 30 s | cancelled, same rule: whoever posted a team goes straight back into the pool, the other is dropped |
| live | the server hands both teams down and the battle starts | — |
Both sides are moved by the server or neither is, so the old failure — one player waiting 28 s and being told the opponent never readied while the opponent saw nothing at all — cannot occur. Cancelling also returns the other player to the pool immediately rather than making them wait out a dead window.
The poll interval is dictated by the server (pollMs, currently 2000 ms), so
load can be shed centrally without shipping a client. It has its own rate bucket (mm, cap 60,
45/min) rather than sharing db with the round exchange and the leaderboards: at a one-second poll the
old shared bucket refilled at 40/min against 60/min of demand, so a player queueing for two minutes would have
started answering 429 while the timer kept counting. An ordinary poll writes zero rows —
the lease renewal is a single conditional UPDATE whose WHERE matches nothing until the
lease is half spent. Sweep and pass are coalesced to one tick for the whole object, so a thousand players polling
do not cause a thousand passes. There are no timers, no alarms and no sockets: the object stays hibernatable.
A matchmaking call that fails says so on screen, and three consecutive failures stop the queue outright rather than leaving a timer counting against nothing. That silent shape — the screen saying LOOKING while the server held no ticket at all — is what made the original bug so hard to see.
A pass over 1,000 queued takes ~120 ms and 5,000 takes ~320 ms, both inside the one-second tick;
20,000 takes ~1.6 s, which exceeds it, so passes would stack. Single-object matchmaking is therefore good to
roughly 5,000 concurrently searching — not registered, not online, actively searching at the
same moment. Above that it must shard by rating band with promotion to a shared overflow object; the design is
recorded in BACKLOG.md and in research.html entry 9, and deliberately not built yet.
Matchmaking, identity, rating, the round number, the round deadline and the time banks are the server’s (A17, 2026-10-23: a round closes on both reveals or on the deadline, and a returning player takes the next round). The per-round commit–reveal payloads themselves still travel between the two clients through the /api/db facade under m/<mid>/g<n>/r<n>/<side>, authorised from the match table; that path carries no timestamp the server reads. The server does not re-simulate a live round both players played (layer 5 above settles two agreeing reports, and fights the match itself only when one side is absent), and there is no per-round state checksum (layer 4).
LIVE on release since 2026-08-06 (0.36). A match can be taken in the middle of a PvE fight. Owner ruling: that fight is paused and restored afterwards — it is not forfeited and nothing is lost.
park. Parking works from
any battle phase, not only the phases that already persist.The server holds nothing mid-fight: a spawn slot is marked dead only when a kill is credited, so the paused enemy is still standing when the player returns, and battle chains are keyed per chain id so a PvP fight cannot break a paused PvE chain's continuity.
No mode refuses the join. The hardcore mode that used to refuse taking a match out of a live
fight left the game on 2026-08-28; pvpAcceptGo has no mode guard, so every player may queue and take the
match from anywhere, a live PvE fight included (parked as above).
One all-time board per metric (Highest Climb keeps the best floor ever; the other three show the current value). There are no seasons and no realms — one game mode since 2026-08-28, and the season / all-time switch left the rankings window on 2026-09-16 (#900, #944). Standings are cut every week for pay (Challenge by code, inactivity and weekly pay below) but the boards never reset; a player idle for a week stops showing (T33). Two tabs since #939: PVP (Auto PvP | Competitive PvP) and PVE (Character Power | Highest Climb), each tab’s two boards side by side, the team shown as thumbnails right of the name — the last team the player won with on the PvP boards, the team currently fielded on the PvE boards (#939b).
| Board | Metric | Notes |
|---|---|---|
Competitive PvP (ladder) | Rating (plain Elo, K = 32, floor 0, tables.json elo) | Bo3 results only, settled by /api/pvp/finish. Rank tiers named Worn → Void by rating band; the rating floor of each tier is Worn 0, Common 900, Uncommon 1,100, Rare 1,300, Epic 1,500, Legendary 1,700, Abyssal 1,900, Divine 2,100, Primordial 2,300, Void 2,500 (a client constant, PVP_TIER_BANDS in play/app.js; no CSV carries it). The board shows the current rating (lbUpsert mode set), not the peak; no season reset. |
Auto PvP (autopvp) | Rating (Glicko-2) | The one Glicko-2 board; current rating. |
Highest Climb (climb) | Highest floor reached | Mode max: the best floor ever; the Abyss shows as Abyss N. |
Character Power (power) | Power computed by the server from the stored save | Mode set: the current build; it drops when gear does. |
Neither player may see the other’s moves before choosing. A round is agreed in two phases: each side first writes a hash of its moves, and only once both hashes are present does either reveal the moves themselves. The round then resolves from a seed derived from both sides, so the same pair of inputs always produces the same round.
A commit is only real once the server has taken it. The write is awaited and retried; the player is shown as locked in only after it lands, and if the commit later goes missing from the match record it is sent again. This matters because every downstream step tests for your own commit being present — including the timeout that resolves a round when the clocks run out. Until v0.34 the client marked itself locked before writing and discarded the result, so a failed write shut three doors at once: it blocked its own retry, the opponent never saw a commit, and the timeout could not fire either. Both players waited forever.
The client may never invent an opponent. A match comes from the queue meeting a real partner or it does not happen. There is no simulated opponent, as a game feature or a testing one, and no fabricated name, rating or lock countdown.
The round/bank/game readout sits directly under the turn-order button, on the same centre axis, in one row. It was anchored bottom:648px, which on the 1080 stage puts it mid-screen across the battlefield. It is anchored to the top for the same reason the button is: a bottom anchor moves with every change to the panels below it.
The band between the button and the creature sprites is about 40 stage pixels and the row was about 43, so its padding is tightened to fit rather than the row being allowed to clip a sprite. That is a measurement, not a taste — tools/probe_pvpui.py asserts the HUD overlaps nothing else drawn, which is what caught the first placement.
netWait — the moves are committed and the round is waiting on the opponent.pvpNetCommit() already returned early while netWait was set, so this only makes the screen agree with what the code was doing.lockIn() runs, so the gate is simply false there.Client: confirmLocked and the confirmBtn* values in play/app.js; the HUD and decree in play/markup.html. Probe: tools/probe_pvpui.py — BEFORE 4/4, AFTER 8/8.
Both networked PvP paths — pvpNetCommit() and pvpNetPoll() — read
state.pvp once, at entry, and then await the network. pvpNetCommit retries
netSet up to three times with 400 ms between attempts and then the round announce up
to three times with 500 ms between attempts, so it can be in flight for several seconds. The match can end
inside that window — an opponent forfeits, a side is wiped, the Bo3 is decided — and
pvpMatchOver() sets pvp:null.
Every continuation that then resumed wrote with Object.assign({}, p.pvp, {…}).
Object.assign ignores a null source, so the state was not left null: it was
resurrected as {netWait:false, oppC:false} — truthy, with no games, no
opp, no net. Six sites did this.
The rule now is identity, not presence:
pvpAlive(mid) answers “is the live match still THAT match”, comparing
state.pvp.net.mid against the id the call was fired under. Not “is there a pvp
object”.pvpPatch(mid, patch) returns a React updater that yields null — a no-op —
when it is not. That is what stops a null pvp being resurrected.const _mid = v.net.mid at entry. All six writes, both desync forfeits,
the opponent-forfeit path and both lockIn() callbacks are guarded by it.Why the match does not instead wait for the server before ending (D's ruling, 2026-08-08). Waiting cannot fix this: however long the deadline, a reply can always land one moment after it. Worse, the most common reason a match ends is an opponent vanishing, so a match that ends only on a reply would freeze the player owed the win — the exact failure the 2026-08-06 “a PvP snapshot is not authority” work removed. The fix is that a late reply cannot write, not that it is unlikely to arrive.
pvpReal(v) is !!(v && v.opp && Array.isArray(v.games)). Four things key
off it, because each one changes what a fight is:
endRound()'s PvP branch — otherwise a PvE party wipe entered the Bo3 machinery.battleKindNow() — otherwise a PvE fight was persisted to the PvP localStorage
slot, and tryResumeBattle(null) tries 'pvp' first, so a reload resumed straight
back into it.persistBattle()'s slot choice and the battle log's kind.A truthy pvp also hides Auto-Battle, which is what players saw first.
pvpGameEnd() and pvpForfeitWin() build with
(v.games || []).concat([…]), never v.games.push(…). A match-end path is
the only way out of a fight, so it must not be able to throw on a malformed object.endRound()'s catch is a watchdog, and a thrown round is not a live round. If nobody on the
player's side can act, it ends the fight and clears the battle snapshot instead of returning the player to
'select' with a dead party — moves visible, none selectable, and the next
endRound() throwing in the same place.Client: pvpAlive, pvpPatch, pvpReal,
pvpNetCommit, pvpNetPoll, pvpGameEnd, pvpForfeitWin,
persistBattle, battleKindNow and endRound in play/app.js.
Probe: tools/probe_r64.py (9/9, including a control that a live match still takes its own reply, and
one that a clean PvE defeat is untouched). Replayer: tools/_r64_patch.py.
Challenge by code. A player at the Competitive rung asks the server for a code (6 characters, valid 60 minutes, one live code per player; codex/pvp_ladder.csv). Another player enters it. If the code's owner is waiting in the queue for that code, the two get an ordinary live Competitive match, and the code is used up. A player waiting with a code is never matched with anyone else. If the owner is not waiting, the challenger's team fights the owner's server-pinned defense team as an Auto PvP match: same ticket, same limit of two attacks per defender per day, and the server replays and settles the result. You cannot redeem your own code. An expired or unknown code is refused.
The challenge screen. The PvP arena header carries CHALLENGE. MAKE A CODE shows the server’s code, the time it is valid until and a COPY button; NEW CODE replaces it. WAIT FOR THEM LIVE puts you in the Competitive queue for that code only (the Competitive card says so). Cancelling, making a new code or the code expiring ends the wait. ENTER A CODE takes the code (letters and digits; dashes and spaces are dropped, lower case is raised) and FIGHT sends it. The server answers with a live match, which opens like any matchmaking pop, or an Auto PvP fight against the owner’s defense, whose result is the server’s settle. A refusal is one plain line: your own code, no such code or expired, two attacks on this player today, no defense saved, no attacks left. Every line is in codex/strings_en.csv (chal_*).
Inactivity (T33). After 7 days without a played match, a player no longer shows on the Competitive and Auto PvP boards. A Competitive match counts once it finishes; for Auto PvP, an attack counts once it settles and is not void. An account that only defends has played nothing. From idle week 2, each full idle week takes 5% off the rating, down to the starting rating (1000 Competitive, 1500 Auto) and never below it. A rating already at or under the start does not change. The server applies the decay lazily, when it reads the rating for a fight, the queue or the daily board cut; there is no timer. The next match starts from the decayed rating and resets the idle count.
Weekly pay (T33, T46). You are paid only if you played a match that week. The tiers come from codex/pvp_weekly_pay.csv: rank 1 gets incense and bait for 30 / 30 days plus the medal, ranks 2–3 incense and bait for 30 / 30 days, ranks 4–10 incense for 30 days. A thin week is one where fewer than 10 players took part (distinct players with a settled attack that week). A thin week pays 50% of the days and no medal. The medal is recorded on the reward row for the profile to show.
2026-11-03 — codex review (dfmc-client lane codexreview-a; D 2026-11-03 “review the codex…”). WIKI ONLY. The arena paragraph no longer places PVP ARENA “between Adventure Log and Game Mode” (both left the menu) and names the unlock floors from unlocks.csv through csv-bind; the Competitive integrity lead and the former What is NOT server-owned yet section are retexted to the A17 state recorded in the 2026-10-23 entry (the server owns the live round; the clients still exchange the commit–reveal payloads); the Leaderboards paragraph says plainly that there are no seasons or realms, that weekly cuts pay but never reset the boards, and points at the T33 inactivity rule.
2026-11-02 — a per-account reset takes the badges, the board rows and the ratings with it (T61; dfmc-client lane resetall). The profile work (B15, 2026-10-23) assumed badges were account history and survived your own reset; D reversed that on rulings card T61 (“per account reset should RESET everything”). Resetting your own account now clears your badges (each badge’s holder % moves down by the rows removed, exactly the inverse of the award), your rows on every board (the climb board is keep-best, so before this a reset account stayed ranked at its old floor and power), your Competitive and Auto PvP ratings, your defense snapshot, your match rows, your season standing and reward, your won-with lineups, the day’s attack tickets and your challenge codes — together with the hero name T30 already freed. Another player’s rows are untouched. The global wipe path (worldWipeCatchUp) is unchanged. Gate tools/probe_t61_resetall.py 4/11 -> 11/11.
2026-10-26 — the Competitive card's band line no longer reads [OBJECT OBJECT] (dfmc-client lane pvpnote). Display only; held for the next client build. The arena's one notice slot is written two ways: live PvP writes a plain line (“match not taken — still looking”), Auto PvP writes a title and a detail (YOUR DEFENSE FOUGHT…, QUEUED, THAT MATCH EXPIRED, NO ATTACKS LEFT). The Competitive band printed the notice as-is, so every Auto PvP notice showed as [OBJECT OBJECT] there, and the notice box over the Auto PvP card read only title and detail, so a live PvP line opened an empty box. Both now read the notice the same way: a plain line is a title with no detail; the band shows title · detail, the box shows the title over the detail. No rule changed. Gate tools/probe_pvpnote.py 12/19 → 19/19.
2026-10-26 — arena header: both mode crests and both MATCHMAKING plates share one line again (dfmc-client lane arenahdr). Visual only; held for the next client build. Each card's crest box takes the height its card has left over, so anything in flow on one card only moved that card's crest. Two did: the padlock line of a locked mode (“OPENS AT FLOOR 60”, T6) sat in flow above its plate, and the PvP notice sat in flow under the Auto PvP blurb. Now both cards carry the same fixed padlock slot above the plate, locked or not, and the notice floats over the foot of the Auto PvP card instead of growing it. Holds at desktop, phone portrait and phone landscape. Gate tools/probe_pvparena_hdr.py 5/8 → 8/8.
2026-10-23 — hardcode-3: the Auto PvP daily tickets read pvp_ladder.csv apvp_daily (dfmc-client lane hardcode-3; D: “The csvs need to be the source of all truth. Hardcoded NEVER.”). Held for release 0.90. 10 a UTC day, refill-to; src/apvp.js no longer types it, the worker installs it from the bake (apvpSetDaily). No number changed. A pin that predates the row keeps the old value and names it (never a refusal); it reaches players when DATA_PIN advances and the bake runs. Gate tools/probe_hardcode3.mjs 33/72 → 72/72.
2026-10-23 — a locked Auto PVP queue says locked first (dfmc-client lane probe-triage-5). Server; ships with the next worker deploy. Below its rung (codex/unlocks.csv auto_pvp, T6b) POST /api/apvp/queue answered 409 team_illegal (F197: no legal unit) before the lock check ran, so a new account was told its team was illegal instead of that the door is not open yet. The team verdict is now held and given only after the lock answers: below the rung the door says 403 locked; at or past it an illegal team is refused 409 team_illegal as before. Gate: tools/probe_t6b_unlock_doors.py 83/85 → 85/85.
2026-10-23 — the profile card’s avatar no longer 404s on boot (dfmc-client lane loadout-bugs). On dev. The avatar image was the one bound image without lazy loading, so the browser fetched its unfilled binding text on every boot. It loads lazily like every other bound image, only once the card is open. Gate tools/probe_eqhint_avatar.py.
2026-10-23 — the move timers table is generated from the engine (dfmc-client lane a8-s5-c17-tail, C17 part 4). WIKI ONLY. tools/codex_tables.py block pvp-timers: the round and bank seconds from src/tables.json pvp_live (MM_ROUND_MS / MM_BANK_MS), the damage ramp from src/battle_round.js rampMult. No number changed.
2026-10-23 — the weekly-pay sentence binds each row it summarises (dfmc-client C17 part 3, lane c17-gen-2). WIKI ONLY. “each for 30 days” summarised five pvp_weekly_pay.csv rows; each tier’s Days and Rank_Max are now their own binding.
2026-10-23 — the thin-week share and the first decaying week read pvp_ladder.csv (dfmc-client C17 part 2, lane c17-gen). WIKI ONLY. “pays half the days” and “from the second idle week” were prose; they are thin_week_pay_mult and decay_from_week spans now, filled by csv-bind.js.
2026-10-23 — the challenge-by-code screen (dfmc-client lane b16-client; T33). The server could mint and redeem challenge codes, but the client had no screen for them, so no player could make or enter a code. The PvP arena now has a CHALLENGE panel: make a code (shown with its expiry and a copy button, with a toggle to wait for its redeemer live) or enter one. A redeemed code opens the existing live match or Auto PvP fight, and the server settles both. The code length is read from codex/pvp_ladder.csv; every text is in codex/strings_en.csv. Probe: tools/probe_b16_client.py, red 0/9 -> green 9/9.
2026-10-23 — challenge by code, inactivity and thin-week pay (dfmc-client lane b16-pvp; T33, T46). Nobody could challenge a chosen player. An idle account kept its rating and its spot on the board forever, and the weekly settle paid fixed tiers no matter how few people played. Now a player can mint a server code that another player redeems: as a live Competitive match when the owner is waiting for it, otherwise as an Auto match against the owner's pinned defense, settled by the server. Seven days with no match takes a player off the Competitive and Auto boards. From the second idle week the rating drops 5% per week, down to the starting rating, applied lazily on read. A week with fewer than 10 players pays half the days and no medal. Every number is in codex/pvp_ladder.csv and codex/pvp_weekly_pay.csv. Probe: tools/probe_b16_pvp.py, red 1/11 → green 16/16.
2026-10-23 — a lone report with no resolved round fights on the live board (dfmc-client lane battle-tidy, T4). When one Competitive side goes absent before the server has resolved any round and the other reports alone, the server used to settle it with a separate whole-fight sim at the slices’ plain max HP (×1), while every live round stands on the ×1.5 slice HP T4 set. The same match settled on two different boards depending on whether a round had been resolved. Now both cases continue the match the same way: from the last round the server resolved, or from game 1 round 0 on the live board (×1.5 HP), both seats on the arbiter’s own auto picks and round seeds, to the end of the Bo3. The flag names the round it continued from (srv g1r0+). Gate: tools/probe_pvp_replay.py L5.
2026-10-23 — the server replays every live PvP round (dfmc-client lane pvp-replay). Until now no Competitive round was ever computed by the server: each client resolved it in its own frame (itself as a0–a2, the two reveal salts in its own order), and a lone report against an absent opponent re-fought the match AI-vs-AI from round 0, ignoring the rounds played. Now the round arbiter resolves each round the moment its last input lands — the second reveal, or the auto ruling for a side that never committed (that side plays on the PvE autoplay brain, the same picks the clients already derived) — with the shared battle engine in side A’s frame, and stores the board and the Bo3 tally on the match row. Both clients read that board in the round node they already poll and adopt it at the top of the next round; a game end a client computed is confirmed against the server’s board, and a client whose fight differed is corrected, not trusted. The match result is the server’s: the first report after the server has decided the match settles it by the server’s verdict, a claim that differs is answered with the real result and flagged (pvp_claim_overruled), never paid. A lone report continues from the last round the server resolved, both seats on the arbiter’s auto picks, to the end of the Bo3 — the rounds actually played stand. The boss signature rules (the ten SIGFX handlers and the Two Deaths ring) moved from the client into the shared engine so the server can replay a round where one fires; boss fights resolve identically. The old local-arena “opponent disconnected” roll and its five-round auto-forfeit are deleted (T29: the AI keeps playing until the end; a real absence never ends a match).
2026-10-23 — the Power board’s claim-vs-computed check is readable (C1 balance remainder; dfmc-client lane c1-balance). The daily fold computes every save’s power with src/power.js and compares it with the power the client last showed the player (pwlast); until now it kept only the first five mismatches in the sweep’s return and stored nothing. The whole comparison now rides the GM snapshot as standard.pwcheck — claims, equal, differ, the spread of (computed − claimed) / claimed in percent, and the ten widest — served by the existing /api/backup/gmsnap cache (no new walk, no recompute); tools/power_claimcheck.py prints it. Measured on the rig: the client sheet and the port agree to the digit on all 22 alpha saves (probe_power_parity.py) when both read the same codex; the same save reads 9,323 on the pinned 0.88 tables and 8,257 on today’s CSVs, so the board moves with the next DATA_PIN advance and the check is how to see it. Ops data; nothing a player sees changes.
2026-10-23 — an empty rankings board says how to get onto it (screen review #11; dfmc-client lane screen-fixes-2). Under NO ENTRIES YET each board now shows one line from codex/info.csv (keys rankings_power, rankings_deepest, rankings_autopvp, rankings_ladder; D rewords them there). The lines state what the server's daily fold does: character power is counted from the saved build and listed when above 0; Highest Climb lists a hero once the highest floor reached is above 1; Auto PvP lists every hero with a defense snapshot (a class soul and a creature in the team), at the 1500 seed until a fight moves the rating; Competitive PvP lists a hero after one rated match. Display only; no board rule changed. Gate tools/probe_b9_rankempty.py.
2026-10-23 — live PvP: the returning player takes the next round, no forfeit cap (A17; Tower rulings T29; dfmc-client lane a17-livepvp). The server owns the live round: the round number, its start and each side's time bank sit on the match, and a round closes when both sides have locked in or when a side's round clock plus its remaining bank runs out, in which case the server marks that side for the autoplay AI for that round. New: a player who comes back cannot send orders into the round the AI already played for them; the server refuses them and they take over from the next round, so both screens always show the same round. The old “5 fully-AI rounds forfeits the game” rule is gone from this page, as ruled: the AI plays to the end unless you return, and the result pays rating normally. The round and bank lengths and the matchmaking shard count (1) are now server config values, not code.
2026-10-23 — a refused name does not use up your tries (T30; dfmc-client lane b15-2). Claiming a name is limited to 3 tries, then one every 20 minutes. A name refused for its letters, for the word filter or because someone holds it no longer counts against that limit; only a name you actually claim does.
2026-10-23 — a reset frees the hero name (T30; dfmc-client lane b15-2). Resetting your own account now releases your hero name at once: anyone, you included, can claim it at the next character creation. Before, only a global wipe freed a name.
2026-10-23 — Class Mastery and Void Completionist are awarded (B15; dfmc-client lane b15-2). A class line's Mastery badge is awarded by the server at the battle whose class XP carries that line to its level cap (a line already at the cap is awarded at its next battle). Void Completionist is awarded at the hatch that mints, or the upgrade that reaches, the last line's Void-grade creature, counted from the creatures the server itself issued (any stage of each line). Each is awarded once and moves its holder % the moment it lands.
2026-10-23 — player profiles, launch badges and the name rules (B15; Tower rulings T46 and T30; dfmc-client lane b15-profiles, docs/profiles-2026-10.md). (1) Click a name on any rankings board to open that hero's profile: class and line, the party (the daily server-built Auto PvP slice), best floor, power, Auto PvP and Competitive ratings, and badges, rarest first, each with how many players hold it. The profile is read from GET /api/profile/<name>, a fixed public projection of rows the server wrote itself; it never shows an account id, the save or the wallet. (2) Launch badges, catalogue codex/badges.csv: Floor 10 / 30 / 60 / 100 / 120 (awarded by the server the moment its own best-floor record crosses the mark), Warrior / Mage / Archer / Assassin / Brute / Professor Mastery and Void Completionist (a Void-grade creature of every line, D #938). Each badge is awarded once; the holder % is a counter the award itself moves. The Tower Echo marker is not a badge. Mastery and Void Completionist are catalogued; their award walk is the next step. (3) Names (T30): Latin letters A–Z, digits, spaces, dash and underscore, 3–16; refused at the claim by the server, with a multi-language word filter (codex/name_filter.csv, drawn from the LDNOOBW lists, CC BY 4.0). Names already held are kept.
2026-10-22 — the PvP doors are the server's: the team is the ledger's, a lone report settles by the server's own fight, pvpSnap retires (server-authority run § 3 item 9, F190 F197 F198 F203 F255; Tower rulings form card T29, D 2026-10-22: “the AI keeps playing until the end unless the real player returns, and the result pays out normally”). LIVE on release since 2026-10-23 (0.89). (1) Provenance (R12): before the Worker builds a Competitive or Auto PVP slice from the stored save - at mm/team, at the Auto PVP mint and in the daily defense fold - the player object checks every companion and soul source against the account's live items rows in one batched read; a creature the ledger never issued or has already spent is no unit (seat empty, soul unsocketed, flagged pvp_team_illegal_uid), a save with no class soul is refused 409 team_illegal; a ledger with no creature row at all (an ops restore before the account's first save) judges nothing. (2) A lone Competitive report: until now only a self-reported FF loss settled alone, so the player whose opponent closed the tab played the AI to the end, reported, and no rating moved for either side. Now, when the round arbiter has ruled the opponent absent (auto on the latest round it recorded) and that side has not reported, the server plays the match itself from the two slices pinned at start (src/apvp_sim.js apvpAutoFight, both seats on the module's autoplay, seeded by the match id, Competitive HP) and pays the sim's winner exactly like the dual-report settle - rating, W/L, last-won-with, the match closed to matchmaking; a present opponent keeps the dual-report wait, a second report replays the verified rating, a report with no match row stays 404. The claim is recorded beside the verdict (pvp_lone_settled), never paid on its own. (3) The client no longer writes pvpSnap into its save: the server has built every slice from the stored save since TEAMSTATS, and now nothing authors the field. (4) Re-gear after match start was checked and already does not reach the fight: the slice is pinned on the match row at mm/team / the mint and the fight reads only that (F255 confirmed; gear pieces carry no uid and are not ledger-checked, the residual stays). The Competitive integrity section is retexted to what exists (F203). Gate: tools/probe_sa_pvp.py 19/24 → 24/24 on the local rig (two restored accounts, the real matchmaker: a forged companion uid dropped, server stats on the stored snapshot, 404 on a rowless WIN, the lone settle paid once, re-gear not reaching the fight).
2026-10-22 — the Auto PVP queue is never empty: a rung-bounded defender pool, a power window, and TOWER ECHO house teams (Tower rulings form card T6 part 5, D 2026-10-18; 0.89-4 board card Q15 "Queue never empty: every player past 20 is a defender; add power-filtered matching + house Tower Echo bot teams"). LIVE on release since 2026-10-23 (0.89). Until now the mint walked every defense snapshot regardless of floor or power and answered 409 no_defenders when the walk found nobody - D's standing note: "PvP at 90 leaves nobody in the queue". Three changes, server only: (1) the pool is every account whose best floor (PlayerDO.progressOf, T6b's ratchet, now written on the daily defense snapshot as maxf) has reached the Auto PvP rung (codex/unlocks.csv, floor 15) with a fold-built defense team; a snapshot below the rung is never served, a snapshot from before this change (no maxf) stays in until its next fold. (2) the Worker computes the attacker's power from his stored save on the way to the mint (src/power.js computePower, the T8 rule, the same number on every defender's snapshot) and the world object keeps the pick inside ×1.25 of it, widening to ×1.5 and ×2 when the pool is thin; the hidden-rating band (apvpBandPick) then picks inside the window as before. (3) when no human stands in the widest window the match is minted against a TOWER ECHO: src/apvp_echo.js builds a house team in the Worker from the codex for the attacker's ratchet floor - a class line, a level within two of the floor, two companions native to the floor band (creatures.csv Floor_Found), through the same buildTeamSlice every human defense is built by, then stood at the T4 wild-HP band for that floor (×0.6 below 50) - seeded by attacker + day + idem, so the same attacker meets varied but reproducible echoes and an idem retry replays the same one. The row carries bot=1 and defender bot:<seed>, the response defender.bot:1; the finish door runs the pin and the re-sim exactly as for a human, and the settle writes NO ladder result for any human: no autopvp row either side, no rating or W/L move, no last-won-with for the house, and playedWeek skips bot rows so an echo week earns no season reward. The client paints TOWER ECHO [ECHO] on the arena card. An attacker power of 0 (unreadable save) or a Worker that carried no echo (tables missing) keeps the old unfiltered pick, so a missing table never empties the queue. Competitive matchmaking is untouched. Gate: tools/probe_t6e_tower_echo.py 13/23 → 23/23 (a local worker: a ratchet-10 snapshot never served, a level-1 attacker at 15 meets an echo on a thin pool and the human once one is in the window, an echo forfeit leaves the rating at 1500 while a human forfeit moves it, a level-90 attacker meets an echo with level-1 humans in the pool and the level-90 human once snapshotted).
2026-10-22 — the Auto PVP attacker's Targeting is pinned at the mint, so a dead target re-aims by it on both ends (Tower rulings form card T13 remainder, D 2026-10-18: “Follow Targeting in autoplay”). LIVE on release since 2026-10-23 (0.89). T13 re-aims a queued attack whose target died earlier in the round by the side’s Targeting, and in Auto PVP only the defense was pinned: the match row carried the defense’s snapshot settings alone, so the attacker’s side fell to the first enemy in line on the client and in the server’s replay alike (consistent, but not what the attacker’s own autoplay does). The mint now pins the attacker’s Targeting on the match row beside his team (apvp_match.tg_att), read from his stored save the way the daily fold reads the defender’s (never from the client’s request), one of the three settings-page modes or the page’s own default (Lowest Health); the mint and its idempotent replay answer it as attacker.targeting, the client pins its side from that answer, and the finish door’s re-simulation pins the same value, so both ends draw the same re-aim. A match minted before this landed carries no pin and its attacker keeps the first enemy in line, as the fight was played. The defense’s pin, Competitive and manual play are unchanged. Gate tools/probe_t13b.py 10/20 before, 20/20 after.
2026-10-22 — Auto PVP fights stand on ×1.5 HP, both seats (Tower rulings form card T4, D: "C1"). LIVE on release since 2026-10-23 (0.89). The board card Q13 sim measured Auto PVP at a median of 2 rounds, 56% of fights over in two. C1 (the damage formula now takes the square root of Attack / Defense, see the Battle system) adds an Auto PVP-only HP lever: every unit in the fight, attacker and defender, starts on ×1.5 of its team-card max HP (apvpUnitHp in the shared engine; the team card, the power rating and the defense snapshot keep the real number). The client builds its fight units through the same function and the server pin demands exactly that HP, so a client on the old number is flagged field_<slot>_maxHp, not silently accepted - the deploy order matters as it did for the slot pin. Live Competitive PvP is untouched. Re-run on the real engine: median 5 rounds, 38% of fights in 4–6, 9% in ≤ 2.
2026-10-22 — the queue waits for an armed Abyss fight (Tower rulings form card T12, D: “Block PvP during an Abyss fight”). LIVE on release since 2026-10-23 (0.89). A player in a losing Infinite Abyss fight (floor 121+) could queue Competitive PvP; the match went live over the armed fight and tore it down with neither a battle credit nor a /loss — no loss recorded, and the next arm filed abyss_unclosed (“armed over an open fight at 125”). D’s pick: the Abyss fight resolves first, the match only starts after. The Abyss row lives on the player object and the ticket on the world, so the Worker asks the player object (/api/_internal/abyss/busy) on every queue/poll/ready and forwards hold:'abyss' in a body it rewrites (the client’s body is discarded, as for team); the ticket keeps it. A held ticket stays searching, lease and all, and the pass skips it; /ready on it is refused; the status carries hold so the client can say why — the queue band reads “your Abyss fight comes first · the match starts once it ends”, the MATCH FOUND card adds FINISH YOUR ABYSS FIGHT TO JOIN, and JOIN during an armed Abyss fight no longer parks it under the match. A credited battle or /loss clears the arm and the next poll (2 s) lifts the hold and pairs. Leaving the fight is still the loss /loss records. Auto PvP already queued behind any fight in progress (client side, “QUEUED — your Auto PVP match starts when this fight ends”) and is unchanged. Probe: tools/probe_t12.py 2/7 → 7/7.
2026-10-01 — the server builds both Competitive teams (TEAMSTATS lane 2, Rulings board row teamstats, D). LIVE on release since 2026-10-23 (0.89). D: “SERVER AUTHORITATIVE, EVERYTHING FOR FUCKS SAKE.” In the teams phase the client posted its own serializeTeam() to /api/pvp/mm/team and the world stored it as that side — every stat, move power and cooldown was whatever the player’s client said, so a console edit (HP/speed/power ×10, cooldowns 0) was the fight both players saw. Now the Worker makes the save hop the Auto PvP mint makes (the player’s own object hands over the loadout) and builds the team with src/team.js buildTeamSlice; anything the client sends is discarded. The client flushes its save and posts no team; an older client’s team is simply ignored, so it keeps working. No buildable team (no save, codex tables unreachable) stores nothing and answers 503, and the client retries on its next poll — before, a post with no team stored an empty side and took the match live. The “whoever posted a team” rule in the lifecycle table now means whoever’s team the server could build. Probe: tools/probe_teamstats2.py (5/12 → 12/12: a forged ×10 team changes nothing, a player who posts nothing fights the built team, identical saves fight identical teams).
2026-10-01 — the PvP Arena fills the screen, and QUEUE reads MATCHMAKING (#1046, D). ON DEV (0.89). D: “The two pannels for each mode can be much bigger, there is a lot of empty space in the screen. All their fonts can be bigger. The QUEUE buttons should say MATCHMAKING instead. the autoplay button can be smaller horizontally. It is not matching the style of the rest of the UI. The subtext underneath the queue buttons is ridiculously tiny. Increase the font. The queue buttons are surrounded by a weird outline that is rectangular, remove it. The same type of outline (purple and yellow) is in the inner part of each mode’s square card, remove that outline as well.” Since #989 each card was a fixed 560×560 box: at 1920×1080 the pair covered about 1250×600 of a 1870×1000 body, and a phone, which lays the game out at 1080 tall, showed the same at half size. Now the two frames share the body’s width (up to 1720px) and stretch to its height; the crest absorbs the leftover (120–420px); every font on the cards scales with the window height — 1× on a 720px-tall window, 1.5× at 1080p (title 33px, plate 30px, the line under the plate 24px where it was 11px, the blurb 21px). Both plates read MATCHMAKING (the searching state is still SEARCHING · elapsed · CANCEL). The tier-coloured rim and glow on the plates and the tier rim with the inset glow inside each card are gone — the tier colour stays on the tier name, so board 6.3’s “one tier-coloured unit” is now the name, not an outline. AUTOPLAY is a content-wide 8px plate beside its cog, centred, no longer a full-width pill. Both cards’ text under the plate claims the same height, so both plates stay on one line (#1017’s rule). The #1017 art files are untouched. Gate: tools/probe_01046.py 4/19 → 24/24 at 1920×1080, 1366×768 and the 844×390 touch landscape.
2026-10-01 — the accept prompt and the select-phase clocks count down without re-rendering (dfmc-client 0.89 carry-over s4.2, PREP_PERFORMANCE § 1.5). LIVE on release since 2026-10-23 (0.89). The 20 s accept countdown forceUpdate’d the whole template every 500 ms and the chess clock (ROUND, then BANK) setState’d it every second. Each is a ref’d span the tick writes; the clocks still live in state and drain exactly as before (round first, then the bank, the timeout pick at 0/0), and the template commits only when a clock crosses a threshold it colours on: round 0, bank 15, bank 0, the opponent’s lock, the timeout. Gate: tools/probe_perf_2.py, commits over 5 s 10 → 0 (accept) and 5 → 0 (clocks), one commit at round 0.
2026-09-30 — review lane X: the Competitive queue name is server-owned, for real this time (server). ON DEV. The queue took the name opponents see from the names table and, for a player with no claimed name, fell back to the kv ladder row lb/standard/<acct>/ladder — a row the client writes. So a player with no claimed name chose the string their opponent is shown (anyone’s name, any text), while the code comment said the client’s string was never consulted. Lane V froze that kv root for writes (F184), but rows already written stayed readable and the fallback still served them. Now: the claimed name, else the account’s own row in the SQL lb table (written only by the server, from the names table or the save’s hero name), else “Rival”. The kv root is not read. Gate: tools/probe_mmname.py 4/8 → 8/8 on a local worker (it plants a legacy kv row and reads the name the opponent is shown); tools/probe_worlddoors.py 14/17 → 17/17 (its test team now carries real codex tokens, which lane O’s team-token rule requires).
2026-09-30 — review lane Y: F192, the missed-Sunday catch-up (server, dev 67c38ce). ON DEV. The weekly settle fired only on the cutoff Sunday itself, so a Sunday the fold never reached (the spend sentinel pausing it for the rest of a month, a crash) closed nothing, and the next Sunday closed the week after: the missed week paid nobody. Now the first fold on any later day closes the week that most recently ended when the season stamp says it is still open - the same stamp as the Sunday path, so it closes once. It needs a prior settle (a fresh world never catches up), and several missed weeks close as one, the latest: there is one board to snapshot, and paying it once per missed week would multiply the top ten's rewards. A late close snapshots the board as it stands when the fold comes back.
2026-09-30 — review lane V: F104 season half (server). ON DEV. The fold’s season-reward delivery ignored the entitlement grant’s answer. On the designed retry (the world’s ‘paid’ mark failed after the player object paid) the grant was a duplicate and wrote nothing, yet the spawn row was moved again to first boon end + days — the spawn budget ran boosted for days the entitlement (the floor clock) did not cover. A rate-limit refusal wrote no entitlement and still boosted the budget, and the world marked the row paid. Now a duplicate leaves the spawn row alone and still answers ok (the world marks it paid); a refusal moves nothing for that kind and answers 429, so the row stays pending and the next fold retries it under the same grant key (lane N’s apvp:<n>:<kind> / legacy season:<old>:<kind> stems unchanged, so nothing pays twice). The shop half was lane Q. Gate: tools/probe_lane_v.mjs (F104 rows red on the old tree), tools/probe_lane_n_sweep.mjs 24/24.
2026-09-30 — review lane S: F205 (client, dead code). ON DEV. The last local-AI arena remnants are gone from the client: the accept popup's local branch (a match offer is only ever a server match), the fabricated-opponent builder and the local match start, the legacy pick-team builder, an unused team-power sum, and the retired creature picker (its markup and producers; its show flag was a constant false). A Competitive series with no server match behind it is now dropped rather than played against an invented team. The simulated-disconnect branch stays for now; its probe pins the #467 guard. Probe tools/probe_review_lanes.mjs.
2026-09-30 — review lane N: F6-051 (server). ON DEV. The weekly settle stamped the season that OPENS on the cutoff Sunday, not the one it closed, so a forced (dev) Saturday settle and the Sunday cron closed the same week under two numbers and paid the top ten twice. The settle now stamps apvpSeasonClosed: the week that just ended on a cutoff day, the week in progress on a forced one. The change is one-time migrated and cannot pay twice: on the first run the last closed season is read from the old marker (P → P−1), existing apvp_season_final / apvp_season_reward rows are relabelled to the week they closed, and new grants are keyed apvp:<season>:<kind> while a relabelled legacy row is still delivered under its original season:<old label>:<kind>, so neither a replayed legacy grant nor the next week’s grant collides. Gate tools/probe_lane_n_sweep.mjs (settle against node:sqlite) red first.
2026-09-30 — review lane O: F6-040, F204, F206, F208 (client + server). ON DEV. F6-040: a PvP team unit’s art token (tok) is the id the opponent’s client builds the unit’s picture from, and the unit sheet put it inside a CSS url(...) raw — a forged team could inject style (a full-screen overlay, an off-site beacon) onto the other player’s screen. A token is now only ever a codex id: DFMC-nnn (creature), CLS-LINE-n (class soul) or BOSS-nnn. The server refuses a Competitive team with any other token (mm/team answers 400, the match stays in the teams phase), and the client draws no art for a token outside that shape. F206: the round commit, its self-heal rewrite and the reveal no longer carry a timestamp, as this page already said. F208: the round arbiter accepts a next-game announcement only up to game 3 of the Bo3 (it took up to game 5, letting a modified client reset both banks after the series was decided). F204 (WIKI): the screen table described a rank card / team-lock / right-rail layout that no longer exists; it now describes the two queue cards, and says the boards live in the RANKINGS window and that replays have no UI entry point.
2026-09-30 — review P6 F6-031: the rank-tier rating floors are written down. WIKI ONLY. The Leaderboards table said only that rank tiers are named Worn → Void by rating band; it now states the ten floors the client ships (0 / 900 / 1,100 / 1,300 / 1,500 / 1,700 / 1,900 / 2,100 / 2,300 / 2,500, PVP_TIER_BANDS), so the spec can be checked against the build. Documentation of what ships; no number changed and the bands stay a client constant.
2026-09-30 — review fix F201 (client; the settled half of Q14). The timeout pick and a disconnected side’s picks are now the autoplay AI this page promises on expiry: DFMC_BATTLE.autoQueue, brain 2 (the PvE brain), on FIXED settings that are never either player’s (the settings-schema defaults: Lowest Health targeting, no prioritise passes) with the ultimate set to Never Use. They were a random ready move and a random target. The picker lays the board out in that side’s canonical order and seeds its rng from the match, game, round and side, so both clients compute the same queue for a disconnected side. The forfeit after 5 AI rounds is still D’s Q14 and is not built. Gate tools/probe_f201.py 3/7 → 7/7.
2026-09-30 — review P6 F6-011, F6-010 (server). ON DEV. A teams window that ran out with ONE team posted dropped both players: the sweep returned the innocent side to the pool only for the ready window, so the player who had posted their team silently fell out of the queue and had to press FIND MATCH again. The teams window now follows the ready rule, as the lifecycle table always said: whoever posted goes back into the pool, the other is dropped. The PvP kv door (/api/db/*, round, end and board nodes) now refuses with 413 a body over 64 KB, over 256 leaves or deeper than 8 levels before anything is written — one PUT used to become one row per leaf in the shared object, unbounded; a real round node is 4 leaves and a few hundred bytes. mm/team refuses a serialized team over 64 KB (a real one is ~11–15 KB). A body that is not JSON is a 400. Probe tools/probe_worlddoors.py.
2026-09-30 — review P6 Q14: transport wording follows system-networking (Cloudflare Worker, UGS retired); F203. WIKI ONLY. The PvP authority row and the Competitive integrity section named Unity Relay as the packet relay and Cloud Code / Cloud Save as the validator, ledger and replay host; Networking Architecture records that the Unity/UGS design is retired and the live build runs on the Cloudflare Worker with SQLite-backed Durable Objects. Those five lines now name the Worker. Descriptive only: no rule, layer or promise changed, and the retired UGS Economy pricing note is kept as history.
2026-09-30 — review fix F199 (server + client). A desynced (stale) client that abandons a live Competitive match now concedes it: mm/cancel with the abandon flag writes the match’s end node for the abandoning side and records that side’s forfeit loss, so the opponent’s round poll closes the match as a win by forfeit and its own finish settles the ratings. Before, the abandon deleted both tickets silently and the ahead player waited on a round that could never close. The cancel sent at a normal match end is unchanged.
2026-09-30 — review drift fixes: F178, F179, F202, F207. WIKI ONLY. The Leaderboards table listed a Glicko-2 PvP ladder with season resets and peak rating, plus Longest streak and Swiftest ascent boards; the server has exactly four boards (ladder, climb, power, autopvp), the Competitive ladder is plain Elo showing the current rating, only Auto PvP is Glicko-2, and there are no seasons. The table now lists the four. Joining out of a PvE battle still described the removed mode refusing the join; the mode left on 2026-08-28 and pvpAcceptGo has no guard. The Alt+F4 heading drops the mode name. DESCRIPTIVE ONLY.
2026-09-30 — your rank past the top 100 is your real rank (client review F176). ON DEV. The board serves its top 100; a player outside it was appended and shown as 101. Opening the rankings now asks /api/lb/me once per open (only for a full board you are not on) and the self bar shows that rank from the daily cut; with no answer (or on the Auto PvP board, which has no such route) it shows a dash, never 101.
2026-09-30 — Competitive PvP rounds are held to their commitments by the server, and the queue rates you by your real Elo (review F196, F200). ON DEV. The commit–reveal round was only checked after the match, at /api/pvp/finish, so a round's commitment bound nothing while the match was being played. Now the server keeps each side's first commitment for a round (mm_commit, which no client path writes or deletes), refuses any second, different commitment for that round, and refuses a reveal that does not open it before it is stored; a player can write only their own side of a round. New matches commit with SHA-256 (a match already in flight keeps the old 32-bit hash to its end). The client also checks the opponent's reveal against their commitment before resolving, and a mismatch closes the match with no result, the same close a desync takes. Matchmaking reads the ratings table (1000 with no row) instead of the client-written ladder row. Not changed: the team a client submits is still unvalidated (F197, a server stat bake — D's call). Probe: tools/probe_f196_mm_commit.mjs (22/22 on a local worker; 10/22 before).
2026-09-28 — the PvP Arena art is repainted grimdark (#1017 art, D). ON DEV (0.88). “ALL the art assets are cartoonish and THE WRONG ART STYLE. Go review out prompt guidelines and art style and REDO.” #989’s nine files were flat SVG drawn in code; the project’s image pipeline (index → Art pipeline) was never used. All nine are now painted by it — gpt-image-2.5-flare (snapshot 2026-09-08), native transparent PNG, one STYLE block for the whole set: grimdark dark-fantasy, painterly oil-painted semi-realism, soot black / pitted iron / tarnished bronze / dried-blood crimson with one ember-orange accent, hard rim light, no cartoon, no flat vector. The set: a ruined colosseum pit at night (blood-soaked sand, broken weapons, braziers) behind the body; a spiked gauntlet over crossed spears on a dented buckler (Auto) and a laurel-crowned cracked skull on a blackened heater shield (Competitive); a spiked, riveted iron frame (the 9-slice is rebuilt from its painted corners and edge middles; the hover state is the same frame warmed with ember light); a pitted bronze QUEUE plaque with iron end caps and rune bands, its middle burnished so the engraved label reads (the pressed plate is the same plaque sunk, the engraving burning); a torn crimson war banner on a spiked rod; a blood-rag war spear between the halves. Same file names and sizes as #989, so the window’s layout is unchanged. Tooling: tools/make_pvp_art.py gen|bake in the client repo (raws kept outside every repo). Gate: probe_01017art 9/27 on the #989 files → 29/29.
2026-09-28 — the realm plate leaves the PvP Arena header, and the two queue cards line up (#1018, #1017 alignment, D). ON DEV (0.88). #1018: “PVP ARENA - still says ETERNAL REALM on the header. REMOVE IT!!! Already ordered this done.” #944 had taken the plate off the RANKINGS header only; the arena header still painted the constant “ETERNAL REALM”. The plate is gone, and with its last binding gone the client prop that fed it is retired too — no replacement text. #1017 (the alignment half; the art redo is separate work): “The buttons for Q and the mode ICONS need to be horizontally aligned for both Q modes.” Each card’s crest box used to grow to fill whatever height the card’s other content left over, so the crest floated to the middle of that space; Auto PvP carries an extra AUTOPLAY row, its notice and a blurb, so its crest and QUEUE sat higher than Competitive’s. Now the crest box is a fixed height, both rating rows are one fixed height, Competitive keeps an empty slot the height of Auto’s AUTOPLAY row, and Auto’s PvP notice sits under the blurb instead of above the rating — so both crests share one centre line and both QUEUE buttons share one top line, idle, queued, or with a notice up. Gate: tools/probe_pvparena_hdr.py 0/8 → 8/8.
2026-09-28 — the PvP Arena is dressed for battle: first versions of its art (#989, D). SHIPPED IN 0.87. D’s report asked for the arena’s buttons and panels to be “more ornate. More arena and battle themed”; on the 0.86 board (Q10) he ruled “Review your ideas and figure out the first versions.” No image model exists in the pipeline, so every piece is drawn in code by tools/make_pvp_art.py (deterministic SVG, rasterised to webp under play/img/pvp/) and can be redrawn or replaced file for file. What the window now wears: backdrop — the colosseum seen from above, sand floor, stone tiers cut by aisles, torches round the podium wall, full-bleed behind the window body at 40% so the two cards stand on the sand; panel frames — each queue card sits in a riveted-iron 9-slice frame with a bronze bevel, lit bronze with an ember glow on hover; crests — Auto PvP an iron gauntlet over crossed spears on a riveted buckler, Competitive PvP a laurel-wreathed skull on a heater shield; QUEUE — a hammered-bronze plate with engraved rune bands, which sinks and its runes burn while pressed and while Competitive is searching; header — PVP ARENA hangs on a torn crimson war banner that drops past the header line; divider — a spear stands between the two halves. The mode headers are Cinzel gold. Nothing moved underneath: Auto left, Competitive right, each card 560px wide with its tier-coloured border, and the QUEUE button keeps its tier-coloured rim (6.3: card and button are one tier-coloured unit). Markup and art only; no queue logic changed. Gate tools/probe_00989art.py 6/25 → 25/25 at 1067x487 and 1920x1080; probe_00989 12/12 and probe_pvpentry 6/6 unchanged.
2026-09-25 — the arena’s two queue blocks sit side by side: Auto PvP left, Competitive PvP right (#989, D). SHIPPED IN 0.87. D: “the pvp arena ui - auto pvp needs to be on the left side, competitive on the right side.” Since #733 moved the boards out to the RANKINGS window, the two queue blocks had been STACKED in one 560px column at the centre of the screen — AUTO PVP above COMPETITIVE PVP. The column is a row now: Auto PvP on the left, Competitive PvP on the right, each block keeping its 560px width and its own height, the pair centred where the column was. Nothing inside either block moved — tier name, rating, AUTOPLAY toggle, cogwheel, QUEUE, tickets / record line and the attack-with-equipped note are as they were. Markup only; the order was never state-driven. The rest of #989 — more ornate, arena-themed panels and the images to embellish the window — is a design ruling and waits on D. Gate tools/probe_00989.py 6/12 → 12/12, measured inside the zoomed 1920x1080 stage at 1423x687 (the report’s viewport) and 1600x900.
2026-09-17 — competitive PvP had never settled a single match, and the gate that was supposed to pin it could not parse (#943 follow-up, D). ON DEV (0.84). D, asked whether to revive it: “has anyone played? I don’t think anyone played. But if there is a defect do fix it.” The defect is two column names. The report handler’s prologue projected SELECT a_acct,b_acct FROM mm_match and then asked that same row for a_sid / b_sid twenty-eight lines later — columns the projection never fetched — so aSid and bSid were always null and BOTH settles of /api/pvp/finish, the forfeit branch and the dual-report branch, sat behind an if (winnerSid && loserSid) that could not be true. Dead since b943223 (0.38, 2026-08-07): no competitive rating has moved in that time, matches.winner was never written, and the dual-report wonWithPut the entry below describes had never once fired. The projection now fetches the two columns it reads. Reviving the settle then exposed a second gap the dead code had been hiding: the forfeit branch returns before the close at the foot of the dual-report path, so a forfeited match was never set phase=‘done’ and its mm_ticket was never deleted — both players would have sat state=‘live’ for the full MM_LIVE_MS hour, unable to re-queue, which is exactly the 2026-08-06 incident that close was written for. Both statements added to the forfeit branch. THE GATE ITSELF WAS THE THIRD DEFECT: tools/probe_943ff.mjs ended in a console note whose text closed its own single-quoted string on the apostrophe in “D’s order”, so the file stopped parsing the moment that line was written and every run since died with a SyntaxError before check 1 — invisible because gates.sh runs the five audits and no probes. Repaired, and the note is promoted to a check: it existed only to print that the settle wrote zero ratings rows and to call reviving it D’s decision, and D has now decided, so a settle that fires is a ruling to pin rather than an observation to print. Check 10 asserts two ratings rows, the winner up and the loser down, and matches.winner stamped. tools/probe_943ff.mjs 9/10 on the pre-fix worker (zero ratings rows) → 10/10. CONSEQUENCE, and D was told before he ordered it: both settles start firing on the next deploy, so live ratings move for every account that has queued competitive since 2026-08-07.
2026-09-17 — a competitive win by FORFEIT now records the winning team, so the rankings window has something to paint beside the name (#943, D). ON DEV (0.84). D: “competitive pvp, and both pve rankings should also show the team composition images the same as async pvp.” The COMPETITIVE PVP pane already paints whatever a board row’s won string carries — exactly as the AUTO PVP pane does — and the string comes from pvp_wonwith. The miss was a date, not a parse: the forfeit settle in /api/pvp/finish is 2026-08-07 and the team record is 2026-08-16, nine days YOUNGER, and it was only ever wired into the DUAL-REPORT settle further down. The forfeit branch settles the ratings and returns first, so a match that ended by concession recorded no team at all and the winner’s ladder row stayed blank. The forfeit branch makes its own wonWithPut(…, ‘comp’, …) call now, reading mm_match for the sides and their teams the same way the dual-report call does; the side that reaches this branch is the one that CONCEDED, so the team recorded is the other one’s. No rating, win or loss arithmetic moves, and the dual-report call is untouched. Gate tools/probe_943ff.mjs (the shipped handler and wonWithPut sliced out of src/worker.js at run time and run against a real SQLite database built from the shipped schema) 4/9 → 9/9.
2026-09-17 — an old board row’s class thumbnail draws the final form, and the realm plate leaves the rankings header (#942, #944, D). ON DEV (0.84). #942: “on the rankings window, the player class images are serving as stage 1 instead of 3.” #939b below taught both the parser and wonWithPut the whole token, but a pvp_wonwith row is overwritten only by that account’s NEXT win — so every row written before #939b keeps arriving with the letters only, and kept drawing the line’s base form forever; release 0.82, which D was looking at, had the stage hardcoded to 1 outright. The fallback is the FINAL form now. A token that does name a stage is untouched and still draws that stage. #944: “rankings window shows ETERNAL REAL on the header, remove that text — realms and seasons were removed from the game scope.” The header carried a bordered plate binding the constant ETERNAL REALM, the last visible survivor of the realm concept #900 retired alongside seasons; the span is deleted from the RANKINGS header, and the Leaderboards section above no longer says the boards are scoped per realm. The prop itself stays: the PvP Arena header still binds it, and that window was not this report’s. Gate tools/probe_rankwin.py 3/5 → 5/5; tools/probe_939b.py check 4 re-aimed by #942 (it pinned the stage-less row to CLS-MAGE-1) 6/7 → 7/7.
2026-09-16 — the PvE boards show the team too, the class thumbnail shows its stage, and the “Updated daily” line paints (#939b, D). ON DEV (0.82). The stopped half of #939: the Character Power and Highest Climb rows carried no class or party, so the PVE tab drew no thumbnails. The server now joins each player’s current team into those rows at the daily cut — the class and party from the same defense snapshot the daily fold already keeps — in the same string the PvP boards carry, so the PvP boards read “last won with” and the PvE boards read “currently fielding”. Same change, two more: the class thumbnail draws the stage the player is at (the fold used to keep only the class name, so everyone showed the base form), and the rankings window’s “Updated daily · 2h ago” line, blank since it was built, now reads the cut time the server sends. Gate tools/probe_939b.py 2/7 → 7/7.
2026-09-16 — the rankings window becomes two tabs with side-by-side boards, and the team readout becomes thumbnails (#939, D). ON DEV (0.81). “I want it to show thumbnails of the class used and each creature on the party instead, directly to the right of the player name. The tabs instead of 4 become 2 = PVP and PVE.” PVP paints Auto PvP on the left and Competitive PvP on the right; PVE paints Character Power on the left and Highest Climb on the right; each board keeps its own list, empty state and the player’s own bar. In every row the last-won-with text (“MAGE · Bristle, Thornhide”) is drawn instead as thumbnails — the class, then each party creature — directly right of the name; the server still publishes the same string in the daily cut, the client resolves it against the codex. The climb and power boards carry no team in their rows, so they show none until the worker joins one in. Same ruling, second surface: the arena rank card’s “SEASON · 3W · 2L” drops the SEASON word (there are no seasons, #900); the record stays. Gate tools/probe_939.py 0/6 → 6/6.
2026-09-16 — the season / all-time switch leaves the rankings window (#900, D). ON DEV (0.81). “There are no seasons in game anymore so we should remove the toggle between season and all time from the rankings UI window.” Traced before removing: the switch’s scope was read in exactly one place, its own highlight, and written in exactly one, its own click — no board, row or request ever scoped by it. The button pair, its producer and its state seed go together (#86). The rank card’s “SEASON · W/L” label on the arena is a separate surface and is not touched by this change. Gate tools/probe_rankings.py check 2 re-aimed from “two scope buttons” to “none”.
2026-09-04 — the rankings get their own window (#733, D). LIVE on release since 2026-09-06 (0.70). The four boards (auto PvP, competitive PvP, highest climb, character power) and the season / all-time switch moved out of the PvP Arena into a RANKINGS window with its own menu plate, placed right after PVP ARENA. The arena keeps the two queue blocks and carries a RANKINGS plate in its header so the boards are still one click from where they used to be. Same boards, same rows, nothing on the server changed.
tools/probe_pvpentry.py 6/6 (1/6 before) on the painted page; probe_pvpqueue 21/21 unchanged.src/battle_round.js rampMult), so the live fight, the replay and the
server's re-simulation can never disagree.endRound error: Cannot read properties of undefined (reading 'push'), with F5 returning him to the
same round and only a relog clearing it. Reproduced first (8/8) and the probe then inverted to verify (9/9).
Three links in one chain: a late network reply resurrected state.pvp from null into a truthy stub
via Object.assign; that stub sent his next PvE fight into the PvP save slot and into the Bo3
match-end code; and that code threw on a games array the stub never had, after which the round
watchdog handed him back an unplayable 'select' every time. Fixed with an identity stamp
(pvpAlive/pvpPatch), pvpReal on every branch that decides what a fight
is, a match-end path that cannot throw, and a watchdog that ends a fight nobody can act in.acctKey(steamid),
and rating and name are read server-side. Match creation is arbitrated by a UNIQUE constraint
instead of a “lower id wins” convention. Both sides transition together, so one player can no longer
hang while the other is told the opponent never readied. Own rate bucket and a server-dictated 2 s cadence;
an ordinary poll writes zero rows. Verified: 33/33 against the real matcher and real SQLite, and 9/9 with two
real browsers one of which was pinned 220 s fast. Untested until release: the Cloudflare router and session
seam, which the harness stubs.Vael hardcoded, so both sides of a real match saw their opponent as Vael. Locking in no longer hangs both players. And the opponent fabricator was deleted along with its name pools and simulated lock countdowns.