Your team is your character and two creatures. You set it in the Equipment window: the two creature slots, Monster I and Monster II, take any creature from your soul bag, and a slotted creature can be swapped for another but never removed — the party is always full. Each party card shows the creature’s live stats and its innate aptitude chart, the same chart the picker shows.
In the code, a battle team is the character plus two creatures: a0 is the avatar, a1 and a2 are the two creature slots. The creature slots ARE equipment slots — Monster I and Monster II, equipOv[8] and equipOv[9] — and the equipment window is the only place the team is set.
Saves written before the swap-only rule can still hold an empty creature slot (equipOv[9] = {name:''}), so the empty case must still behave. It resolves to a unit with no HP, no moves and no species, flagged empty:1:
ORDER/aliveOrder() filter on hp > 0, so an empty slot never takes a turn and can never be targeted.un.empty), so it earns no XP and puts no row on the victory screen.alliesAlive is satisfied by the avatar.Reported by D: “I don't have any mon equipped on the second slot, but one still appears in battle and gets experience. It's a Taproot I don't even own.”
initUnits() builds the two creature slots with mkMon(gi, id, fb), and fb was the hardcoded Scoria and Taproot starters — dev-mock survivors from the pre-release build, still carrying sub:'Starter', SSR rarity and a YGGDRASIL/MAGMACRAWLER species the account had never owned. An empty slot returned that fallback, so the phantom fought, could be targeted, and was credited XP — and the XP was written back into the nameless equipOv record, where it accumulated invisibly. Reproduced on the reporting account: slot 9 held {name:'', xp:2431} and produced a Lv 1 Taproot that earned +47 XP in a single battle.
The general shape, worth remembering: a fallback that substitutes real game content for missing content is worse than no fallback. It cannot be told apart from a legitimate state by anything downstream, and it silently accrues real progress against content the player does not own.
Reported by D: reloading landed on a level 70 enemy party — Garrote, Crestmantis and Kesh — with identical HP on every reload, on an account nowhere near that content.
This is the phantom starter again, on the other side of the field. initUnits() builds all six slots from one mock literal and then re-builds only the ally side: the if(RELEASE) block replaces a0, a1 and a2 and stops, so e0/e1/e2 shipped as the Designer-era seed — Garrote (Rare, Lv 70, hp 138/229), Crestmantis (Uncommon, Lv 70, hp 178/229) and Kesh (Assassin avatar, Lv 70, hp 196/200), four moves each. Those are literals, which is why the HP never varied.
It was not a resume. The battle snapshot in localStorage was assumed to be the source, and namespacing it per environment and account changed nothing. It reproduces with localStorage completely empty: the party arrives in the component's initial state (units:this.initUnits()) at mount, and setState never writes units once during boot. Every later floor rebuild (refreshPartyFromCsv) re-seeds it.
reenter() made it live rather than cosmetic. It is the one battle entry that calls initUnits() and never assigns e0/e1/e2, so it opened a real fight — phase:'select', round 1, four moves a side — against a party the character had never met. enterBattle() assigns all three through mkEnemy, and challengeBoss() assigns the boss plus its two actives, so both of those were always clean.
Fix: mkEmpty now takes a side, and the RELEASE block empties the enemy slots exactly the way it empties an unused ally slot — hp:0, ko:true, empty:1, which ORDER/aliveOrder() already skip. reenter() refuses a field with no living enemy and returns to the floor rather than fabricating a battle out of leftover state; the floor is where the server owns the pool.
The general shape, again: mock content surviving in a path that sanitises only half of what it builds. The ally half of this same function was fixed on 2026-08-02; the enemy half was never looked at.
Client: initUnits() / mkMon / mkEmpty, battleRewards(), reenter(), the onEqCurrentClick unequip branch in play/app.js. Probe: tools/probe_p1.py (5/5); repro: tools/repro_p1.py.
Reported by LongRangeBonk: a Riptide advancing into Maelstrom also turned his Typhoon into a Maelstrom.
Riptide is STORMFATHER stage 1 (advances at level 36); Typhoon is TYPHON stage 2. Different lines. The advancement is detected on the battle unit but committed to the equipment slot — equipOv[8] for a1, equipOv[9] for a2 — and the victory screen lets the player swap a party creature while the advancement scene is still playing. The commit wrote the new form into whatever occupied the slot at that moment, keeping the occupant's own uid. Reproduced exactly: slot 8 detected the Riptide, the slot was swapped to a Typhoon, and the commit wrote Maelstrom / DFMC-179 into the Typhoon's record.
Why nothing downstream caught it. The result is a legitimate creature with a legitimate name, a legitimate id and a real uid — it is simply the wrong creature. It passes validateBag, it passes provenance (the uid was genuinely issued), and it reads as correct everywhere. Cross-line corruption of this kind is invisible to every check the game has.
The fix, and where it already existed. commitSoulEvolution has carried this guard since 2026-07-27 — "the socket changed under the animation: never rewrite a different creature" — because socketed souls hit the same race first. The party path was simply never given it. The detector now carries the uid of the creature it detected, and the commit refuses a slot whose occupant no longer matches: uid where both sides have one, falling back to an imgId compare for records written before uids existed.
The pattern, for the third time in this file: a fix applied to one half of a pair and not the other. The phantom starter fixed the ally slots and left the enemy slots; the enemy mock was found on 2026-08-03; and this guard covered souls but not the party. When a race is fixed in one place, grep for every other place that commits the same kind of write.
Client: advancingAlly() / commitEvolution() / commitSoulEvolution() in play/app.js. Probe: tools/probe_advslot.py (6/6, with controls proving an undisturbed slot and a pre-uid record both still advance).
Every floor carries two portals: one up, one down. They are worth documenting because their prop names do not contain the word "portal", and a search that assumes they do will conclude the ascend portal does not exist — which is exactly what happened on 2026-08-03.
ap* family. apClick is the handler; apLabel renders ↑ ASCEND, or BOSS DEN on a floor ending in 9; apSubText names the destination floor; apCursor, apSkull, apPal (the palette class: fp fp-green, fp fp-red before a boss den, fp fp-gray fp-closed when shut) and apShut (the dim, by opacity) carry its state and appearance — since #1052 (2026-10-01); before that six gradient props (apGlow, apOuter, apInner, apCore and their shadows) and a wrapper filter apDim did. The look itself is markup CSS (the .fp-* block) over the tiles tools/make_portal_art.py bakes into img/portal/.portalPrevOpen, handled by onDescend, labelled from portalPrevFloor.portalOpen is not bound in markup and is not dead: it is consumed by apSubText. Props in renderVals feed each other, so "computed but not bound" never means unused.
Travel is gated by maxFloor, not by the portal. The Atlas floor list computes opened = (f <= mx) && !boss with mx = maxFloor, and onTravel only fires when a floor is open. travel() itself carries no streak guard, which is safe only because nothing player-reachable calls it ungated — worth remembering before anyone wires a new caller to it.
Both party cards draw the same aptitude chart the picker draws — the same APT_RADAR, the same four Grade_* values, read from the creature's own row. It cannot drift from the card it is compared against, which is the whole point of a shape a player reads side by side.
Why it was missing. The chart was added to the picker when the selector was reworked, and the party card was never given it — so the one chart a player uses to choose a creature vanished the moment they chose it. Reported as #55.
Client: invMons in play/app.js. Probe: tools/probe_partyapt.py — BEFORE 5/5 (asserting no chart), AFTER 7/7, including a guard that the header did not wrap and the gear rows survived.
2026-11-03 — codex review (dfmc-client lane codexreview-a; D 2026-11-03 “review the codex, clean it up, update whatever needs updating”). WIKI ONLY. A player-facing lead before the implementation one (the page opened on slot indices); the swap-only rule, the two slot names and the aptitude chart re-checked against dev 0.93 — unchanged. The fixes below are history and stay as written.
2026-10-23 — the party slot no longer says CLICK TO UNEQUIP (dfmc-client lane loadout-bugs). On dev. The active party is swap-only (D 2026-08-02) and the click on a slotted creature is refused, so its CURRENTLY SLOTTED row now reads THE PARTY CAN ONLY BE REPLACED and is no button. Gear slots are unchanged. Gate tools/probe_eqhint_avatar.py.
2026-10-01 — the two floor portals are reworked: the spinning alchemical circle over a slow psychedelic-waves well, one palette per state (#1052, D). LIVE on release since 2026-10-23 (0.89); screenshots of every state are on the board for D’s sign-off. “Use our spinning alchemical circle with a cool gentle psychadelic waves VFX based on the aether website as the inner part of the circle. RED for boss portals. Green for going up (not too bright). Dark Blue for going down. Gray for closed. Does not spin. The inside vfx is super slow. Dark purple for 120 -> 121.” The portal was an oval of conic gradients spinning under a brightness pulse with a blurred halo, red or blue, and the closed one was the same portal under a grayscale filter. It is now a circle: the game’s own rune circle (assets/rune_circle.webp) tinted per palette and spinning, over a round well where two copies of a seamless noise tile (the Aether value-noise fbm with domain warping, folded into contour waves, baked in code by tools/make_portal_art.py into img/portal/) drift by transform, one mirrored and breathing, under a static halo and core whose opacity breathes. The ascend portal is green, red when the next room is the boss den, gray when shut — and a shut portal’s ring does not spin while its waves crawl at a period of a quarter hour. The descend portal and the den’s return portal are dark blue; the den’s ascend portal is green once the Alpha is down and gray-closed while sealed. The 120 → 121 portal is dark purple (system-void-apex). Nothing in a portal animates a filter, a blur or a box-shadow any more (dfmc-client PREP_PERFORMANCE § 3.2, the 0.89 carry-over s4.5 chunk that shares this region): everything that moves is transform or opacity, and the same pass turned the autoplay and arena-queue pulse from an animated box-shadow into a glow whose opacity pulses (§ 3.6). The ap* family keeps apClick / apLabel / apSubText / apCursor / apSkull and hands markup apPal (the palette class) and apShut (the dim) in place of the six gradient props and the apDim filter; the section above is updated. Gate tools/probe_01052.py 14/14, red 0/14; the floor’s idle repaint at a landscape phone viewport 30,243 → 20,459 px/frame.
2026-09-09 — move previews show the stat they change (#801, D). LIVE on release since 2026-09-09 (0.73). A move that raises or lowers Attack, Defense or Speed now shows the matching icon in its preview instead of a cooldown and nothing else. The icons were only ever recognising phrasings like “attack up” and never the form the move table actually uses — “Self +50% Attack, 3t.” — which is a third of every move in the game: 128 of 401. Counted on the real previews, 14 cards carried a stat icon before and 272 do now, and nothing that already showed an icon lost one. Still to come: the “self” icon for a move that targets its own user. There is no such icon drawn anywhere yet and none in the art set, so it needs generating first. Gate tools/probe_moveicons.py 5/5, red 4/5.
Line_ID key, title-cased. The rule now: the line’s display name is its highest-stage creature’s name. The sweep D asked for found 9 of the 100 lines whose key is not their stage-3 name — SANDSKITTER (Banshee), DUNECOIL (Sidewinder), STONECALLER (Despot), SANGLEECH (Sanguinarch), STORMCROW (Tempestcrow), STORMFATHER (Charybdis), LAMPYRE (Kitsune), CINDERSHARD (Protogale), INFERNOCHIMERA (Omnichimera); the other 91 already matched. The data did not change: Line_ID is a save key and its title-cased form is the items.csv weapon family key, so the client resolves the display name from the loaded creature rows at the display sites only — the plate, and the soul name fallbacks (a soul with no source creature prints its line). Class souls (“the Soul of the Mage”) and item families keep their names. Gate tools/probe_936.py 8/8, red 4/8; the sweep 9 of 100 before, 0 after.tools/probe_soulmut.py 4/4, red 2/4.initUnits() seeded a mock Lv 70 enemy party (Garrote/Crestmantis/Kesh) that the release sanitiser never stripped, and reenter() opened a real battle on it. Enemy slots now empty the same way ally slots do, and reenter() refuses an empty field. Root cause found by instrumenting the booted client, not by reading code: it reproduces with localStorage entirely empty and with zero setState writes of units. Verified 5/5.