# M4-b — the character creation screen **Date:** 2026-07-13 **Status:** approved, ready for planning **Charter:** §2 (code owns state), §7 (Luck), §8 (stats), §10 (seeding), §13 (authored fallback is content), §15 (button-driven), §16 (editor-first UI), §18 (git) **Consumes:** [M4-a — the creation model](2026-07-12-creation-model-design.md). Everything here is a screen over rules that already exist and are already tested. **Mock:** `mockups/Character Creation.dc.html` — the UI bible, and where it and the model disagree, §4 below records the decision. **Roadmap:** M4, split **M4-a (the model, done) → M4-b (this: the screen) → M4-c (the title→creation→shell flow)**. --- ## 1. What this is The mock's creation screen, over M4-a's proven model. The player picks a race and a calling, sees five rolled attributes (LCK never), spends a pool of three points, chooses proficiencies, types a name, and presses ENTER THE WORLD. **It makes no AI calls.** Not one. The DM's real entrance is the shell's opening narration one screen later (M4-c). This screen is authored text and local rules end to end, and runs with no proxy and no model reachable. **It does not construct the character.** It emits a creation `Dictionary` and M4-c calls `NewGame.construct`. The scene is a producer of that Dictionary, never the only possible producer — the saga guardrail M4-a wrote down. --- ## 2. What is actually there today (verified at `b4b72e4`) - `NewGame.construct(origin, world, creation)` is built, seeded and tested. It takes a **seed, not a stat block**, rolls the five attributes itself in a fixed order, then rolls LCK off the same RNG, applies `spend`, race grants, calling picks, and returns `{ok, errors, log, state}`. - `_validate` is **private**. `_roll_attribute` is **private** and called in a loop inside `construct`. - `Races` (4), `Callings` (7), `Skills` (9), `Attributes` (5) are static tables in `client/scripts/rules/`. `CharacterSheet` stores inputs and derives the rest. - `ContentDB` loads `content/world/races/` and `callings/`. Each JSON carries `{id, name, blurb}` and **no mechanics** — the M4-a split. - The theme has a `Chip` variation (`ThemeKeys.CHIP`) and a `PrimaryCTA`. Both were built for a single visual state. - `TitleScreen` exists and `MainWindowShell` exists. Nothing connects them to creation — that is M4-c. --- ## 3. Architecture Three units, one of which is a scene. | File | Responsibility | |---|---| | `client/scenes/creation/CharacterCreation.tscn` | **new.** The screen. Editor-authored tree, theme on the root (ADR 0001). | | `client/scripts/ui/creation/character_creation.gd` | **new.** The binder. `@onready` refs, click → draft, draft → nodes. **No rules.** | | `client/scripts/ui/creation/creation_draft.gd` | **new.** `CreationDraft` — pure, node-free. Every rule the screen enforces. | | `client/scripts/ui/creation/creation_copy.gd` | **new.** `CreationCopy` — pure, static. Formats *mechanics* into display strings by reading the rules tables. | | `client/scripts/newgame/new_game.gd` | `roll_attributes()` and `validate()` become **public**; `construct` calls `roll_attributes` instead of its own loop. | | `content/world/{races,callings}/*.json` | Each gains a `fragment` (§6). | | `client/scripts/theme/build_game_theme.gd` + `theme_keys.gd` | Chip `pressed`/`disabled`, CTA `disabled` (§7). | **Why a `CreationDraft` and not logic in the scene script.** ADR 0001 already decided this: the script binds, it does not reason. A draft that is a plain `RefCounted` makes every rule ("can a Human re-pick a skill his race already granted?") answerable headlessly, with no scene tree, which is the difference between a rule that is tested and a rule that is merely written. `ShellState` is the same pattern; this follows it. --- ## 4. Where the mock and the model disagree — and what wins The mock predates the seven callings and the skill system. It is a **UI bible, not a rules bible.** Five reconciles, all decided: | # | The mock says | We do | Why | |---|---|---|---| | 1 | No skill picking at all | **A skills section** — chips, N of a pool | `construct` *rejects* a creation dict without exactly `Callings.skill_count(id)` legal picks. Without the UI the pools do nothing, every Cutpurse is identical, and we ship a validation rule no player exercises. | | 2 | Five callings | **Seven**, nameplate cards | The seven are named and settled (races/classes spec §4). | | 3 | Blurb on every calling card | **Blurb on the *chosen* calling only** | Seven blurbs shouting at once is a menu you skim, not a choice. See §5. | | 4 | `+` only on the spend | **`+` and `−`** | "Additive only" constrains where the number lands, not whether a mis-click is recoverable. A `−` that floors at the rolled base produces a dict `construct` already accepts. The mock did not think about mis-clicks; this is a bug in it, not a position it took. | | 5 | `‹ ›` arrows on the portrait | **Cut** | They cycle model variants. We have neither art nor a portrait field on `CharacterSheet`. Two buttons that do nothing are worse than none. The dashed art slot stays (ADR 0001 — art slots are placeholders). | The **"THE DM SIZES YOU UP" panel is authored, not an AI call** (§6). The **re-roll is unlimited**, as mocked (§8). --- ## 5. Layout — variant C 1920×1080. The mock's split holds: a 660px dark portrait bay on the left, a 1260px parchment sheet on the right. **Left bay:** kicker (`WHO WILL YOU BE?`), the dashed character-model art slot, the nameplate (`{name or "Nameless"}` · `{Race} · {Calling}`), and the DM origin card. **Right sheet, top to bottom:** - Header — *Create Your Character* / `THE WORLD HAS REAL PROBLEMS AND DOES NOT CARE ABOUT YOU` - **RACE** — four cards (name, blurb, derived trait line). Selected card gets the crimson border + `CHOSEN` tag. - **CALLING** — **seven nameplate cards in one row: name + primary-stat chip, no blurb.** Beneath the row, a detail panel for the *chosen* calling carrying **its blurb and its mechanics together**: `d8 · light armour · cooldowns · saves DEX + MAG · picks 4 skills · talent backstab` - **PROFICIENCIES** — chips. The chosen calling's pool, `N of N chosen`, plus (Human only) a second row over all nine skills for the bonus pick. - **ABILITY SCORES** — five cards (STR DEX CON FTH MAG — **never LCK**), each with the final value, `rolled X +Y`, and `−`/`+`. `points left: N`, the `⟳ re-roll` button, and the dry line *rolled 3d6 — that roll is your floor, spend up only*. The calling's primary stat gets the `PRIMARY` ring. - **Footer** — the name field and ENTER THE WORLD. **Why C.** The CALLING block becomes the visual mirror of the PROFICIENCIES block below it: *pick, and the consequence appears.* One blurb, for the thing you chose, in a panel that exists because you chose it. It also buys ~70px and kills the cramped-italic problem that seven 157px cards create. Worst case for vertical space is **Human + Cutpurse** (the bonus row appears *and* the pool is the widest in the game, 4 of 6) — it fits inside 1080 with room. **LCK appears nowhere on this screen.** Not as a number, not as a descriptor, not as a hidden tooltip. §7. --- ## 6. The DM origin panel is authored The panel composes, exactly as the mock does: > `{race.fragment}` Now you carry a `{calling.name}`'s work — `{calling.fragment}` Each race and calling JSON gains a **`fragment`**: ```json { "id": "cutpurse", "name": "Cutpurse", "blurb": "Purses, locks, confidences — you have taken all three. …", "fragment": "quick fingers, quicker exits, and a knife for the rest." } ``` Eleven fragments — four races, seven callings. **Real prose, not stubs**, authored through the **world-building skill** so they carry the world's voice. A fragment a shade too warm poisons the first screen the player ever sees. **Why not a live Narrator call.** The player clicks race and calling cards a dozen times while fiddling; that is a dozen calls with a spinner between them, against §14's "cache aggressively, pre-generate." Worse, it asks the AI to narrate a character *nothing has happened to yet* — there is no scene, no event, nothing to narrate. §13 is explicit that authored text is **content**, not error handling; the DM's voice does not require a model behind it to be the DM's voice. The DM's real entrance is the shell's opening narration, one screen later, at the first moment where speaking means something. **Degradation:** a missing `fragment` falls back to the `blurb`. The parity test (§9) prevents that from ever shipping, but the screen does not crash on it. --- ## 7. Mechanics are derived, never written twice `CreationCopy` is a pure static formatter that reads `Races` / `Callings` / `Skills` and returns display strings: - **Race trait line** — `+1 to every save · +1 skill of choice` (human), `Poison resist · nightsight` (dwarf), … - **Calling detail line** — `d8 · light armour · cooldowns · saves DEX + MAG · picks 4 skills · talent backstab` **Prose lives in content; numbers live in code; the card reads both.** A hit die written into a JSON is a hit die that will eventually disagree with the table. If someone retunes the Cutpurse's d8, the card follows — because the card never knew the number. **Theme additions.** A skill chip has three states — unpicked, picked, and *granted by your race so it cannot be picked* — which is a `Button` in `toggle_mode` needing `normal`/`pressed`/`disabled` styleboxes. The existing `Chip` is a display badge with no `disabled` and no selected look, and the race/calling cards need a "chosen" crimson-ring state that nothing in the theme has. (`PrimaryCTA` **already** carries `disabled` + `font_disabled_color` — the CTA needs nothing new.) The new variations are added to **`build_game_theme.gd`** + `theme_keys.gd` and the `.tres` **regenerated** — never hand-edited, no hex in a scene or script (the M3-a rule). --- ## 8. The draft, the seed, and the data flow ### `CreationDraft` Stores exactly what a creation Dictionary is made of, and nothing derived: ```gdscript var name: String var race_id: String var calling_id: String var seed: int var spend: Dictionary # {stat: int} — additive only var skills: Array # the player's picks var bonus_skill: String # human only ``` **The interaction rules** — where a creation screen actually gets buggy, so they are stated rather than discovered: - **Changing calling clears the skill picks.** The pool changed underneath them; keeping stale picks leaves the player holding an invalid draft he did not cause and cannot see. - **Changing race drops a now-granted pick.** An Elf is granted `perception`; a Cutpurse who had picked it now holds a pick that "buys nothing" and `construct` rejects by name. `set_race` drops it, and clears `bonus_skill` if the new race is not Human. - **Re-roll clears the spend, and only the spend.** Race, calling, skills and name survive. Points spent against a roll that no longer exists are meaningless. - **`−` floors at the rolled base.** It removes only what you added. - **`errors()` delegates to `NewGame.validate`.** The screen owns no second copy of the rules. It asks, and shows the answer. ### The seed — and why the screen can show numbers without owning them ``` randomize() ──> seed ──┬──> draft.rolled() ────────> the five numbers ON SCREEN │ (NewGame.roll_attributes) │ └──> creation Dictionary ──> NewGame.construct │ └──> rolls them AGAIN, itself ──> CharacterSheet ``` The screen and `construct` reach the same five numbers by calling **the same pure function on the same seed** — but the screen never *hands* a number to anything. It hands over a seed and four choices. **§2 holds without the screen having to be trustworthy**, which is the only kind of holding that counts. `randomize()` is the one place non-determinism enters the entire pipeline, and what it produces is the seed — which is thereafter the character's determinism anchor. **This forces two small M4-a changes:** - **`roll_attributes(seed: int) -> Dictionary`** — public, pure. `construct` **calls it** rather than keeping its own loop, so there is exactly one implementation of the roll. - **`validate(origin, world, creation) -> Array`** — today's `_validate`, made public, so the screen can gate the button on the real rules. **Neither takes a default parameter.** See §9, trap 2. ### Re-roll is unlimited As mocked. The floor of 8 already prevents the dead-primary "fine" §7 forbids, so limiting re-rolls buys no protection — it only adds friction and calls it grit. And it earns something. The seed drives the five visible attributes **and the hidden LCK roll**, which comes off the same RNG immediately after MAG. A player who re-rolls twenty times chasing a 16 STR is, on every press, blindly re-rolling his Luck. He cannot see it, he cannot count it, and he will never know what he traded away. That is the superstition §7 is built to produce, and the seeding decision M4-a already made hands it to us for free. **The screen says nothing about this, ever.** The `⟳ re-roll` button and the dry subtitle are all the player is told. ### Exit The screen emits **`creation_confirmed(creation: Dictionary)`**. It does **not** call `construct` — the flow is M4-c's job, and a saga later synthesizes the same Dictionary and skips the scene entirely. `origin` and `world` are **injected** the way the shell injects `DmService`: settable between `instantiate()` and `add_child()`, with `_ready` loading the deserter origin and a real `ContentDB` when nothing was supplied. Tests stay hermetic, and the origin's `allowed_callings` gates which calling cards appear rather than the screen hardcoding seven. **ENTER THE WORLD is disabled while `errors()` is non-empty**, with the first error printed dry beneath it — *choose two more proficiencies*, *name your character*. The player cannot press his way into a rejection. --- ## 9. Testing All headless (GUT, `client/run_tests.sh`) except the visual gate. `traps.md` records two bugs M4-a shipped **past a green suite**; both have a direct analogue here, so the guard is named, not hoped for. ### Trap 1 — a regression test that cannot fail is not a regression test The load-bearing claim of this entire screen is **the numbers you saw are the numbers you got.** The lazy test builds a `CreationDraft`, builds a `CharacterSheet`, and asserts both roll the same — which passes even if the screen and `construct` hold two separate roll implementations that merely agree today. **The real test asserts across the pipeline:** ```gdscript var draft := CreationDraft.new() # seed S, calling, spend var result := NewGame.construct(origin, world, draft.to_creation()) assert_eq(result.state.sheet.attributes, draft.final()) ``` Re-implement the roll in either place and it goes red. Per `traps.md`: before accepting the work, **re-break it and watch the test fail.** ### Trap 2 — GDScript defaults turn an arity change into a silent miscompile `validate` and `roll_attributes` become public. **Neither gets a default parameter on any argument.** A wrong-arity call to a defaulted function compiles clean and binds the wrong values — exactly how M4-a shipped a player block of four empty strings into the live shell, past a grep that came back clean. Required args only. ### The rest - **`CreationDraft`** (no scene tree): re-roll clears the spend and only the spend; changing calling clears the picks; changing race drops a now-granted pick and clears a non-Human's bonus skill; `−` floors at the rolled base; the fourth point cannot be spent; a granted skill cannot be picked; `to_creation()` round-trips into `construct` cleanly. - **`CreationCopy`**: for **all seven** callings the detail line *contains* `"d%d" % Callings.hit_die(id)` and that calling's actual saves — the guard that fails if anyone types `d8` into a string literal instead of reading the table. Same for the four race trait lines. - **`NewGame`**: same seed → same five attributes; the floor of 8 holds at the bottom of the die; `validate` returns the same errors `construct` would reject with. - **The scene**: mounts with an injected origin + `ContentDB`; nodes are non-zero; the CTA is disabled on a fresh draft and enabled on a complete one; `creation_confirmed` emits exactly `draft.to_creation()`. - **Content parity** (extends the existing test): every id in `Races.IDS` / `Callings.IDS` has a content file with a **non-empty `fragment`**. - **§7 guard**: no LCK value, descriptor, or accessor is reachable from the creation scene. ### The visual gate Headless GUT proves the nodes mount and the bindings run. **It cannot prove the screen looks like the mock.** ADR 0001 is explicit that this gate is a **human F6 eyeball**, and this is the screen where that matters most. --- ## 10. Out of scope - **Title → creation → shell wiring** — M4-c, next. - **Portrait art and the `‹ ›` arrows** — no art, no portrait field. - **Origin *selection*** — the deserter is the only origin. The screen is *handed* one; it does not choose. - **Persisting the creation seed past creation** — M9 saves the sheet, and the sheet *is* the character. - **Any AI call.** The DM's entrance is the shell, one screen later. - **The MP curve and the level curve** — M5, deferred by the races/classes spec.