XP & LEVELING

Other Systems › XP & Leveling

Two-phase leveling built for an autoplay climb: a gentle journey to level 66 that opens the whole tower fast, then a steep geometric endgame that carries hundreds of hours.

Formulas

QuantityExpression
XP required, levels 1–6610 × L^1.75
XP required, levels 67–100The rows of XPCurve.csv: per-level growth ramps from 3.1% at 67 to 16.0% at 99 (C1, 2026-10-23; the ramp’s end +12% → +16% by D’s T53 ruling, 2026-11-02)
XP required, player level 101+XPCurve.csv row 100 XP_Account_Flat = 354,245 — flat forever (player level only; creatures and classes cap at 100)
XP per killNot a formula — a table. XP_Kill_<tier> in codex/XPCurve.csv, indexed by the enemy’s level, on the game-wide value ladder C 1.0 / UC 1.5 / R 2.5 / SR 4.0 / SSR 7.0 (level 1 floored at 4 XP). Client xpKill() and server TABLES.xp_kill read the same 100 rows; no multiplier is applied on top
XP per battleThe sum over the battle’s 3 kills. Because encounter_weights.csv is tuned per floor against that same ladder, a full battle on floor L averages ≈ 10 × L — within ±0.8% from level 10 up (floor 100: 996.6 against 1,000). Below level 10 the 4-XP floor lifts it above the line
Battles per levelXP_To_Next(L) ÷ XP-per-battle(L): 1 at level 1, 24 at 66, 24 at 67, 308 at 99. Exact per-level values in the table below

Computed from codex/XPCurve.csv and the on-level battle of tools/codex_tables.py (xp-formulas).

The two phases are continuous at level 67. Per-kill XP is not flat in the enemy's level — it is the authored table — but a whole battle tracks 10 × L closely enough that the battle counts follow the requirement curve almost exactly.

XP curve

