Documentation pass before a compact
ci / verify (push) Successful in 53s

Audited rather than appended. What had drifted, now corrected:

- ROADMAP's opening still described the project before this session: no
  word of the anti-cheat fixes, 468 tests, 23 smoke assertions, and a
  screenshot list half its current size. It now summarises the session,
  lists seven open items raised with the user and not yet decided (the
  boss-kill experience edge, the hub frame rate, aggro through corner
  cracks, the Warden-hall alcoves, one-tile arena doors, the Proving
  Grounds before release, hit feedback), and quotes current numbers.
  Its radius table still gave BULLET_INTEREST_RADIUS as the bullet cull
  at 2200; it is a 2900 ceiling over a per-bullet check.
- NETCODE described the flat bullet radius as the design, claimed the
  respawn lockout gates connected players (they are unbound on death),
  and called actor scoping "next stage". All corrected, and a new section
  covers what the handshake and server now refuse: duplicate sessions,
  reasons that now arrive, paced control messages, coalesced store
  writes, bounds-checked hot decoders.
- DECISIONS gains the audit's rules, the Proving Grounds' status, the
  tunnel and entrance rules, and the interface calls -- each flagged as
  the user's or as a judgement call.
- ARCHITECTURE's file map lacked the actor classes, the dev auth
  provider, the menu backdrop and any tools section.
- README told people to start a client with --name, which now only names
  bot characters, and its feature list predated fog, tunnels and carried
  health. CLAUDE.md gains three invariants (health on the record, one
  session per account, tools that host use scratch data); WORKFLOW gains
  the four traps this session fell into or nearly did.

Every doc was checked for unintended deletions against the session's
start, and every relative link resolves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-05 18:05:56 +02:00
1 parent 94ad6b3429
commit 08f58af1a8
7 files changed
+251 -26

No files matched your search

