Storyline Mode — "Ascent: The Spectrum" — Design Spec
Storyline Mode — “Ascent: The Spectrum” — Design Spec
Section titled “Storyline Mode — “Ascent: The Spectrum” — Design Spec”Date: 2026-06-25
Status: In progress (autonomous build loop)
Mode codename: story (a.k.a. “Ascent”)
A second game mode for Bullet Heaven: a hand-authored, gradually-escalating campaign you fly through — a connected neon world of elemental domains, with locked combat rooms, talking enemies, NPCs you meet, scripted weapon progression, and hidden secret areas. The existing endless survival mode is untouched and remains the default.
1. Vision & pillars
Section titled “1. Vision & pillars”Survival mode is chaos-from-second-one: instant swarms, random upgrades, score attack. Story mode is the opposite curve — a deliberate, legible ramp that teaches the elemental build-craft system through play and gives the world a reason to exist.
Pillars:
- Very gradual progression. You start with one weak weapon against one weak enemy. Difficulty climbs across four domains. Weapons and abilities are story rewards, not random drops — every acquisition is a designed beat.
- A varied map you fly through. A connected graph of rooms (corridors, arenas, safe rooms, secret rooms, boss arenas) with real walls and doors. The camera follows your ship; you explore, not auto-scroll.
- Characters & voice. NPCs you meet and talk to; enemies that taunt you; a rival; an antagonist needling you the whole way up. Short, punchy, neon-flavored dialogue.
- Locked rooms & secrets. Combat arenas seal behind you (energy gates) until cleared. Hidden passages lead to secret rooms holding weapons and power-ups, rewarding exploration.
Non-goals (for now): branching story paths, save/continue mid-run, voice audio. Keep it a single linear-with-secrets ascent first; depth later.
2. The fiction
Section titled “2. The fiction”World — The Spectrum. A vast dying prism of pure light. It was once in elemental harmony; a corruption called the Null is unweaving it from the top down, turning its inhabitants hostile and draining color from the world. You are a Spark — a surviving fragment of clean light — flying up through the Spectrum toward the Core to reignite it before the Null reaches the bottom and the whole structure goes dark.
Structure — four Domains, ascending:
- Ember Reach (Fire) — warm oranges/reds. Tutorial domain. Gentle. Meet your guide.
- The Hush (Cold) — blues/whites, slowed and quiet. Introduces hazards, a tragic ally.
- Live Wire (Lightning) — yellows/violets, fast and loud. A rival Spark; chaotic arenas.
- The Null Core (Void) — desaturated, glitching. The final ascent; the antagonist made flesh.
Each domain ends with a corrupted Warden (a boss with a domain-themed attack set).
Characters:
- Echo — your guide. A soft, older fragment who has done this before and failed. Speaks at checkpoints; gives your first weapon; warm, weary, encouraging. “Up is the only way that was ever open. Go.”
- The Archivist — a neutral collector who lives in safe rooms. Trades lore for nothing, sells power-ups, hints at where secrets hide. Dry, amused. “Two walls in this domain are lying to you. I won’t say which.”
- Rime — a fragment frozen mid-flight in The Hush; you free her. Bitter, then loyal. Her arc: she helps you, then chooses to stay behind to hold a gate. The first real loss.
- Vex — a rival Spark in Live Wire. First fights you (a taunting mini-boss), then, beaten, flies with you out of self-interest. Cocky, fun, unreliable. “You’re slow, bright, and lucky. I can work with two of those.”
- The Null — the antagonist. A voice with no body until the Core. Taunts you across the whole run, learns your name, gets quieter and more personal as you climb. “You light one room and call it dawn.”
Talking enemies: domain captains (elite/skirmisher types) get one-liners on spawn and on death; Wardens get a telegraph taunt before each attack phase.
3. Architecture — the mode-shell seam
Section titled “3. Architecture — the mode-shell seam”Story mode is additive and opt-in. The determinism baseline (4152236597/1267954985)
must hold: the survival test builds Sim.new(seed, content) and ticks with no story set.
Simgains an optional memberstory: StoryState = null. Default null → every existing behavior is byte-identical (survival). Story is enabled bysim.enable_story(story_data)AFTER construction (never in the determinism test path).- In
tick(), story hooks are guarded:- Spawning:
if story == null: _spawn_enemies(dt)else theStoryDirectorscripts spawns (the ring-spawn SpawnDirector is replaced, not augmented, in story mode). - Walls:
if story != null: _resolve_walls()after movement integration (circle-vs-AABB push-out for the player and enemies). - Room logic / dialogue triggers:
if story != null: story.update(self, dt).
- Spawning:
- All story types live in
/simandextends RefCounted(pure logic, no Node/Engine/File APIs) so the campaign sim stays headless-testable and Phase-3-ready:sim/story_state.gd(StoryState) — the loaded map + live run state (current room, gate open/closed flags, which secrets found, story flags, dialogue queue).sim/story_director.gd(StoryDirector) — the tick-driven brain: room enter/clear detection, scripted spawn waves, gate sealing/opening, dialogue + reward triggers.sim/story_data.gd(StoryData) — pure parsed campaign data (rooms, walls, encounters, dialogue, rewards), built by a loader OUTSIDE/sim.
- Loader
content/story_loader.gd(StoryLoader, usesFileAccess/JSON, lives outside/sim) parsesres://data/story.json→StoryData, with fail-loud validation (schemaVersiongate, ref-integrity room↔door↔encounter, required entry room). Test seam:StoryLoader.load_from_dict(raw). - Dialogue & story events flow render-ward exactly like
fx_events:story.events: Array[Dictionary](kinds:say,reward,gate,room,objective), cleared/appended per tick, NOT included insnapshot_string()/state_checksum()(determinism-neutral).
This mirrors the existing decoupling: sim produces data, render consumes it.
4. Map / room model
Section titled “4. Map / room model”The world is one large 2D space partitioned by walls into rooms connected by doors.
- Room
{id, kind, rect:Rect2, region, doors:[door_id], encounter?, secret?}. Kinds:corridor(travel),arena(locked fight),safe(NPC/heal/shop),secret(hidden reward),boss(Warden),portal(region transition / domain end). - Door
{id, rect:Rect2, a:room_id, b:room_id, gate?:gate_id}— a gap in a wall joining two rooms. A door with agatecan be sealed (an energy wall spawns across it). - Wall — derived: each room contributes its 4 boundary AABBs minus the door gaps. Gates are togglable wall AABBs across a door rect. Collision is circle-vs-AABB push-out; cheap and deterministic.
- Current room is whichever room rect contains the player center; transitions fire enter/ exit triggers. (Doors overlap both rooms slightly so the handover is clean.)
The map is authored coarsely in story.json as a list of rectangular rooms + doors; walls are
generated from them at load (so authors never hand-place wall segments).
5. Room gating & scripted encounters
Section titled “5. Room gating & scripted encounters”When the player enters an arena room with an uncleared encounter:
- Seal — the room’s gated doors close (gate walls activate). A
gateevent + a short “Hold this room.” dialogue beat may fire. - Spawn waves — the
StoryDirectorruns the encounter’s wave script: each wave lists{type, count, delay}; spawns are placed at the room’s spawn points (or ringed inside the room rect), into the existingEnemyPool. Enemy HP/damage come from the bible, optionally scaled by an encounterdifficulty. - Clear condition — when all encounter-spawned enemies are dead (tracked by a live count),
the encounter is
cleared: gates open, an optionalrewardtriggers (weapon grant, heal, power-up), and a story flag is set.
Encounters are data: {waves:[...], reward?:..., on_clear_flag?:..., dialogue?:[...]}.
Progression is gradual because the author controls every wave — Ember Reach room 1 is literally “1 swarmer,” room 2 “3 swarmers + a line of dialogue,” and it climbs from there.
6. Dialogue system
Section titled “6. Dialogue system”- Data:
story.jsondialogueentries{id, speaker, color, lines:[...]}andspeakers{id, name, color}. Triggers reference dialogue ids. - Triggers (evaluated in
StoryDirector.update):on_enter_room,on_clear_room,on_boss_spawn,on_boss_phase,on_low_hp,on_secret_found,on_reward, plus enemyon_spawn/on_deathtaunts attached to an encounter or enemy type. - The sim emits a
sayevent{speaker, name, color, line}; the render layer (ui/dialogue_box.gd, a neon speaker box bottom-center) queues and time-reveals lines. Each fires once (guarded by aseenset inStoryState), so it’s deterministic and non-repeating. - Dialogue does not pause the action by default (lines float during play); Warden pre-fight taunts may use a brief beat. Keep lines ≤ ~60 chars so they read at a glance.
7. NPCs & interactions
Section titled “7. NPCs & interactions”- NPCs are lightweight story entities (
StoryState.npcs: [{id, pos, speaker, dialogue, reward?, one_shot}]), not in the EnemyPool (they don’t fight). Rendered as friendly glowing diamonds with a name tag. - Interaction: fly within
NPC_TALK_RADIUS→ fire their dialogue + (once) their reward. Safe-room NPCs (Archivist) can offer a small choice later; v1 is “approach → talk → receive.” - Rewards reuse the same grant path as encounter rewards (weapon unlock, heal, power-up, ability). This is how Echo gives weapon #1 and Rime/Vex give later kit.
8. Talking enemies
Section titled “8. Talking enemies”- Encounter/enemy dialogue triggers (§6) cover taunts. A captain spawns → one
say; a Warden enters a new attack phase → asay. Cheap, data-driven, fire-once. - Reuses the existing boss/skirmisher entities; only the dialogue triggers are new. No new combat code for “talking.”
9. Secret areas
Section titled “9. Secret areas”- A
secretroom is hidden: reached through a door whose gate wall looks like a normal wall but is actually passable (or breaks when shot / when the player nudges it). v1: a “false wall” door — flaggedsecret, rendered like a wall but with a subtle tell, and non-colliding so the ship can slip through. Entering the secret room marks itfound, fires a discovery event + dialogue, and grants its reward (a weapon or a strong power-up). - The Archivist hints which domains hide secrets (flavor + a soft nudge). A run can be completed without secrets; finding them makes the ascent easier and unlocks bonus weapons.
10. Progression & weapon gating
Section titled “10. Progression & weapon gating”- Start: blade only (as survival). No random level-up picker in story mode — instead:
- Killing enemies still grants XP and the stat level-up picker can still appear (reuse the existing flow) OR be suppressed in favor of pure story grants. Decision: keep the stat picker (it’s a proven, satisfying beat) but make weapons purely story-granted, so the two systems don’t fight. (Survival keeps weapon-in-picker.)
- Weapon grants by beat: Echo → (already have blade); Ember Warden clear → nova (fire);
Hush secret/Rime → beam (cold); Live Wire/Vex → pulse (lightning) + scatter;
pre-Core → orbit/turret. Exact mapping tunable in
story.jsonrewards.
Sim.grant_weapon(id)already exists (loadout system). Story rewards call it directly, so no new weapon plumbing.
11. Data format — data/story.json
Section titled “11. Data format — data/story.json”{ "schemaVersion": 1, "speakers": [ {"id":"echo","name":"Echo","color":"#ffd27f"}, ... ], "dialogue": [ {"id":"intro","speaker":"echo","lines":["...", "..."]}, ... ], "regions": [ {"id":"ember","name":"Ember Reach","tint":"#ff7a3c"}, ... ], "rooms": [ {"id":"ember_entry","kind":"corridor","region":"ember","rect":[0,0,1200,500], "doors":["d_entry_a1"], "on_enter":"intro"}, {"id":"ember_a1","kind":"arena","region":"ember","rect":[0,520,1200,900], "doors":["d_entry_a1","d_a1_safe"], "encounter":"ember_e1"}, ... ], "doors": [ {"id":"d_entry_a1","rect":[520,500,160,40],"a":"ember_entry","b":"ember_a1","gate":"g1"} ], "encounters": [ {"id":"ember_e1","waves":[{"type":"swarmer","count":1,"delay":0.5}], "reward":null,"dialogue":["first_blood"]} ], "npcs": [ {"id":"echo_intro","room":"ember_entry","pos":[600,250],"speaker":"echo", "dialogue":"intro","reward":null,"one_shot":true} ], "rewards": [ {"id":"give_nova","kind":"weapon","weapon":"nova"} ], "start_room":"ember_entry"}Hand-edited (like bible.json), tab-indented python round-trip for clean diffs. Loader
validates and fails loud on bad refs / missing start room / bad schemaVersion.
12. Determinism strategy (the rule we never break)
Section titled “12. Determinism strategy (the rule we never break)”storydefaults null; the determinism test never enables it → survival baseline byte-identical.- Story spawns use
sim.rng(the spawn stream) for any jitter, neverupgrade_rng. Prefer deterministic placement (fixed spawn points) over RNG where possible. story.events(dialogue/rewards) are excluded fromsnapshot_string/state_checksum.- Walls/gates change positions, so in story mode the checksum legitimately differs from survival — story mode gets its own determinism test (same seed + same scripted input → identical trace) rather than sharing survival’s pinned numbers.
13. Iteration plan (build order)
Section titled “13. Iteration plan (build order)”Each chunk: build + TDD in main → --import if new class → boot-check (no SCRIPT ERROR) →
full suite + check-test-count.sh → survival baseline 4152236597/1267954985 unchanged →
copy gameplay files to tvOS + bump Sim_Const.BUILD (only when it’s a shippable feature) →
commit to main. Progress tracked in docs/superpowers/plans/2026-06-25-storyline-mode.md.
- Spec + plan + progress tracker + loop (this chunk).
- Map model + StoryState skeleton — room/door/wall data, room-graph nav, wall generation from rooms; pure-logic tests. No tick changes yet.
- StoryLoader + StoryData + story.json (Ember skeleton) — load/validate; test seam.
- Wall collision —
_resolve_walls()circle-vs-AABB for player + enemies; story tick seam (enable_story, guarded branches); survival baseline re-verified unchanged. - Room tracking + gating — current-room detection, seal/open gates on enter/clear.
- StoryDirector scripted spawns — wave scripts spawn into EnemyPool; clear detection.
- Dialogue events + triggers (sim) —
sayevents, fire-once, all trigger kinds. - Dialogue render box (
ui/dialogue_box.gd) + speaker styling. - Map renderer — rooms, walls, gates, doors, region tint; the flythrough world.
- NPCs — entities, talk radius, reward-on-talk; render + tests.
- Talking enemies — captain/Warden taunt triggers wired to encounters.
- Secret areas — false-wall doors, discovery, secret rewards.
- Weapon/ability story gating — reward→grant wiring; suppress weapon picker in story.
- Region 1 content (Ember Reach) — full domain: rooms, encounters, dialogue, first Warden.
- Mode select — start screen Story vs Survival; main.gd wiring; story HUD/objective. 16+. Regions 2–4 content, Warden attack sets, secret weapons, polish.
Deploy a playable web build + tvOS build and update the site changelog once §15 lands (a playable Ember Reach vertical slice), then continue authoring domains.