A GameFlow coordinator node (new main_scene) owns the Title → Creation → Shell sequence in one file: instantiate → inject → add_child, reusing the existing injection seam that change_scene_to_file cannot. Title becomes emit-only; the shell gains injected_log/injected_state seams and stops self-seeding on the real path (seed stays as the F6 fallback), so the created character's vitals, location, and opening narration are real. The ⛨ saga constraint is honored structurally: _show_creation and _start_campaign are separate, so the fourth entry path is an added arm, not surgery on existing edges. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 KiB
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:
- Title skips creation.
title_screen.gd'snew_gameitem doesget_tree().change_scene_to_file(SHELL)— it jumps straight to the shell and never touches the creation screen. - The shell fakes a character.
main_window_shell.gd._build_seed_log()hardcodes "Vexcca, a human sellsword" andShellState.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 → worldflow 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.
constructre-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_fileeverywhere, 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 ownservice == nullfallback 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.godotrun/main_scenechanges fromres://scenes/title/TitleScreen.tscntores://scenes/flow/GameFlow.tscn.
The one swap primitive:
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)→ callNewGame.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_gameandquitbodies of_on_menu_activated(and the now-dead_on_menu_activatedconnection 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:
var injected_log: CanonLog = null
var injected_state: GameState = null
In _ready(), the seed calls become fallbacks (same shape as service == null):
_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_copperlocation_label←log.location.nameturn_order,consumables,round_label← the same placeholdersseed()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_campaignlogs 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. Drive screens by emitting their real
signals, never by calling handlers (traps.md: tests that called handlers
instead of pressing nodes).
-
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 aCharacterCreation. Build a validcreationDict for a known character (e.g. name "Dagnet"),emit_signal("creation_confirmed", creation)→ assert the child is aMainWindowShelland 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. -
Shell injected path — vitals derive from the real sheet. Inject a
log+statefor a character with a known HP (distinct from the seed's 42/60);add_child; assert theHPlabel reads the injected HP. Re-break: force theseed()branch and watch it fail. -
ShellState.for_characterunit. Vitals + location derive from the passedstate/log;turn_order/consumables/round_labelequalseed()'s placeholders. Re-break: change one derived field's source and watch the assertion fail. -
Construct-failure stays on creation (§5). Drive
_start_campaignwith acreationDict that failsvalidate; assert noMainWindowShellis added and the flow is unchanged. Re-break: make_start_campaigntransition unconditionally and watch this go red. -
Title is emit-only. Assert
title_screen.gdno longer references a shell scene path and emitsmenu_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.gdclient/scenes/flow/GameFlow.tscnclient/tests/unit/test_game_flow.gd
Modified:
client/project.godot—run/main_scene→GameFlow.tscnclient/scripts/ui/title/title_screen.gd— emit-onlyclient/scripts/ui/shell/main_window_shell.gd—injected_log/injected_stateseams + fallback wiringclient/scripts/ui/shell/shell_state.gd—for_character(state, log)factoryclient/tests/unit/test_main_window_shell.gd— injected-path testclient/tests/unit/test_shell_state.gd—for_charactertestclient/tests/unit/test_title_screen.gd— emit-only assertions