Difficulty modes (Normal + Hard) implementation plan
Difficulty modes (Normal + Hard) Implementation Plan
Section titled “Difficulty modes (Normal + Hard) 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. In this repo also read skillbh-sdd-concurrentbefore Task 1 and runsdd-worktree-leak-guardafter every dispatch.
Goal: Survival gets a NORMAL (new default) / HARD (today’s tuning, unchanged) switch on the start-menu Survival row, with Normal’s knobs as live console tunables, per-mode personal bests, and the mode carried through replays, telemetry and the results/pause screens.
Architecture: A new pure sim/difficulty.gd (class_name Difficulty, the sim/hard_tuning.gd pattern) holds every Normal multiplier as a registered static var tunable; each helper returns the identity unless sim.difficulty == Difficulty.NORMAL in Survival. Sim.difficulty defaults to HARD, so every existing test, soak and the seed-1234 baselines run unchanged without a re-pin. main.gd picks the run’s difficulty at run start (human → save, attract → Normal, replay → header) right after sim.enable_survival().
Tech Stack: Godot 4.7 typed GDScript, GUT, node (tunable docs generator / control tests).
Spec: docs/superpowers/specs/2026-10-15-difficulty-modes-design.md (gold revised to 1.0x both modes, cef5cd11).
Global Constraints
Section titled “Global Constraints”- Hard = today’s game exactly: every
Difficultyhelper returns the identity (1.0 / the base value) whensim.difficulty == HARD, gold rate included (GOLD_MULT_HARD = 1.0). The three pinned determinism files (tests/test_determinism_survival.gd,tests/test_determinism.gd,tests/test_determinism_coop.gd) must pass UNCHANGED. Never edit a pinned hash. /simstays pure:sim/difficulty.gdextends RefCounted, no Node/Engine/Time/Input APIs.- Story and Tutorial never read the mode (
Difficulty.activerequiressim.story == null and sim.tutorial == null). - Multipliers apply ON TOP of the live base values (remote-config patches move the base, Normal scales it).
- Every new number is a registered remote/console tunable:
net/tunables_registry.gdentry + label intools/tunable_docs/docs.d/difficulty.json+net/live_scope.gdclassification;control/src/tunable_docs.jsis GENERATED (node tools/tunable_docs/generate.mjs), never hand-edited. - One gold wallet (
banked_gold). Both modes pay 1.0x. - Save field values are the strings
"normal"/"hard"; missing or invalid →"normal". - Player-facing copy: “NORMAL” / “HARD”; subtitles Normal “Endless waves, build your run”, Hard “The full challenge”. Marketing copy has no em-dashes (Lumara copy rule) and must match what ships.
- Gate before every commit:
scripts/check-test-count.sh(never pull a raw GUT run into context). After merges that add aclass_name, rungodot --headless --path . --importfirst, thengit checkout -- project.godot audio/bus_layout.tres. - Work in a worktree off
origin/main(.claude/worktrees/difficulty-modes, branchdifficulty-modes), never in the main checkout.
File map
Section titled “File map”| File | Responsibility |
|---|---|
sim/difficulty.gd (new) |
Mode ids, every Normal knob (static var tunables), identity-unless-Normal helpers, apply_run_start |
sim/sim.gd |
var difficulty, hooks: wave fill, Charger contact + telegraph (dash_charge_s), XP, boss HP/dmg, damage gate window, gold |
sim/enemy_attacks.gd, sim/enemy_behaviors.gd |
Shooter/Skirmisher shot damage hook; telegraph reads go through sim.dash_charge_s |
net/tunables_registry.gd, net/live_scope.gd, tools/tunable_docs/docs.d/difficulty.json, tools/tunable_docs/generate.mjs, control/src/tunable_docs.js (generated) |
Tunable registration |
tools/soak/soak_run.gd |
cfg.difficulty (“hard” default) |
sim/meta_state.gd, meta/review_prompt.gd |
survival_difficulty, best_survival_by_mode, per-mode best |
main.gd, input/replay_recorder.gd, tools/replay_viewer/viewer.gd |
Run-start pick, replay header, telemetry fields |
ui/start_menu.gd |
NORMAL/HARD chip on the Survival row |
ui/results_panel.gd, ui/pause_menu.gd |
Mode tag beside the run time |
tools/balance_probe/balance_probe.gd (from branch balance-1.1-proposals) |
--difficulty, boss-seen count; acceptance runs |
docs/research/2026-10-16-difficulty-normal-calibration.md (new) |
Probe + economy results, final defaults |
marketing/appstore/whats_new_1.1_{ios,tvos,macos}.txt, control/src/whats_new.js, docs/architecture/meta-and-story.md, docs/RELEASE-1.1.md |
Copy + docs |
Starting values (Normal; Hard = identity)
Section titled “Starting values (Normal; Hard = identity)”Sources: balance proposal A2 (docs/research/2026-10-14-balance-1.1-proposals.md on balance-1.1-proposals) relative to live bible values (Shooter 17 dmg → 12; Charger enemies.elite contact 12 → 9, charge_s 1.5 → 2.0; biomass 200 → 400 over 120 s). Task 7 calibrates them.
| Tunable | Value | Range |
|---|---|---|
NORMAL_BIOMASS_START_FRAC |
0.5 | 0.1–1.0 |
NORMAL_BIOMASS_RAMP_S |
120.0 | 0–600 |
NORMAL_SHOT_DMG_MULT |
0.7 | 0.2–1.0 |
NORMAL_CHARGER_DMG_MULT |
0.75 | 0.2–1.0 |
NORMAL_CHARGER_TELEGRAPH_MULT |
1.33 | 1.0–3.0 |
NORMAL_XP_MULT |
1.5 | 1.0–3.0 |
NORMAL_XP_BOOST_LEVELS |
8 | 0–30 (int) |
NORMAL_BOSS_HP_MULT |
0.75 | 0.2–1.0 |
NORMAL_BOSS_DMG_MULT |
0.75 | 0.2–1.0 |
NORMAL_HIT_GRACE_MULT |
1.5 | 1.0–4.0 |
NORMAL_MAX_HP_MULT |
1.25 | 1.0–3.0 |
GOLD_MULT_NORMAL |
1.0 | 0.25–3.0 |
GOLD_MULT_HARD |
1.0 | 0.25–3.0 |
Task 1: Difficulty core, Hard identity, tunable registration, soak key
Section titled “Task 1: Difficulty core, Hard identity, tunable registration, soak key”Files:
- Create:
sim/difficulty.gd,tests/test_difficulty.gd,tools/tunable_docs/docs.d/difficulty.json - Modify:
sim/sim.gd(nearvar hard_tuning, ~:453),net/tunables_registry.gd(after the HardTuning block ~:225),net/live_scope.gd(after:160),:46-47)tools/tunable_docs/generate.mjs(GROUP_RULES),tools/soak/soak_run.gd( - Regenerate:
control/src/tunable_docs.js
Interfaces:
-
Produces:
Difficulty.HARD: int = 0,Difficulty.NORMAL: int = 1;Difficulty.from_id(s: String) -> int("hard"→ HARD, anything else → NORMAL);Difficulty.id(d: int) -> String;Difficulty.label(d: int) -> String(“NORMAL”/“HARD”);Difficulty.applies(sim) -> bool(Survival run: story and tutorial null);Difficulty.active(sim) -> bool(applies AND NORMAL); helpersbiomass_target(sim, base: int) -> int,shot_dmg_mult(sim) -> float,charger_dmg_mult(sim) -> float,charger_telegraph_mult(sim) -> float,xp_mult(sim, level: int) -> float,boss_hp_mult(sim) -> float,boss_dmg_mult(sim, source: String) -> float,hit_grace_mult(sim) -> float,gold_mult(sim) -> float,apply_run_start(sim) -> void;Difficulty.TUNABLE_NAMES: Array[String];Sim.difficulty: int(defaultDifficulty.HARD); soak cfg key"difficulty". -
Step 1: Write the failing test
tests/test_difficulty.gd:
extends GutTest
# Difficulty modes (spec docs/superpowers/specs/2026-10-15-difficulty-modes-design.md): HARD is# today's game exactly (every helper is the identity, gold included), NORMAL scales the knobs,# Story/Tutorial never read the mode. Liveness of each knob through the real paths lives in# tests/test_difficulty_knobs.gd.
var _saved: Dictionary = {}var _d: GDScript = load("res://sim/difficulty.gd")
func before_each() -> void: _saved = {} for k: String in Difficulty.TUNABLE_NAMES: _saved[k] = _d.get(k)
func after_each() -> void: for k: String in _saved: _d.set(k, _saved[k])
func _sim(d: int) -> Sim: var sim := Sim.new(7, SimContentFixture.db()) sim.enable_survival() sim.difficulty = d return sim
func test_sim_defaults_to_hard() -> void: assert_eq(Sim.new(1, SimContentFixture.db()).difficulty, Difficulty.HARD)
func test_ids_round_trip_and_default_to_normal() -> void: assert_eq(Difficulty.from_id("hard"), Difficulty.HARD) assert_eq(Difficulty.from_id("normal"), Difficulty.NORMAL) assert_eq(Difficulty.from_id(""), Difficulty.NORMAL) assert_eq(Difficulty.from_id("HARD!"), Difficulty.NORMAL) assert_eq(Difficulty.id(Difficulty.HARD), "hard") assert_eq(Difficulty.id(Difficulty.NORMAL), "normal") assert_eq(Difficulty.label(Difficulty.HARD), "HARD")
func test_every_hard_helper_is_the_identity_even_with_extreme_knobs() -> void: # Push every Normal knob far from 1.0: Hard must still not move. for k: String in Difficulty.TUNABLE_NAMES: if k.begins_with("NORMAL_"): _d.set(k, 3 if typeof(_d.get(k)) == TYPE_INT else 0.33) var sim := _sim(Difficulty.HARD) sim.run_time = 5.0 assert_eq(Difficulty.biomass_target(sim, 400), 400) assert_eq(Difficulty.shot_dmg_mult(sim), 1.0) assert_eq(Difficulty.charger_dmg_mult(sim), 1.0) assert_eq(Difficulty.charger_telegraph_mult(sim), 1.0) assert_eq(Difficulty.xp_mult(sim, 1), 1.0) assert_eq(Difficulty.boss_hp_mult(sim), 1.0) assert_eq(Difficulty.boss_dmg_mult(sim, "boss"), 1.0) assert_eq(Difficulty.hit_grace_mult(sim), 1.0) assert_eq(Difficulty.gold_mult(sim), 1.0) var hp := sim.player.max_hp Difficulty.apply_run_start(sim) assert_eq(sim.player.max_hp, hp)
func test_both_gold_rates_ship_at_one() -> void: assert_eq(Difficulty.GOLD_MULT_HARD, 1.0) assert_eq(Difficulty.GOLD_MULT_NORMAL, 1.0)
func test_normal_scales_in_survival_only() -> void: var sim := _sim(Difficulty.NORMAL) assert_true(Difficulty.active(sim)) assert_almost_eq(Difficulty.boss_hp_mult(sim), Difficulty.NORMAL_BOSS_HP_MULT, 1e-6) sim.tutorial = TutorialLessons.new() # any non-null tutorial disables it assert_false(Difficulty.active(sim)) assert_eq(Difficulty.boss_hp_mult(sim), 1.0)
func test_biomass_ramp_is_linear_to_full() -> void: var sim := _sim(Difficulty.NORMAL) Difficulty.NORMAL_BIOMASS_START_FRAC = 0.5 Difficulty.NORMAL_BIOMASS_RAMP_S = 120.0 sim.run_time = 0.0 assert_eq(Difficulty.biomass_target(sim, 400), 200) sim.run_time = 60.0 assert_eq(Difficulty.biomass_target(sim, 400), 300) sim.run_time = 500.0 assert_eq(Difficulty.biomass_target(sim, 400), 400)
func test_xp_boost_stops_at_the_level_cap() -> void: var sim := _sim(Difficulty.NORMAL) Difficulty.NORMAL_XP_MULT = 1.5 Difficulty.NORMAL_XP_BOOST_LEVELS = 8 assert_eq(Difficulty.xp_mult(sim, 7), 1.5) assert_eq(Difficulty.xp_mult(sim, 8), 1.0)
func test_boss_dmg_only_scales_boss_sources() -> void: var sim := _sim(Difficulty.NORMAL) assert_almost_eq(Difficulty.boss_dmg_mult(sim, "boss"), Difficulty.NORMAL_BOSS_DMG_MULT, 1e-6) assert_eq(Difficulty.boss_dmg_mult(sim, "swarmer"), 1.0)
func test_apply_run_start_scales_every_pilot_hull() -> void: var sim := _sim(Difficulty.NORMAL) Difficulty.NORMAL_MAX_HP_MULT = 1.25 sim.player.max_hp = 100.0 sim.player.hp = 100.0 Difficulty.apply_run_start(sim) assert_almost_eq(sim.player.max_hp, 125.0, 1e-4) assert_almost_eq(sim.player.hp, 125.0, 1e-4)
func test_every_tunable_is_registered() -> void: var keys := {} for e: Dictionary in TunablesRegistry.ENTRIES: keys[String(e["key"])] = true for k: String in Difficulty.TUNABLE_NAMES: assert_true(keys.has("Difficulty." + k), "Difficulty.%s registered" % k)Check the registry’s array name first (git grep -n "^const .*:= \[" net/tunables_registry.gd) and use it in place of ENTRIES if it differs; check TutorialLessons.new() constructs without args (grep -n "func _init" sim/tutorial_lessons.gd), else build the tutorial the way tests/test_hard_tuning.gd / other tests do.
- Step 2: Run to verify it fails
Run: scripts/check-test-count.sh → FAIL (parse error: Difficulty not declared).
- Step 3: Implement
sim/difficulty.gd
class_name Difficultyextends RefCounted
# Survival difficulty modes (1.1, Chris 2026-10-15: "we have been tuning for Toby"). HARD is# today's game exactly: every helper below returns the identity while sim.difficulty == HARD# (gold rate included, both 1.0), so the seed-1234 baselines and every soak keep running on HARD# unchanged. NORMAL (the new default) scales the knobs below ON TOP of the live base values, so a# remote balance patch moves the base and Normal follows it. Story and the tutorial never read# the mode. main.gd sets sim.difficulty at run start (human: the save; attract: NORMAL; replay:# the header). Every knob is a remote tunable, "Difficulty" group in the console.# Spec: docs/superpowers/specs/2026-10-15-difficulty-modes-design.md
const HARD := 0const NORMAL := 1
# Gentler first minutes: the wave fill target starts at this fraction and ramps linearly to full.static var NORMAL_BIOMASS_START_FRAC: float = 0.5static var NORMAL_BIOMASS_RAMP_S: float = 120.0# Shooter/Skirmisher/Centurion bullet damage, and the Charger's contact hit + telegraph length.static var NORMAL_SHOT_DMG_MULT: float = 0.7static var NORMAL_CHARGER_DMG_MULT: float = 0.75static var NORMAL_CHARGER_TELEGRAPH_MULT: float = 1.33# Faster early levels: kill XP x NORMAL_XP_MULT while the pilot is below NORMAL_XP_BOOST_LEVELS.static var NORMAL_XP_MULT: float = 1.5static var NORMAL_XP_BOOST_LEVELS: int = 8# Softer bosses (same hooks as HardTuning: phase-spawn HP, boss damage sources).static var NORMAL_BOSS_HP_MULT: float = 0.75static var NORMAL_BOSS_DMG_MULT: float = 0.75# Forgiving hits: the damage-gate window (Sim.DAMAGE_GATE_WINDOW) and the run-start hull.static var NORMAL_HIT_GRACE_MULT: float = 1.5static var NORMAL_MAX_HP_MULT: float = 1.25# Run gold per mode. Both 1.0 (Chris 2026-10-15): gold is per kill, so Hard's denser field# already pays more per minute; kept separate so the economy sim can move one.static var GOLD_MULT_NORMAL: float = 1.0static var GOLD_MULT_HARD: float = 1.0
const TUNABLE_NAMES: Array[String] = ["NORMAL_BIOMASS_START_FRAC", "NORMAL_BIOMASS_RAMP_S", "NORMAL_SHOT_DMG_MULT", "NORMAL_CHARGER_DMG_MULT", "NORMAL_CHARGER_TELEGRAPH_MULT", "NORMAL_XP_MULT", "NORMAL_XP_BOOST_LEVELS", "NORMAL_BOSS_HP_MULT", "NORMAL_BOSS_DMG_MULT", "NORMAL_HIT_GRACE_MULT", "NORMAL_MAX_HP_MULT", "GOLD_MULT_NORMAL", "GOLD_MULT_HARD"]
static func from_id(s: String) -> int: return HARD if s == "hard" else NORMAL
static func id(d: int) -> String: return "hard" if d == HARD else "normal"
static func label(d: int) -> String: return "HARD" if d == HARD else "NORMAL"
# A Survival run (the only place the mode exists).static func applies(sim: Sim) -> bool: return sim.story == null and sim.tutorial == null
static func active(sim: Sim) -> bool: return sim.difficulty == NORMAL and applies(sim)
static func biomass_target(sim: Sim, base: int) -> int: if not active(sim): return base var ramp := 1.0 if NORMAL_BIOMASS_RAMP_S <= 0.0 else clampf(sim.run_time / NORMAL_BIOMASS_RAMP_S, 0.0, 1.0) return int(round(float(base) * lerpf(NORMAL_BIOMASS_START_FRAC, 1.0, ramp)))
static func shot_dmg_mult(sim: Sim) -> float: return NORMAL_SHOT_DMG_MULT if active(sim) else 1.0
static func charger_dmg_mult(sim: Sim) -> float: return NORMAL_CHARGER_DMG_MULT if active(sim) else 1.0
static func charger_telegraph_mult(sim: Sim) -> float: return NORMAL_CHARGER_TELEGRAPH_MULT if active(sim) else 1.0
static func xp_mult(sim: Sim, level: int) -> float: return NORMAL_XP_MULT if active(sim) and level < NORMAL_XP_BOOST_LEVELS else 1.0
static func boss_hp_mult(sim: Sim) -> float: return NORMAL_BOSS_HP_MULT if active(sim) else 1.0
static func boss_dmg_mult(sim: Sim, source: String) -> float: return NORMAL_BOSS_DMG_MULT if active(sim) and HardTuning.is_boss_source(source) else 1.0
static func hit_grace_mult(sim: Sim) -> float: return NORMAL_HIT_GRACE_MULT if active(sim) else 1.0
static func gold_mult(sim: Sim) -> float: if not applies(sim): return 1.0 return GOLD_MULT_NORMAL if sim.difficulty == NORMAL else GOLD_MULT_HARD
# Once, after MetaRunSetup + enable_survival (main._new_run, SoakRun): the bigger Normal hull.static func apply_run_start(sim: Sim) -> void: if not active(sim): return for p: PlayerState in sim.pilots: p.max_hp *= NORMAL_MAX_HP_MULT p.hp = p.max_hpIn sim/sim.gd beside var hard_tuning (~:454):
# Survival difficulty (sim/difficulty.gd). Default HARD = today's game, so every test, soak and the# determinism baselines that never set it are unchanged; main sets it at run start.var difficulty: int = Difficulty.HARD- Step 4: Register the tunables. In
net/tunables_registry.gd, addconst DIFFICULTY := "res://sim/difficulty.gd"besideHARD_TUNING(:16) and, after the HardTuning entries (:225):
# Difficulty modes (1.1): Normal's knobs; Hard is the identity. Liveness pinned by # tests/test_difficulty_knobs.gd through Sim.tick and the real spawn/hit/kill paths. {"key": "Difficulty.NORMAL_BIOMASS_START_FRAC", "script": DIFFICULTY, "name": "NORMAL_BIOMASS_START_FRAC", "min": 0.1, "max": 1.0}, {"key": "Difficulty.NORMAL_BIOMASS_RAMP_S", "script": DIFFICULTY, "name": "NORMAL_BIOMASS_RAMP_S", "min": 0.0, "max": 600.0}, {"key": "Difficulty.NORMAL_SHOT_DMG_MULT", "script": DIFFICULTY, "name": "NORMAL_SHOT_DMG_MULT", "min": 0.2, "max": 1.0}, {"key": "Difficulty.NORMAL_CHARGER_DMG_MULT", "script": DIFFICULTY, "name": "NORMAL_CHARGER_DMG_MULT", "min": 0.2, "max": 1.0}, {"key": "Difficulty.NORMAL_CHARGER_TELEGRAPH_MULT", "script": DIFFICULTY, "name": "NORMAL_CHARGER_TELEGRAPH_MULT", "min": 1.0, "max": 3.0}, {"key": "Difficulty.NORMAL_XP_MULT", "script": DIFFICULTY, "name": "NORMAL_XP_MULT", "min": 1.0, "max": 3.0}, {"key": "Difficulty.NORMAL_XP_BOOST_LEVELS", "script": DIFFICULTY, "name": "NORMAL_XP_BOOST_LEVELS", "min": 0, "max": 30}, {"key": "Difficulty.NORMAL_BOSS_HP_MULT", "script": DIFFICULTY, "name": "NORMAL_BOSS_HP_MULT", "min": 0.2, "max": 1.0}, {"key": "Difficulty.NORMAL_BOSS_DMG_MULT", "script": DIFFICULTY, "name": "NORMAL_BOSS_DMG_MULT", "min": 0.2, "max": 1.0}, {"key": "Difficulty.NORMAL_HIT_GRACE_MULT", "script": DIFFICULTY, "name": "NORMAL_HIT_GRACE_MULT", "min": 1.0, "max": 4.0}, {"key": "Difficulty.NORMAL_MAX_HP_MULT", "script": DIFFICULTY, "name": "NORMAL_MAX_HP_MULT", "min": 1.0, "max": 3.0}, {"key": "Difficulty.GOLD_MULT_NORMAL", "script": DIFFICULTY, "name": "GOLD_MULT_NORMAL", "min": 0.25, "max": 3.0}, {"key": "Difficulty.GOLD_MULT_HARD", "script": DIFFICULTY, "name": "GOLD_MULT_HARD", "min": 0.25, "max": 3.0},Match the int-range style of an existing int tunable entry for NORMAL_XP_BOOST_LEVELS (git grep -n '"min": [0-9]*, "max": [0-9]*}' net/tunables_registry.gd | head -3).
In net/live_scope.gd after the HardTuning rows (:160):
"Difficulty.NORMAL_MAX_HP_MULT": [NEW_RUN, "", "hull scaled once at run start (Difficulty.apply_run_start)"], "Difficulty.NORMAL_CHARGER_DMG_MULT": [NEW_SPAWNS, "", "Charger contact baked at spawn (_spawn_one)"], "Difficulty.NORMAL_BOSS_HP_MULT": [NEW_SPAWNS, "", "boss HP baked at the phase spawn"], "Difficulty.NORMAL_BIOMASS_START_FRAC": [LIVE, "", "read every time a wave fills"], "Difficulty.NORMAL_BIOMASS_RAMP_S": [LIVE, "", "read every time a wave fills"], "Difficulty.NORMAL_SHOT_DMG_MULT": [LIVE, "", "read on every enemy shot fired"], "Difficulty.NORMAL_CHARGER_TELEGRAPH_MULT": [LIVE, "", "read every Charger tick"], "Difficulty.NORMAL_XP_MULT": [LIVE, "", "read on every kill"], "Difficulty.NORMAL_XP_BOOST_LEVELS": [LIVE, "", "read on every kill"], "Difficulty.NORMAL_BOSS_DMG_MULT": [LIVE, "", "read on every boss hit"], "Difficulty.NORMAL_HIT_GRACE_MULT": [LIVE, "", "read when a damage window opens"], "Difficulty.GOLD_MULT_NORMAL": [LIVE, "", "read on every gold award"], "Difficulty.GOLD_MULT_HARD": [LIVE, "", "read on every gold award"],Use the scope constant names the file actually defines (grep -n "^const [A-Z_]* :=" net/live_scope.gd); if there is no run-start scope, use the one the file uses for values baked at run start (e.g. ship/meta HP) and keep the “why” text.
In tools/tunable_docs/generate.mjs add [/^Difficulty\./, 'Difficulty'], as the FIRST GROUP_RULES entry. Create tools/tunable_docs/docs.d/difficulty.json with one entry per key, same shape as hard_tuning.json, e.g.:
{ "Difficulty.NORMAL_BIOMASS_START_FRAC": { "label": "Normal: opening field density", "desc": "Normal Survival only. The wave fill target starts at this fraction of Sim.BIOMASS_TARGET and ramps linearly to full over NORMAL_BIOMASS_RAMP_S. Hard ignores it.", "unit": "×", "src": "code" }, "Difficulty.NORMAL_BIOMASS_RAMP_S": { "label": "Normal: density ramp time", "desc": "Seconds for Normal's wave fill target to ramp from the opening fraction to full. 0 = no ramp.", "unit": "s", "src": "code" }}…and the remaining 11 keys in the same style (labels: “Normal: enemy bullet damage”, “Normal: Charger contact damage”, “Normal: Charger telegraph length”, “Normal: early XP boost”, “Normal: XP boost until level”, “Normal: boss HP”, “Normal: boss damage”, “Normal: hit grace window”, “Normal: hull HP”, “Gold rate: Normal”, “Gold rate: Hard”). Check valid unit values in control/test/tunable-docs.test.mjs before using “lvl” for the level key. Then run node tools/tunable_docs/generate.mjs.
- Step 5: Soak key. In
tools/soak/soak_run.gdaftersim.hard_tuning = …(:47):
# Difficulty (sim/difficulty.gd): "hard" by default so every existing soak, economy-sim # calibration and balance baseline keeps today's tuning; the balance probe passes "normal". sim.difficulty = Difficulty.from_id(String(cfg.get("difficulty", "hard"))) Difficulty.apply_run_start(sim)-
Step 6: Verify.
godot --headless --path . --import && git checkout -- project.godot audio/bus_layout.tres, thenscripts/check-test-count.sh→ all pass, determinism files untouched;cd control && npm test(or the commandscripts/gate.shuses for control tests) → pass. -
Step 7: Commit
feat(difficulty): Difficulty core, Normal knobs as tunables, Hard = identity.
Task 2: Wire the field-density, Shooter and Charger knobs
Section titled “Task 2: Wire the field-density, Shooter and Charger knobs”Files:
- Modify:
sim/sim.gd(:2292 wave fill; :1709 contact column in_spawn_one; :832 area for a newdash_charge_s; :2662enemy_charge_frac),sim/enemy_attacks.gd(:167),sim/enemy_behaviors.gd(:150, :186, :412) - Create:
tests/test_difficulty_knobs.gd
Interfaces:
-
Consumes: Task 1 helpers.
-
Produces:
Sim.dash_charge_s(tid: int) -> float(the charge wind-up every reader uses). -
Step 1: Write failing liveness tests (
tests/test_difficulty_knobs.gd; reuse_quiet_simstyle fromtests/test_hard_tuning.gd):
extends GutTest
# Liveness of every Normal knob through the real paths (Sim.tick, _spawn_one, enemy fire, the hurt# path, _sweep_dead). Each test runs the same scenario on HARD and NORMAL and compares.
var _saved: Dictionary = {}var _d: GDScript = load("res://sim/difficulty.gd")
func before_each() -> void: _saved = {} for k: String in Difficulty.TUNABLE_NAMES: _saved[k] = _d.get(k)
func after_each() -> void: for k: String in _saved: _d.set(k, _saved[k])
func _quiet_sim(diff: int, seed_v: int = 7) -> Sim: var sim := Sim.new(seed_v, SimContentFixture.db()) sim.enable_survival() sim.difficulty = diff sim.dev_suppress_spawns = true sim.player.pos = Vector2.ZERO return sim
func _tick(sim: Sim, n: int) -> void: for _k in range(n): sim.tick_single(InputState.new())
func test_normal_opens_with_a_thinner_field() -> void: var counts := {} for diff: int in [Difficulty.HARD, Difficulty.NORMAL]: var sim := Sim.new(1234, SimContentFixture.db()) sim.enable_survival() sim.difficulty = diff sim.player.dev_invuln = true _tick(sim, 120) counts[diff] = sim.total_biomass() assert_lt(int(counts[Difficulty.NORMAL]), int(counts[Difficulty.HARD]))
func test_charger_contact_is_softer_on_normal() -> void: var c := {} for diff: int in [Difficulty.HARD, Difficulty.NORMAL]: var sim := _quiet_sim(diff) sim._spawn_one(EnemyPool.TYPE_ELITE, Vector2(300, 0)) c[diff] = sim.enemies.contact_dmg[sim.enemies.count - 1] assert_almost_eq(float(c[Difficulty.NORMAL]), float(c[Difficulty.HARD]) * Difficulty.NORMAL_CHARGER_DMG_MULT, 0.01)
func test_charger_telegraph_is_longer_on_normal() -> void: var hard := _quiet_sim(Difficulty.HARD) var normal := _quiet_sim(Difficulty.NORMAL) assert_almost_eq(normal.dash_charge_s(EnemyPool.TYPE_ELITE), hard.dash_charge_s(EnemyPool.TYPE_ELITE) * Difficulty.NORMAL_CHARGER_TELEGRAPH_MULT, 1e-4) # Other dash types are untouched. for tid: int in range(EnemyPool.TYPE_NAMES.size()): if tid != EnemyPool.TYPE_ELITE and normal._dash_charge.has(tid): assert_eq(normal.dash_charge_s(tid), hard.dash_charge_s(tid))
func test_shooter_bullets_hit_softer_on_normal() -> void: var dmg := {} for diff: int in [Difficulty.HARD, Difficulty.NORMAL]: var sim := _quiet_sim(diff) sim.player.dev_invuln = true sim._spawn_one(EnemyPool.TYPE_SHOOTER, Vector2(250, 0)) var guard := 0 while sim.enemy_proj.count == 0 and guard < 600: _tick(sim, 1) guard += 1 assert_gt(sim.enemy_proj.count, 0, "shooter fired") dmg[diff] = sim.enemy_proj.damage[0] assert_almost_eq(float(dmg[Difficulty.NORMAL]), float(dmg[Difficulty.HARD]) * Difficulty.NORMAL_SHOT_DMG_MULT, 0.01)Before writing, confirm the real signatures: _spawn_one (grep -n "func _spawn_one" sim/sim.gd), total_biomass, the _dash_charge container type (Dictionary vs Array: adapt the “has” check), and enemy_proj.damage. If _spawn_one’s first draw makes the HARD vs NORMAL spawns differ (it shouldn’t: no Normal knob draws rng), use the same seed for both, as above.
-
Step 2: Run
scripts/check-test-count.sh→ the four tests FAIL (dash_charge_smissing / values equal). -
Step 3: Implement.
sim/sim.gd:2292:
var biomass_target: int = Difficulty.biomass_target(self, HardTuning.biomass_target(self, BIOMASS_TARGET)) # T6 + Normal's opening rampsim/sim.gd _spawn_one contact column (:1709 expression v["contact"] * area_difficulty_mult * LETHALITY_MULT * e_c * b_dmg * c_time): append * (Difficulty.charger_dmg_mult(self) if tid == EnemyPool.TYPE_ELITE else 1.0).
sim/sim.gd, after the block that fills _dash_charge (~:832), add:
# The wind-up a dash enemy charges before lunging (every reader goes through here so Normal's# longer Charger telegraph reaches the AI and the render cue alike).func dash_charge_s(tid: int) -> float: var s: float = _dash_charge[tid] return s * Difficulty.charger_telegraph_mult(self) if tid == EnemyPool.TYPE_ELITE else sReplace the reads at sim/sim.gd:2662 (_dash_charge[enemies.type_id[i]] → dash_charge_s(enemies.type_id[i])), sim/enemy_behaviors.gd:186 (sim._dash_charge[tid] → sim.dash_charge_s(tid)) and :412 (same). After: git grep -n "_dash_charge\[" sim shows only the assignment.
sim/enemy_attacks.gd:167: var dmg := sim.SHOOTER_PROJ_DAMAGE * Difficulty.shot_dmg_mult(sim) * (ELITE_SHOOTER_DAMAGE_MULT if is_elite_shooter else 1.0). sim/enemy_behaviors.gd:150 (Skirmisher): multiply its SHOOTER_PROJ_DAMAGE read by Difficulty.shot_dmg_mult(sim) the same way.
-
Step 4: Verify
scripts/check-test-count.sh→ pass, the three determinism files unchanged and green. -
Step 5: Commit
feat(difficulty): Normal field ramp, softer Shooter bullets, gentler Charger.
Task 3: Wire XP, boss, hit-grace, hull and gold knobs
Section titled “Task 3: Wire XP, boss, hit-grace, hull and gold knobs”Files:
-
Modify:
sim/sim.gd(_xp_worth:2962, bosshp_mult:2413,_hurt_player:3309 and :3322,_add_gold:873) -
Modify:
tests/test_difficulty_knobs.gd -
Step 1: Add failing tests to
tests/test_difficulty_knobs.gd:
func _kill_one_swarmer(sim: Sim) -> float: var before := sim.player.xp sim._spawn_one(EnemyPool.TYPE_SWARMER, Vector2(40, 0)) var i := sim.enemies.count - 1 sim.enemies.data[i] = 0.0 # HP 0: the real end-of-tick _sweep_dead awards it _tick(sim, 1) return sim.player.xp - before
func test_early_kills_pay_more_xp_on_normal_via_biomass() -> void: var xp := {} for diff: int in [Difficulty.HARD, Difficulty.NORMAL]: var sim := _quiet_sim(diff) sim.player.dev_invuln = true xp[diff] = _kill_one_swarmer(sim) assert_gt(float(xp[Difficulty.HARD]), 0.0, "swarmer worth XP via biomass") assert_almost_eq(float(xp[Difficulty.NORMAL]), float(xp[Difficulty.HARD]) * Difficulty.NORMAL_XP_MULT, 0.01)
func test_xp_boost_ends_at_the_level_cap() -> void: var sim := _quiet_sim(Difficulty.NORMAL) sim.player.dev_invuln = true var base := _kill_one_swarmer(_quiet_sim(Difficulty.HARD)) sim.player.level = Difficulty.NORMAL_XP_BOOST_LEVELS assert_almost_eq(_kill_one_swarmer(sim), base, 0.01)
func test_boss_hp_and_damage_are_softer_on_normal() -> void: var hp := {} for diff: int in [Difficulty.HARD, Difficulty.NORMAL]: var sim := _quiet_sim(diff) sim._spawn_boss_phase() hp[diff] = sim.boss.max_hp assert_almost_eq(float(hp[Difficulty.NORMAL]), float(hp[Difficulty.HARD]) * Difficulty.NORMAL_BOSS_HP_MULT, 0.5) var taken := {} for diff: int in [Difficulty.HARD, Difficulty.NORMAL]: var sim := _quiet_sim(diff) var hp0 := sim.player.hp sim._hurt_player(20.0, "boss", false, true) taken[diff] = hp0 - sim.player.hp assert_almost_eq(float(taken[Difficulty.NORMAL]), float(taken[Difficulty.HARD]) * Difficulty.NORMAL_BOSS_DMG_MULT, 0.01)
func test_hit_grace_window_is_longer_on_normal() -> void: for diff: int in [Difficulty.HARD, Difficulty.NORMAL]: var sim := _quiet_sim(diff) sim._hurt_player(10.0, "swarmer", false, true) assert_almost_eq(sim.player.inv, Sim.DAMAGE_GATE_WINDOW * Difficulty.hit_grace_mult(sim), 1e-4) assert_gt(Difficulty.NORMAL_HIT_GRACE_MULT, 1.0)
func test_gold_rate_hook_is_live() -> void: var sim := _quiet_sim(Difficulty.NORMAL) Difficulty.GOLD_MULT_NORMAL = 2.0 sim._add_gold(10.0) var normal_gold := sim.run_gold var hard := _quiet_sim(Difficulty.HARD) hard._add_gold(10.0) assert_eq(normal_gold, hard.run_gold * 2)Confirm names before writing: the boss phase spawn function at ~:2405 (grep -n "^func _spawn_boss" sim/sim.gd; use the function that contains var hp_mult := _danger_mult(), and its real args), EnemyPool.TYPE_SWARMER, player.xp/level field names, and that enemies.data is HP (it is, per boss_warden.gd:137). If a boss spawn needs boss_rotation state, copy the setup used by tests/test_hard_tuning.gd::_spawn_boss instead.
-
Step 2: Run → FAIL.
-
Step 3: Implement in
sim/sim.gd:
_xp_worth (:2966): return bm * XP_PER_BIOMASS * Difficulty.xp_mult(self, player.level) (co-op is gated off; XP is shared party credit, pilot 0’s level gates the boost).
Boss HP (:2413): var hp_mult := _danger_mult() * boss_rotation.hp_scale(self) * HardTuning.boss_hp_mult(self) * Difficulty.boss_hp_mult(self).
_hurt_player after the hard_tuning block (:3310):
amount *= Difficulty.boss_dmg_mult(self, source) # Normal: boss hits only; 1.0 on Hardand at :3322: pilot.inv = DAMAGE_GATE_WINDOW * Difficulty.hit_grace_mult(self).
_add_gold (:873): _gold_frac += amount * GOLD_MULT * meta_gold_mult * Difficulty.gold_mult(self).
-
Step 4: Verify
scripts/check-test-count.sh→ pass; determinism files green and unchanged. -
Step 5: Commit
feat(difficulty): Normal XP boost, softer bosses, longer hit grace, gold hook.
Task 4: Save fields + per-mode best
Section titled “Task 4: Save fields + per-mode best”Files:
- Modify:
sim/meta_state.gd(:69, :367, :407-408),meta/review_prompt.gd(:46-58),main.gd:2512,tests/test_review_prompt.gd,docs/architecture/meta-and-story.md:30 - Create:
tests/test_meta_difficulty.gd
Interfaces:
-
Produces:
MetaState.survival_difficulty: String("normal"default);MetaState.best_survival_by_mode: Dictionary({"normal": float, "hard": float});MetaState.best_survival(mode_id: String) -> float;ReviewPrompt.note_run(meta, mode, run_time, bosses, difficulty_id: String = "normal") -> String.best_survival_sis REMOVED (readers updated). -
Step 1: Failing tests
tests/test_meta_difficulty.gd:
extends GutTest
func test_fresh_save_is_normal_with_zero_bests() -> void: var m := MetaState.new() assert_eq(m.survival_difficulty, "normal") assert_eq(m.best_survival("normal"), 0.0) assert_eq(m.best_survival("hard"), 0.0)
func test_round_trip() -> void: var m := MetaState.new() m.survival_difficulty = "hard" m.best_survival_by_mode = {"normal": 200.0, "hard": 95.5} var r := MetaState.new() r.from_dict(m.to_dict()) assert_eq(r.survival_difficulty, "hard") assert_eq(r.best_survival("normal"), 200.0) assert_eq(r.best_survival("hard"), 95.5)
func test_legacy_best_loads_as_the_hard_best_and_lands_on_normal() -> void: var d := MetaState.new().to_dict() d.erase("survival_difficulty") d.erase("best_survival_by_mode") d["best_survival_s"] = 140.0 var r := MetaState.new() r.from_dict(d) assert_eq(r.survival_difficulty, "normal") assert_eq(r.best_survival("hard"), 140.0) assert_eq(r.best_survival("normal"), 0.0)
func test_corrupt_values_fall_back() -> void: var d := MetaState.new().to_dict() d["survival_difficulty"] = 7 d["best_survival_by_mode"] = {"normal": "x", "hard": -3.0, "nightmare": 50.0} var r := MetaState.new() r.from_dict(d) assert_eq(r.survival_difficulty, "normal") assert_eq(r.best_survival("normal"), 0.0) assert_eq(r.best_survival("hard"), 0.0) assert_false(r.best_survival_by_mode.has("nightmare"))
func test_new_best_is_per_mode() -> void: var m := MetaState.new() m.best_survival_by_mode = {"normal": 0.0, "hard": 300.0} assert_eq(ReviewPrompt.note_run(m, "survival", 150.0, 0, "hard"), "") assert_eq(m.best_survival("hard"), 300.0) ReviewPrompt.note_run(m, "survival", 150.0, 0, "normal") assert_eq(m.best_survival("normal"), 150.0) assert_eq(ReviewPrompt.note_run(m, "survival", 200.0, 0, "normal"), ReviewPrompt.REASON_BEST)Update every best_survival_s use in tests/test_review_prompt.gd (lines from the map: 49-141) to best_survival_by_mode["normal"] / best_survival("normal"), keeping each test’s intent; note_run calls without the new arg default to "normal".
-
Step 2: Run → FAIL.
-
Step 3: Implement.
sim/meta_state.gd:
var survival_difficulty: String = "normal" # Difficulty id for the next Survival run (start-menu chip)var best_survival_by_mode: Dictionary = {"normal": 0.0, "hard": 0.0} # longest human run per mode
func best_survival(mode_id: String) -> float: return float(best_survival_by_mode.get(mode_id, 0.0))Replace best_survival_s in to_dict with "survival_difficulty": survival_difficulty, "best_survival_by_mode": best_survival_by_mode.duplicate(),. In from_dict replace :407-408 with:
var sd: Variant = d.get("survival_difficulty", "normal") survival_difficulty = String(sd) if sd is String and (String(sd) == "normal" or String(sd) == "hard") else "normal" best_survival_by_mode = {"normal": 0.0, "hard": 0.0} var bm: Variant = d.get("best_survival_by_mode", null) if bm is Dictionary: for k: String in ["normal", "hard"]: best_survival_by_mode[k] = _sane_best((bm as Dictionary).get(k, 0.0)) else: # Legacy (Dev saves only, a67b0d36): the single best was set on today's tuning = Hard. best_survival_by_mode["hard"] = _sane_best(d.get("best_survival_s", 0.0))
static func _sane_best(v: Variant) -> float: return float(v) if (v is int or v is float) and is_finite(float(v)) and float(v) >= 0.0 else 0.0(Put _sane_best beside _sane_int, not inside from_dict.)
meta/review_prompt.gd note_run: add difficulty_id: String = "normal" and replace the best block with:
var best := meta.best_survival(difficulty_id) if mode == "survival" and run_time > best: if reason == "" and best > 0.0 and run_time >= MIN_BEST_S: reason = REASON_BEST meta.best_survival_by_mode[difficulty_id] = run_timemain.gd:2512: pass Difficulty.id(sim.difficulty) as the 5th arg. Update docs/architecture/meta-and-story.md:30 to describe best_survival_by_mode + survival_difficulty.
-
Step 4: Verify
git grep -n best_survival_s -- '*.gd'→ only the legacy read infrom_dict;scripts/check-test-count.sh→ pass. -
Step 5: Commit
feat(difficulty): save the chosen mode and a best per mode.
Task 5: Run-start pick, replay header, telemetry, results + pause tags
Section titled “Task 5: Run-start pick, replay header, telemetry, results + pause tags”Files:
- Modify:
main.gd(:1594-1595 run start; :2018-2029 run_start; :2620-2631 run_end;_bank_run→report_runaround :2518-2524; :818-819 pause),input/replay_recorder.gd(:63-65 + adifficultyfield),tools/replay_viewer/viewer.gd:29,ui/results_panel.gd:301,ui/pause_menu.gd:489 - Create:
tests/test_difficulty_run_start.gd
Interfaces:
- Consumes:
Difficulty.*,MetaState.survival_difficulty. - Produces:
main.run_difficulty_for(auto: bool, learning: bool, replay: Dictionary, meta: MetaState) -> int(static, pure);ReplayRecorder.difficulty: String; telemetry key"difficulty"("normal"/"hard"in Survival,""otherwise);PauseMenu.set_run_time(secs: float, tag: String = "");ResultsPanelTIME caption carries the tag.
Decision recorded here (flag to Chris in the merge summary): attract runs play NORMAL (spec); learning-autopilot persona runs stay HARD so the MAP-Elites archive and benchmark stay comparable with everything learned so far; a replay plays the mode in its header, and a header without the key is HARD (every pre-1.1 replay was recorded on today’s tuning). Co-op (gated off) is untouched: NetCoopRun.build_sim leaves the HARD default; the spec’s “host’s mode applies” is deferred with co-op itself (1.2).
- Step 1: Failing tests
tests/test_difficulty_run_start.gd:
extends GutTest
const MainScript := preload("res://main.gd")
func _meta(id: String) -> MetaState: var m := MetaState.new() m.survival_difficulty = id return m
func test_human_plays_the_saved_mode() -> void: assert_eq(MainScript.run_difficulty_for(false, false, {}, _meta("hard")), Difficulty.HARD) assert_eq(MainScript.run_difficulty_for(false, false, {}, _meta("normal")), Difficulty.NORMAL)
func test_attract_plays_normal_whatever_the_save() -> void: assert_eq(MainScript.run_difficulty_for(true, false, {}, _meta("hard")), Difficulty.NORMAL)
func test_learning_runs_stay_hard() -> void: assert_eq(MainScript.run_difficulty_for(true, true, {}, _meta("normal")), Difficulty.HARD)
func test_replay_header_wins_and_old_replays_are_hard() -> void: assert_eq(MainScript.run_difficulty_for(false, false, {"seed": 1, "difficulty": "normal"}, _meta("hard")), Difficulty.NORMAL) assert_eq(MainScript.run_difficulty_for(false, false, {"seed": 1}, _meta("normal")), Difficulty.HARD)
func test_replay_header_records_difficulty() -> void: var rec := ReplayRecorder.new(5, {}) rec.difficulty = "normal" var path := "user://test_difficulty_replay.json" assert_true(rec.save(path)) var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) assert_eq(String((d as Dictionary).get("difficulty", "")), "normal") DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
func test_pause_time_line_carries_the_tag() -> void: var p := PauseMenu.new() add_child_autofree(p) p.set_run_time(75.0, "NORMAL") assert_string_contains(p._time_label.text, "NORMAL")Check ReplayRecorder.new’s real args (input/replay_recorder.gd _init) and whether PauseMenu.new() builds _time_label in _ready (if it needs a scene, follow the existing pause-menu test setup: git grep -ln "PauseMenu" tests | head). If preload("res://main.gd") is heavy in other tests, follow how tests/test_replay_picks_main.gd reaches main’s static helpers (fresh_save_wanted is one).
-
Step 2: Run → FAIL.
-
Step 3: Implement.
main.gd, a static helper next to fresh_save_wanted:
# Which Survival difficulty this run plays (sim/difficulty.gd). A replay plays the mode it was# recorded on (no key = pre-1.1 = today's tuning = HARD); learning-autopilot runs stay HARD so the# persona archive stays comparable; attract shows the default (NORMAL); a human plays their pick.static func run_difficulty_for(auto: bool, learning: bool, replay: Dictionary, m: MetaState) -> int: if not replay.is_empty(): return Difficulty.from_id(String(replay.get("difficulty", "hard"))) if learning: return Difficulty.HARD if auto: return Difficulty.NORMAL return Difficulty.from_id(m.survival_difficulty if m != null else "normal")At :1594-1595:
if _mode == "survival" and not net_start: # NetCoopRun.build_sim already did it, on every peer sim.enable_survival() sim.difficulty = run_difficulty_for(not human, _learning_this_run, _replay_play, meta) Difficulty.apply_run_start(sim) if _replay_rec != null: _replay_rec.difficulty = Difficulty.id(sim.difficulty)Check that human is in scope at that point in _new_run (_auto is assigned at :2010, which may be later); if it is not, use the same expression _new_run uses to decide human and note it in the commit.
input/replay_recorder.gd: add var difficulty: String = "" and add "difficulty": difficulty to the saved dict. tools/replay_viewer/viewer.gd:29: include data.get("difficulty", "hard") in the print.
Telemetry: add "difficulty": _difficulty_tag() to the run_start dict (:2018-2029), the run_end dict (:2620-2631) and the report_run body (pass it through GameplayTelemetry.report_run’s existing dict merge; check its signature at net/gameplay_telemetry.gd:73), with
func _difficulty_tag() -> String: return Difficulty.id(sim.difficulty) if sim != null and _mode == "survival" and Difficulty.applies(sim) else ""Results (ui/results_panel.gd:301): caption "TIME" if tag == "" else "TIME · " + tag where tag := Difficulty.label(sim.difficulty) if sim.story == null and sim.tutorial == null else "".
Pause (ui/pause_menu.gd:489): func set_run_time(secs: float, tag: String = "") appending " · " + tag when non-empty; main.gd:818 passes Difficulty.label(sim.difficulty) if _difficulty_tag() != "" else "".
-
Step 4: Verify
scripts/check-test-count.sh→ pass. A headless boot check perdocs/reference/running-and-testing.md(main boots to menu, no script errors). -
Step 5: Commit
feat(difficulty): pick the run's mode, record it in replays/telemetry, tag results + pause.
Task 6: NORMAL / HARD chip on the start-menu Survival row
Section titled “Task 6: NORMAL / HARD chip on the start-menu Survival row”Files:
- Modify:
ui/start_menu.gd(build_items:81-92,_make_row:358-402,_input:470-515,_on_row_gui_input:523-533),main.gd(:736 area + :745 signal hookup),tests/test_start_menu.gd
Interfaces:
- Produces:
StartMenu.difficulty: String("normal"/"hard");signal difficulty_changed(id: String);StartMenu.flip_difficulty() -> void;StartMenu.survival_subtitle(id: String) -> String(static).
Behaviour (spec): the Survival row shows a NORMAL ‹ › HARD chip on its right with the active value lit. Pad / Siri Remote: left/right while the Survival row is selected flips it (no vertical move); select starts. Touch/mouse: a tap on the chip flips; a tap elsewhere on the row starts. The row subtitle follows the mode. The flip is saved at once (main writes meta.survival_difficulty and calls MetaStore.save_state(meta)).
- Step 1: Failing tests added to
tests/test_start_menu.gd(reuse that file’s existing helpers for building the menu, sending actions and tapping rows; read them first):
func test_subtitle_follows_the_mode() -> void: assert_eq(StartMenu.survival_subtitle("normal"), "Endless waves, build your run") assert_eq(StartMenu.survival_subtitle("hard"), "The full challenge")
func test_left_right_on_survival_flips_the_mode_and_saves() -> void: var m := _menu_with_survival_selected() # helper: build, select the "survival" row var got: Array = [] m.difficulty_changed.connect(func(id: String) -> void: got.append(id)) _press(m, "ui_right") assert_eq(m.difficulty, "hard") assert_eq(got, ["hard"]) assert_eq(_selected_id(m), "survival", "no vertical move") _press(m, "ui_left") assert_eq(m.difficulty, "normal")
func test_left_right_elsewhere_still_do_nothing() -> void: # keeps test_left_right_do_not_move_the_vertical_list's intent on non-Survival rows var m := _menu_with_row_selected("shop") _press(m, "ui_right") assert_eq(m.difficulty, "normal")
func test_tap_on_chip_flips_tap_on_row_starts() -> void: var m := _menu_with_survival_selected() var started: Array = [] m.mode_chosen.connect(func(id: String) -> void: started.append(id)) _tap_at(m, _chip(m).get_global_rect().get_center()) assert_eq(m.difficulty, "hard") assert_eq(started, []) _tap_at(m, _survival_row(m).get_global_rect().position + Vector2(20, 20)) assert_eq(started, ["survival"])…plus extend test_every_row_is_on_screen_at_every_device_profile (:309) and test_every_row_with_online_fits_on_every_device (:341) to also assert the chip rect is inside the safe rect AND inside its row, at all three ShopLayoutFixture profiles, with difficulty = "hard" (the wider subtitle state) as well as "normal". Write any helper (_menu_with_survival_selected, _press, _tap_at, _chip, _survival_row, _selected_id) from the file’s existing patterns; _chip(m) finds the node by name "DifficultyChip".
-
Step 2: Run → FAIL.
-
Step 3: Implement in
ui/start_menu.gd:var difficulty: String = "normal"andsignal difficulty_changed(id: String).static func survival_subtitle(id: String) -> String: return "The full challenge" if id == "hard" else "Endless waves, build your run";build_itemsuses it for the survival row’s desc (both variants at :86 and :92 — the :92 non-v0.1 text stays for that variant if it is a different mode set; check which flag selects it and only swap the v0.1 one if :92 is a Dev-only list).- In
_make_row, whenid == "survival", append to the row’s HBox aHBoxContainernamed"DifficultyChip"with twoLabels (“NORMAL”, “HARD”) using the menu’s existing label font token andTokenscolours (lit = the row’s accent role, unlit = the dim/secondary token the Desc label uses),size_flags_horizontal = SIZE_SHRINK_END,mouse_filter = MOUSE_FILTER_STOP. Give the Title/Desc VBoxSIZE_EXPAND_FILLso the Desc ellipsis trims before the chip is pushed off-row. func flip_difficulty() -> void: toggle, refresh chip + desc,difficulty_changed.emit(difficulty)._input: when the selected row issurvivalandevent.is_action_pressed("ui_left") or ("ui_right")(and the joypad d-pad equivalents the file already maps throughMenuNav), callflip_difficulty()andget_viewport().set_input_as_handled(). Other rows: unchanged (no-op).- Touch tap / mouse click: before activating the row, if the press position is inside the chip’s global rect, flip instead of activate.
- Fit: no new token literals (the literal ratchet: use existing
Tokensvalues;docs/architecture/ui-tokens.md).
main.gd: next to :736 set start_menu.difficulty = meta.survival_difficulty if meta != null else "normal"; next to :745 connect:
start_menu.difficulty_changed.connect(func(id: String) -> void: if meta != null: meta.survival_difficulty = id MetaStore.save_state(meta))-
Step 4: Verify
scripts/check-test-count.sh→ pass; run./scripts/ui-sweep.shfor the start menu at iPhone/iPad/TV and look at the captures (both chip states). -
Step 5: Commit
feat(ui): NORMAL / HARD chip on the Survival row. -
Step 6: Dispatch
bh-visual-qaon the start menu (both states, three devices) before merge; fix what it flags in a follow-up commit.
Task 7: Calibrate Normal with the balance probe + economy check
Section titled “Task 7: Calibrate Normal with the balance probe + economy check”Files:
-
Add from branch:
tools/balance_probe/balance_probe.gd(git checkout origin/balance-1.1-proposals -- tools/balance_probe/balance_probe.gd; tool only, NOT the proposal patches or defaults) -
Modify:
tools/balance_probe/balance_probe.gd(--difficulty, boss-seen count),sim/difficulty.gd(final defaults only if the probe says so) -
Create:
docs/research/2026-10-16-difficulty-normal-calibration.md(Starlighttitle:frontmatter; reachable via the docs-siteresearchsymlink) -
Step 1: Add
--difficulty=normal|hard(defaulthard) to the probe:cfg["difficulty"] = String(args.get("difficulty", "hard")); add"boss_seen": not sim.boss_log.is_empty()to each row and| boss seen %d/%dto the summary’s first line. Print the difficulty in the==header. -
Step 2: Baseline + Normal runs (40 seeds per arm,
median= new-player proxy; run each in the background, wrap withperl -e 'alarm 3600; exec @ARGV'):
godot --headless --path . -s res://tools/balance_probe/balance_probe.gd -- --seeds=40 --seed0=5000 --ticks=21600 --profile=median --difficulty=hard --label=hard --json=$TMPDIR/probe-hard.jsongodot --headless --path . -s res://tools/balance_probe/balance_probe.gd -- --seeds=40 --seed0=5000 --ticks=21600 --profile=median --difficulty=normal --label=normal --json=$TMPDIR/probe-normal.jsonAcceptance: Normal median survival ≥ 150 s AND boss seen in > 50% of runs; Hard within noise of the 59.1 s baseline in the proposals doc (it must be: Hard is the identity). Also run --profile=strong --difficulty=normal once to confirm Normal isn’t trivial for a skilled player (record it, no gate).
-
Step 3: Tune if short. Re-run the Normal arm with
--tun=Difficulty.NORMAL_XP_MULT=…,Difficulty.NORMAL_BIOMASS_START_FRAC=…etc. (Tunables.apply handles registered keys). Change at most two knobs per iteration; record every arm in the research doc. When an arm passes, set those values as thestatic vardefaults insim/difficulty.gdand re-runtests/test_difficulty*.gd(they read the vars, not literals, excepttest_both_gold_rates_ship_at_one). -
Step 4: Economy check (skill
bh-economy-sim): run the careers on Normal and Hard (add the soakdifficultykey to the sim’s run cfg iftools/economy_simbuilds SoakRun cfgs; follow the skill). Report gold/minute and runs-to-complete-shop per mode. Gold rates stay 1.0/1.0 unless a gap is large enough that Chris should decide; if so, write the numbers in the doc and flag, don’t change the rate. -
Step 5: Write the research doc (all arms, the chosen defaults, the economy numbers, caveats: the bot proxy is not a human; final values stay console tunables).
-
Step 6: Verify + commit
scripts/check-test-count.sh→ pass; commitbalance(difficulty): calibrate Normal defaults with the balance probe.
Task 8: Copy, docs, release-queue tick
Section titled “Task 8: Copy, docs, release-queue tick”Files:
-
Modify:
marketing/appstore/whats_new_1.1_ios.txt,whats_new_1.1_tvos.txt,whats_new_1.1_macos.txt,control/src/whats_new.js(the singlebuild: nullentry),docs/architecture/meta-and-story.md,docs/RELEASE-1.1.md(§2 Difficulty item:✅ DONE (commit)once merged) -
Step 1: Add one bullet to each What’s New file, matching that file’s bullet style, no em-dashes:
• Normal and Hard Survival: Normal is the new default, Hard is the full challenge. -
Step 2: Add to the
build: nullentry’sitemsincontrol/src/whats_new.js:{ text: 'Survival has Normal (the new default) and Hard (the old tuning). Flip it on the Survival row: left/right on a pad or remote, or tap the chip.', where: 'Start menu: Survival row' },and run the control tests (control/test/whats-new.test.mjs). -
Step 3:
docs/architecture/meta-and-story.md: a short “Difficulty modes” section (where the mode lives, Hard = identity, the run-start pick rules from Task 5, the knobs table pointer tosim/difficulty.gd). -
Step 4:
scripts/check-test-count.sh→ pass; commitdocs(difficulty): What's New, control changelog, architecture.
After the tasks (controller, not a subagent)
Section titled “After the tasks (controller, not a subagent)”bh-revieweron the whole branch, then merge via skillbh-merge-reviewed-branch;git push origin mainimmediately.- Dev install (skill
bh-deploy) for Chris’s playtest: Eve on Normal, Toby on Hard. - B1 Orbit/Nova remote patch (
remote-config/proposals/orbit-nova-b1.jsononbalance-1.1-proposals): publish ONLY with Chris’s explicit OK (docs/architecture/remote-config.md). - Remind Chris of the two decisions made here that the spec didn’t spell out: learning-autopilot runs stay HARD; co-op keeps HARD until co-op ships.
Execution handoff
Section titled “Execution handoff”- Decisions from the planning chat (2026-10-15): Chris approved the spec (“ok”) after one change: gold is 1.0x in both modes (he first said Hard should get less; the counter-argument, Hard’s denser field already pays more per minute and a lower Hard rate makes Normal the farm, was accepted: “go with 1.0 both”). Spec commit cef5cd11.
- Decisions this plan makes that the spec didn’t spell out (tell Chris in the merge summary): learning-autopilot runs stay HARD (archive comparability); co-op keeps the HARD default until co-op ships; soak cfg
difficultydefaults to"hard"so existing soak/economy baselines don’t move; the “store description line” from the spec: add one line to the App Store description file inmarketing/appstore/if one exists for 1.1 (Task 8), matching the veracity rules (memorydark-cosmos-launch-assets). - Authorized: building on a branch/worktree, merging after
bh-reviewerMERGE + gate, pushing main. Needs Chris’s same-day OK: the B1 Orbit/Nova remote patch publish, any Dev device install that uninstalls (wipes the Dev save), any App Store submission. - Nothing else done yet outside the plan/spec commits.
balance-1.1-proposalsbranch is untouched; only its probe tool file is taken (Task 7). - Deadline: 1.1 feature freeze 2026-10-17. If time runs short, Tasks 1-6 + 8 are the shippable core; Task 7 calibration can land the same day as a defaults-only commit or a remote tunables patch.
- Next action: set up the worktree
.claude/worktrees/difficulty-modes(branchdifficulty-modesofforigin/main), readbh-sdd-concurrent, dispatch Task 1 to abh-implementer(model opus) with the worktree’s absolute path (do NOT also setisolation: "worktree").