Log scale, plotted from codex/XPCurve.csv. Requirement for the next level and cumulative XP to reach the level. The knee at 67 is where 10 × L^1.75 hands off to a geometric phase whose per-level growth ramps smoothly from +2.7% (the polynomial's own rate at 66) to +16% at level 99 — no step anywhere; levels 90–100 are 60% of the total (C1, 2026-10-23, ramp end +12% → +16% by T53, 2026-11-02; before C1 it was a flat +17.5% from 67 and 78.5%).

Battles curve

The same curve divided by what a battle actually pays — battles spent at each level and cumulative battles to reach it, at the level-appropriate floor's own rarity mix over the −3…+3 spawn spread. Log scale. Level 100 has no bar of its own: it is the finish line, about 3,730 battles out (see Milestones).

Milestones

LevelBattles at that levelCumulative battlesCumulative time*

*At ~109 battles/hour on autoplay — the economy simulator’s figure (dfmc-client tools/sim_econ.mjs BATTLES_PER_HOUR), about 33 s per battle. Generated by tools/codex_tables.py.

The curve is back-loaded, not walled: levels 90–100 are 60% of the XP to 100 (2,936,130 in all). A fresh account that stays on its floor’s level reaches 100 in ~3,730 battles ≈ 34 h at the measured 109 battles/h, 15.9 h of it in levels 90–100 — python tools/statmodel.py xp in dfmc-client. Before C1 (2026-10-23) the same climb was ~18,900 battles ≈ 173 h, 130 h of it in 90–100; the C1 curve (ramp ending at +12%) was ~2,900 battles ≈ 27 h until D’s T53 ruling (2026-11-02) ended the ramp at +16%. Reaching level 67 — where wild Stage 3 creatures appear — is unchanged at ~885 battles.

Level-by-level curve

Read live from codex/XPCurve.csv — the same 100 rows the client's xpLevel()/xpKill() and the server's TABLES.xp_cum/TABLES.xp_kill read. Nothing here is restated by hand. XP to next and cumulative are the requirement curve and apply identically to creatures, classes and the player level; the five per-kill columns are indexed by the enemy’s level, not the recipient's. XP/battle and battles are derived against that floor's own rarity mix in codex/encounter_weights.csv over 3 kills — a boss floor has no wild spawns, so it inherits the last wild floor's mix.

LOADING…
“On-level” is a floor, not a forecast — and live accounts do not sit on it. Those two columns assume the character is standing on the floor whose number matches their level. That is the case where the level-gap penalty is zero, so it is the cheapest the climb can possibly be. It is not what the tower actually looks like. Measured against all 11 closed-alpha accounts (live saves, floors 1–99): the class level runs +1 to +19 levels above the floor through the early and middle tower — a median of about +7 — and then falls 17–19 levels behind at floors 97–99, where the requirement curve outruns the climb. Exactly two of eleven sit inside the zero-penalty band. Treat the two on-level columns as a lower bound and multiply by the table below.

What actually sets the number. Per-kill XP is indexed by the enemy’s level, so XP per battle is set by the floor you farm and never by your own level — out-levelling a floor earns you nothing extra, it only costs. Three things are measured rather than assumed:

GapLevel penaltyXP retainedBattles per level
On-level, or up to 3 above / 10 below the floor0100%×1.00 (the table)
5 levels above the floor0.08291.8%×1.09
8 levels above the floor (measured, reference account)0.20579.5%×1.26
14 levels above the floor0.45055.0%×1.82
19 levels above the floor0.65534.5%×2.89
25+ levels above the floor0.90010.0%×10.0
19 levels below the floor (measured, floors 97–99)0.20379.8%×1.25
50+ levels below the floor0.90010.0%×10.0

The multiplier applies to class and creature levels only. Account (player) XP takes no penalty at all — it is the raw sum — so the account level tracks the on-level column exactly, provided the character is farming the floor that matches it. Full curve in Level Penalty.

Source: codex/XPCurve.csv · mix from codex/encounter_weights.csv · spawn spread from app.js buildSpawn(). Level 100 carries XP_To_Next = 0: creatures and classes are maxed there, and the player level continues at a flat XP_Account_Flat (354,245). Battle counts assume the enemy is level-appropriate, so the level-gap penalty is zero — see Level Penalty.

Player level (uncapped)

Separate from creature levels and class levels. The player (account) level is an account-wide marker of total time in the tower. It grants no stats and no unlocks, but since 2026-08-25 it has one job: it gates character gear — tier N needs account level ≥ (N−1)×10, T10 = 90 (D ruling: “Account level gates gear”; On release.; mechanics on Equipment). Tags are gated by the creature's own level, never by this one. It exists so that the endgame keeps counting, and so that a future system has a natural place to hook into. (This paragraph said “no gear requirements” until 2026-08-25; that was true only because nothing enforced them.)

PropertyRule
XP sourceEvery point of XP the account earns, from every battle. Player XP is never split — it is the raw total.
Curve, levels 1–100Identical to the creature / class curve: the rows of XPCurve.csv (10 × L^1.75 to 66, then the C1 ramp). There is no formula fallback: with no curve loaded the account reads level 1, as a creature does.
Curve, levels 101+Flat. Every level past 100 costs XP_Account_Flat (row 100 of XPCurve.csv) = 354,245 XP: the C1 ramp continued one step past 99 (+16.40% on the 304,327 of 99→100), then held constant. No cap; the counter never stops. It is codex data so the vanity pace can be retuned without touching the leveling model; a DATA_PIN older than the column continues the curve’s own 98→99 growth one step instead (never a constant in the client).
GrantsNothing. Deliberately inert.

Pacing, past 100: at level-100 enemies (kill XP 118–827 by rarity, XPCurve.csv row 100 — roughly 1,000 XP per battle) each player level is ~355 battles ≈ 3¼ h of autoplay at the measured 109 battles/h — a level most evenings for a steady player, so the counter visibly ticks over instead of freezing. Reaching player level 100 itself takes 2,936,130 XP (the curve’s cumulative at 100, the same number playerLv() reads); every level after is the flat 354,245, climbing linearly forever instead of exploding. Continuing the ramp past 100 was rejected as too slow to feel alive.

Who receives XP

XP is addressed to a CREATURE, never to a party slot — LIVE on release since 2026-08-05 (0.32)

The party occupies gear slots 8 and 9, and the battle units a1/a2 are derived from whatever record sits in them. The credit used to be written back the same way — straight into equipOv[8|9] at commit time. The Equipment window can be opened during a battle, so that record is not guaranteed to still be the creature that fought.

Reported by a player on 0.31 and reproduced on his own save: a level 28 Scorchscale fought a full battle, was swapped out mid-fight, and received nothing; a level 1 Vaettir swapped into the slot received 282 — the gain computed from the fighter's level and applied to the newcomer's base. That is also a power-levelling exploit: swap a fresh creature in before the killing blow and it earns at a veteran's rate.

The advance check had the same defect, with a worse outcome. advancingAlly also read the slot, so after a mid-battle swap it took the newcomer's level and stamped the newcomer's uid — which meant commitPartyEvolution's own guard passed and wrote the fighter's next form into the newcomer's record. That is the exact cross-line corruption the guard exists to prevent, arriving through a different door. It now resolves through partyRecFor as well.

Probe: tools/probe_partyswapxp.py — BEFORE 11/11 (the bug reproduces on the reporter's save), AFTER 12/12, including an assertion that the unit carries a real uid so the match is exact rather than a species-name guess.

Class XP — who computes it

The server, since 2026-08-01. Class XP is the avatar's share of the same battle, and it is derived from the same authored tables the account total comes from — the client is not asked and is not believed.

QuantityRule
Account XP (player level)The raw sum of XP_Kill_<tier> over the kills the server derived from its own seeded slot. No penalty is applied — player XP is never split
Class XPThe same kills, each scaled by the level-gap penalty between the avatar's class level and that enemy: round(base × (1 − penalty(enemyLevel − classLevel))). The class level is read once, before the battle
Class levelPer class since 2026-10-01: each class line has its own row in ledger_cls and the battle credits the class it was fought as. Derived from that class's credited total against XP_Cumulative in XPCurve.csv — the server needs nothing from the client to know it
CeilingClasses cap at level 100, so 18,464,128 (the cumulative at 100) is an absolute ceiling on class XP. Below that, a save may only claim what the server has credited since the previous accepted save

Two consequences fall straight out of the definitions. Class XP can never exceed account XP, because the penalty is never negative — an account where it does has a starved account total, not a rich class total. And at a level gap inside the grace window the penalty is zero, so the two totals are identical; that is the sharpest available check that both are being computed from the same table.

What this replaces. Until this landed the ledger held account XP, Ichor, kills and battles — and nothing at all for class XP. The save door capped it only on a newborn account, then merged it grow-only forever after, so on any established account a crafted save could set class XP to any number and keep it permanently. Class advancement was the last progression system still authored entirely by the client.

Grandfathering. The class ledger starts counting from the day it shipped while every live account's class XP is historical, so the ceiling is a delta: each accepted save is stamped with the server's class total at that moment, and the next save may claim what was credited since, plus the standard grace. An account with no stamp yet keeps what it holds. This is the same shape as the kill and battle counters, and for the same reason — a flat total would have confiscated real progress on the first save.

Who credits a battle's XP — nobody but the server

LIVE on release since 2026-08-04 (0.31). Owner ruling, standing: no client-credited battle rewards. Account XP, class XP and Ichor are not moved when a battle ends. /api/battle answers with the figures the server derived from its own kill list, and those are the only numbers that ever reach the account or the screen.

What this replaced, stated precisely, because the end state was already correct. Since 2026-08-03 the client credited its own arithmetic and then reconciled to the server's number as a delta when the response landed — so the stored total was already the server's. What was wrong was the transient: for the 67–256 ms until the response arrived, the player's totals held a figure the server had never agreed to, and an uncredited battle (rate cap, spawn budget) had to be adjusted back down to zero afterwards. The local credit is now zero, which makes that same delta reconciliation an absolute apply with no second code path.

Why it matters beyond correctness. The two authorities disagreeing by design is what made xp_over_ledger fire on every live account — hundreds of times each, on four of four accounts measured. A guard that fires on every honest player describes the system, not the players, and a real offender would have been invisible inside it. With one authority the flag becomes a signal again.

Advancement is asked for, not given

LIVE on release since 2026-08-04 (0.31). A creature, a socketed soul or the class still becomes eligible to advance the same way — by passing its line's advance level with a further stage to reach. What changed is who starts it. A victory no longer interrupts itself to play the scene. The win resolves, the rewards land, and the floor returns.

The advancement waits on the record that earned it. An upward arrow sits on that record's icon — the teammate's portrait, the equipment slot holding the soul, or the creature's tile in the soul bag (ON DEV since 2026-09-02, LIVE on release since 2026-09-02 (0.66).) — and clicking it plays the advancement scene. A creature in the bag can cross its advancement level through devour, and until 2026-09-02 nothing looked at it there: the arrow only ever reached equipped creatures, so a bag creature had to be equipped to advance.

The arrow — where it sits, and when it goes

The arrow is dead centre on the image in all three places it can appear: the equipped soul's tile, the party creature's card, and the class portrait. One shape, one size, one placement, so it means the same thing everywhere.

The stale battle unit (report #41, measured 2026-08-07). advancingAlly() reads the creature's current stage off units[id].src — the battle unit — and commitEvolution() only ever rewrote equipOv. Measured against a real save: the slot became a Lich and units.a1.src stayed on DFMC-265, so the arrow was re-derived forever and every click replayed Reliquary → Lich on a creature that was already a Lich — word for word what the player reported. refreshPartyFromCsv() could not rescue it either: it rebuilds the party only while the phase is 'floor', and an advancement runs in phase 'evolution', so it fell through to a bare forceUpdate() and the stale unit survived even a return to the floor. The commit now moves the battle unit with the record.

The equipment arrow was clipped, not missing. It sat at top:-9px inside an overflow:hidden 72 px soul tile — measured 17×17 with its top cut off. It is now centred on the image and no longer clipped.

Two sizes, because one constant cannot serve both. The party card and the class portrait have room and take 44 px. The soul tile is a 72 px chip already carrying a creature image, a gender glyph, a grade glyph and a full-width multiplier band, so it takes 30 px and sits at top:32% — centred on the image area rather than on the whole tile, clear of the band below and the glyph in the corner. That is a real constraint, not a preference: at 44 px the arrow covered the tile’s own multiplier, which read ×1.?9 on screen. It can grow once that multiplier moves into the details panel.

How that was missed, and what now catches it. The probe measured size and centring, reported a clean pass, and never asked what else was in the box — the collision was found by taking a screenshot and looking at it. Every sibling carrying text is now checked with document.elementFromPoint at its own centre: is the arrow what is actually painted there? Rect-versus-rect was tried first and is the wrong test, because the multiplier is a full-width band whose box spans the tile even though its digits sit centred — it reported a collision that was not on screen.

Client: advKey(), advFiredHas(), advClearFired(), advReady(), advancingClassLive(), commitEvolution() in play/app.js. Probe: tools/probe_advarrow2.py (12/12).

An advancement waits for the battle — and the server is told

The advancement arrows live in the equipment window, and the equipment window opens during a fight. Clicking one mid-battle used to drop the phase straight to the advancement scene and take the player out of a live battle — gear is frozen for the duration of a fight, and this was the one thing that could still change a fighter mid-round.

The advancement claim

Every committed advancement appends a row to _advQ, which rides the next ordinary save and is then consumed and deleted. Same contract as the dismantle claim: the client claims, the server decides. The row names the creature (uid), the form it left, the form it reached and the level it held — and every one of those is re-derived server-side. They are sent so the server knows which record to look at, never so it can take their word for it. The claim is cleared once the save is accepted, keyed on uid+to rather than uid alone, because one creature can advance twice in a session.

The check is split, and the split is deliberate. creatures.csv lives behind a fetch and only the worker can read it; the previous save — the only thing that can say what that creature actually was and what level it held — lives in the player's object. So the worker answers “does this line take that step, and at what level?” and stamps the requirement; the object answers “is this that creature, and had it reached it?” The worker decides nothing about legality.

Four conditions must all hold, all of them against the server's own previous save:

A fifth is a consequence rather than a rule: the creature must actually be the new form in the save now arriving, or nothing advanced and there is nothing to record. Legal advancements are written to adv_ledger, one row per creature per form reached — the UNIQUE key is the idempotence, so a replayed claim writes nothing. Nothing is clamped or refused. An illegal claim is flagged advance_claim_bad and not written; the save still lands. This is a record of what legitimately happened, and a save must never be lost to it.

The class needs no claim. clsAdv is already in the save and the server already derives the class level from the same cumulative curve, so class legality is checkable from the save itself without a queue.

Client: deferAdvance(), drainAdvDefer(), advQPush() in play/app.js. Server: _advReq in the worker, adv_ledger and levelOfXp() in the object. Probes: tools/probe_advdefer.py (10/10), tools/probe_advledger.mjs (15/15, real SQLite).

A creature does not state its tier

LIVE on release since 2026-08-04 (0.31). Rarity still exists and still governs everything it always did — spawn tables, encounter weights, art, the codex. What it no longer does is announce itself. Owner ruling: players work it out by meeting a creature more or less often.

Removed from every in-game surface: the badge on the creature card, the badge on the creature sheet, the badges in the hatch and codex lists, and the tier baked into a creature's subtitle. The abbreviation function still exists and is still correct — nothing renders it.

The tier colour stays (owner ruling). A card still reads at a glance without naming what it is. Item grades are a separate system and are untouched.

Aptitude is a shape, not a score — LIVE 0.31

Base attributes are drawn as the same three-axis radar the class pages use: ATK at the top, DEF lower right, SPD lower left, rings at 25/50/75/100%, values scaled out of ten. It replaced the numeric rows on the creature sheet and the bar charts on the class card and the class selector. HP is not charted — the class pages chart three axes and list HP nowhere, and the in-game chart follows that exactly. Utility went with the bars for the same reason.

The point is what a player reads off it. A grade of 9 reads as a quantity to be compared; a shape reads as an aptitude — what this creature is for. The geometry is shared with the codex so the two can never drift into two different triangles.

Move upgrades follow the stages — LIVE 0.31

A move exists in three versions, and a creature moves up through them by level. Until 0.31 every one of the hundred lines upgraded its entire four-move kit on a single level: all four to V2 at once, then all four to V3 at once. Learn levels were already staggered, so only the upgrade columns were flat — a column filled per line rather than per move. The result was a cliff: a creature crossed one level and gained its whole next kit.

The rule now:

What found it. A player reported Hafgufa as overtuned. It was: Hafgufa is the second stage of the kraken line and advances at 69, but every move in that line upgraded to V3 at 54 — so for fifteen levels a mid-stage creature with the top attack grade held the full final kit, on the floors where it spawns. It was throwing Collapse of the Trench, a Kraken's move, at power 120. That move now cannot exist below level 80, and the line advances at 69, so a Hafgufa can never fire it.

Changelog