Files
transcience/docs/ROADMAP.md
T
claude b8332b697d
ci / verify (push) Successful in 49s
Documentation pass for a cold start
Audited the whole set rather than appending to it, and the two files a fresh
session reads first were both wrong.

README claimed the emergency escape takes three seconds and is CANCELLED BY
DAMAGE. It takes one, and damage explicitly does not interrupt it -- that is a
settled decision with its own entry in DECISIONS.md, and the front page said
the opposite. It also described one stationary boss, one hub portal, and none
of characters, permadeath, levels, inventory, loot, upgrades, settings or
credits. Rewritten, with the "not ready for the internet" warning made explicit.

ROADMAP had no "what is next" at all: every stage reads *done*, which for a
cold start is a dead end. It opens with where things stand and a table of
candidates -- auth, a reason to play past level 15, an economy, hit feedback,
replacing the non-redistributable packs, DTLS -- each with what blocks it, and
says plainly that the user has chosen none of them. Open questions renumbered
from the orphaned 8-11 they were left at, with the solved one dropped and three
real ones added.

ARCHITECTURE's file map listed two files twice, missed five subsystems
(upgrades, stats, poison, settings, credits, the UI theme), and still said
MapGen.build() is "the entry point both sides use" -- it is server-only, and
the whole anti-map-hack story depends on that. Rebuilt by layer and audited
against the tree: every path listed exists, and every one of the 64 source
files is covered.

CLAUDE.md's security paragraph said a client can send "exactly two things"
plus two roster requests. There are five client -> server messages. That number
is the security model, so it is now a table naming each one and what it
carries. Also records that nothing automated can see the screen.

Smaller: a stale 156/156 test count in ASSETS.md, and test counts refreshed to
468 where they are quoted.

check.sh clean, 468 tests, SMOKE PASS, all four diagnostics green, no broken
internal links.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:20:10 +02:00

28 KiB
Raw Blame History

Status and roadmap

Read this first when picking up work. It maps every feature in the design brief to its current state and the files that implement it, so you can find the relevant code without re-reading the whole project.

Art and audio source packs, and their licence status, are recorded in ASSETS.md — the packs themselves are not in git.

Design decisions that are already settled — and the reasoning behind them — live in DECISIONS.md. Check there before asking the user something that may already have an answer.

Legend: done · partial (works, with a stated gap) · todo (not started)


Where things stand

Every stage in the original brief is built. The MVP asked for a twin-stick bullet hell, server-side hit validation, predictable enemies, an adaptable boss, a lobby hub with dungeon runs, and an emergency escape. All of that exists, plus the four stages that followed: characters and permadeath, inventory and loot, upgrades, and a second boss that moves.

So there is no obvious "next ticket". What follows is the honest list of candidates, none of which the user has chosen — ask before starting one.

Candidate Why it might be next What it needs first
Replace LocalAuthProvider It is the one thing standing between this and a server anyone else can reach. Any client can claim any account id. Nothing technical — it is one AuthProvider subclass. Needs a decision about Steam vs something else.
A reason to keep playing past level 15 Levels cap, --depth never rises in play, and a cleared dungeon gives experience and a potion. The long loop is missing. Open question 1 below.
An economy Loot only drops from kills; nothing sells anything. The quartermaster is the obvious shop and has no currency to take. Open questions 2 and 3.
Impact and hit feedback Art.IMPACT is loaded and validated and nothing plays it. No hit flash, no screen shake, no death animation. Nothing. This is the cheapest visible win.
Replace the two non-redistributable packs Bullet and FX art is local-only and non-commercial, which blocks both a public repo copy and a commercial release. CC0 or CC BY replacements. See ASSETS.md.
Transport encryption ENetMultiplayerPeer supports DTLS. Required before a public server, not before then. Follows the auth decision.

Each stage below also carries its own Known gaps section — those are smaller and more specific than the table above.

Known flake

tools/smoke.sh failed once in a pre-push hook and then passed on four consecutive runs with no stray processes. It was not reproduced and is not diagnosed. The suspicion is machine load — the smoke test has fixed tick budgets and was competing with a full suite run — but that is a guess. If it recurs, that is the first thing to check.


Verification surface

