# 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` | 266 GUT tests, no SceneTree | ~3s | | `tools/smoke.sh` | 16 assertions over a real ENet socket: handshake, auth, character creation and persistence, portal, 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 | The three diagnostics exist because the smoke test structurally cannot reach what they cover: bots are poor shots (so they neither level up nor produce drops), 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. --- ## 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 | | | Audio buses and a volume setting | todo | Everything plays on Master at hardcoded dB | | **In-game credits screen** | **todo** | Not cosmetic: the SFX 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). --- ## Stage 4 — Upgrades · *todo, blocked on decisions* **Do not start this without answering the open questions below.** The damage formula in particular determines the shape of every upgrade. ### The mechanism - An NPC in the hub. Each level gained grants one choice. - The choice offers **3 random upgrades**. - **Every upgrade also carries a +5% damage buff, additive** ("adaptively scaling" in the brief — read as additive, confirm if wrong). - The choice screen must show that buff **and** all the upgrade's other effects. - A separate screen lists the upgrades already taken. ### The upgrades, as specified | Upgrade | Rarity | Effect | | --- | --- | --- | | Split shot | common | Hitting an enemy spawns 2 of the same bullet at a 45° angle behind the enemy. A shot cannot split twice unless the upgrade is taken again. | | Glass cannon | common | +100% damage, −50% health. | | Spread | common | Adds 2 side projectiles in a cone. −10% damage. | | Sniper | common | 2× damage (**multiplicative, not additive**), 0.5× fire rate, 2× bullet speed. | | Doubleshot | rarer than common | Adds 1 projectile firing parallel to the others. −50% damage. | | Poison | rare | Each projectile deals an additional 50% of its damage over the next 10 seconds. | | Eraser | legendary | Shots have a 1% chance to delete a projectile they pass through. | Upgrades stack — "cannot split twice *unless upgraded again*" says so directly. ### What this implies Damage is currently the constant `SimConfig.PLAYER_BULLET_DAMAGE`. It becomes a per-player computed stat, so `SimWorld._fire_player_shot` grows a stats block. Split, spread and doubleshot all change how many bullets a shot produces, so they belong in the same place. Two constraints already pinned by tests that upgrades will collide with: - **Sniper doubles bullet speed.** `test_bullet_speeds_stay_below_the_tunnelling_threshold` asserts that even at 2× a bullet stays under one tile per tick. Stacking two snipers would break wall collision, so the multiplier needs a ceiling. - **Longer/faster bullets widen `BULLET_INTEREST_RADIUS`.** `test_interest.gd` recomputes the floor from live content; upgrades change bullet travel *per player*, which that test does not currently model. --- ## Stage 5 — Boss features and new bosses · *todo* | Feature | State | | --- | --- | | Stationary phases | done — every current phase | | Boss confined to its room | done — `SimWorld._step_boss` clamps to `SimBoss.room` | | Roaming / chasing within the boss room | todo | | Phases that move to preset locations | todo | | Attacks spawned at a distance with a telegraph indicator | todo — a new event type plus a renderer, and it must survive fog | | More bosses | partial — `Rooms.choir_vault()` is authored but has no `BossDef` | The boss format is proven: `tests/unit/test_boss.gd` builds one from scratch and asserts the simulation needs no changes to run it. Movement is the first thing that format has not covered, so expect `BossPhase` to gain a movement field rather than `SimWorld` gaining a per-boss branch. Remember boss rooms **do not lock** (a settled decision): a player can always walk out, and the boss cannot follow. Fights cannot rely on trapping anyone. --- ## 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 Stage 4 (upgrades) 1. **Damage stacking order.** Sniper is explicitly multiplicative; the +5% per upgrade and the ± percentages read as additive. Is it `base × (1 + Σ additive) × Π multiplicative`, or something else? 2. **Rarity weights** for common / rarer / rare / legendary. 3. **Split shot geometry** — ±22.5° from the original heading (45° total), or 45° to each side (90° total)? 4. **Poison stacking** — do applications stack, or does a new hit refresh one damage-over-time effect? 5. **Eraser's target** — does it delete *enemy bullets* it passes through? 6. **Do unclaimed level-ups queue?** Reaching level 4 and 5 inside one run: two pending choices at the NPC, or one? 7. **Glass cannon's −50% health** — of base HP, or of the character's levelled maximum? ### 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.