Documentation pass before context compaction
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.
This commit is contained in:
2026-09-04 20:34:45 +02:00
parent d8197885ca
commit ded7bf96d5
4 changed files with 235 additions and 65 deletions
+38 -6
View File
@@ -45,11 +45,32 @@ This project deliberately does not, for four reasons:
prototyping and wrong for this shape: it would replicate per-bullet state and
saturate the link. See [NETCODE.md](NETCODE.md).
The trade is that there is no physics engine — no arbitrary collision shapes, no
tilemap collision. The arena is a rectangle and every hit is circle-vs-circle.
For a bullet hell that is not a limitation; hitboxes in the genre are circles by
convention anyway. A dungeon with real geometry would need a static collision
representation added to `SimWorld`, not a switch back to nodes.
The trade is that there is no physics engine, so collision is hand-written.
Since Stage 1 that means a tile grid ([MapGrid](../src/sim/map_grid.gd)):
circle-vs-tile for actors, a point test for bullets, and Bresenham line of sight
shared by fog and aggro. Actor-vs-actor hits stay circle-vs-circle, which is the
genre convention anyway.
That was the right call rather than a compromise: the same grid answers
collision, sight, and per-peer interest management as array lookups. On arbitrary
polygons all three become intersection tests, and the fog query in particular
stops being cheap enough to run every frame.
What it costs: geometry is axis-aligned and 32px-quantised, and bullets must
travel less than one tile per tick or the point test steps over walls (pinned by
`test_bullet_speeds_stay_below_the_tunnelling_threshold`).
## Where the layers sit now
Stage 1 and 2 added two things worth knowing before reading any file:
- **Geometry is per-world.** There is no global arena. `SimWorld.map` is a
[MapGrid](../src/sim/map_grid.gd); the hub and every dungeon have their own,
and the client holds a *partial* copy streamed to it in chunks.
- **`src/meta/` is server-only.** Accounts, characters, levels and experience
live there. The simulation reads a player's level and maximum health; it never
writes progression. One writer means a level cannot disagree with the
experience that earned it.
## Tick
@@ -100,4 +121,15 @@ after `--`:
| `src/net/client_runtime.gd` | Prediction, reconciliation, interpolation, bot input. |
| `src/instances/instance.gd` | Lobby hub and dungeon progression. |
| `src/autoload/net.gd` | ENet lifecycle, RPCs, local loopback for listen servers. |
| `src/view/bullet_renderer.gd` | The whole bullet field in one MultiMesh draw call. |
| `src/sim/map_grid.gd` | Tile grid: collision, line of sight, chunked streaming. |
| `src/sim/map_gen.gd` | Dungeon generation. `build()` is the only entry point both sides use. |
| `src/content/rooms.gd` | Hand-authored stamps: the hub and each boss arena, as text. |
| `src/meta/progression.gd` | XP curve and what a level is worth. Pure functions. |
| `src/meta/character.gd`, `character_store.gd` | Characters and their JSON persistence. Server-owned. |
| `src/meta/auth_provider.gd` | Identity, shaped like Steamworks so it swaps out. |
| `src/net/net_codec.gd` | Snapshot / event / input / roster / character / map-chunk codecs. |
| `src/net/server_runtime.gd` | Instances, ticking, transfers, interest, progression, map streaming. |
| `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. |
| `src/view/art.gd` | Every atlas rect and sound path, in one table. |
| `src/view/debug_draw.gd` | F1 overlay: what the simulation collides against. |
| `src/ui/character_select.gd` | Roster screen: pick or create. |
+119 -53
View File
@@ -13,6 +13,22 @@ 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*
@@ -105,50 +121,83 @@ path cannot be covered by the bot smoke test, because bots are poor shots.
## 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.
Nothing blocks this. The pieces it needs — accounts, characters, a server that
owns per-player state — all exist.
| 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 115, +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. |
### Requirements
Current behaviour to replace: `SimPlayer` has no identity beyond a peer id;
`ServerRuntime.peer_names` is client-supplied and trusted for display only.
| 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*
## Stage 4 — Upgrades · *todo, blocked on decisions*
| 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 | |
**Do not start this without answering the open questions below.** The damage
formula in particular determines the shape of every upgrade.
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.
### 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.
| 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 | |
### 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.
---
@@ -157,15 +206,19 @@ bullets spawn per shot, so they belong in the same place.
| Feature | State |
| --- | --- |
| Stationary phases | done — every current phase |
| Roaming / chasing within the boss room | todo; needs `SimBoss.room` clamping (see Stage 1 partial) |
| 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 — new event type plus a renderer, and it must survive fog |
| More bosses | partial — `Rooms.choir_vault()` is authored but has no `BossDef` yet |
| 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 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.
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.
---
@@ -188,18 +241,31 @@ Not oversights — each was considered and rejected for now, with the reasoning
## Open questions for the user
Genuinely unspecified; do not guess at these, they change the design:
Genuinely unspecified. **Do not guess at these** — each changes the design, and
several have no obvious default.
Deferred to the Stage 4 discussion (upgrades), but they block that stage:
### Blocking Stage 4 (upgrades)
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?
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?
Still open outside Stage 4:
### Blocking nothing yet
8. What advances dungeon depth? `--depth` is a dev flag; nothing raises it in play.
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.
+62 -2
View File
@@ -106,10 +106,16 @@ Runs the simulation with no transport and no rendering and prints milliseconds
per tick. Current numbers, 4 players, 60s of play:
```
boss fight 0.237 ms/tick peak 352 bullets headroom x70
trash wave 0.179 ms/tick peak 145 bullets headroom x93
boss fight 0.26 ms/tick peak ~300 bullets headroom x64
trash wave 0.16 ms/tick peak ~82 bullets headroom x103
```
Note what this does *not* cover: it measures the simulation only, with no
transport. Per-peer snapshot encoding was measured separately (95.6us for four
filtered snapshots against 24.9us for one shared, or 0.032 ms/tick amortised).
Quoting a bench number for something the bench does not exercise is its own
version of the trap below.
A 60 Hz tick has a 16.6 ms budget, so one instance uses ~1.4% of one core. That
is the measurement that says a single server process can host dozens of
concurrent dungeons, and it took ten seconds to get because the simulation has
@@ -164,6 +170,60 @@ auto-reload racing against agent file writes is a known source of stale state.
The honest recommendation: **start with the headless CLI loop, add the MCP when
scene inspection becomes the bottleneck.** It is one command to add later.
## Verification traps, learned the hard way
Every entry below cost at least one round trip of reporting something as fixed
when it was not. They are specific to this setup, and they generalise.
### Verify the behaviour, not the artefact you produced
Bullet sprites were reported fixed three times before they were. Each time the
*atlas* was checked and found correct. The actual fault was one layer down: a
`MultiMeshInstance2D` cannot display a sub-region of a texture, so the renderer
was structurally incapable of showing one cell no matter what the atlas held.
Checking the input to a broken stage will confirm the input forever.
Ask what the *player* would see, and find a way to check that.
### An ASCII dump only proves what you dumped
The same sprites were "verified" by rendering their alpha channel. The pack
animates as a colour shimmer over a fixed silhouette, so identical-looking
frames proved nothing at all. The check was real; it was measuring the wrong
channel.
### Measure the configuration that has the bug
The client-prediction gap was measured at 0.1px on a listen server and reported
fixed. A listen server runs both halves in one process on one physics tick, so
the quantity that had drifted — two independent clocks — was constant *by
construction*. The bug needed two processes to exist at all.
If a bug is described as appearing "after a while", ask what accumulates, and
make sure your harness lets it accumulate.
### Have the test tell you the setup was valid
`test_firing_is_reported_even_when_the_bullet_dies_instantly` asserts that no
`BULLET_SPAWN` event occurred, purely to prove the interesting condition was
actually reached. The first version placed a turret as the victim — and turrets
shoot, so the assertion passed on the turret's own bullets while testing
nothing. A setup check inside the test caught it.
### `git checkout` to clean up a probe reverts real work too
A one-file revert to remove a temporary debug hook also discarded a fix made to
the same file earlier in the session, and it had already been verified and
reported. Only `git status` showing the file *missing* from the staged set
caught it. Prefer editing the probe back out, or stash.
### A pattern-based edit can match twice
A `replace()` intended for the snapshot decoder also matched inside the
character decoder, which then read a field its encoder never wrote. Both are
codecs and both had the same trailing lines. Check the match count when patching
by pattern, not by line.
## CI
`.github/workflows/ci.yml` runs check → test → smoke on a container image with