What "everything passes" currently means. Numbers move; the shape does not.

Gate Covers Runtime
tools/check.sh every script parses and type-checks ~5s
tools/test.sh 468 GUT tests, no SceneTree ~4s
tools/smoke.sh 23 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect ~40s
diag_prediction.tscn client-prediction gap, with injected clock drift ~10s
diag_progression.tscn kill → xp → level → health, death → retire → roster, swap guards ~10s
diag_loot.tscn drop → snapshot → pick up → persist → use → drop, and both loot visibilities on the wire ~10s
diag_upgrades.tscn level → banked choice → refused in a dungeon and away from the NPC → taken → new stats → persisted → survives every zone change ~10s

tools/screenshot.tscn is a manual check, not a gate: it needs a display, and it is the only thing in the project that can tell you the interface actually rendered. Run it after touching src/ui/. It captures the main menu, the HUD, the pause menu, settings, credits and the upgrade choice — the last of those by staging an offer, because earning one would make the shot depend on a dungeon run.

It has now caught four bugs that every other gate passed: the theme applying to nothing, the test suite rewriting the player's settings, an invisible slider track and an invisible scrollbar.

The four diagnostics exist because the smoke test structurally cannot reach what they cover: bots are poor shots (so they neither level up, produce drops, nor walk to the quartermaster), and a listen server cannot drift its own clock against itself.


Stage 1 — World and exploration · done

Feature State Where
Tile grid: movement / bullet / sight blocking done src/sim/map_grid.gd
Dungeon generation from (seed, depth) done src/sim/map_gen.gd
Hand-authored boss arenas and hub done src/content/rooms.gd
Walls, pillars, pits, barricades done MapGrid.Kind + the three BLOCKS_* tables
Enemies placed per room at creation done Instance._populate()
Scrolling camera done src/view/game_scene.gd
Hard fog of war done WorldView._draw_terrain / _visible
Per-peer map streaming (anti map-hack) done ServerRuntime._stream_map
Aggro: range and line of sight done SimWorld._aggro_target
Cursor-to-world aiming under a scrolling camera done ClientRuntime.screen_to_world
Boss confined to its room done SimWorld._step_boss clamps to SimBoss.room. A no-op while bosses are stationary — established now so Stage 5's movement cannot quietly break it.
Actor interest management done NetCodec.encode_snapshot(world, countdown, for_peer); per-peer encode in ServerRuntime. Bullet spawns filtered separately, see below.

The invariants that matter most here

Maps are streamed per peer, and the generation seed is never sent. See NETCODE.md. MAP_STREAM_RADIUS must stay wider than FOG_VIEW_RADIUS, or the client predicts movement against terrain it does not have.

Three radii, deliberately different, and each has a floor it must respect:

Radius Value Must exceed Why
FOG_VIEW_RADIUS 460 What the player can see.
ACTOR_INTEREST_RADIUS 800 fog radius Enemies beyond it are never sent. Above the fog radius so nothing pops in at the edge of sight.
MAP_STREAM_RADIUS 900 fog radius Client predicts movement and simulates bullets against terrain it cannot see.
BULLET_INTEREST_RADIUS 2200 longest bullet travel + fog radius A bullet is announced once at spawn. Withhold one that later flies into view and it becomes invisible damage. test_interest.gd computes the floor from real content, so a faster bullet fails a test instead.

Fog is a rendering rule and defends nothing on its own — a modified client draws whatever it holds. The defence is what the server declines to send.


Dungeon kinds · done

Three entrances stand side by side in the hub, labelled with the fight each one ends in.

Dungeon Dungeons id Arena / boss Enemy HP Boss HP Loot chance
Warden's Descent warden_descent warden_hall → the Warden ×1 ×1 ×1 (trash 8%)
The Choir Vault choir_vault choir_vault → the Cantor ×1 ×1 ×1
Proving Grounds proving_grounds whichever the seed rolls ×0.2 ×0.08 ×10 (clamped)

A dungeon names its arena, and the arena decides its boss. Which boss you are about to fight is the one thing a player chooses before walking into a dungeon, so it must not be rolled for them. Only the Proving Grounds leaves arena empty — it exists to reach a fight fast, and runs there last a couple of minutes, so the one you want is one re-entry away.

