# M4-a — the character creation model **Date:** 2026-07-12 **Status:** approved, ready for planning **Charter:** §2 (code owns state), §7 (Luck), §8 (stats), §10 (seeding), §11 (canon log), §18 (git) **Implements:** the M4 half of [races & classes](2026-07-11-races-and-classes-design.md) — its §5 *Character-creation integration*, and the two contract migrations it flagged. **Roadmap:** M4, split into **M4-a (this: the model, no UI) → M4-b (the screen) → M4-c (title→creation→shell flow)**. --- ## 1. What this is The rules a character is made of, in code, with no screen attached. Races, callings, and skills become **static tables**. The character sheet becomes a **typed model that stores what was rolled or chosen and derives everything else**. `NewGame.construct` grows from "roll five stats" into the real creation pipeline — and becomes **seeded and reproducible**. It also lands the two contract migrations the races/classes spec said to do **at M4, not silently**: the canon log gains `race_id`, and `class_id` becomes `calling_id`. **No UI.** The creation screen is M4-b; it consumes what this builds. Everything here is a pure function or a dumb store, and the whole thing is testable headlessly. --- ## 2. What is actually there today (verified at `198ea27`) - `NewGame.construct(origin, world, creation, rng)` exists and is tested. It validates the origin's refs, checks `class_id` against `build_constraints.allowed_classes`, rolls Luck, rolls five stats with a placeholder `_roll_stat` (plain 3d6, marked `TUNABLE`), and assembles the `CanonLog` + `GameState`. - **`GameState.stats`** is a flat `Dictionary` of five ints. There is no sheet, no HP, no AC, no skills, no race. - **`class_id` is hardcoded as `["sellsword", "assassin", "priest"]` in three places** — all three name callings that no longer exist: - `docs/schemas/canon-log.schema.json` (an `enum`, `additionalProperties: false`) - `client/scripts/canon_log/log_player.gd` (`LogPlayer.CLASSES`) - `content/origins/deserter.json` (`build_constraints.allowed_classes`) - `docs/schemas/origin.schema.json` enumerates the same dead three under `build_constraints.allowed_classes` (`additionalProperties: false`, `minItems: 1`). - **The server renders the class into the prompt.** `api/app/prompts.py:53` emits `Player: {name}, a {class_id}` and `:107` emits `The player is {name}, a {class_id}.` This is *why* `race_id` must reach the log — so the AI can say "a beastfolk cutpurse". - `content/world/items/` is **hand-written** JSON loaded by `ContentDB._load_dir`, not emitted by `content_build`. `callings/` and `races/` will work the same way. --- ## 3. Where the rules live **Mechanics in code; blurbs in content.** The same split the currency work settled: a rule is a rule and belongs in code, and one fact gets one source of truth. ### Code — three static tables New directory `client/scripts/rules/`. These are *rules*, not state; `Luck` and `Currency` sit in `state/` because they are read *by* state, but a table of hit dice is neither. - **`Skills`** (`rules/skills.gd`) — the 9 skills and the attribute each is governed by. - **`Races`** (`rules/races.gd`) — the 4 races as feature flags. - **`Callings`** (`rules/callings.gd`) — the 7 callings. All static, all pure, shaped exactly like `Luck` / `Currency` (`class_name`, `extends RefCounted`, consts at the top, no autoload). ### Content — the words Hand-written JSON, loaded by `ContentDB` exactly like `items/`: ``` content/world/callings/reaver.json { "id": "reaver", "name": "Reaver", "blurb": "..." } content/world/races/beastfolk.json { "id": "beastfolk", "name": "Beastfolk", "blurb": "..." } ``` `ContentDB` gains `callings` and `races` dicts (two more `_load_dir` calls). **No build-tool change** — these are hand-written directories, so M4-a does not need the `calling` namespace the roadmap books for M7. Blurbs ship as **placeholders** and are swapped for authored prose later without touching code. That is the whole point of the split: the content BOM's calling blurbs stop blocking the engine. **Parity test:** `Callings.IDS` ≡ the ids in `content/world/callings/`, and likewise for races. Drift fails the suite. (Same guard as the currency parity test.) --- ## 4. The tables ### Skills (9) | Skill | Attribute | |---|---| | `athletics` · `intimidation` | STR | | `stealth` · `sleight_of_hand` · `acrobatics` | DEX | | `endurance` | CON | | `perception` · `faith_lore` | FTH | | `sorcery` | MAG | LCK owns no skill, no save, no row (§7). ### Races (4) Features only — **no race touches an attribute score**, which keeps the roll + spend pure. | Race | Headline | Minor | |---|---|---| | `human` | **+1 to all 5 saves** | **+1 skill proficiency of choice** (any of the 9) at creation | | `elf` | **`perception` proficiency** | **Nightsight** | | `dwarf` | **+2 on CON saves vs poison / the affliction track** | **Nightsight** | | `beastfolk` | **Claws** — innate unarmed attack, always available | **Keen scent** — situational bonus to detect/track | **Nightsight**, **claws**, and **keen scent** are **flags on the sheet**, not numbers on it. They are consumed by combat (M5) and by the DM's exploration checks. M4-a stores and exposes them; it does not act on them. The **dwarf's +2** is *situational* (vs poison and the affliction track), so it is **not** folded into `save("con")` — it is a separate query, `poison_save_bonus()`. Baking a conditional into a flat number is how a sheet starts lying. ### Callings (7) | Calling | Primary | Hit die | Save profs | Skill pool | Picks | Resource | Armor | L1 talent | |---|---|---|---|---|---|---|---|---| | `sellsword` | STR | d10 | STR, CON | athletics · intimidation · endurance · perception | 2 | cooldowns | heavy | `second_wind` | | `reaver` | STR | d12 | STR, CON | athletics · intimidation · endurance · acrobatics | 2 | cooldowns | medium | `blood_fury` | | `cutpurse` | DEX | d8 | DEX, MAG | stealth · sleight_of_hand · acrobatics · perception · athletics · intimidation | **4** | cooldowns | light | `backstab` | | `trapper` | DEX | d10 | DEX, CON | stealth · acrobatics · perception · endurance · athletics | **3** | cooldowns | light | `set_snare` | | `hedge_mage` | MAG | d6 | MAG, FTH | sorcery · perception · sleight_of_hand · faith_lore | 2 | MP (MAG) | none | `hexbolt` | | `bonesetter` | FTH | d8 | FTH, CON | faith_lore · perception · endurance · intimidation | 2 | MP (FTH) | medium | `mend` | | `bloodsworn` | FTH | d8 | FTH, MAG | faith_lore · sorcery · intimidation · perception | 2 | MP (FTH) | light | `pact_mark` | The **skill pools are new here** — the races/classes spec said only "drawn from the class's stat-appropriate slice of the 9-skill list" and never enumerated them. Each pool is the calling's primary-stat skills plus a small adjacent slice, sized so the choice is real but not wide open. Cutpurse gets 4 picks from 6 (breadth is the rogue's identity); Trapper 3; everyone else 2. **L1 talents are id stubs.** They name a thing M5 will implement. Nothing here resolves them. --- ## 5. The sheet `CharacterSheet` (`client/scripts/state/character_sheet.gd`) — state, so it lives with state. **Stored** (rolled, chosen, or genuinely mutable): ```gdscript var attributes: Dictionary # {str, dex, con, fth, mag} — final, post-spend var race_id: String var calling_id: String var level: int = 1 var skills: Array # chosen + race-granted proficiencies var bonus_skill: String # human only, "" otherwise var hp: int # CURRENT — combat writes this var mp: int # CURRENT ``` **Derived** (functions, never stored): ```gdscript func modifier(stat) -> int # floor((score - 10) / 2) func prof_bonus() -> int # +2 at L1 func max_hp() -> int # hit_die_max + CON_mod func max_mp() -> int # TUNABLE — see below func ac() -> int # 10 + DEX_mod (armor is M7) func save(stat) -> int # mod + prof_if_proficient + 1_if_human func poison_save_bonus() -> int # +2 for dwarf, else 0 func skill_bonus(skill) -> int # mod(governing attr) + prof_if_proficient func spell_dc() -> int # 8 + prof + casting_mod (casters only) func initiative() -> int # DEX_mod func has_nightsight() -> bool # etc. — the race flags ``` **LCK is not on the sheet.** It stays in `GameState.luck` / `luck_base`, exactly where it is today — no save, no skill, no row, no accessor. §7 holds by construction, not by discipline. `GameState.stats` (the flat five-int Dictionary) is **replaced** by `GameState.sheet`. ### Two honest seams - **`ac()` is `10 + DEX_mod`.** Armor categories are proficiency categories; actual armor is an item, and items are M7. The armor category is on the calling and unused until then. - **`max_mp()` is a marked `TUNABLE` placeholder.** The races/classes spec **explicitly defers the MP formula to M5**, so this must not pretend to be the answer: ```gdscript # TUNABLE — M5 owns the real MP curve (races-and-classes spec §6). # Martials have no pool at all; a caster gets a small one off the casting stat. func max_mp() -> int: if not Callings.is_caster(calling_id): return 0 return maxi(4, 4 + 4 * modifier(Callings.casting_stat(calling_id))) ``` Flagging it beats quietly inventing it — the same courtesy `_roll_stat` was given. The **shape** (martials 0, casters scale off the casting modifier, floor of 4) is what M4-b's screen needs to render a bar; the **numbers** are M5's to replace, and no test asserts them beyond "a martial has 0 and a caster has some." --- ## 6. Creation is seeded, and `construct` re-derives Charter §10: *"Seed the RNG per encounter from save state… the difference between a bug you can reproduce and a bug you cannot."* The races/classes spec says **"character creation is the first place seeding matters."** So it is seeded. ### The creation Dictionary `NewGame.construct` keeps taking a **plain `Dictionary`** — the saga guardrail: a later saga synthesizes one and skips the UI entirely, so the creation *scene* must never become the only thing that can produce this. ```gdscript { "name": "Aldric", "race_id": "beastfolk", "calling_id": "cutpurse", "seed": 8675309, # the whole character, reproducibly "spend": {"dex": 2, "con": 1}, # additive-only, ≤ 3 total "skills": ["stealth", "sleight_of_hand", "acrobatics", "perception"], "bonus_skill": "" # human only } ``` **It carries a seed, not a stat block.** `construct` reconstructs the RNG from `seed`, **rolls the base attributes itself**, and applies `spend`. It never trusts numbers handed to it. Consequences: - **Same seed → identical character.** Every time. Directly asserted in test. - `spend` is **validated**, not assumed — and it is the *only* thing the player adds. - The UI cannot inject an attribute value, because it never passes one. **Code owns state.** - The entire character is reproducible from four primitives: `seed`, `race_id`, `calling_id`, `spend`. ### Order of operations (fixed, because the RNG order is the determinism) 1. Seed the RNG from `creation.seed`. 2. **Roll five attributes** in the fixed order `str · dex · con · fth · mag` — **3d6, hard floor of 8** (`maxi(8, 3d6)`). 3. **Roll LCK hidden** — `Luck.roll_base(rng, origin.build_constraints.luck_modifier)`. Same RNG, after the stats, so the order is part of the contract. Never shown, never on the sheet (§7). 4. **Apply `spend`** — additive only. 5. **Apply race** — granted skills, save bonus, flags. 6. **Apply calling** — hit die, save profs, chosen skills, resource, armor category, talent. 7. `hp = max_hp()`, `mp = max_mp()` — the character starts whole. ### The die: 3d6, floor 8 The pool caps at +3, and straight 3d6 rolls 3–6 on a given stat about 9% of the time. A Hedge-Mage who rolls MAG 5, spends the entire pool, and lands at 8 has a **−1 modifier on his primary** — bad at the one thing he is *for*, before the game starts. That is not grit. §3's grit is *the world does not care about you*; §7 is explicit that bad luck costs **dignity, not progress**, and that punishing a player for a build choice "is not comedy. It is a fine." A dead-on-arrival primary is a fine, and it is paid for twenty hours. Floor 8 clamps the **die**, not the player: the mean stays low and unheroic (~10.9), the party is still ordinary people, and the worst case on a primary is 8 → 11 with the full pool → a **+0 modifier**, which with the +2 proficiency bonus is a character who is unremarkable rather than broken. It also makes the +3 pool do what it was designed for — *recovering a bad roll on the primary* — instead of failing to. ### Validation (all of it, in `construct`, front-loaded) Rejects with `{"ok": false, "errors": [...]}` — never a half-built character: - `name` non-empty after trimming. - `race_id` ∈ `Races.IDS`; `calling_id` ∈ `Callings.IDS`. - `calling_id` ∈ the origin's `build_constraints.allowed_callings`. - `spend`: keys ⊆ the five attributes (**`lck` is never spendable**), values non-negative integers, **sum ≤ 3**. - `skills`: exactly `Callings.skill_count(calling_id)` of them, all drawn from that calling's pool, no duplicates, and **none already granted by the race** (an Elf cannot spend a pick on `perception` — he has it). - `bonus_skill`: required **iff** race is `human`, empty otherwise; one of the 9; not already proficient. - The origin's refs resolve (existing check, unchanged). --- ## 7. The migrations `class_id` is the rulebook's word. The world has **callings** — that was the whole §17 reconcile ("Priest is what a rulebook calls him; Bonesetter is what a village calls him"). The contract the *Narrator* reads should say what the world says. There are no saves (persistence is M9) and no deployed clients, so this rename is as cheap now as it will ever be, and strictly more expensive every milestone after. | Where | Change | |---|---| | `docs/schemas/canon-log.schema.json` | `class_id` → **`calling_id`** (enum of the 7); **new required `race_id`** (enum of the 4) | | `docs/schemas/origin.schema.json` | `build_constraints.allowed_classes` → **`allowed_callings`** (enum of the 7) | | `client/scripts/canon_log/log_player.gd` | `class_id` → `calling_id`; add `race_id`; **`CLASSES` const deleted** — validate against `Callings.IDS` | | `client/scripts/newgame/new_game.gd` | the pipeline of §6 | | `client/scripts/state/game_state.gd` | `stats` Dictionary → `sheet: CharacterSheet` | | `client/scripts/content/content_db.gd` | load `callings/` + `races/` | | `api/app/prompts.py:53,107` | `a {class_id}` → **`a {race_id} {calling_id}`** — the AI can finally describe the player's race | | `content/origins/deserter.json` | `allowed_classes` → `allowed_callings`, with live ids | | api + client tests, `api/tests/fixtures/canon_log_valid.json` | the renamed fields | **The deserter allows all seven callings.** He is a mercenary-company deserter; a thematic gate is a *content* decision that belongs with the Greywater authoring, not an engine change invented here. `minItems: 1` is satisfied. **Schema/code parity test.** The roster now lives in two places — `Callings.IDS` and the schema's `enum`. A client test reads `../docs/schemas/canon-log.schema.json` (the same reach `ContentDB` already uses for `../content`) and asserts the enum **equals** `Callings.IDS`, and likewise `race_id` ≡ `Races.IDS`. They cannot drift. --- ## 8. Testing All headless — the model is pure functions over a dumb store. **Tables:** every calling's skill pool is a subset of the 9; every id in `Callings.IDS` has a content blurb and vice versa (parity); same for races. **Derivation:** `modifier()` at the boundaries (7→−2, 8→−1, 10→0, 11→0, 12→+1); `max_hp()` per calling (a d12 Reaver with CON 14 → 12 + 2 = 14); `save()` picks up calling proficiency; a **human's +1 lands on all five saves**; a **dwarf's +2 does NOT appear in `save("con")`** but does in `poison_save_bonus()`; `skill_bonus()` uses the governing attribute; `spell_dc()` for each caster. **The roll:** the floor holds at the bottom of the die (a seed that would roll 3 yields **8**); the distribution is still 3d6-shaped above the floor. **Determinism — the headline test:** the same `seed` + same choices produces a byte-identical sheet **and** the same hidden LCK. Twice, from two fresh `construct` calls. **Validation:** `spend` rejects a negative, a >3 total, and **any attempt to spend on `lck`**; skills reject a pick outside the pool, a duplicate, and an Elf re-picking `perception`; `bonus_skill` is rejected when present on a non-human and when missing on a human; `construct` rejects a calling the origin disallows. **§7 guard (extend the existing one):** no numeric Luck and no attribute block appears in `log.to_dict()` — the log carries only `{name, race_id, calling_id, luck_descriptor}`. **Server:** the prompt digest renders `a beastfolk cutpurse`; the canon-log schema rejects a dead `class_id` and an unknown `calling_id`. --- ## 9. Out of scope - **The creation screen** — M4-b. This spec builds only what it will consume. - **Title → creation → shell** — M4-c. `MainWindowShell._build_seed_log()` still hand-seeds a `CanonLog`; retiring that placeholder is M4-c's job. - **The MP formula and the level curve** — M5 (deferred by the races/classes spec). - **Armor / AC beyond `10 + DEX`** — M7 (armor is an item). - **The L1 talents' actual effects** — M5. They are id stubs here. - **Calling and race blurb prose** — the content BOM. Placeholders ship; the split is what stops that from blocking this. - **The beastfolk social tax** — deferred to the canon session + M8's shop.