# Play Mode — Ranked Elo & Bot Ladder (Design)

Status: **BUILT 2026-09-13** — live in `app/` (`economy.js` Elo engine, `ai.js` TIERS + `buildBotArmy`, `pieces.js` god retinues + Magnus, `ui.js` lobby/result/ambush UI). Only §8 calibration still pending. Companion mockup: `design/mockups/elo-play.html`. Tests: `app/test-elo.js` (node, 1550 checks) + `app/test-elo.html` (browser e2e, 36).
Current code touched later: `economy.js` (applyElo/K schedule), `ai.js` (TIERS + bot army builder), `ui.js` (lobby cards, god picker, victory screen).

---

## 1. Principles

1. **Canonical Elo, one formula.** FIDE math, no per-tier multipliers, no streak bonuses, no decay. The sign and size of every change is exactly what the formula says.
2. **You choose your opponent.** Every bot tier is always available; the formula prices the risk. Beating a much stronger bot pays a lot, losing to a much weaker one costs a lot. Farming is self-correcting — as your rating rises, easy bots stop paying. No anti-farm hacks needed (and none allowed, to stay canonical).
3. **Bots have fixed ratings.** Only the player's rating moves. The bot's Elo is a constant of the tier (this is the standard chess.com-bot-ladder model).
4. **Transparent math.** The lobby shows win probability per tier; the result screen shows `K`, `S`, `E` and the delta. Nothing hidden.
5. **Bot strength = engine config × card army.** The Elo label is what the formula uses; a calibration pass (§8) keeps the labels honest.

---

## 2. The formula — the real FIDE Elo system (Rating Regulations, effective 1 March 2024)

Verified against the FIDE Handbook (B.02, clauses 8.2–8.3). We apply the system **per game** instead of per tournament — the regulations define a "rating period", and chess platforms run it per game; the math is unchanged. *(Citation corrected 2026-09-15: the ±400-at-2650 rule below is the amendment effective **1 Oct 2025**, not the Mar 2024 edition — the code matches the current rule.)*

```
D   = R_you − R_bot                                   rating difference
D   = clamp(D, −400, +400)                            8.3.1 — capped at ±400 for anyone rated < 2650;
                                                      at 2650+ the full difference applies
PD  = 1 / (1 + 10^(−D/400))                           score probability — continuous form of Table 8.1.2
                                                      (agrees with the table within ±0.005)
ΔR  = round₀.₅ₐᵥᵥᵃʸ( K × (S − PD) )                   8.3.2 + 8.3.4, per rating period (= per game here)
R_you ← max(100, R_you + ΔR)                          platform floor, see below
```

`S = 1` win · `0.5` draw · `0` loss. Rounding is to the nearest whole number with **0.5 rounded away from zero** (8.3.4) — not `Math.round`, which rounds half toward +∞.

**K schedule (8.3.3, verbatim):**
- **K = 40** — new to the rating list, until **30 rated games** are completed (`eloGames < 30`). (FIDE also grants K=40 to under-18s under 2300 — N/A for us, no ages.)
- **K = 20** — rating under 2400.
- **K = 10** — **once 2400 has been reached, permanently** — even if the rating later drops below 2400. Requires a persistent `k10` flag.
- (FIDE also caps `K × n ≤ 700` per period; with one game per period it can never bind.)