The Proving Grounds is a test harness you can walk into: same generator, same rooms, same enemies, but everything dies far faster and drops far more. A manual pass over loot, the inventory, dropping and all four boss phases takes a couple of minutes instead of a quarter of an hour, and because it is a portal rather than a launch flag you can compare it to a real run without restarting the server.

Adding a dungeon is one entry in Dungeons.ORDER plus one more P marker in the lobby stamp — the Nth marker, in reading order, opens the Nth entry, so the two have to stay in the same order.

Two more things worth knowing:

  • It is multipliers over the shared content, not a copy of it. A duplicated Content would drift the moment anything was tuned, and "identical but easier" would quietly stop being true.
  • Which dungeon you enter comes from where you stand. SimWorld.portal_at() resolves the server-side position; the PORTAL_USED event carries the answer. No client message names a dungeon, which is what stops anyone picking the generous loot table from a real run. accepts_new_party_member compares dungeon ids too, so walking into one entrance can never drop you into another's forming run on timing alone.

Art and audio · first pass done

Placeholders are gone: terrain, actors and bullets are sprites, and four sounds play off server events. Enough to prove the pipeline, not a finished look.

Feature State Where
Atlas/sound table in one place done src/view/art.gd
Terrain, actors, boss from the 0x72 atlas done WorldView._draw_tile / _draw_sprite
Animated bullet sprites, one MultiMesh per kind done src/view/bullet_renderer.gd
SFX pool driven by server events done src/view/sfx.gd
Rects validated without a display done tests/unit/test_art.gd
Impact/death VFX animation todo Art.IMPACT is loaded and validated but nothing plays it yet
Directional sprites, hit flashes, screen shake todo
Rebindable controls done src/ui/settings_screen.gd, src/core/settings.gd
Themed controls from a UI pack done src/view/ui_theme.gd, art in assets/sprites/ui/
Dialogs on panels, sliders, scrollbars done UiTheme.dialog_panel, HSlider / VScrollBar theme entries
Upgrade cards framed by rarity done UiTheme.card_panel, UpgradeScreen._make_card
Textured HUD bars and inventory slots done HUD._bar, HUD._draw_inventory
Audio buses and a volume setting done Settings, an SFX bus created at runtime, sliders in the settings screen
In-game credits screen done Settings → Credits, from src/core/credits.gd. Two packs are CC BY 4.0 and attribution is a licence requirementCREDITS.md is not reachable by a player.
Replace the two non-redistributable packs todo Bullet and FX art is local-only and non-commercial. CC0 replacements would let them into the repo and unblock a commercial release. See ASSETS.md.

Stage 2 — Characters, persistence, levels · done

Feature State Where
Steam-shaped identity abstraction done src/meta/auth_provider.gd, local_auth_provider.gd
Character store, JSON, survives restart done src/meta/character_store.gd
Up to 5 living characters, random colour done CharacterStore.MAX_ACTIVE, Character.create
Last-played auto-selected on login done CharacterStore.last_played
Permadeath → retired, never deleted done ServerRuntime._on_player_died
Roster screen: pick or create done src/ui/character_select.gd
Levels 115, +10 max HP each done src/meta/progression.gd
XP from kills, bosses worth far more done ServerRuntime._award_kill
Colour visible in world and on the HUD done snapshot carries it; WorldView._draw_ship tints
Swap character from the hub done Esc menu → Change character; refused server-side in a dungeon
XP percentage to next level done HUD._draw_xp_bar, fed live from the snapshot
Dead characters hidden from the roster done ServerRuntime._send_characters sends living only
Suggested name when creating done Character.random_name
Passive health regeneration done SimPlayer.regenerate, 0.5%/s of maximum

Verified end to end by tools/diag_progression.tscn, which drives the real server through kill → xp → level → health and death → retire → roster. That path cannot be covered by the bot smoke test, because bots are poor shots.

Still open in this area

  • The local identity provider is insecure by design. Any client can claim any account id. Fine for a LAN; must be replaced before the game is reachable from the internet. Swapping in Steam is one AuthProvider subclass and no schema change.
  • --account and --store exist so several clients and test runs can coexist on one machine. A real provider makes --account unnecessary.

