# Title screen (M3) + unified version — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build the editor-first Title screen (entry point) from the mock, and stand up a single `/VERSION` source of truth shared by the client and API. **Architecture:** Editor-first UI scene (ADR 0001): layout in `.tscn`, script does on-load work only. A root `/VERSION` file feeds the API (read at startup) and the client (synced into `project.godot`, read via `ProjectSettings`). Distinct title text and atmosphere reuse the theme/Palette system. **Tech Stack:** Godot 4.7 / GDScript / GUT; Python / FastAPI / pytest. Reuses the M3 Theme, `Palette`, `VignetteOverlay`, and the `MainWindowShell` scene (New Game target). ## Global Constraints - **Godot 4.7 / GDScript; Python / FastAPI.** No C#. - **`Palette` is the only home for hex** — no colour literal in any script, `.tscn`, or shader (shaders take `Palette` values as uniforms set in code, the `DarkBay` precedent). - **Theme styling via `ThemeKeys` constants**; new title text styles are theme variations built from `Palette` in `build_game_theme.gd`, then the `.tres` is **regenerated** (never hand-edited): `godot --headless -s res://scripts/theme/build_game_theme.gd`. - **Editor-first (ADR 0001):** author the scene tree in `.tscn`; the script only binds/wires. New `class_name` scripts resolve only after `godot --headless --import`, which `./run_tests.sh` runs first. - **Scene-root sizing:** the `.tscn` root gets a fixed size (1920×1080) but **no full-rect anchors on the root**; the script keeps `size = get_viewport_rect().size` + `size_changed` (M3-a collapse rule). - **Version value now:** `0.01-alpha`. Client footer renders `v0.01-alpha · BUILT IN GODOT 4.7 · © 2026`. - **GUT `.gutconfig`** promotes any engine error/warning to a failure — load defensively, keep output pristine. - **§2:** presentation/state, code-owned. No AI text on this screen. --- ### Task 1: Root `/VERSION` + API reads it (+ `/version` endpoint) **Files:** - Create: `VERSION` (repo root) - Create: `api/app/version.py` - Modify: `api/app/main.py` - Test: `api/tests/test_version.py` **Interfaces:** - Consumes: nothing. - Produces: `api/app/version.py` — `get_version() -> str` (reads `/VERSION`, cached) and module constant `VERSION: str`. `GET /version` → `{"version": VERSION}`. `FastAPI(version=VERSION)`. - [ ] **Step 1: Create the source-of-truth file** Create `VERSION` (repo root) with exactly one line: ``` 0.01-alpha ``` - [ ] **Step 2: Write the failing test** Create `api/tests/test_version.py`: ```python from pathlib import Path from fastapi.testclient import TestClient from app.main import app from app.version import VERSION, get_version client = TestClient(app) ROOT_VERSION = Path(__file__).resolve().parents[2] / "VERSION" def test_get_version_matches_file(): assert ROOT_VERSION.is_file() assert get_version() == ROOT_VERSION.read_text(encoding="utf-8").strip() def test_app_version_matches(): assert app.version == VERSION == get_version() def test_version_endpoint(): assert client.get("/version").json() == {"version": VERSION} ``` - [ ] **Step 3: Run test to verify it fails** Run: `cd api && python -m pytest tests/test_version.py -q` Expected: FAIL — `ModuleNotFoundError: No module named 'app.version'`. - [ ] **Step 4: Write `version.py`** Create `api/app/version.py`: ```python """Single source of truth for the app version (charter §4: client and API share one version). Reads the repo-root /VERSION file — the ONLY place a human edits the version. Mirrors canon_log._schema_dir's env-override + walk-up so it resolves in a local checkout and (once /VERSION is bundled) an image alike. """ import os from functools import lru_cache from pathlib import Path LAST_RESORT = "0.0.0-dev" def _version_file() -> Path | None: env = os.environ.get("COC_VERSION_FILE") if env: return Path(env) here = Path(__file__).resolve() for parent in here.parents: candidate = parent / "VERSION" if candidate.is_file(): return candidate return None @lru_cache(maxsize=1) def get_version() -> str: path = _version_file() if path is None or not path.is_file(): return LAST_RESORT return path.read_text(encoding="utf-8").strip() or LAST_RESORT VERSION = get_version() ``` - [ ] **Step 5: Wire it into `main.py`** In `api/app/main.py`, add the import near the other `from .` imports: ```python from .version import VERSION ``` Change the app construction from `app = FastAPI(title="coc-rpg proxy", version="0.0.1")` to: ```python app = FastAPI(title="coc-rpg proxy", version=VERSION) ``` Add the endpoint (next to the existing `/health` route): ```python @app.get("/version") def version() -> dict: return {"version": VERSION} ``` - [ ] **Step 6: Run test to verify it passes** Run: `cd api && python -m pytest tests/test_version.py -q` Expected: PASS — 3 passed. - [ ] **Step 7: Commit** ```bash git add VERSION api/app/version.py api/app/main.py api/tests/test_version.py git commit -m "feat(api): /VERSION single source read at startup + GET /version" ``` --- ### Task 2: Client version plumbing (`Version` helper + sync script) **Files:** - Create: `scripts/sync-version` - Modify: `client/project.godot` (via the script) - Create: `client/scripts/util/version.gd` - Test: `client/tests/unit/test_version.gd` **Interfaces:** - Consumes: root `/VERSION` (Task 1). - Produces: `class_name Version` — `func string() -> String` (reads `application/config/version`, `LAST_RESORT` if empty) and `func footer() -> String`. `project.godot` now has `application/config/version="0.01-alpha"`. - [ ] **Step 1: Write the sync script** Create `scripts/sync-version` (make it executable): ```bash #!/usr/bin/env bash # Sync the single source of truth (/VERSION) into the Godot client's project.godot # (application/config/version). Run after bumping /VERSION. set -euo pipefail root="$(cd "$(dirname "$0")/.." && pwd)" version="$(tr -d '[:space:]' < "$root/VERSION")" proj="$root/client/project.godot" if grep -q '^config/version=' "$proj"; then sed -i "s|^config/version=.*|config/version=\"$version\"|" "$proj" else sed -i "s|^\(config/name=.*\)|\1\nconfig/version=\"$version\"|" "$proj" fi echo "synced client config/version = \"$version\"" ``` Then: `chmod +x scripts/sync-version` - [ ] **Step 2: Run the sync script** Run: `./scripts/sync-version` Expected: prints `synced client config/version = "0.01-alpha"`. Verify `client/project.godot`'s `[application]` block now contains `config/version="0.01-alpha"` (run `grep 'config/version' client/project.godot`). - [ ] **Step 3: Write the failing test** Create `client/tests/unit/test_version.gd`: ```gdscript extends "res://addons/gut/test.gd" func test_string_matches_project_setting(): var expected := str(ProjectSettings.get_setting("application/config/version", "")) assert_ne(expected, "", "project.godot must carry application/config/version (run scripts/sync-version)") assert_eq(Version.new().string(), expected) func test_footer_contains_version_and_engine(): var v := Version.new() assert_string_contains(v.footer(), v.string()) assert_string_contains(v.footer(), "BUILT IN GODOT 4.7") ``` - [ ] **Step 4: Run test to verify it fails** Run: `cd client && ./run_tests.sh -gselect=test_version.gd` Expected: FAIL — `Version` is an unknown identifier. - [ ] **Step 5: Write the `Version` helper** Create `client/scripts/util/version.gd`: ```gdscript class_name Version extends RefCounted ## The client's view of the single source of truth (/VERSION, charter §4). The ## value is baked into project.godot's application/config/version by ## scripts/sync-version on each bump; read it via ProjectSettings so an exported ## build (which has no repo-root /VERSION) still knows its own version. const SETTING := "application/config/version" const LAST_RESORT := "0.0.0-dev" func string() -> String: var v := str(ProjectSettings.get_setting(SETTING, "")) return v if v != "" else LAST_RESORT func footer() -> String: return "v%s · BUILT IN GODOT 4.7 · © 2026" % string() ``` - [ ] **Step 6: Run test to verify it passes** Run: `cd client && ./run_tests.sh -gselect=test_version.gd` Expected: PASS — 2 tests. - [ ] **Step 7: Commit** ```bash git add scripts/sync-version client/project.godot client/scripts/util/version.gd client/scripts/util/version.gd.uid client/tests/unit/test_version.gd client/tests/unit/test_version.gd.uid git commit -m "feat(client): Version helper + sync-version script; project.godot config/version" ``` --- ### Task 3: Theme title text variations (`TitleLogo`, `TitleKicker`) **Files:** - Modify: `client/scripts/theme/theme_keys.gd` - Modify: `client/scripts/theme/build_game_theme.gd` - Modify: `client/assets/theme/game_theme.tres` (regenerated) - Test: `client/tests/unit/test_title_theme.gd` **Interfaces:** - Consumes: `Palette` (`CREAM`, `GOLD`). - Produces: `ThemeKeys.TITLE_LOGO`, `ThemeKeys.TITLE_KICKER` (Label variations). `TitleLogo` = serif 116px `CREAM`; `TitleKicker` = mono 13px `GOLD`. - [ ] **Step 1: Write the failing test** Create `client/tests/unit/test_title_theme.gd`: ```gdscript extends "res://addons/gut/test.gd" const Builder = preload("res://scripts/theme/build_game_theme.gd") func test_title_variations_built(): var t: Theme = Builder.build_theme() assert_eq(t.get_font_size(&"font_size", ThemeKeys.TITLE_LOGO), 116) assert_eq(t.get_color(&"font_color", ThemeKeys.TITLE_LOGO), Palette.CREAM) assert_eq(t.get_color(&"font_color", ThemeKeys.TITLE_KICKER), Palette.GOLD) ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd client && ./run_tests.sh -gselect=test_title_theme.gd` Expected: FAIL — `ThemeKeys.TITLE_LOGO` is not defined (parse error). - [ ] **Step 3: Add the ThemeKeys constants** In `client/scripts/theme/theme_keys.gd`, alongside the font-role consts `HEADING`/`ACCENT`/`MONO` (these are Label font-roles kept OUT of `ALL`, so do NOT add the new ones to `ALL`): ```gdscript const TITLE_LOGO := &"TitleLogo" const TITLE_KICKER := &"TitleKicker" ``` - [ ] **Step 4: Build the variations** In `client/scripts/theme/build_game_theme.gd`, add a call inside `build_theme()` right after `_fonts(theme)`: ```gdscript _title_type(theme) ``` And define the function (place it after `_fonts`): ```gdscript static func _title_type(theme: Theme) -> void: # Title-screen display type (mock Title Screen): the big serif logo + the mono # kicker above it. Label font-role variations (font/size/colour), the same # shape as Heading/Accent/Mono. theme.set_type_variation(ThemeKeys.TITLE_LOGO, "Label") theme.set_font(&"font", ThemeKeys.TITLE_LOGO, load(SERIF)) theme.set_font_size(&"font_size", ThemeKeys.TITLE_LOGO, 116) theme.set_color(&"font_color", ThemeKeys.TITLE_LOGO, Palette.CREAM) theme.set_type_variation(ThemeKeys.TITLE_KICKER, "Label") theme.set_font(&"font", ThemeKeys.TITLE_KICKER, load(MONO)) theme.set_font_size(&"font_size", ThemeKeys.TITLE_KICKER, 13) theme.set_color(&"font_color", ThemeKeys.TITLE_KICKER, Palette.GOLD) ``` - [ ] **Step 5: Regenerate the committed theme** Run: `cd client && godot --headless -s res://scripts/theme/build_game_theme.gd` Expected: prints `wrote res://assets/theme/game_theme.tres`. - [ ] **Step 6: Run tests to verify they pass** Run: `cd client && ./run_tests.sh -gselect=test_title_theme.gd` → PASS. Then: `cd client && ./run_tests.sh -gselect=test_theme_resource.gd` → still PASS (no drift regression). - [ ] **Step 7: Commit** ```bash git add client/scripts/theme/theme_keys.gd client/scripts/theme/build_game_theme.gd client/assets/theme/game_theme.tres client/tests/unit/test_title_theme.gd client/tests/unit/test_title_theme.gd.uid git commit -m "feat(theme): TitleLogo + TitleKicker display variations" ``` --- ### Task 4: TitleScreen core scene + script + boot **Files:** - Create: `client/scripts/ui/title/title_screen.gd` - Create: `client/scenes/title/TitleScreen.tscn` - Modify: `client/project.godot` (set `run/main_scene`) - Test: `client/tests/unit/test_title_screen.gd` **Interfaces:** - Consumes: `Version` (Task 2), `ThemeKeys.TITLE_LOGO/TITLE_KICKER` (Task 3), `Palette`, `VignetteOverlay` scene, `MainWindowShell` scene. - Produces: `class_name TitleScreen extends Control` — `signal menu_activated(id: StringName)`; `var _rows: Array`; `var _sel: int`; `func _move(delta)`, `func _activate(i)`. The atmosphere (bg shader/glow/embers) is added in Task 5; this task builds the composition + interaction. - [ ] **Step 1: Write the failing test** Create `client/tests/unit/test_title_screen.gd`: ```gdscript extends "res://addons/gut/test.gd" const SCENE := "res://scenes/title/TitleScreen.tscn" func _title() -> TitleScreen: var t = load(SCENE).instantiate() add_child_autofree(t) # runs _ready() return t func _index_of(t: TitleScreen, action: StringName) -> int: for i in range(t._rows.size()): if t._rows[i].get_meta("action") == action: return i return -1 func test_six_menu_rows_each_with_an_action(): var t := _title() assert_eq(t._rows.size(), 6) for row in t._rows: assert_true(row.has_meta("action")) func test_down_moves_selection(): var t := _title() assert_eq(t._sel, 0) t._move(1) assert_eq(t._sel, 1) func test_up_from_first_wraps_to_last(): var t := _title() t._move(-1) assert_eq(t._sel, t._rows.size() - 1) func test_new_game_and_quit_emit_their_action(): var t := _title() # Isolate the signal seam so the test does not actually change scene / quit. t.menu_activated.disconnect(t._on_menu_activated) watch_signals(t) t._activate(_index_of(t, &"new_game")) assert_signal_emitted_with_parameters(t, "menu_activated", [&"new_game"]) t._activate(_index_of(t, &"quit")) assert_signal_emitted_with_parameters(t, "menu_activated", [&"quit"]) func test_footer_shows_version(): var t := _title() assert_string_contains(t._footer_left.text, Version.new().string()) ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd client && ./run_tests.sh -gselect=test_title_screen.gd` Expected: FAIL — cannot load `TitleScreen.tscn` / `TitleScreen` unknown. - [ ] **Step 3: Write the script** Create `client/scripts/ui/title/title_screen.gd`: ```gdscript class_name TitleScreen extends Control ## The entry-point screen (mock Title Screen). Editor-first (ADR 0001): the whole ## composition lives in TitleScreen.tscn; this script drives selection, input, and ## dispatches the menu. §2: presentation/state — no AI here (the DM enters at the ## shell). The atmosphere (bg shader, glow, embers) is layered on in a later task. signal menu_activated(id: StringName) const SHELL := "res://scenes/shell/MainWindowShell.tscn" var _rows: Array = [] var _sel: int = 0 @onready var _menu: VBoxContainer = $Content/Menu @onready var _footer_left: Label = $FooterLeft func _ready() -> void: # Root Control run via F6 is not sized by full-rect anchors — size explicitly. _fit_to_viewport() get_viewport().size_changed.connect(_fit_to_viewport) _rows = [] for row in _menu.get_children(): if row.has_meta("action"): _rows.append(row) (row.get_node("Marker") as Label).add_theme_color_override("font_color", Palette.GOLD) _wire_row_mouse() _footer_left.text = Version.new().footer() menu_activated.connect(_on_menu_activated) _update_selection() func _fit_to_viewport() -> void: size = get_viewport_rect().size func _wire_row_mouse() -> void: for i in range(_rows.size()): var idx := i # fresh per-iteration binding for the closures var row: Control = _rows[idx] row.mouse_entered.connect(func(): _sel = idx _update_selection()) row.gui_input.connect(func(e: InputEvent): if e is InputEventMouseButton and e.pressed and e.button_index == MOUSE_BUTTON_LEFT: _activate(idx)) func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("ui_down"): _move(1) elif event.is_action_pressed("ui_up"): _move(-1) elif event.is_action_pressed("ui_accept"): _activate(_sel) func _move(delta: int) -> void: if _rows.is_empty(): return _sel = wrapi(_sel + delta, 0, _rows.size()) _update_selection() func _activate(i: int) -> void: if i < 0 or i >= _rows.size(): return menu_activated.emit(_rows[i].get_meta("action")) func _on_menu_activated(id: StringName) -> void: match id: &"new_game": get_tree().change_scene_to_file(SHELL) &"quit": get_tree().quit() _: pass # inert placeholder — the screen lands in a later milestone func _update_selection() -> void: for i in range(_rows.size()): var row: Control = _rows[i] var selected := i == _sel (row.get_node("Marker") as Label).modulate.a = 1.0 if selected else 0.0 var label := row.get_node("Body/Label") as Label label.add_theme_color_override("font_color", Palette.CREAM_BRIGHT if selected else Palette.MUTED_MONO) ``` - [ ] **Step 4: Write the scene** Create `client/scenes/title/TitleScreen.tscn` (root has a fixed size but **no full-rect anchors**; menu rows carry an `action` meta and `mouse_filter = 0` so they receive hover/click): ``` [gd_scene load_steps=4 format=3] [ext_resource type="Script" path="res://scripts/ui/title/title_screen.gd" id="1"] [ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"] [ext_resource type="PackedScene" path="res://scenes/theme/surfaces/VignetteOverlay.tscn" id="3"] [node name="TitleScreen" type="Control"] layout_mode = 3 anchors_preset = 0 offset_right = 1920.0 offset_bottom = 1080.0 theme = ExtResource("2") script = ExtResource("1") [node name="Background" type="ColorRect" parent="."] layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 color = Color(0.039216, 0.031373, 0.023529, 1) [node name="KeyArt" type="Label" parent="."] layout_mode = 1 anchors_preset = 6 anchor_left = 1.0 anchor_top = 0.5 anchor_right = 1.0 anchor_bottom = 0.5 offset_left = -520.0 offset_top = -20.0 offset_right = -120.0 offset_bottom = 20.0 grow_horizontal = 0 grow_vertical = 2 theme_type_variation = &"Mono" text = "KEY ART DROPS IN HERE" horizontal_alignment = 1 [node name="Vignette" parent="." instance=ExtResource("3")] layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 mouse_filter = 2 [node name="Content" type="VBoxContainer" parent="."] layout_mode = 1 anchors_preset = 4 anchor_top = 0.5 anchor_bottom = 0.5 offset_left = 120.0 offset_top = -260.0 offset_right = 900.0 offset_bottom = 260.0 grow_vertical = 2 theme_override_constants/separation = 10 [node name="Kicker" type="Label" parent="Content"] layout_mode = 2 theme_type_variation = &"TitleKicker" text = "A DARK FANTASY RPG" [node name="Logo" type="Label" parent="Content"] layout_mode = 2 theme_type_variation = &"TitleLogo" text = "CODE OF CONQUEST" [node name="Rule" type="ColorRect" parent="Content"] custom_minimum_size = Vector2(220, 2) layout_mode = 2 color = Color(0.662745, 0.517647, 0.247059, 1) [node name="Tagline" type="Label" parent="Content"] layout_mode = 2 theme_type_variation = &"Accent" text = "The world has real problems, and it does not care about you." [node name="Menu" type="VBoxContainer" parent="Content"] layout_mode = 2 theme_override_constants/separation = 5 [node name="NewGame" type="HBoxContainer" parent="Content/Menu"] layout_mode = 2 mouse_filter = 0 theme_override_constants/separation = 16 metadata/action = &"new_game" [node name="Marker" type="Label" parent="Content/Menu/NewGame"] layout_mode = 2 theme_override_font_sizes/font_size = 15 text = "◆" [node name="Body" type="VBoxContainer" parent="Content/Menu/NewGame"] layout_mode = 2 [node name="Label" type="Label" parent="Content/Menu/NewGame/Body"] layout_mode = 2 theme_override_font_sizes/font_size = 30 text = "New Game" [node name="Continue" type="HBoxContainer" parent="Content/Menu"] layout_mode = 2 mouse_filter = 0 theme_override_constants/separation = 16 metadata/action = &"continue" [node name="Marker" type="Label" parent="Content/Menu/Continue"] layout_mode = 2 theme_override_font_sizes/font_size = 15 text = "◆" [node name="Body" type="VBoxContainer" parent="Content/Menu/Continue"] layout_mode = 2 [node name="Label" type="Label" parent="Content/Menu/Continue/Body"] layout_mode = 2 theme_override_font_sizes/font_size = 30 text = "Continue" [node name="Sub" type="Label" parent="Content/Menu/Continue/Body"] layout_mode = 2 theme_type_variation = &"Mono" text = "CHAPTER II · VEXCCA · 14:22:07" [node name="LoadGame" type="HBoxContainer" parent="Content/Menu"] layout_mode = 2 mouse_filter = 0 theme_override_constants/separation = 16 metadata/action = &"load_game" [node name="Marker" type="Label" parent="Content/Menu/LoadGame"] layout_mode = 2 theme_override_font_sizes/font_size = 15 text = "◆" [node name="Body" type="VBoxContainer" parent="Content/Menu/LoadGame"] layout_mode = 2 [node name="Label" type="Label" parent="Content/Menu/LoadGame/Body"] layout_mode = 2 theme_override_font_sizes/font_size = 30 text = "Load Game" [node name="Settings" type="HBoxContainer" parent="Content/Menu"] layout_mode = 2 mouse_filter = 0 theme_override_constants/separation = 16 metadata/action = &"settings" [node name="Marker" type="Label" parent="Content/Menu/Settings"] layout_mode = 2 theme_override_font_sizes/font_size = 15 text = "◆" [node name="Body" type="VBoxContainer" parent="Content/Menu/Settings"] layout_mode = 2 [node name="Label" type="Label" parent="Content/Menu/Settings/Body"] layout_mode = 2 theme_override_font_sizes/font_size = 30 text = "Settings" [node name="Credits" type="HBoxContainer" parent="Content/Menu"] layout_mode = 2 mouse_filter = 0 theme_override_constants/separation = 16 metadata/action = &"credits" [node name="Marker" type="Label" parent="Content/Menu/Credits"] layout_mode = 2 theme_override_font_sizes/font_size = 15 text = "◆" [node name="Body" type="VBoxContainer" parent="Content/Menu/Credits"] layout_mode = 2 [node name="Label" type="Label" parent="Content/Menu/Credits/Body"] layout_mode = 2 theme_override_font_sizes/font_size = 30 text = "Credits" [node name="Quit" type="HBoxContainer" parent="Content/Menu"] layout_mode = 2 mouse_filter = 0 theme_override_constants/separation = 16 metadata/action = &"quit" [node name="Marker" type="Label" parent="Content/Menu/Quit"] layout_mode = 2 theme_override_font_sizes/font_size = 15 text = "◆" [node name="Body" type="VBoxContainer" parent="Content/Menu/Quit"] layout_mode = 2 [node name="Label" type="Label" parent="Content/Menu/Quit/Body"] layout_mode = 2 theme_override_font_sizes/font_size = 30 text = "Quit" [node name="FooterLeft" type="Label" parent="."] layout_mode = 1 anchors_preset = 2 anchor_top = 1.0 anchor_bottom = 1.0 offset_left = 120.0 offset_top = -60.0 offset_right = 700.0 offset_bottom = -32.0 grow_vertical = 0 theme_type_variation = &"Mono" text = "v0.01-alpha · BUILT IN GODOT 4.7 · © 2026" [node name="FooterRight" type="Label" parent="."] layout_mode = 1 anchors_preset = 3 anchor_left = 1.0 anchor_top = 1.0 anchor_right = 1.0 anchor_bottom = 1.0 offset_left = -520.0 offset_top = -60.0 offset_right = -120.0 offset_bottom = -32.0 grow_horizontal = 0 grow_vertical = 0 theme_type_variation = &"Mono" text = "[ ↑ ↓ ] CHOOSE [ ↵ ] ENTER" horizontal_alignment = 2 ``` Note the two `Color(...)` literals in the `.tscn` (`Background` fallback + `Rule`) are the stage-black and gold Palette values; they are the one allowed spot (an authored `ColorRect.color` has no theme-variation equivalent) — they equal `Palette.STAGE_1` / `Palette.GOLD` and the real background is the Palette-driven shader added in Task 5. If a reviewer objects, the alternative is setting them in `_ready` from `Palette` (loses editor preview). - [ ] **Step 5: Set the boot scene** In `client/project.godot`, under `[application]`, add (or set): ``` run/main_scene="res://scenes/title/TitleScreen.tscn" ``` - [ ] **Step 6: Run test to verify it passes** Run: `cd client && ./run_tests.sh -gselect=test_title_screen.gd` Expected: PASS — 5 tests. - [ ] **Step 7: Run the full suite** Run: `cd client && ./run_tests.sh` Expected: PASS — all prior tests plus the new title tests, 0 failing. - [ ] **Step 8: Commit** ```bash git add client/scripts/ui/title/title_screen.gd client/scripts/ui/title/title_screen.gd.uid client/scenes/title/TitleScreen.tscn client/project.godot client/tests/unit/test_title_screen.gd client/tests/unit/test_title_screen.gd.uid git commit -m "feat(title): TitleScreen composition, menu + selection, boot scene" ``` --- ### Task 5: Atmosphere — warm background, glow pulse, embers **Files:** - Modify: `client/scripts/theme/palette.gd` (warm tokens) - Create: `client/assets/theme/shaders/title_background.gdshader` - Modify: `client/scenes/title/TitleScreen.tscn` (add `Glow` + `Embers` nodes) - Modify: `client/scripts/ui/title/title_screen.gd` (configure bg/glow/embers in `_ready`) **Interfaces:** - Consumes: `Palette` (new `TITLE_WARM`, `TITLE_MID`, existing `STAGE_1`, `GOLD_BRIGHT`, `BLOOD`). - Produces: no new public API. Visual only — verified by the F6 gate (Task 6), not by a unit test. - [ ] **Step 1: Add warm Palette tokens** In `client/scripts/theme/palette.gd`, after the dark-background block: ```gdscript # --- Title-screen ambiance (warm radial background) --- const TITLE_WARM := Color("2a2018") # radial centre const TITLE_MID := Color("160f0a") # radial midpoint (STAGE_1 is the edge) ``` - [ ] **Step 2: Write the background shader** Create `client/assets/theme/shaders/title_background.gdshader`: ```glsl shader_type canvas_item; // Warm radial for the Title screen (mock: radial-gradient warm centre -> near // black edge, centred at 70% 20%). Colours are Palette uniforms set by the title // script (the DarkBay precedent), so no hex lives in the shader. uniform vec4 warm : source_color; uniform vec4 mid : source_color; uniform vec4 edge : source_color; uniform vec2 center = vec2(0.7, 0.2); void fragment() { float d = distance(UV, center); vec3 c = mix(warm.rgb, mid.rgb, clamp(d / 0.45, 0.0, 1.0)); c = mix(c, edge.rgb, clamp((d - 0.45) / 0.55, 0.0, 1.0)); COLOR = vec4(c, 1.0); } ``` - [ ] **Step 3: Add the Glow + Embers nodes to the scene** In `client/scenes/title/TitleScreen.tscn`, add these two nodes as children of the root (after `Background`, before `Vignette` so the vignette sits over them). Insert into the node list: ``` [node name="Glow" type="ColorRect" parent="."] layout_mode = 1 anchors_preset = 12 anchor_top = 1.0 anchor_right = 1.0 anchor_bottom = 1.0 offset_top = -420.0 mouse_filter = 2 color = Color(0.560784, 0.227451, 0.203922, 0.22) [node name="Embers" type="GPUParticles2D" parent="."] position = Vector2(960, 1080) amount = 24 lifetime = 9.0 preprocess = 4.0 ``` (The `Glow` colour is `Palette.BLOOD` at low alpha — a warm member of the fire glow; its pulse is driven in code. `Embers` gets its process material in code so the colour comes from `Palette`.) - [ ] **Step 4: Configure bg, glow, embers in the script** In `client/scripts/ui/title/title_screen.gd`, add `@onready` refs beside the existing ones: ```gdscript @onready var _background: ColorRect = $Background @onready var _glow: ColorRect = $Glow @onready var _embers: GPUParticles2D = $Embers ``` In `_ready()`, add these calls (after `_fit_to_viewport()` / before the menu harvest is fine): ```gdscript _apply_background() _configure_embers() _pulse_glow() ``` Add the three functions: ```gdscript func _apply_background() -> void: # Palette-driven shader material set in code (the DarkBay precedent) so no hex # lives in the shader/scene; the editor shows the ColorRect fallback. var mat := ShaderMaterial.new() mat.shader = load("res://assets/theme/shaders/title_background.gdshader") mat.set_shader_parameter("warm", Palette.TITLE_WARM) mat.set_shader_parameter("mid", Palette.TITLE_MID) mat.set_shader_parameter("edge", Palette.STAGE_1) _background.material = mat func _configure_embers() -> void: var pm := ParticleProcessMaterial.new() pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX pm.emission_box_extents = Vector3(760, 4, 0) # a wide strip along the bottom pm.direction = Vector3(0, -1, 0) pm.spread = 12.0 pm.gravity = Vector3(0, -18, 0) pm.initial_velocity_min = 24.0 pm.initial_velocity_max = 52.0 pm.scale_min = 0.4 pm.scale_max = 1.0 pm.color = Palette.GOLD_BRIGHT # fade out over life var ramp := Gradient.new() ramp.set_color(0, Color(Palette.GOLD_BRIGHT, 0.0)) ramp.set_color(1, Color(Palette.GOLD_BRIGHT, 0.0)) ramp.add_point(0.15, Palette.GOLD_BRIGHT) var tex := GradientTexture1D.new() tex.gradient = ramp pm.color_ramp = tex _embers.process_material = pm func _pulse_glow() -> void: # A slow warm pulse (the off-screen fire). A code tween, not an AnimationPlayer: # a 2-keyframe modulate loop is trivial in code and motion is runtime-only anyway. var tween := create_tween().set_loops().set_trans(Tween.TRANS_SINE) tween.tween_property(_glow, "modulate:a", 0.8, 3.0) tween.tween_property(_glow, "modulate:a", 0.5, 3.0) ``` - [ ] **Step 5: Run the title + full suite (no regression)** Run: `cd client && ./run_tests.sh -gselect=test_title_screen.gd` → PASS (the added nodes/refs must not break `_ready`). Then: `cd client && ./run_tests.sh` → PASS, all tests. - [ ] **Step 6: Commit** ```bash git add client/scripts/theme/palette.gd client/assets/theme/shaders/title_background.gdshader client/assets/theme/shaders/title_background.gdshader.uid client/scenes/title/TitleScreen.tscn client/scripts/ui/title/title_screen.gd git commit -m "feat(title): warm shader background, pulsing glow, drifting embers" ``` --- ### Task 6: Visual gate — human F6 + boot check **Not automatable.** Headless GUT proves structure and interaction wiring, not rendering or motion. - [ ] **Step 1: Run the project (boot path)** In the Godot editor press **F5** (run project) — it should boot straight to the Title screen (proves `run/main_scene`). Also open `client/scenes/title/TitleScreen.tscn` and press **F6**. - [ ] **Step 2: Confirm by eye** - Warm radial background (bright toward upper-right), heavy vignette, the key-art dashed slot on the right. - Drifting embers rising from the bottom; the fire glow along the bottom slowly pulsing. - Logo block: kicker "A DARK FANTASY RPG" (gold, tracked), "CODE OF CONQUEST" (large cream serif), gold rule, tagline. - Menu: ◆ marker + brightened label on the selected item; `↑`/`↓` move it (and wrap), the mouse hovering an item selects it. - Footer: `v0.01-alpha · BUILT IN GODOT 4.7 · © 2026` (left), the controls hint (right). - **New Game** (Enter or click) → the Main Window shell; **Quit** exits. - [ ] **Step 3: Report the outcome** If anything is collapsed, mis-placed, or the boot/nav doesn't work, fix and re-run before the milestone is done. When it looks right, the Title is eyeball-confirmed and ready to merge. --- ## Self-Review **Spec coverage:** - Root `/VERSION` + API reads it + `/version` → Task 1. ✓ - Client `Version` helper + sync script + `project.godot` config/version → Task 2. ✓ - `TitleLogo`/`TitleKicker` theme variations → Task 3. ✓ - Title composition (logo/menu/footer/key-art/vignette), selection + keyboard/mouse, `menu_activated` seam, New Game→shell / Quit / inert → Task 4. ✓ - Boot scene (`run/main_scene`) → Task 4 Step 5. ✓ - Warm shader background (palette uniforms) + Palette warm tokens + embers (GPUParticles) + glow → Task 5. ✓ - Editor-first, viewport-fit rule, palette-clean → Global Constraints + honored per task. ✓ - Tests (Version, API version, title selection/activation/footer) + F6 gate → Tasks 1–4 + Task 6. ✓ **Placeholder scan:** no "TBD"/"handle edge cases"/"similar to". Every code step has full code. ✓ **Type consistency:** `Version.string()`/`footer()`; `get_version()`/`VERSION`; `ThemeKeys.TITLE_LOGO`/`TITLE_KICKER`; `TitleScreen._rows`/`_sel`/`_move`/`_activate`/`_on_menu_activated`/`menu_activated`/`_footer_left`/`_menu`/`_background`/`_glow`/`_embers`; `Palette.TITLE_WARM`/`TITLE_MID`/`STAGE_1`/`GOLD`/`GOLD_BRIGHT`/`CREAM`/`CREAM_BRIGHT`/`MUTED_MONO` — consistent across tasks and match the existing code read during planning. ✓ **Noted deviation from the spec:** the glow uses a **code Tween**, not an `AnimationPlayer` — a 2-keyframe modulate loop is trivial in code and motion is runtime-only regardless; the embers `ParticleProcessMaterial` and the bg `ShaderMaterial` are set in code (the `DarkBay` precedent) rather than authored in the `.tscn`, keeping colours Palette-sourced at the cost of an editor preview of the motion. Flagged here so it is a conscious call, not a silent drift.