**Why no invented clamps are needed.** The real ±400 cap bounds `PD` to [0.091, 0.909] for everyone below 2650, so with any K a win rounds to ≥ **+1** and a loss to ≤ **−1** automatically. The only ±0 is a draw against an even bot — which is correct. (Above 2650 the cap lifts, and a +0 win against a far weaker bot becomes possible again — that is FIDE's actual anti-farming design, and it maps perfectly onto a chosen-opponent ladder.)

**Explicit adaptations (the only places we deviate from FIDE):**
- **Floor 100** — FIDE has no numeric floor; it *unpublishes* ratings below 1400 (7.1.4 / 7.2.1). Our ladder includes an 800 bot, so players must be able to sit below 1400: we use the USCF/chess.com-style numeric floor of 100 instead.
- **Seed 800** — FIDE computes an initial rating from performance against a rated pool (8.2: average opponents + two hypothetical 1800s, min 5 games, max 2200). With no rated pool, we seed at 800 = the ladder's entry bot.
- **Bots keep fixed ratings** — the opponent's rating is a constant; the formula itself is untouched.
- **Per-game periods** — FIDE aggregates a whole tournament per period; we aggregate one game. Per-game rounding can therefore surface ±0-ish edge cases FIDE never shows; with the ±400 cap the only one is the even draw.
- **AUTO-PLAY games stay unrated** (already true) and earn no ★ (already true).

### Reference table (score probability `PD` for you)

| Bot − you | −400 (cap) | −200 | −100 | 0 | +100 | +200 | +400 (cap) |
|---|---|---|---|---|---|---|---|
| PD (you) | 91% | 76% | 64% | 50% | 35% | 24% | 9% |

Every gap beyond 400 collapses to the cap — by design. That is the rule that makes "farm the weak bot" structurally pointless in the real system.

### Worked deltas — per game, rounded 0.5 away from zero

At **800**, K = 40 (provisional):

| You \ Bot | 800 | 1600 | 2400 | 3200 God |
|---|---|---|---|---|
| **Win** | +20 | +36 | +36 | +36 |
| **Draw** | ±0 | +16 | +16 | +16 |
| **Loss** | −20 | −4 | −4 | −4 |

(Columns 1600+ are identical — the ±400 cap at work.)

At **2450**, K = 10 (2400 reached → locked):

| You \ Bot | 800 | 1600 | 2400 | 3200 God |
|---|---|---|---|---|
| **Win** | +1 | +1 | +4 | +9 |
| **Draw** | −4 | −4 | −1 | +4 |
| **Loss** | −9 | −9 | −6 | −1 |

The story the table tells: at 2450, farming the 800 bot pays +1 a win and a draw *costs* 4 — the ladder naturally pushes you back to your level.

---

## 3. Bot ladder — tiers & army composition

Engine configs stay as shipped in `ai.js`. The new column is the **card army** the bot fields (today bots field only stock pieces).

| Tier | Elo | Engine (depth / time / noise) | Card army (rest = stock commons) |
|---|---|---|---|
| **Apprentice** | 800 | 2 / 250ms / ±120 | **1 random Legendary with AEGIS** |
| **Master** | 1600 | 3 / 700ms / ±35 | 1 random Legendary (AEGIS) + **2 random Epics** |
| **Grandmaster** | 2400 | 4 / 1500ms / ±0 | 1 random Legendary (AEGIS) + 2 Epics + **2 random Rares** |
| **God** | **3200** | 7 / 2600ms / ±0 | **Fixed retinue, per god — §5** (25 gods, 5 races × 5 elements) |

Notes:
- **God = 3200**, not the current 3000 — completes an even 800-point ladder (800/1600/2400/3200) and matches the copy already in the game ("a 3200 God plays your side"). One-constant change in `ai.js`.
- Mid tiers roll **mixed-race, mixed-element** cards from the gacha roster; **race purity is a God-tier privilege** — it's what makes gods feel like gods.
- Every bot obeys the AEGIS rule (max 1 per army) by construction: exactly the tier's Legendary carries it.
- High apprentice noise (±120) stays — it keeps the 800 bot beatable even though it fields a Legendary. The card is the gacha hook; the engine keeps it honest.
- **Hidden inside the God tier: Magnus, the 26th god** — a rare ambush boss at Elo 5000. Full spec: §5b.

---

## 4. Bot army builder (random tiers)

The roster's shape constrains what a bot can field — pool sizes per piece type (from `RARITY_LISTS` in `pieces.js`):

| Type | Legendary | Epic | Rare |
|---|---|---|---|
| Pawn | — | 1 | 3 |
| Knight | 1 | 1 | 3 |
| Bishop | 1 | 1 | 3 |
| Tower | 1 | 1 | 3 |
| Queen | 4 | 2 | — |
| **Total** | **7** | **6** | **12** |

So a "random Legendary" is always a Knight/Bishop/Tower/Queen (no legendary pawns exist), and "2 random Epics" can never be two Queens (one queen slot per side).

**Builder algorithm** (`buildBotArmy(tier, color)` — new, lives in `ai.js`):

1. Start from the slot multiset for the bot's color: `P×8, N×2, B×2, R×2, Q×1`.
2. Draw the **Legendary**: pick a type with free slots (weighted by pool), then a random unused Legendary card of that type → place on a matching slot, mark **AEGIS** (`aura`).
3. Draw each remaining **Epic / Rare** the same way — unused cards only, types must fit remaining slots, never exceed 1 queen.
4. All remaining slots stay **stock** (`std-*`). King is always classical.
5. Return the map `{ e2: tplId, … }` in the same shape as the player army; `startGame()` consumes it and sets `G.aura[sq]` for the AEGIS square (today that flag only reads the player's collection — one added branch for the bot side).

Rerolled **every game** — the ladder stays fresh, and no one savescums a weak Master roll… except by losing, which the Elo tax already prices.

---

## 5. God tier — 25 gods, 5 races × 5 elements

The user spec names 5 Human gods; we extend the same template to **every race**. One god per race per element:

**Fixed retinue (identical shape for all 25 gods):**

| Card | Slot |
|---|---|
| **Legendary Queen, AEGIS** | queen slot (d1/d8) |
| **2× Epic Tower** | both rook slots |
| **2× Rare Bishop** | both bishop slots |
| **2× Rare Knight** | both knight slots |
| **1× Epic e-pawn** | the king-pawn slot — e2 when the god plays White ("the e4 pawn"), e7 when Black ("the e5 pawn") — 1 epic pawn total |
| 7 stock pawns + classical King | the rest |

Interpretation note: "1 epic e4 pawn, 1 epic e5 pawn" reads as the god's **e-file pawn** in either color case (both can't exist in one game — same file). If the intent was *two* epic pawns (d- and e-file), it's a one-line toggle in the retinue table.

**Retinue cards are generated, not pulled from the roster.** The gacha roster has only **1 epic Tower**, 1 epic Knight, 1 epic Bishop — a god needs two of each, and every card must carry the god's race + element. So each god owns a bespoke retinue generated at load: stats = standard `BASE × RSTEP` values (no custom balance), names drawn from that race's name pool (`pieces.js NAMES`) with element epithets, deterministic via the existing seeded RNG. Race + element purity is what makes "Full Human race, Fire" literal.

**The 25 gods** (names are placeholders — easy to retheme):

| Race \ Element | 🔥 Fire | 💧 Water | 🌿 Grass | 🌑 Dark | ✨ Light |
|---|---|---|---|---|---|
| 👑 **Human** | **God1 · Ares** | **God2 · Poseidon** | **God3 · Demeter** | **God4 · Hades** | **God5 · Apollo** |
| 🐾 Beast | Gryx the Cinderfang | Torv the Tidebreaker | Rah the Rootmother | Nakk the Nightsnout | Rukk the Dawnhowl |
| 🎩 Mage | Ozmi the Emberworm | Myr the Deepgrimoire | Wedra the Verdant Lexicon | Sable the Voidtome | Azor the Lumenarch |
| 🏹 Elves | Faeli the Ashstring | Liraen the Tidebow | Rowan the Greenwarden | Sylth the Shadowquiver | Mirawen the Lightstring |
| 🪽 Angel | Aurel the Pyreseraph | Caelia the Delugewarden | Sera the Bloomwing | Ithiel the Duskveil | Seraphel the Radiant |

Internal key: `god_{race}_{elem}`. Human gods keep ids `god1..god5` in fire→water→grass→dark→light order, matching the user spec.

**Gameplay texture this buys for free:** gods are mono-element armies, so the conquest triangle (fire→grass→water→fire, light↔dark) turns god selection into a **counter-pick metagame** — bring Water cards against the Fire god. The lobby god grid shows each god's element prominently for exactly this reason.

**God picker UI:** selecting the God tier expands a 25-chip roster grid (grouped/filterable by race) plus a **"Random God"** default chip. *(2026-09-14: the picker was replaced by seeded-player matchmaking — the foe is never chosen.)* Retinue **base stats** are identical across gods (same BASE+RSTEP shapes and the 3200 label), but the shipped kin system makes race mechanically real: race-pure retinues arm their kin crest (an 8-Angel god fields a 13/27/11 queen; Beast/Mage gods push queen ATK to 27; Human gods sit on the −1% penalty rung) — see wiki pieces-roster §3b. Elemental matchup remains the other real axis.

### 5b. Magnus — the hidden 26th god [APPROVED 2026-09-13]

A 26th god outside the race × element grid: the **ambush boss of the God tier**. User framing: *the standard god, maxed* — the hardest possible opponent without breaking anything.

| | |
|---|---|
| Key / Elo | `god_magnus` · **5000** (flavor constant — see the math below) |
| Race / element | none — outside the grid, so no counter-pick exists against him |
| Retinue | the standard god retinue with **full-epic support**: Legendary Queen **AEGIS** · 2× **Epic** Tower · 2× **Epic** Bishop · 2× **Epic** Knight · 1× Epic e-file pawn · 7 stock pawns · **classical King** |
| Engine | `maxDepth 12 · timeMs 5000 · noise 0` — the deepest safe config (below) |

**No king AEGIS, no second aura.** A king cannot carry AEGIS: the King stays classical — no HP, never killed, so the respawn trigger can never fire (locked pillar, `core-rules.md` §1) — and the max-1-AEGIS rule stands untouched. The AEGIS queen (respawns once at her origin) is the real second life under the 2 HP glass queen — she dies to any hit and comes back once at full 2 HP (pieces-roster.md §2b); everything else is all-epic stats plus the engine.

**Why `12 / 5000ms / ±0` is the deepest safe config.** `bestMove` is iterative deepening under a hard deadline (checked every 1024 nodes in `ai.js`), so the depth ceiling can never hang the engine — the clock binds first, and completed depth lands ~7–9 in real middlegames. 5000 ms ≈ 2× the god tier's 2600 ms stays within the shipped synchronous-search pattern; if playtest calls the wait rude, `timeMs` is the only knob that comes down (calibration rule: tune depth/time/noise, never the Elo constant). The async path to longer thinking (Web Worker) is a later engine upgrade, out of scope here.

**Encounter (3% — tunable; user quoted 3–5%).** Starting any God-tier match rolls 3%: the chosen god is replaced by the ambush. A fixed-frame reveal card shows Magnus, **5000**, the retinue preview and the live odds line, with **Play (rated)** or **Back to lobby** — no ⚡ charged on a decline. Transparency principle (§1.4) holds: the math is on the card before anything is spent. AUTO-PLAY never rolls the ambush (unrated study mode).

**The math that makes him shippable — and why "resign" is the wrong instinct:**

| Player rating | K | Win | Draw | Loss |
|---|---|---|---|---|
| < 2400, first 30 games (cap binds) | 40 | +36 | +16 | −4 |
| < 2400, established (cap binds) | 20 | +18 | +8 | −2 |
| 2400–2650 (cap binds) | 10 | +9 | +4 | −1 |
| 2650 – ~4488 (cap lifted) | 10 | **+10** | **+5** | **±0** |

From 2650 up, a loss to Magnus **rounds to zero** — the same score as resigning, since a resign is S=0 too — so playing it out strictly dominates: identical worst case, +10/+5 upside. He is the **cheapest loss on the entire ladder** (a regular 3200 god costs −2 per loss at 3000). Anti-farm is inherent: at a realistic 3–5% player winrate the EV is ~+0.3–0.5 per game, capped — a lottery ticket, not an exploit. The odds line `You win <1% · +10 / +5 / ±0` is the design lever that turns "resign" into "free spin". **Exempt from §8 label honesty:** the 5000 label is flavor (any opponent ≥ ~4490 above the player pays identically); his acceptance test is the design target itself — near-unwinnable.

---

## 6. Persistence (`chessgods_profile_v1`, additive migration)

```js
profile.elo          // exists — unchanged
profile.eloGames     // NEW: rated games played (drives K=40 while < 30 — FIDE 8.3.3)
profile.k10          // NEW: permanent flag — set the moment the rating reaches 2400 (K=10 forever, FIDE 8.3.3)
profile.eloPeak      // NEW: high-water mark
profile.eloHist      // NEW: last 40 entries { d: result, opp: tierElo, s: score, delta, r: ratingAfter, t: ts }
profile.tierStats    // NEW: { apprentice:{w,l,d}, master:{…}, grandmaster:{…}, god:{…}, magnus:{w,l,d} }
```

Migration: `load()` back-fills missing fields with defaults; existing players keep their current Elo. Rank titles (`rankFromElo`) unchanged — Warlord ≥ 2800 now has a ladder to climb into.

---

## 7. UI changes

1. **Bot cards (lobby):** keep ELO line; upgrade pips to a **composition readout** — rarity dots (amber/purple/blue counts) + a ✦ AEGIS mark; add a **live odds line**: `You win 62% · +10 / ±0 / −10`, recomputed from the profile's current rating via the canonical formula.
2. **God picker:** the God card expands the 25-god grid (race filter row of emojis); selected god shows name, race+element badges, retinue preview (8 card glyphs with rarity colors).
3. **Victory screen:** keep the existing ±ELO chip; add one math line: `PD 9% · K 40 · win → +36 (800 → 836)` (plus a "capped ±400" marker when 8.3.1 engages), and the new-to-list counter while `eloGames < 30`.
4. **Header chip:** ELO + rank title as today; while new to the list, a small "unranked · n/30" tag.
5. All result/dialog surfaces stay **fixed-frame** (no full re-render) per established UI practice.
6. **Magnus ambush card (§5b):** fixed-frame reveal at God-tier match start on the 3% roll — name plate (**5000**, deliberately no race/element badges: he is outside the grid), 8-card retinue preview with the ✦ mark on the queen, live odds line `You win <1% · +10 / +5 / ±0`, and two actions: **Play (rated)** / **Back to lobby** (no ⚡ charged on decline).

---

## 8. Calibration (keeps the labels honest)

The formula is canonical, but a label only means something if the bot plays near it. One-time + after any engine change:

- Self-play harness (extend `test-harness.js`): every tier pair × 200 games, alternating colors, bots field their real armies.
- Check the observed winrate against the E-table (±400 gap ⇒ ~91%). Tune `maxDepth/timeMs/noise` — **never the Elo constants** — until each tier sits within ~5% of its expected score against both neighbours.
- Apprentice is the guardrail: if new players tank below ~45% winrate vs 800 in the first 10 games, raise noise before touching anything else.
- God must hold ≥ ~85% vs Grandmaster; if it doesn't, depth+time go up before the 3200 label moves.
- **Magnus is exempt** from the label-honesty pass — his 5000 is a flavor constant (payout is identical for any opponent rated ≥ ~4490 above the player), and his acceptance test is the design target itself: near-unwinnable, ~3–5% observed player winrate at 3000-level play (§5b).

---

## 9. Out of scope (future)

- **PvP:** the same formula applies unchanged once there's a server — both players K-rated, bot tiers replaced by opponent ratings.
- Leaderboards / rating decay / season resets (need a backend).
- King-vs-King "Throne Duel": own ladder, own rating — do not share this one.
- Elo-gated rewards (e.g. peak-2400 badge) — design later, never retroactive.

---

## 10. Open decisions before build

1. **God = 3200** — **RESOLVED by build**: shipped at 3200.
2. **e-pawn reading** — **RESOLVED by build**: 1 epic king-pawn shipped (e2/e7).
3. **Generated god retinues** — **RESOLVED by build**: generated at load in `pieces.js` (BASE + RSTEP per type; queens follow the Glass Sovereign override, rarity buys ATK only).
4. God names — placeholder table above, rename freely.
5. **Magnus encounter roll** — spec'd at **3% [tunable]**; the reveal-card-with-free-decline shape is user-approved (2026-09-13). Everything else about Magnus is **resolved by that same approval** (§5b): hidden 26th god, standard-god retinue maxed (Legendary Queen AEGIS + all-epic support), classical king — no king AEGIS (locked pillar), engine `maxDepth 12 / timeMs 5000 / noise 0`, 5000 flavor label exempt from §8.

Resolved by this revision: the K schedule, rounding, cap and flags are now FIDE-verbatim (§2) — no longer a decision. Floor 100 and seed 800 stand as the two documented platform adaptations.
