Skip to content

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 skill bh-sdd-concurrent before Task 1 and run sdd-worktree-leak-guard after 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).

  • Hard = today’s game exactly: every Difficulty helper returns the identity (1.0 / the base value) when sim.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.
  • /sim stays pure: sim/difficulty.gd extends RefCounted, no Node/Engine/Time/Input APIs.
  • Story and Tutorial never read the mode (Difficulty.active requires sim.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.gd entry + label in tools/tunable_docs/docs.d/difficulty.json + net/live_scope.gd classification; control/src/tunable_docs.js is 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 a class_name, run godot --headless --path . --import first, then git checkout -- project.godot audio/bus_layout.tres.
  • Work in a worktree off origin/main (.claude/worktrees/difficulty-modes, branch difficulty-modes), never in the main checkout.
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

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 (near var hard_tuning, ~:453), net/tunables_registry.gd (after the HardTuning block ~:225), net/live_scope.gd (after :160), tools/tunable_docs/generate.mjs (GROUP_RULES), tools/soak/soak_run.gd (:46-47)
  • 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); helpers biomass_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 (default Difficulty.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 Difficulty
extends 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 := 0
const 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.5
static 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.7
static var NORMAL_CHARGER_DMG_MULT: float = 0.75
static 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.5
static 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.75
static 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.5
static 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.0
static 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_hp

In 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, add const DIFFICULTY := "res://sim/difficulty.gd" beside HARD_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.gd after sim.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, then scripts/check-test-count.sh → all pass, determinism files untouched; cd control && npm test (or the command scripts/gate.sh uses 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 new dash_charge_s; :2662 enemy_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_sim style from tests/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_s missing / 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 ramp

sim/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 s

Replace 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, boss hp_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 Hard

and 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.


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_s is 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_time

main.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 in from_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_run around :2518-2524; :818-819 pause), input/replay_recorder.gd (:63-65 + a difficulty field), 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 = ""); ResultsPanel TIME 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 per docs/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" and signal 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_items uses 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, when id == "survival", append to the row’s HBox a HBoxContainer named "DifficultyChip" with two Labels (“NORMAL”, “HARD”) using the menu’s existing label font token and Tokens colours (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 VBox SIZE_EXPAND_FILL so 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 is survival and event.is_action_pressed("ui_left") or ("ui_right") (and the joypad d-pad equivalents the file already maps through MenuNav), call flip_difficulty() and get_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 Tokens values; 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.sh for 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-qa on 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 (Starlight title: frontmatter; reachable via the docs-site research symlink)

  • Step 1: Add --difficulty=normal|hard (default hard) 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/%d to 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 with perl -e 'alarm 3600; exec @ARGV'):

Terminal window
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.json
godot --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.json

Acceptance: 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 the static var defaults in sim/difficulty.gd and re-run tests/test_difficulty*.gd (they read the vars, not literals, except test_both_gold_rates_ship_at_one).

  • Step 4: Economy check (skill bh-economy-sim): run the careers on Normal and Hard (add the soak difficulty key to the sim’s run cfg if tools/economy_sim builds 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; commit balance(difficulty): calibrate Normal defaults with the balance probe.


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 single build: null entry), 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: null entry’s items in control/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 to sim/difficulty.gd).

  • Step 4: scripts/check-test-count.sh → pass; commit docs(difficulty): What's New, control changelog, architecture.


After the tasks (controller, not a subagent)

Section titled “After the tasks (controller, not a subagent)”
  1. bh-reviewer on the whole branch, then merge via skill bh-merge-reviewed-branch; git push origin main immediately.
  2. Dev install (skill bh-deploy) for Chris’s playtest: Eve on Normal, Toby on Hard.
  3. B1 Orbit/Nova remote patch (remote-config/proposals/orbit-nova-b1.json on balance-1.1-proposals): publish ONLY with Chris’s explicit OK (docs/architecture/remote-config.md).
  4. 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.
  • 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 difficulty defaults 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 in marketing/appstore/ if one exists for 1.1 (Task 8), matching the veracity rules (memory dark-cosmos-launch-assets).
  • Authorized: building on a branch/worktree, merging after bh-reviewer MERGE + 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-proposals branch 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 (branch difficulty-modes off origin/main), read bh-sdd-concurrent, dispatch Task 1 to a bh-implementer (model opus) with the worktree’s absolute path (do NOT also set isolation: "worktree").