# M4-c — Title → creation → shell flow **Milestone:** M4-c (roadmap §M4). **Date:** 2026-07-15. **§2 side:** n/a (flow orchestration — code owns it, no AI). **Depends on:** the Title screen (M3), the creation screen (M4-b), the creation model `NewGame.construct` (M4-a), the shell (M3-a). **Goal it advances:** a playable start-to-shell loop — the player builds a character and lands in the exploration shell as *that* character. --- ## 1. Problem The flow from the title screen into the game is fictional in two places: 1. **Title skips creation.** `title_screen.gd`'s `new_game` item does `get_tree().change_scene_to_file(SHELL)` — it jumps straight to the shell and never touches the creation screen. 2. **The shell fakes a character.** `main_window_shell.gd._build_seed_log()` hardcodes "Vexcca, a human sellsword" and `ShellState.seed()` hardcodes her vitals. The shell has never consumed a real, created character. Meanwhile the creation screen already emits `creation_confirmed(creation)` and `NewGame.construct(origin, world, creation)` already returns `{ok, log: CanonLog, state: GameState}` — the two objects the shell needs. Nothing connects the emit to the construct to the shell. M4-c wires those three edges: **Title → Creation → Shell**, with the created character flowing all the way through. ### Constraints - **⛨ saga (roadmap §M3/§M4):** do NOT hardcode a linear `new game → creation → world` flow as one welded unit. The saga's fourth entry path — "begin the next campaign of an existing saga" — must be addable later without rewriting existing edges. - **§2:** the screen never hands game state upward. `construct` re-rolls the character from the creation seed itself; the flow only carries plain data. - **§13:** a failure never surfaces to the player as an error. - **No new global state.** The codebase uses an injection-seam pattern (`instantiate()` → set property → `add_child()`), not autoloads. There are zero autoloads today; this milestone adds none. --- ## 2. Approach (chosen) **A flow coordinator node.** A new script-first root `Node`, `GameFlow`, becomes the `main_scene`. It owns one `ContentDB` and the current screen (its only child), and it holds the entire Title → Creation → Shell sequence in one file. Rejected alternatives: - **Autoload handoff singleton** — keep `change_scene_to_file` everywhere, carry the character in a global. Smallest screen diff, but smears the flow across three handlers + global mutable state, fighting the no-hardcoded-flow constraint and cutting against the injection-seam pattern. - **Screens instantiate their own successor** — hardcodes the flow *into* the screens and couples them; worst for the saga constraint. The coordinator is the only option where the sequence is a single editable object (saga-friendly) and it reuses the existing injection seam (no global state). --- ## 3. Components ### 3.1 `GameFlow` (new) - **Files:** `client/scripts/ui/flow/game_flow.gd`, `client/scenes/flow/GameFlow.tscn` (a trivial one-node scene). - **Type:** script-first `Node`. It has no Control layout, so ADR 0001's editor-first rule does not apply — that rule governs *Control layout* scenes; this is a pure coordinator. (Noted explicitly so a reviewer does not read the code-built structure as an ADR 0001 violation.) - **Owns:** - `world: ContentDB` — loaded once in `_ready` (`ContentDB.default_content_root()`). - `service: DmService` — built once and injected into the shell, so the shell's own `service == null` fallback is not needed on the real path (it stays for F6/standalone). - `origin: Dictionary` — the deserter origin (the single origin that exists). - the current child screen. - **`main_scene`:** `project.godot` `run/main_scene` changes from `res://scenes/title/TitleScreen.tscn` to `res://scenes/flow/GameFlow.tscn`. **The one swap primitive:** ```gdscript func _show(next: PackedScene, inject: Callable) -> void: # free current child, instantiate next, run the injection seam, then add. for c in get_children(): c.queue_free() var screen := next.instantiate() inject.call(screen) # set injected properties BEFORE add_child (_ready) add_child(screen) ``` `inject` is the seam: it runs after `instantiate()` (which does NOT run `_ready`) and before `add_child()` (which does) — the same window the tests use. This is precisely what `change_scene_to_file` cannot provide. **The sequence (the whole table lives here):** | Step | Trigger | GameFlow method | |---|---|---| | boot | `_ready()` | `_show_title()` | | title | `menu_activated(&"new_game")` | `_show_creation()` | | title | `menu_activated(&"quit")` | `get_tree().quit()` | | creation | `creation_confirmed(creation)` | `_start_campaign(creation)` | - `_show_title()` → `_show(TITLE, func(s): s.menu_activated.connect(_on_menu))`. - `_show_creation()` → `_show(CREATION, func(s): s.world = world; s.origin = origin; s.creation_confirmed.connect(_start_campaign))`. - `_start_campaign(creation: Dictionary)` → call `NewGame.construct`; on success `_show_shell(result.log, result.state)`; on failure see §5. - `_show_shell(log, state)` → `_show(SHELL, func(s): s.service = service; s.injected_log = log; s.injected_state = state)`. **Saga seam:** `_show_creation()` and `_start_campaign(creation)` are deliberately separate. `_start_campaign` takes a plain Dictionary and does not assume the creation came from the screen. The saga's fourth path becomes a new `menu_activated` arm that synthesizes a `creation` Dict and calls `_start_campaign` directly, skipping the creation screen — no existing edge changes. ### 3.2 `title_screen.gd` (modified) Becomes **emit-only**. It already emits `menu_activated(id)`. Remove: - `const SHELL`, - the `new_game` and `quit` bodies of `_on_menu_activated` (and the now-dead `_on_menu_activated` connection if it holds nothing else). The screen no longer knows what comes after it. Its atmosphere/selection/input behavior is untouched. ### 3.3 `character_creation.gd` (unchanged) No change. It already accepts injected `world`/`origin` and already emits `creation_confirmed(draft.to_creation())`. M4-b left the signal dangling for exactly this milestone to connect. ### 3.4 `main_window_shell.gd` + `ShellState` (modified) Two new injection seams on the shell, mirroring `service`: ```gdscript var injected_log: CanonLog = null var injected_state: GameState = null ``` In `_ready()`, the seed calls become fallbacks (same shape as `service == null`): ```gdscript _state = ShellState.for_character(injected_state, injected_log) if injected_state \ else ShellState.seed() _log = injected_log if injected_log else _build_seed_log() ``` New factory **`ShellState.for_character(state: GameState, log: CanonLog)`**: - `vitals.hp / hp_max` ← `state.sheet.hp / state.sheet.max_hp()` - `vitals.mp / mp_max` ← `state.sheet.mp / state.sheet.max_mp()` - `vitals.purse_copper` ← `state.purse_copper` - `location_label` ← `log.location.name` - `turn_order`, `consumables`, `round_label` ← **the same placeholders `seed()` uses** (combat = M5, inventory = M7 own these; not M4-c's to invent). `_build_seed_log()` and `ShellState.seed()` **stay** — they are the F6/standalone fallback, unchanged. On the real path the injected objects win and the created character's vitals, location, and opening narration (via the injected log) are all real. --- ## 4. Data flow ``` project.godot main_scene = GameFlow.tscn │ GameFlow._ready → loads ContentDB (world), origin (deserter), builds DmService │ _show_title TitleScreen ──menu_activated(&"new_game")──▶ GameFlow._show_creation │ inject world + origin CharacterCreation ──creation_confirmed(creation)──▶ GameFlow._start_campaign │ NewGame.construct(origin, world, creation) → {ok, log, state} │ inject log + state + service MainWindowShell (vitals/location/narration all from the real character) ``` `creation` is a plain Dictionary carrying `{name, race_id, calling_id, seed, spend, skills, bonus_skill}`. `construct` re-rolls the attributes and Luck from `seed` itself (§2/§10) — the flow never carries a computed stat. --- ## 5. Error handling (§13) `NewGame.construct` returns `{ok: false, errors}` on invalid input. Through the UI this is **unreachable** — `character_creation._bind_cta` disables the CTA on any `NewGame.validate` error, so `creation_confirmed` only fires on a legal draft. But §2 requires `construct` to distrust its caller, and so does `GameFlow`: - On `result.ok == false`, `_start_campaign` **logs the errors and returns** — it does not swap to the shell. The player remains on the valid creation screen. - No crash, no half-built shell reaches the player. This path exists to be handled, not hit; a test drives it deliberately (§6). --- ## 6. Testing All new guards get the revert-and-watch-it-go-red treatment per [`docs/traps.md`](../../traps.md). Drive screens by emitting their **real signals**, never by calling handlers (traps.md: tests that called handlers instead of pressing nodes). 1. **GameFlow flow — new game reaches the shell as the created character.** Instantiate GameFlow with an injected fake `world`/`origin`/`service`; let it show the title; `emit_signal("menu_activated", &"new_game")` → assert the child is a `CharacterCreation`. Build a valid `creation` Dict for a known character (e.g. name "Dagnet"), `emit_signal("creation_confirmed", creation)` → assert the child is a `MainWindowShell` **and its canon log player name == "Dagnet"**. The name assertion proves the character flowed through, not merely that a scene swapped. *Re-break:* feed a Dict whose name differs from the asserted one and watch it go red. 2. **Shell injected path — vitals derive from the real sheet.** Inject a `log`+`state` for a character with a known HP (distinct from the seed's 42/60); `add_child`; assert the `HP` label reads the injected HP. *Re-break:* force the `seed()` branch and watch it fail. 3. **`ShellState.for_character` unit.** Vitals + location derive from the passed `state`/`log`; `turn_order`/`consumables`/`round_label` equal `seed()`'s placeholders. *Re-break:* change one derived field's source and watch the assertion fail. 4. **Construct-failure stays on creation (§5).** Drive `_start_campaign` with a `creation` Dict that fails `validate`; assert no `MainWindowShell` is added and the flow is unchanged. *Re-break:* make `_start_campaign` transition unconditionally and watch this go red. 5. **Title is emit-only.** Assert `title_screen.gd` no longer references a shell scene path and emits `menu_activated` (the existing title test may already cover the emit; extend it to assert the routing bodies are gone). --- ## 7. Scope boundary **In:** the three flow edges; the character flowing through them; the shell's vitals + location + narration deriving from the real character; the emit-only title; error handling for the unreachable construct failure. **Out (owned elsewhere):** - Origin selection UI — deserter is the single origin; the seam accepts one origin now and a choice later. - Save/load, continue, load — M9. - Quit-confirmation dialog. - Any HUD field beyond vitals + location (turn order = M5, consumables = M7). - The saga's fourth entry path itself — this milestone only keeps it *addable*. --- ## 8. Files **New:** - `client/scripts/ui/flow/game_flow.gd` - `client/scenes/flow/GameFlow.tscn` - `client/tests/unit/test_game_flow.gd` **Modified:** - `client/project.godot` — `run/main_scene` → `GameFlow.tscn` - `client/scripts/ui/title/title_screen.gd` — emit-only - `client/scripts/ui/shell/main_window_shell.gd` — `injected_log`/`injected_state` seams + fallback wiring - `client/scripts/ui/shell/shell_state.gd` — `for_character(state, log)` factory - `client/tests/unit/test_main_window_shell.gd` — injected-path test - `client/tests/unit/test_shell_state.gd` — `for_character` test - `client/tests/unit/test_title_screen.gd` — emit-only assertions