ded7bf96d5
ci / verify (push) Successful in 47s
The roadmap was actively misleading: an earlier stage renumbering left Stage 2's completed work sitting under a "Stage 3 -- todo" heading, and the inventory table orphaned with no heading at all. Since that file is the primary handoff document, a fresh session would have started by re-implementing accounts and characters. Rewritten. Captured in full, from the original brief rather than from memory, the two stages not yet built: - Stage 3 (inventory and loot): 4 slots, potions rare from trash and guaranteed from bosses, world-shared loot, and the player-instanced food item -- with a note that two loot visibilities must exist from the start, because proving the instanced path works is the food item's entire purpose. - Stage 4 (upgrades): every upgrade with its exact stated effect, plus the two constraints it will collide with -- sniper's 2x bullet speed against the tunnelling threshold, and per-player bullet travel against the interest radius that test_interest.gd currently derives from static content. Ten open questions are listed as explicitly do-not-guess, seven of them blocking Stage 4. ARCHITECTURE.md still claimed "the arena is a rectangle" and "no tilemap collision", both untrue since Stage 1. Rewritten around MapGrid, with what the grid costs (axis-aligned, 32px-quantised, bullets under a tile per tick) rather than only what it buys. Added a "verification traps" section to WORKFLOW.md recording six mistakes made during this work, each of which cost a round trip of reporting something fixed that was not: verifying the artefact rather than the behaviour, dumping the wrong channel, measuring a configuration where the bug cannot exist, a test whose setup silently invalidated it, git checkout reverting real work alongside a probe, and a pattern edit matching in two files. They are specific enough to be actionable. Also documented the diagnostics in CLAUDE.md -- they were undiscoverable -- and recorded the current verification surface so "everything passes" has a stated meaning. 206 tests, 15 smoke assertions, both diagnostics pass.
272 lines
14 KiB
Markdown
272 lines
14 KiB
Markdown
# 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` | 206 GUT tests, no SceneTree | ~3s |
|
||
| `tools/smoke.sh` | 15 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 |
|
||
|
||
The two diagnostics exist because the smoke test structurally cannot reach what
|
||
they cover: bots are poor shots, 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 · *todo, next*
|
||
|
||
Nothing blocks this. The pieces it needs — accounts, characters, a server that
|
||
owns per-player state — all exist.
|
||
|
||
### Requirements
|
||
|
||
| Feature | Decided | Notes |
|
||
| --- | --- | --- |
|
||
| Inventory, small enough to sit on screen permanently | **4 slots** | May grow later; do not build a paged or scrolling UI for it. |
|
||
| Health potions | rare from normal enemies, **guaranteed from bosses** | The only item with an effect for now. |
|
||
| World-shared loot | shared between all players in the instance | First to reach it takes it. |
|
||
| A unique, useless food item from bosses | **player-instanced now** | Its whole purpose is to exercise the instanced-loot path rather than defer it. Always dropped by bosses, does nothing when used. |
|
||
| Dropping items | any item, back into the world, pickable by others | The path that makes shared loot meaningful. |
|
||
|
||
### What this implies
|
||
|
||
Two loot *visibilities* have to exist from the start, because the food item is
|
||
specifically there to prove the second one works:
|
||
|
||
- **World-shared:** one entity in the instance, visible to everyone, gone when
|
||
anyone picks it up.
|
||
- **Player-instanced:** one entity per eligible player, each seeing and taking
|
||
only their own. Others must not see it, which makes it an interest-management
|
||
question as much as a loot one — see `ACTOR_INTEREST_RADIUS` and how the
|
||
snapshot is already encoded per peer.
|
||
|
||
Server owns all of it: the client sends "I want to pick that up" as intent and
|
||
learns the outcome. There is no message that grants an item.
|
||
|
||
Persistence is an open question — inventories are not in `CharacterStore` yet,
|
||
and a potion that vanishes on server restart may or may not matter at this
|
||
stage.
|
||
|
||
---
|
||
|
||
## 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. **Do inventories persist?** Characters do. A potion surviving a server
|
||
restart may or may not matter at this stage.
|
||
10. **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.
|