Stage 3 — Inventory and loot · done

Feature State Where
4 slots, permanently on screen done SimConfig.INVENTORY_SLOTS, HUD._draw_inventory
Items defined as data, not code done src/content/items.gd, src/actors/items/item_def.gd
Loot tables on enemies and bosses done EnemyDef.loot / BossDef.loot, rolled in SimWorld._drop_loot
Health potion, rare from trash done Content.TRASH_POTION_CHANCE = 0.08
…guaranteed from the boss done Content.warden() loot table, chance 1.0
World-shared loot done SimLoot.owner_peer == 0
Player-instanced loot done one SimLoot per living player, filtered per peer in NetCodec.encode_snapshot
Warden's Ration — useless, instanced done Items.wardens_ration()
Pick up, use, drop done SimWorld._try_pickup / _use_slot / _drop_slot
Inventories persist done stored on Character, written by ServerRuntime._persist_inventory
Ground loot drawn with a pickup prompt done WorldView._draw_loot, HUD._draw_pickup_prompt

Controls: E picks up, 14 use a slot, shift+14 drop one.

The decisions worth knowing before touching this

Item actions ride the input frame; they are not new messages. InputFrame gained BTN_USE, BTN_DROP and a slot byte. That buys the redundancy that covers a dropped packet, the replay guard on last_input_tick, ordering against movement on the same tick, and a natural rate limit of one action per tick — all of which a separate reliable RPC would have needed bolted back on.

Item actions are edge-triggered; movement and fire are not. The client repeats its last few frames every tick and a starved server coasts on the last one it was given, so a level-triggered read empties the whole inventory in four ticks. SimPlayer.prev_buttons and prev_slot hold the edge, and the slot is part of it — tapping 2 while 1 is held is a second, distinct action.

Instanced loot is enforced on the wire, not in the client. A peer is never told another player's copy exists. That makes it an interest-management rule of the same kind as ACTOR_INTEREST_RADIUS, and it is why the ration is worth having: every boss kill exercises the path.

Anything dropped becomes world-shared, even if it arrived instanced. That is what makes dropping worth having — a trophy you do not want should be able to reach someone who does.

A potion at full health is refused rather than spent. Nobody drinks one on purpose at full health, so a mistimed keypress must not do it for them.

A full bag leaves the item on the floor and does not block the portal, which shares the interact key.

Known gaps

  • Nothing sells items. Loot only comes from kills; the hub has no source. The Stage 4 upgrade NPC is the natural place, and is the reason this is a gap rather than a decision.
  • No stacking. Four potions take four slots. A count byte per slot is cheap to add; nothing needed it yet, so the wire, the save record and the HUD all stayed simpler for not having one.
  • Ground loot never expires, it is only capped at SimConfig.MAX_LOOT_PER_INSTANCE per world, oldest evicted. Dungeons close and take their litter with them; only the hub can realistically reach the cap.
  • The ration draws as a gold flask. The tileset has no food sprite. See ASSETS.md.

The scale of health and damage

Health and damage are an order of magnitude larger than they started. A shot is 60, a fresh character has 1000, the Warden has 36000. Every ratio is unchanged — enemy health, boss health and every emitter's damage were scaled together, so time to kill is exactly what it was.

The reason is rounding. At the old base of 6 damage, the +5% every upgrade carries computed to 6.3 and rounded straight back to 6, so a player's first upgrade visibly did nothing. At 60 it is +3, and every percentage in the game now has somewhere to land.

tests/unit/test_content.gd pins both halves of that: that a single upgrade changes damage, and that shots-to-kill for each enemy is what it was before. Do not tidy these numbers back down without scaling content.gd with them.

The hub's practice dummy stopped relying on a huge health pool at the same time: it is indestructible by flag now. The old 100000 was already past the u16 the snapshot sends enemy health in, and after the rescale a patient player could have destroyed the hub's only practice target for everyone.


Stage 4 — Upgrades · done

Every level gained banks one choice. Choices are spent at the quartermaster, an NPC in the hub: walk to it, press E, pick one of three.

