Files
transcience/docs/ROADMAP.md
T
claude b351bc2d55
ci / verify (push) Successful in 48s
Stage 4: upgrades, and a quartermaster to spend them at
Every level banks one choice. Choices queue, and are spent at an NPC in the
hub: walk to it, press E, take one of three weighted options. Seven upgrades,
all data — split shot, glass cannon, spread, sniper, doubleshot, poison,
eraser — and SimWorld gained no per-upgrade branch to run any of them.

The four ambiguities in the brief were settled with the user first, since
each changes what gets written:

  damage      base x (1 + sum additive) x product multiplicative. The flat
              +5% every upgrade carries, spread's -10%, doubleshot's -50%
              and glass cannon's +100% pool; sniper multiplies on top, so
              two snipers is 4x and not +200%.
  glass       half the LEVELLED maximum, multiplying if taken twice, so the
              price does not fade to a rounding error by level 15.
  poison      independent stacks, not a refresh.
  split       +/-45 degrees from the original heading.

Independent poison stacks sound expensive and are not: every dose lasts the
same number of ticks, so doses expire in the order they were added, the
pending expiries are a plain FIFO, and PoisonTrack only ever looks at its
front. O(1) per actor per tick however many are live.

Stats are derived from the upgrade list and never stored, the way level is
derived from experience -- a saved stat cannot disagree with the upgrades
that produced it. Upgrade riders (split charges, poison, erase chance) travel
on the bullet instead, because a shot in flight has to keep what it was fired
with rather than gaining Poison because the shooter just took it.

Two invariants this collided with, both now pinned:

  - bullet speed gained a ceiling. Wall collision samples once per tick, so
    anything over a tile per tick tunnels; two snipers asked for 2480 u/s
    against a 1920 threshold, and a tunnelling bullet looks like a bullet.
  - BULLET_INTEREST_RADIUS rose to 2900, because an upgraded player shot is
    now the longest-travelling bullet in the game. test_interest measured
    the worst case from static content, which upgrades quietly invalidated.

Choosing is intent checked three ways: a choice must be owed, the index must
name one of the three options the SERVER put on the table, and the player
must be standing at the NPC. The offer is rolled once and persisted, so
closing the screen is not a reroll and neither is a crash.

tools/diag_upgrades.tscn covers level -> banked choice -> refused in a
dungeon and refused across the room -> taken at the NPC -> new stats ->
on disk. Bots never walk to the quartermaster, so the smoke test cannot.

Known gap recorded in the roadmap: at PLAYER_BULLET_DAMAGE = 6, the +5% the
first upgrade carries rounds back to 6 and visibly does nothing. It comes out
right in aggregate, but the fix is a balance edit across content.gd and so is
the user's call.

check.sh clean, 357 tests, SMOKE PASS (18 assertions), all four diagnostics
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:34:58 +02:00

366 lines
20 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` | 357 GUT tests, no SceneTree | ~4s |
| `tools/smoke.sh` | 18 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 | ~10s |
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*
Two entrances stand side by side in the hub, labelled, and open different runs.
| Dungeon | `Dungeons` id | Enemy HP | Boss HP | Loot chance |
| --- | --- | --- | --- | --- |
| Warden's Descent | `warden_descent` | ×1 | ×1 (3600) | ×1 (trash 8%) |
| Proving Grounds | `proving_grounds` | ×0.2 | ×0.08 (288) | ×10 (trash 80%, clamped) |
The Proving Grounds is a **test harness you can walk into**: same generator,
same rooms, same enemies, the same four-phase Warden — everything simply dies
faster and drops 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 the two back to
back without restarting the server.
Adding a third 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.
Three 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 the real run.
- **`accepts_new_party_member` takes the dungeon id.** Without that, walking
into the Proving Grounds would drop you into whatever standard run happened
to still be forming.
---
## 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 · *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, **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](ASSETS.md).
---
## 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
- **The +5% is invisible on the first upgrade.** `PLAYER_BULLET_DAMAGE` is 6, so
6 × 1.05 rounds back to 6. It accumulates correctly (14 upgrades → +70% → 10
damage), but the first one visibly does nothing. See the open question below.
- **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 · *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.
### Worth a decision soon
1. **Base damage is too small for the +5% to show.** `PLAYER_BULLET_DAMAGE` is
6, and 6 × 1.05 rounds to 6 — so the flat bonus every upgrade carries does
nothing at all until the second one. It comes out right in aggregate, but a
player who takes Split Shot and sees no damage change has been told
something untrue.
The clean fix is to scale base damage and enemy health together — ×4 would
put a shot at 24 and make every 5% step land — which changes no time-to-kill
but touches every number in `content.gd`. That is a balance edit, so it is
yours to call rather than mine.
### 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.