+20 -2
View File
@@ -144,6 +144,8 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.**
| File | Role |
| --- | --- |
| `src/sim/sim_world.gd` | The simulation. The authority flag decides what runs. |
| `src/sim/sim_player.gd`, `sim_enemy.gd`, `sim_boss.gd` | Actor state. A player's current health is restored from its character on arrival, never reset. |
| `src/sim/sim_events.gd` | Every event type. Order is a wire format; `NetCodec.event_payload_size` must know each one's length. |
| `src/sim/bullet_pool.gd` | Struct-of-arrays bullet storage and integration. |
| `src/sim/input_frame.gd` | The only thing a client may assert about itself. |
| `src/sim/player_stats.gd` | Combat numbers derived from a character's upgrades. |
@@ -172,6 +174,7 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.**
| `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. |
| `src/meta/auth_provider.gd` | Identity, shaped like Steamworks so it swaps out. |
| `src/meta/local_auth_provider.gd` | The development provider. **Insecure on purpose** — any client can claim any account. Do not ship it. |
| `src/instances/instance.gd` | The hub and one dungeon run: a world plus a peer list. |
### Network
@@ -190,13 +193,28 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.**
| --- | --- |
| `src/view/world_view.gd` | Everything that is not a bullet. Fog, actors, loot, telegraphs. Draw order is the fog's mechanism: everything it may hide, then the darkness, then what it must not. |
| `src/view/visibility.gd` | The fog's geometry: rays from the eye, wall corners, the shadow mesh. Pure, so `test_visibility.gd` asks it which points are lit. |
| `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. |
| `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. Fades bullets past the edge of sight, never sooner than `WARNING_TIME` before one could land (`visibility()`). |
| `src/view/art.gd` | Every atlas rect and sound path, in one table. |
| `src/view/ui_theme.gd` | The control theme, built in code from the UI pack. |
| `src/view/debug_draw.gd` | F1 overlay: what the simulation collides against. |
| `src/view/game_scene.gd`, `sfx.gd` | Wiring the view to whatever client `Net` currently has. |
| `src/ui/hud.gd` | Bars, inventory, prompts. Drawn, not built from controls. |
| `src/ui/hud.gd` | Player panel, party, boss bar, banners, message feed, inventory, prompts. Drawn, not built from controls; stacked by a running cursor so nothing collides. |
| `src/ui/main_menu.gd`, `game_menu.gd` | Connect screen and the in-game menu. |
| `src/ui/menu_backdrop.gd` | The real boss patterns playing behind the connect screen, primed so the first frame is already full. |
| `src/ui/character_select.gd` | Roster screen: pick or create. |
| `src/ui/upgrade_screen.gd` | The quartermaster's three choices, and what you hold. |
| `src/ui/settings_screen.gd`, `credits_screen.gd` | Controls, volumes, attribution. |
### Tools
| File | Role |
| --- | --- |
| `tools/check.sh`, `check_scripts.gd` | Parse and type-check every script; refreshes the class cache first. |
| `tools/test.sh` | The GUT suite, headless. |
| `tools/smoke.sh` | A real server and five bots over ENet, asserting the whole loop. |
| `tools/server.sh`, `client.sh` | Run a dedicated server, or a client. |
| `tools/install-hooks.sh` | Points git at `.githooks/`: check and test on commit, smoke on push. |
| `tools/diag_*.gd` | Scene-based end-to-end checks the bots cannot reach: prediction, progression, loot, upgrades. |
| `tools/bench.gd` | Simulation cost per tick. |
| `tools/screenshot.gd` | Every screen, staged. Needs a display. **Its own store and account.** |
| `tools/export_content.gd`, `setup_input_map.gd` | Content to `.tres` for the inspector; the input map, generated correctly. |
+92
View File
@@ -543,3 +543,95 @@ a different fight in a different room.
**A CHASE boss has a dead band around its preferred distance.** The correction
is signed, so without one the sign flips every tick at the standoff and the boss
vibrates on the spot at the tick rate — a couple of pixels, and unmistakable.
---
## The anti-cheat audit, and the rules it left behind
*(User asked for an audit for design flaws and cheats, then "fix these".)* Each
of these was found by reading the server's code paths and then proving the
problem with a probe before fixing it.
**One account, one session — and the newcomer is refused, not the incumbent.**
Two peers on one account each got a player built from the same character record,
so both held a copy of one bag and whichever wrote last won: drop the one potion
from both copies and two potions hit the floor. Refusing the second connection is
the only fix that closes it. The *new* peer is the one turned away because, with
an auth provider anyone can spoof, "kick whoever holds this account" would let
anyone disconnect anyone. A dropped player's linkdead body still holds the
account until its escape channel resolves, which is deliberate: reconnecting
during that second would put the character in the world twice.
**A rejection must reach the client.** `Net.reject_and_kick` defers the kick,
because an RPC only leaves on the frame's flush and kicking in the same call threw
the reason away — a protocol mismatch used to arrive as "server closed the
connection".
**Bullets are never born on the far side of geometry.** A muzzle offset is a
teleport the pool never sees. The player's shot falls back to the shooter's
centre when blocked (six Doubleshots used to put shots through a one-tile wall);
an enemy's shot is dropped (boss curtains were spawning through the arenas' own
pillars and barricades). A telegraphed burst is checked from its marker, not from
the boss.
**Bullet spawns are culled per bullet, not by a flat radius.** Each spawn event
carries the shooter's exact position, and the flat 2900-unit radius exceeded the
whole depth-1 map, so every firing enemy was located for every player.
`ServerRuntime._bullet_concerns` asks whether *this* bullet can reach ground the
player can see.
**No packet buys unbounded work.** Selecting a character you already hold is a
no-op; select and create are paced per peer (`CONTROL_COOLDOWN_TICKS`), and the
pacing logs once per burst, not once per packet. Item moves and experience grants
mark the store dirty and it is written once per tick at most.
**The hub has no escape channel.** Completing it there meant transferring into
the instance you were already in — teleport, full heal, map re-streamed.
**A player's drop is refused when the floor is full.** The cap evicts the oldest
item, which is right for a dungeon's own drops and was wrong for the hub, where it
let a player destroy whatever someone else had left there longest.
## Health, experience and a debug dungeon
**The Proving Grounds is a debugging tool and does not ship.** *(User.)* It pays
full experience for a boss with 8% of its health. Remove it from `Dungeons.ORDER`
and its `P` marker before a release — see the roadmap.
## Tunnels and what is in them
**Tunnels are 2 to 4 tiles wide, rolled per leg.** *(User: "a varied width with a
minimum of 2".)* A one-tile corridor in a bullet hell is a shooting gallery. The
width is per leg of each L, not varying along a leg; that was the simpler reading
and the user has not asked for the other. The arena is excluded from every carve —
an L that bent the wrong way used to slit 45% of arenas' west walls open.
**Now and then a tunnel has a group in it.** *(User: "rarely".)* A 10% chance per
leg of two or three of one kind; about one depth-1 run in three gets one. Never
within aggro range of the entrance, checked per member.
**Nothing can be watching the entrance when you arrive.** Room 0 was always left
empty for this, but only room 0 — and wider tunnels tripled how often a
neighbouring room could see the entrance (25 to 75 runs in 200). Enemies that
would be awake on arrival are skipped: about one in sixteen. Clearing everything
within aggro range would have removed nearly a third, which is a balance change
the user has not asked for.
## Interface
**Every key a prompt names is the one actually bound.** All actions are
rebindable; prompts go through `Settings.short_label()`.
**The boss bar waits until the boss has been seen.** The server sends the boss
through walls from 800 units, and the bar used to announce it to a player still in
the entrance room. It appears once the fog has lit the boss and stays for the
fight. A judgement call, flagged to the user as reversible.
**The controls hint fades six seconds into a dungeon** and stays in the hub. Also
a judgement call; one constant (`Hud.HINT_HOLD`).
**The connect screen asks for no name.** The field it had only named the
characters bots create; people name characters on the roster screen.
**Getting hit reddens the screen's edge, not the whole screen** — a full-screen
tint dimmed the bullets at the moment you were among them.
+48 -11
View File
@@ -165,7 +165,7 @@ Two consequences fall out of partial knowledge:
Hard fog is therefore a *rendering* rule, not a secrecy mechanism. The secrecy
lives in what the server declines to send: terrain outside the stream radius,
and (next stage) actors outside it.
and actors outside the interest radius (below).
## Actors are scoped per peer
@@ -188,13 +188,24 @@ Two rules fall out of it:
about a bullet must always be told it died, or it keeps a phantom until the
lifetime expires.
Bullet spawns use a much wider radius (`BULLET_INTEREST_RADIUS`, 2200u) than
actors, because the failure modes are not symmetric. An enemy appearing at the
edge of sight is cosmetic; a bullet withheld at spawn that later flies into view
is invisible damage. The floor is *longest bullet travel + fog radius* — 1500 +
460 for the Warden's "Collapse" snipe — and `test_interest.gd` recomputes that
from the live content, so adding a faster or longer-lived bullet fails a test
rather than producing bullets that wink into existence.
Bullet spawns are filtered **per bullet**: a spawn is sent if *that bullet*,
at its speed and acceleration over its remaining life, could reach ground the
player can see (`ServerRuntime._bullet_concerns`). The failure modes are not
symmetric — an enemy appearing at the edge of sight is cosmetic, a bullet
withheld at spawn that later flies into view is invisible damage — so the rule
errs long.
It used to be a flat radius, `BULLET_INTEREST_RADIUS`, sized for the
longest-travelling shot in the game. That was a leak: every spawn event carries
the shooter's exact position, and at 2900 units the radius exceeded the entire
depth-1 map, so every firing enemy anywhere on the floor was located for every
player — past the actor cull, past the map stream, through the fog. The constant
survives only as a hard ceiling, and `test_interest.gd` checks both: that no real
enemy's bullet is announced across a dungeon, and that the ceiling still covers
the longest shot in the game.
The same rule decides what is sent to a player arriving mid-fight
(`_live_bullet_events`), which is the moment they have seen least of the map.
## Loot has two visibilities, and one of them is an interest rule
@@ -318,9 +329,11 @@ transition, not a combat mechanic.
| Upgrade choice made anywhere but the hub NPC | `SimWorld.at_upgrade_npc`, server-side position |
| Upgrade index outside the server's own offer | `CharacterStore.take_upgrade` refuses |
The respawn lockout is worth calling out: the HUD disables its button for the
same three seconds, but that is presentation. A client that ignores its own UI
and holds the bit down still waits, because the check lives in `SimWorld`.
The respawn lockout lives in `SimWorld`, not only in the HUD's disabled button —
but note what reaches it. A connected player is unbound on the tick it dies and
sent to the roster screen (a settled decision; see DECISIONS.md), so it never
waits at all. The lockout governs the *linkdead* body, which has nobody to show a
roster to and asks for itself.
Beyond that, the wire format itself constrains the cheat surface: the move
vector is two signed bytes at 1/100 precision, and `Movement.sanitize_move()`
@@ -411,6 +424,30 @@ the snapshot stream:
`Protocol.VERSION` is checked at handshake and mismatched clients are rejected
with a reason, rather than desyncing later.
## What the handshake and the server refuse
- **One account, one session.** A second connection claiming an account that is
already playing is refused — the newcomer, not the incumbent, or a spoofed
claim would kick anyone off. Two peers on one account each held a copy of one
character's bag, which duplicated items. A linkdead body still holds its
account until its escape channel resolves.
- **The reason arrives.** `Net.reject_and_kick` sends the refusal, then kicks
after `REJECT_LINGER`: an RPC only leaves on the frame's flush, so kicking in
the same call discarded it, and the client's disconnect handler overwrote it
anyway. Both are fixed; smoke asserts a refused client reads its reason.
- **No small packet buys large work.** Character select and create rewrite the
whole store, re-place the player and broadcast a roster, so they are paced per
peer (`ServerRuntime.CONTROL_COOLDOWN_TICKS`), and re-selecting the character
you hold is a no-op. Item moves and experience grants — the two persistence
paths a client can trigger every tick — mark the store dirty, and it is written
once per tick at most (`CharacterStore.save_deferred` / `flush`).
- **The two hot decoders check their bounds.** A short or hostile snapshot
decodes as `valid: false` and is dropped; the snapshot channel is unreliable
with ENet's checksum off, so this is a network event, not only an attack. Event
lengths are declared per type (`NetCodec.event_payload_size`), and an unknown
type stops the stream rather than unaligning everything after it. Both size
tables are pinned to their encoders by tests.
## Instances
One server process hosts the lobby hub plus every concurrent dungeon. An
+42 -7
View File
@@ -23,6 +23,23 @@ a lobby hub with dungeon runs, and an emergency escape. All of that exists, plus
the four stages that followed: characters and permadeath, inventory and loot,
upgrades, and a second boss that moves.
Since then, in one long session:
- **An anti-cheat audit, and its fixes.** Item duplication through two sessions
on one account; shots through walls from muzzle offsets (the player's and every
boss's); every firing enemy located by the flat bullet-interest radius;
unbounded store writes from small packets; the hub's escape channel; hub loot
griefing; unbounded snapshot and event decoders. Each is in DECISIONS.md under
"The anti-cheat audit".
- **Health survives a door**, the hub regenerates it at 10%/s, and experience goes
only to players within `XP_AWARD_RADIUS` of the kill.
- **Fog lit by rays**, so shadows cut through tiles ([visibility.gd](../src/view/visibility.gd)),
and **bullets fade past the edge of sight** without ever arriving unseen.
- **Tunnels 2–4 wide**, an occasional enemy group in them, nothing watching the
entrance on arrival, and arenas that keep their walls.
- **Two interface passes** — see the Interface section of DECISIONS.md.
- **The smoke flake is fixed** (below).
So there is no obvious "next ticket". What follows is the honest list of
candidates, none of which the user has chosen — **ask before starting one.**
@@ -35,6 +52,18 @@ candidates, none of which the user has chosen — **ask before starting one.**
| **Replace the two non-redistributable packs** | Bullet and FX art is local-only and non-commercial, which blocks both a public repo copy and a commercial release. | CC0 or CC BY replacements. See [ASSETS.md](ASSETS.md). |
| **Transport encryption** | `ENetMultiplayerPeer` supports DTLS. Required before a public server, not before then. | Follows the auth decision. |
And smaller things raised with the user and not yet answered or chosen:
| Open item | What is known |
| --- | --- |
| **A boss kill can pay nothing** | Both arenas are wider corner to corner (803 and 630 units) than `XP_AWARD_RADIUS` (506), so a player in the far corner when the boss dies gets no experience for the run. Suggested fix: anyone inside the boss room counts as present for a boss kill. |
| **The hub renders at ~60 fps, a dungeon at ~240** | Measured with the old fog and the new, same scene: not the fog. Cause unknown. |
| **Aggro sees through cracks the fog closes** | `MapGrid.has_line_of_sight` steps diagonally between two tiles that touch only at a corner. The fog treats that as closed, so an enemy can occasionally see and shoot you through a gap you cannot see through. |
| **Two one-tile alcoves in the Warden's hall** | Rows 4 and 8 of `Rooms.warden_hall()` are one character longer than the rest, leaving notches in the east wall. Possibly a typo; asked, not answered. |
| **Arena doors are one tile wide** | Authored in the stamps. Tunnels into them are now at least two. |
| **The Proving Grounds must not ship** | Full experience for an 8%-health boss. Remove it from `Dungeons.ORDER` and its `P` marker before a release. |
| **Hit feedback on enemies** | `Art.IMPACT` is still loaded and never played (the candidate above). |
Each stage below also carries its own **Known gaps** section — those are smaller
and more specific than the table above.
@@ -60,8 +89,8 @@ 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` | 468 GUT tests, no SceneTree | ~4s |
| `tools/smoke.sh` | 23 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s |
| `tools/test.sh` | 576 GUT tests, no SceneTree | ~4s |
| `tools/smoke.sh` | 27 assertions over a real ENet socket: handshake, auth, character creation and persistence, all three dungeons, escape, hard kill, polite disconnect, a duplicate account refused with its reason | ~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 |
@@ -69,10 +98,15 @@ What "everything passes" currently means. Numbers move; the shape does not.
`tools/screenshot.tscn` is a **manual** check, not a gate: it needs a display,
and it is the only thing in the project that can tell you the interface
actually rendered. Run it after touching `src/ui/`. It captures the main menu,
the HUD, the pause menu, settings, credits and the upgrade choice — the last of
those by staging an offer, because earning one would make the shot depend on a
dungeon run.
actually rendered. Run it after touching `src/ui/` or `src/view/`. It captures
the connect screen, the hub standing on a portal, the pause menu, settings,
credits, the upgrade choice, the roster after a death, a dungeon arrival, a
tunnel (with and without F1), a staged boss fight, and the escape banner. It runs
on **its own character store and account** — it used to host on the player's
real ones — and stages what it cannot earn (an offer, a party, a death).
Timings are frame counts and frames run as fast as the display allows, so
anything tied to server ticks is captured by condition instead (see the escape
shot).
It has now caught four bugs that every other gate passed: the theme applying to
nothing, the test suite rewriting the player's settings, an invisible slider
@@ -126,7 +160,8 @@ terrain it does not have.
| `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. |
| `BULLET_INTEREST_RADIUS` | 2900 | longest bullet travel + fog radius | **A ceiling only.** Spawns are culled per bullet — can *this* one reach visible ground (`ServerRuntime._bullet_concerns`). As a flat cull it exceeded the depth-1 map and located every firing enemy for everyone. |
| `XP_AWARD_RADIUS` | 506 | 1.1 × fog radius | Experience goes to the living players within it of the kill, undivided. |
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.
+34
View File
@@ -224,6 +224,40 @@ 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.
### Slicing between two headings deletes whatever was between them
Replacing a section by cutting from its heading to "the next big heading" took
**451 lines** of the roadmap with it — every stage section sat between the two —
and it was committed and pushed before anything noticed, because no gate reads
prose. End a slice at the marker that actually closes the section (here the
`---` rule after it), and before committing any doc edit, check
`git diff --numstat` for a deletion count bigger than the edit you meant.
### A mutation that survives is a test that cannot fail
Twice in one session a new test passed with its fix reverted. One staged a turret
behind a barricade — a turret's muzzle offset cannot clear its own radius plus a
tile, so the scenario could never have failed. Another computed its sample point
*from* the constant under test, so zeroing the constant moved the sample instead
of failing it. Reverting the fix and watching the test go red is the only way to
know it tests anything.
### A flaky gate needs its logs kept, not rerun
The smoke test failed intermittently for weeks, always in the pre-push hook,
which discards logs; every rerun passed and the trail went cold. Pushing with
`KEEP=1 git push` kept the third failure's logs, which named the cause in one
grep (a bot walking through the wrong portal). Keep the evidence first; rerun
second.
### A tool that hosts a server must not use the player's data
`screenshot.tscn` hosted a listen server with the default character store and
account — the player's own — and walked their character into dungeons for
pictures. Every diagnostic already set `GameOpts.store_path` and
`GameOpts.account_override`; the screenshot tool had simply never been asked to.
Anything new that calls `Net.host()` sets both first.
## CI
`.github/workflows/ci.yml` runs check → test → smoke on a container image with