a943aa19f6
ci / verify (push) Successful in 47s
Two labelled portals now stand side by side in the hub. The Proving Grounds runs the same generator, the same rooms, the same enemies and the same four-phase Warden -- enemies at a fifth health, the boss at 288 instead of 3600, and trash dropping potions 80% of the time instead of 8%. A manual pass over loot, the inventory, dropping and every boss phase takes a couple of minutes rather than a quarter of an hour. It is multipliers over the shared content rather than a parallel copy: a duplicated Content would drift the first time anything was tuned, and "identical but easier" would quietly stop being true. And it is a portal rather than a launch flag, so the two can be compared back to back without restarting the server -- which is most of the point. Which dungeon you enter is resolved from the player's server-side position, and PORTAL_USED carries the answer. There is deliberately no client message that names a dungeon: one would let any client ask for the generous loot table and bring the results back to the hub. Instance matching compares dungeon ids too, so walking into one entrance can never drop you into the other's run on timing alone. SimWorld.portals replaces portal_pos/portal_enabled, enter_instance carries the portal list and the dungeon id (the client needs the latter to scale the boss bar's ceiling the way the server scaled the boss), and Protocol.VERSION goes to 7. Also pins what happens when two players reach for one item on the same tick: exactly one gets it -- the loop is sequential and the pickup erases the entity before the next player looks. The tie-break is join order rather than distance, which is arbitrary rather than designed, so it is recorded as such. Stale doc fixed while here: MapGen.build() still claimed the client rebuilds the map from the seed, which has not been true since map streaming landed and is the opposite of the rule. check.sh clean, 288 tests, SMOKE PASS (18 assertions, both dungeon kinds opened over a real socket), all three diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
338 lines
18 KiB
Markdown
338 lines
18 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` | 288 GUT tests, no SceneTree | ~3s |
|
||
| `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 |
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 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 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.
|