Feature State Where
Upgrades as data, no engine branches done src/content/upgrades.gd, upgrade_def.gd
Per-player derived stats done src/sim/player_stats.gd
3 weighted options per choice, held until spent done Upgrades.roll_offer, stored on Character.offer
Choices queue across levels done Character.pending_choices
Spent only at the NPC, enforced server-side done SimWorld.at_upgrade_npc, ServerRuntime.on_choose_upgrade
Choice screen showing the +5% and each effect done src/ui/upgrade_screen.gd
List of upgrades already taken, plus resulting stats done lower half of the same panel
Upgrades persist, die with the character done Character.upgrades, permadeath retires the record

The seven upgrades

Upgrade Rarity Effect
Split Shot common On hitting something, two more of the same shot leave it at ±45°. One charge each; stacks.
Glass Cannon common +100% damage, ×0.5 levelled max health.
Spread common +2 shots in a cone, 10% damage.
Sniper common ×2 damage (multiplicative), ×0.5 fire rate, ×2 bullet speed.
Doubleshot uncommon +1 parallel shot, 50% damage.
Poison rare Each hit deals another 50% of its damage over 10s. Doses stack independently.
Eraser legendary 1% chance per tick to delete an enemy projectile a shot is passing through.

Draw weights: common 100, uncommon 45, rare 18, legendary 4.

The decisions behind the numbers

All four settled with the user; the reasoning is in DECISIONS.md.

  • Damage is base × (1 + Σ additive) × Π multiplicative. The flat +5% every upgrade carries, Spread's 10%, Doubleshot's 50% and Glass Cannon's +100% pool additively; Sniper multiplies the result, which is why two Snipers is 4× and not +200%.
  • Glass Cannon halves the LEVELLED maximum, so the price does not fade to a rounding error by level 15. Taken twice it multiplies again.
  • Poison doses are independent, not refreshed. PoisonTrack makes that free: every dose lasts the same number of ticks, so they expire in insertion order, the pending expiries are a plain FIFO, and the per-tick cost is O(1) however many are live.
  • Split Shot's children leave at ±45° from the original heading, born just past the target so they cannot be resolved against it again on the same tick.

Constraints this collided with, and what it cost

  • Bullet speed now has a ceiling (SimConfig.MAX_BULLET_SPEED, 1500 u/s). Wall collision samples once per tick, so anything over one tile per tick (1920 u/s) tunnels. Two Snipers would have asked for 2480.
  • BULLET_INTEREST_RADIUS rose to 2900, because an upgraded player shot is now the longest-travelling bullet in the game. test_interest.gd measures the worst case from the ceiling rather than from static content, which is what it used to do and what upgrades quietly invalidated.
  • Poison ticks do not emit ENEMY_HIT. They land many times a second on a reliable channel; the client learns hp from the snapshot. Death is still announced, because the experience award is keyed on it.
  • The eraser's bullet-vs-bullet scan costs nothing until someone owns it — it runs only for shots carrying a non-zero chance.

Known gaps

  • One panel, not two screens. The brief asked for a separate screen listing upgrades taken; it is the lower half of the choice panel instead, because the two are read together.
  • No respec, and no way to see another player's build.

Stage 5 — Boss features and new bosses · done

Feature State Where
Stationary phases done every Warden phase
Boss confined to its room done SimWorld._step_boss clamps after moving
Roaming / chasing within the boss room done BossPhase.Move.CHASE, holds a standoff
Phases that move to preset locations done BossPhase.Move.WAYPOINTS, room-relative
Orbiting the arena done BossPhase.Move.ORBIT
Attacks spawned at a distance with a telegraph done telegraphed_strike_emitter.gd, SimEvent.Type.TELEGRAPH
A second boss done Cantor of the Vault, fights in Rooms.choir_vault()

Boss movement

Movement is a property of the phase, not of the boss: a fight that stands still and then starts hunting you is one boss with two phases. Four modes — STATIC, ORBIT, CHASE, WAYPOINTS — all handled generically in SimWorld._move_boss, so adding a boss that moves is still writing data.

  • Waypoints are fractions of the arena, not absolute positions, so one phase works in the Warden's hall and the Choir Vault alike.
  • CHASE holds a distance rather than closing. A boss standing on top of you is a boss whose bullets cannot be read.
  • Every mode is speed-clamped in one place. ORBIT computes an absolute destination and would otherwise snap onto its circle on the first tick.
  • Movement slides against geometry, so a boss cannot walk through the pillars its own arena was designed around.
  • The room clamp is now load-bearing. It was a no-op while every boss stood still — which is exactly when an invariant is cheapest to establish. Boss rooms deliberately do not lock, and that is only an escape if the boss cannot follow you out.

