d8197885ca
ci / verify (push) Successful in 46s
The roster sent to clients now contains living characters only. Retirement stays server-side bookkeeping for archival; from the player's side a dead character is simply gone, and listing it offers a choice that cannot be taken. The XP bar only moved on a level-up or a character swap because it read the character roster, which is re-sent only when the SET of characters changes. Experience now rides the snapshot -- four bytes on a message already going out at 20Hz -- and the server mirrors each grant into the world immediately rather than only when a level is crossed. The create field starts with a suggested name instead of blank, and offers another after each creation. Two bugs found while testing, both mine: The first was a bad patch of my own: a change meant for the snapshot decoder also matched inside decode_characters, which then read a four-byte field its encoder never wrote and ran off the end of every packet. This is precisely the "encodes but decodes wrong" failure the codec tests exist to catch, and it was caught within a minute of the test being written. Chasing that exposed a real robustness gap: StreamPeerBuffer.get_utf8_string() pushes an engine error and returns garbage when the buffer is short, so a truncated or hostile character/roster packet produced error spam instead of degrading. Both decoders now bounds-check every field, with tests that slice each packet at many lengths and assert it degrades rather than inventing entries -- the same guarantee the input decoder already had. 206 tests. check.sh, test.sh, smoke.sh and both diagnostics pass.
206 lines
11 KiB
Markdown
206 lines
11 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)
|
||
|
||
---
|
||
|
||
## 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*
|
||
|
||
Depends on nothing in Stage 1 except a place to stand. Blocked only on the
|
||
identity layer, which is decided but unbuilt.
|
||
|
||
| Feature | Notes |
|
||
| --- | --- |
|
||
| Identity abstraction | Shaped like Steamworks so it swaps out cleanly: opaque ticket from client → server validates → stable 64-bit account id (a SteamID64 stand-in). Local dev provider persists a generated id in `user://`. See [DECISIONS.md](DECISIONS.md#identity). |
|
||
| Character store | Server-side, keyed by account id. Needs a schema and a file format; nothing persistent exists in the project today. |
|
||
| Up to 5 characters, random colour each | Colour is a placeholder for a later cosmetic system. |
|
||
| Last-played character auto-selected on join | |
|
||
| Permadeath → mark inactive, never delete | Archival/troubleshooting. Confirm whether the 5-character cap counts only *active* characters. |
|
||
| Death flow: pick another character or create one | Replaces today's "return to hub" button, which currently just revives you. |
|
||
| Levels 1–15, +10 max HP per level | Confirm whether level 1 is 100 HP (→ 240 at cap) or 110. |
|
||
| XP from kills, bosses worth much more | First full dungeon should give slightly more than one level. |
|
||
|
||
Current behaviour to replace: `SimPlayer` has no identity beyond a peer id;
|
||
`ServerRuntime.peer_names` is client-supplied and trusted for display only.
|
||
|
||
---
|
||
|
||
## Stage 4 — Upgrades · *todo*
|
||
|
||
| Feature | Notes |
|
||
| --- | --- |
|
||
| Lobby NPC, 3 random choices per level gained | Confirm whether unclaimed level-ups queue. |
|
||
| Every upgrade also grants +5% damage, additive | Displayed on the choice alongside its specific effects. |
|
||
| Upgrade list | split shot, glass cannon, spread, sniper, doubleshot (rarer), poison (rare), eraser (legendary) |
|
||
| Rarity weights | **Unspecified.** Needs numbers. |
|
||
| Damage stacking formula | **Unspecified.** Sniper is explicitly multiplicative; the rest read as additive. Order needs pinning down before any of it is built. |
|
||
| "Chosen upgrades" screen | |
|
||
|
||
Damage is currently the constant `SimConfig.PLAYER_BULLET_DAMAGE`; upgrades turn
|
||
it into a per-player computed stat, so `SimWorld._fire_player_shot` grows a
|
||
stats block. Several upgrades (split, spread, doubleshot) change how many
|
||
bullets spawn per shot, so they belong in the same place.
|
||
|
||
---
|
||
|
||
| Feature | Notes |
|
||
| --- | --- |
|
||
| Small always-on-screen inventory | **4 slots** for now, may grow. |
|
||
| Health potions: rare from trash, guaranteed from bosses | |
|
||
| World-shared loot | Player-instanced loot planned later. |
|
||
| A unique, useless food item from bosses | **Player-instanced now**, so the mechanism is exercised rather than deferred. |
|
||
| Dropping items so others can pick them up | |
|
||
|
||
---
|
||
|
||
## Stage 5 — Boss features and new bosses · *todo*
|
||
|
||
| Feature | State |
|
||
| --- | --- |
|
||
| Stationary phases | done — every current phase |
|
||
| Roaming / chasing within the boss room | todo; needs `SimBoss.room` clamping (see Stage 1 partial) |
|
||
| Phases that move to preset locations | todo |
|
||
| Attacks spawned at a distance with a telegraph indicator | todo — new event type plus a renderer, and it must survive fog |
|
||
| More bosses | partial — `Rooms.choir_vault()` is authored but has no `BossDef` yet |
|
||
|
||
The boss format itself is proven: `tests/unit/test_boss.gd` builds a boss from
|
||
scratch and asserts the simulation needs no changes to run it. Movement will be
|
||
the first thing that format has not covered, so expect `BossPhase` to gain a
|
||
movement field rather than `SimWorld` gaining a branch.
|
||
|
||
---
|
||
|
||
## 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, they change the design:
|
||
|
||
Deferred to the Stage 4 discussion (upgrades), but they block that stage:
|
||
|
||
1. Damage stacking order — additive pool then multiplicative, or something else?
|
||
2. Rarity weights for the four upgrade tiers.
|
||
3. Split shot geometry: ±22.5° from the original heading, or 45° to each side?
|
||
4. Poison: do applications stack, or refresh a single DoT?
|
||
5. Eraser: does it delete *enemy bullets* it passes through?
|
||
6. Do unclaimed level-ups queue at the NPC?
|
||
7. Glass cannon's −50% health: of base HP, or of levelled max HP?
|
||
|
||
Still open outside Stage 4:
|
||
|
||
8. What advances dungeon depth? `--depth` is a dev flag; nothing raises it in play.
|