# 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](ASSETS.md) — the packs themselves are not in git. Design decisions that are already settled — and the reasoning behind them — live in [DECISIONS.md](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) ## 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` | 460 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/`. 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](../src/sim/map_grid.gd) | | Dungeon generation from (seed, depth) | done | [src/sim/map_gen.gd](../src/sim/map_gen.gd) | | Hand-authored boss arenas and hub | done | [src/content/rooms.gd](../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](../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](NETCODE.md#maps-are-streamed-never-sent). `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](../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](../src/view/bullet_renderer.gd) | | SFX pool driven by server events | done | [src/view/sfx.gd](../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/ui/settings_screen.gd), [src/core/settings.gd](../src/core/settings.gd) | | Themed controls from a UI pack | done | [src/view/ui_theme.gd](../src/view/ui_theme.gd), art in `assets/sprites/ui/` | | 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](../src/core/credits.gd). Two packs are CC BY 4.0 and attribution is a licence *requirement* — [CREDITS.md](../CREDITS.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](ASSETS.md). | ## Stage 2 — Characters, persistence, levels · *done* | Feature | State | Where | | --- | --- | --- | | Steam-shaped identity abstraction | done | [src/meta/auth_provider.gd](../src/meta/auth_provider.gd), [local_auth_provider.gd](../src/meta/local_auth_provider.gd) | | Character store, JSON, survives restart | done | [src/meta/character_store.gd](../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](../src/ui/character_select.gd) | | Levels 1–15, +10 max HP each | done | [src/meta/progression.gd](../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/content/items.gd), [src/actors/items/item_def.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, **1–4** use a slot, **shift+1–4** 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](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](../src/content/upgrades.gd), [upgrade_def.gd](../src/actors/upgrades/upgrade_def.gd) | | Per-player derived stats | done | [src/sim/player_stats.gd](../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](../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](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](../src/sim/patterns/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](NETCODE.md) or [DECISIONS.md](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](ARCHITECTURE.md). --- ## Open questions for the user Genuinely unspecified. **Do not guess at these** — each changes the design, and several have no obvious default. ### Blocking nothing yet 8. **What advances dungeon depth?** `--depth` is a dev flag; nothing raises it in play. Depth drives map size and could drive difficulty and rewards. 9. **Where do items come from outside a dungeon?** Loot only drops from kills. If the hub should sell potions, that is the Stage 4 NPC's second job — and it needs a currency, which the game does not have. 10. **Should items stack?** Four potions currently take four slots, which makes a 4-slot bag small. Stacking is a count byte per slot plus a rule for splitting one; neither is hard, but both change the UI. 11. **Attribution for four asset packs.** See [ASSETS.md](ASSETS.md) — two are non-redistributable and local-only, and there is no in-game credits screen yet, which CC BY 4.0 requires for the audio.