TDD task-by-task: NewGame's public seeded roll -> the eleven fragments -> CreationCopy -> CreationDraft -> the theme -> the scene -> the docs. Both of traps.md's bugs get a named guard rather than a hope: the pipeline test that asserts what the screen SHOWED is what construct BUILT (and a step that re-breaks the code to prove the test can fail), and a no-default-parameters rule on the two new public functions. Also corrects the spec: PrimaryCTA already has a disabled stylebox.
17 KiB
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. 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, appliesspend, race grants, calling picks, and returns{ok, errors, log, state}._validateis private._roll_attributeis private and called in a loop insideconstruct.Races(4),Callings(7),Skills(9),Attributes(5) are static tables inclient/scripts/rules/.CharacterSheetstores inputs and derives the rest.ContentDBloadscontent/world/races/andcallings/. Each JSON carries{id, name, blurb}and no mechanics — the M4-a split.- The theme has a
Chipvariation (ThemeKeys.CHIP) and aPrimaryCTA. Both were built for a single visual state. TitleScreenexists andMainWindowShellexists. 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 +
CHOSENtag. - 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-rollbutton, and the dry line rolled 3d6 — that roll is your floor, spend up only. The calling's primary stat gets thePRIMARYring. - 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:
{ "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:
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" andconstructrejects by name.set_racedrops it, and clearsbonus_skillif 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 toNewGame.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.constructcalls 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:
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 intoconstructcleanly.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 typesd8into 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;validatereturns the same errorsconstructwould 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_confirmedemits exactlydraft.to_creation(). - Content parity (extends the existing test): every id in
Races.IDS/Callings.IDShas a content file with a non-emptyfragment. - §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.