Files
transcience/docs/ROADMAP.md
T
claude ded7bf96d5
ci / verify (push) Successful in 47s
Documentation pass before context compaction
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.
2026-09-04 20:34:45 +02:00

272 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 115, +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.