Skip to content

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.


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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.


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:

  1. Ember Reach (Fire) — warm oranges/reds. Tutorial domain. Gentle. Meet your guide.
  2. The Hush (Cold) — blues/whites, slowed and quiet. Introduces hazards, a tragic ally.
  3. Live Wire (Lightning) — yellows/violets, fast and loud. A rival Spark; chaotic arenas.
  4. 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.


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.

  • Sim gains an optional member story: StoryState = null. Default null → every existing behavior is byte-identical (survival). Story is enabled by sim.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 the StoryDirector scripts 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).
  • All story types live in /sim and extends 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, uses FileAccess/JSON, lives outside /sim) parses res://data/story.jsonStoryData, with fail-loud validation (schemaVersion gate, 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 in snapshot_string()/state_checksum() (determinism-neutral).

This mirrors the existing decoupling: sim produces data, render consumes it.


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 a gate can 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).


When the player enters an arena room with an uncleared encounter:

  1. Seal — the room’s gated doors close (gate walls activate). A gate event + a short “Hold this room.” dialogue beat may fire.
  2. Spawn waves — the StoryDirector runs 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 existing EnemyPool. Enemy HP/damage come from the bible, optionally scaled by an encounter difficulty.
  3. Clear condition — when all encounter-spawned enemies are dead (tracked by a live count), the encounter is cleared: gates open, an optional reward triggers (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.


  • Data: story.json dialogue entries {id, speaker, color, lines:[...]} and speakers {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 enemy on_spawn/on_death taunts attached to an encounter or enemy type.
  • The sim emits a say event {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 a seen set in StoryState), 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.

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

  • Encounter/enemy dialogue triggers (§6) cover taunts. A captain spawns → one say; a Warden enters a new attack phase → a say. Cheap, data-driven, fire-once.
  • Reuses the existing boss/skirmisher entities; only the dialogue triggers are new. No new combat code for “talking.”

  • A secret room 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 — flagged secret, rendered like a wall but with a subtle tell, and non-colliding so the ship can slip through. Entering the secret room marks it found, 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.

  • 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.json rewards.
  • Sim.grant_weapon(id) already exists (loadout system). Story rewards call it directly, so no new weapon plumbing.

{
"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)”
  • story defaults null; the determinism test never enables it → survival baseline byte-identical.
  • Story spawns use sim.rng (the spawn stream) for any jitter, never upgrade_rng. Prefer deterministic placement (fixed spawn points) over RNG where possible.
  • story.events (dialogue/rewards) are excluded from snapshot_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.

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.

  1. Spec + plan + progress tracker + loop (this chunk).
  2. Map model + StoryState skeleton — room/door/wall data, room-graph nav, wall generation from rooms; pure-logic tests. No tick changes yet.
  3. StoryLoader + StoryData + story.json (Ember skeleton) — load/validate; test seam.
  4. Wall collision_resolve_walls() circle-vs-AABB for player + enemies; story tick seam (enable_story, guarded branches); survival baseline re-verified unchanged.
  5. Room tracking + gating — current-room detection, seal/open gates on enter/clear.
  6. StoryDirector scripted spawns — wave scripts spawn into EnemyPool; clear detection.
  7. Dialogue events + triggers (sim)say events, fire-once, all trigger kinds.
  8. Dialogue render box (ui/dialogue_box.gd) + speaker styling.
  9. Map renderer — rooms, walls, gates, doors, region tint; the flythrough world.
  10. NPCs — entities, talk radius, reward-on-talk; render + tests.
  11. Talking enemies — captain/Warden taunt triggers wired to encounters.
  12. Secret areas — false-wall doors, discovery, secret rewards.
  13. Weapon/ability story gating — reward→grant wiring; suppress weapon picker in story.
  14. Region 1 content (Ember Reach) — full domain: rooms, encounters, dialogue, first Warden.
  15. 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.