Telegraphed strikes

TelegraphedStrikeEmitter marks spots in the arena and fills them a moment later. The moment in between is the whole feature: a burst that appears at your feet is a coin flip, the same burst with a second of notice is a question.

  • The emitter is stateless, like every other one — they are shared resources and two bosses of the same kind must not stomp each other. Strike positions are derived from the volley number, so the announcement and the strike compute the same points with nothing stored between them.
  • EmitContext gained an events list, the only thing an emitter can do besides spawn bullets.
  • Markers are drawn through fog and through walls. Everything else in the view respects line of sight; a warning you cannot see is not a warning. See WorldView._draw_telegraphs.

The Cantor of the Vault

Phase Movement Idea
Call to Prayer static Teaches the marker, and nothing else.
Processional waypoints, four corners The safe half of the room keeps moving.
Antiphon chase at 220u It comes for you; walls punish running straight.
Final Cadence orbit Movement and telegraphs at once.

The Cantor has its own portal in the hub. Which boss a run ends with follows from the arena its dungeon names, not from the seed and certainly not from --depth, which nothing in play raises. test_every_boss_has_a_dungeon_that_reaches_it pins that adding a boss without giving it a way in is a failing test rather than a boss nobody meets.

Content.ALL_ENEMIES / ALL_BOSSES exist because five places were hand-maintaining their own copy of the content list, and tools/export_content.gd had already gone stale and silently stopped writing the second boss.

Known gaps

  • Nothing tests how the fight looks. The boss was drawn straight from the newest snapshot with no interpolation — a visible step at 20 Hz — and every test passed for as long as bosses stood still. There is still no automated check that a moving actor renders smoothly; the ones added cover the interpolation call, not the impression.
  • No boss-specific music, intro or death sequence. A boss dies like an enemy, only louder.
  • Telegraph markers are one shape. A circle is the only warning the client can draw; a lane or a cone would need another event field.
  • Rooms has two arenas. A third boss needs a third stamp, which is where the generator's "boss arena on the right-hand side" assumption will be tested.

Deliberate omissions

Not oversights — each was considered and rejected for now, with the reasoning in NETCODE.md or DECISIONS.md:

  • Lag compensation. Rewinding to a shooter's view means a player who dodged still gets hit; wrong trade for this genre.
  • Snapshot delta compression. Fine at current actor counts.
  • DTLS / encryption. ENetMultiplayerPeer supports it. Required before any public server, not before then.
  • Pattern-level bullet replication. A real bandwidth win that couples the client to emitter behaviour.
  • MultiplayerSynchronizer / MultiplayerSpawner. Right tools, wrong shape for a bullet hell — see ARCHITECTURE.md.

Open questions for the user

Genuinely unspecified. Do not guess at these — each changes the design, and several have no obvious default. The seven questions that blocked Stage 4 were answered and are recorded in DECISIONS.md; these are what is left.

  1. What advances dungeon depth? --depth is a dev flag and nothing raises it in play. Depth already drives map size and could drive difficulty and rewards. Without an answer there is no progression past level 15.
  2. Where do items come from outside a dungeon? Loot only drops from kills. If the hub should sell potions, that is the quartermaster's second job.
  3. Is there a currency? Nothing drops money and nothing costs anything. A shop needs one; so does any reward for a cleared run beyond experience.
  4. Should items stack? Four potions currently take four slots, which makes a four-slot bag small. Stacking is a count byte per slot plus a rule for splitting one — neither is hard, and both change the UI.
  5. Does a dungeon ever get harder within a run? Enemies are placed once at generation and never reinforce. That is a deliberate decision (DECISIONS.md), but it means a slow party is never punished for it.
  6. What happens to a party when one member dies? Permadeath removes them to the roster screen while the run continues without them. Nobody has said whether that is right.