Files
code_of_conquest_dnd/docs/superpowers/specs/2026-07-13-character-creation-ui-design.md
Phillip Tarrant 333d901931 docs(spec): M4-b — the character creation screen
The mock's screen over M4-a's proven model. Five mock/model reconciles
recorded: the skills section the mock never drew, seven callings not five,
nameplate cards with prose on selection, a minus button, no portrait arrows.

No AI call anywhere — the DM's entrance is the shell, one screen later.
The screen shows the roll by calling the same pure roll_attributes(seed)
construct does, so it displays numbers without ever handing one over.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 06:56:31 -05:00

17 KiB
Raw Blame History

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, 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.
  • CALLINGseven 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:

{ "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 lined8 · 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. Chip was built for one state, and PrimaryCTA has no disabled. Both are extended in build_game_theme.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" 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:

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.