Files
code_of_conquest_dnd/docs/superpowers/plans/2026-07-11-title-screen.md
2026-07-11 10:27:08 -05:00

32 KiB
Raw Permalink Blame History

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.pyget_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:

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:

"""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:

from .version import VERSION

Change the app construction from app = FastAPI(title="coc-rpg proxy", version="0.0.1") to:

app = FastAPI(title="coc-rpg proxy", version=VERSION)

Add the endpoint (next to the existing /health route):

@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
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 Versionfunc 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):

#!/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:

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:

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
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:

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):

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):

	_title_type(theme)

And define the function (place it after _fonts):

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
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 Controlsignal 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:

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:

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
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:

# --- 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:

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:

@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):

	_apply_background()
	_configure_embers()
	_pulse_glow()

Add the three functions:

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
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 14 + 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.