Compare commits

...
3 Commits
Author SHA1 Message Date
claudeandClaude Opus 5.5 08f58af1a8 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>
2026-10-05 18:05:56 +02:00
claudeandClaude Opus 5.5 94ad6b3429 Stop drawing bullets past the edge of sight -- but never sooner than is safe
ci / verify (push) Successful in 54s
Bullets were drawn everywhere on screen, including out in the black past
the edge of sight: shots that could not reach you, streaking across the
dark. They now fade out beyond sight, under one condition that bounds the
whole change: no enemy bullet may hit you without having been on screen,
at full strength, for at least WARNING_TIME (0.75s) first.

So each enemy bullet is drawn out to the edge of sight or to however far
it could close in that window with you running straight at it -- its own
speed, plus acceleration over the window, plus yours -- whichever is
further, and fades over 80 units beyond. With today's content the edge of
sight always wins: the fastest enemy bullet is 300 u/s, so head-on it is
on screen 0.83s before it lands. The speed term is what keeps that true
when something faster is added. Player shots fade at the edge of sight
however fast they are, since they cannot hurt you; a Sniper's 1500 u/s
shot was the commonest thing crossing the dark.

Shadow does not hide a bullet. One behind a wall within sight range is
still drawn over the darkness, because you can step round the corner into
it, and hiding it would be exactly the invisible hit the condition rules
out.

The guarantee is tested the hard way: every emitter in the content, and
bullets far faster than any of them, simulated head-on against a player
running at them, measuring how long each was fully drawn before it
landed. The rule's weakenings -- no speed term, forgetting the player's
own approach, ignoring acceleration -- each fail it. The connect screen's
backdrop passes no eye and draws everything, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:57:50 +02:00
claudeandClaude Opus 5.5 170eb409dd Restore the 451 lines of the roadmap the flake note deleted
ci / verify (push) Successful in 53s
Commit 3d1b5da replaced the "Known flake" note by slicing the file from
that heading to "## Open questions for the user", on the assumption the
two were adjacent. They are not: between them sit the verification
surface and every stage section -- what each one built, its decisions and
its known gaps. All of it went, and it was pushed.

Restored from the commit before, with only the two intended changes
applied on top: the flake section replaced with its diagnosis, now ending
at the rule that actually closes it, and the HUD and connect-screen rows
the second UI pass meant to add. Every other document was checked for the
same kind of loss against the start of the session; none had any.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:14:58 +02:00
12 changed files with 886 additions and 29 deletions

No files matched your search

+16 -2
View File
@@ -13,8 +13,8 @@ the obvious default.
```bash ```bash
tools/check.sh # parse-check every script (~5s) -- run after every edit tools/check.sh # parse-check every script (~5s) -- run after every edit
tools/test.sh # 551 GUT tests, headless (~4s) tools/test.sh # 576 GUT tests, headless (~4s)
tools/smoke.sh # real server + 4 bot clients over ENet (~40s) tools/smoke.sh # real server + 5 bot clients over ENet (~40s)
tools/server.sh # dedicated server tools/server.sh # dedicated server
tools/client.sh --listen # host and play, no menu tools/client.sh --listen # host and play, no menu
``` ```
@@ -209,6 +209,14 @@ ticks in milliseconds with no SceneTree.
from `_place`.** `SimWorld` knows nothing about characters and builds a blank from `_place`.** `SimWorld` knows nothing about characters and builds a blank
player on every instance change, so any new path that puts someone in a world player on every instance change, so any new path that puts someone in a world
must go through `_place` or it hands them a level-1 body with an empty bag. must go through `_place` or it hands them a level-1 body with an empty bag.
Current health comes from the record too (`Character.hp`), and is banked by
`_bank_health` wherever a player leaves a world — miss one and that door
becomes a free heal.
- **One account, one session.** `on_hello` refuses a peer whose account is
already playing; two live players on one character duplicate items.
- **Anything that calls `Net.host()` from a tool sets `GameOpts.store_path` and
`GameOpts.account_override` first.** The default is the player's real store and
account; `screenshot.tscn` once played their character into dungeons.
- **A player's combat numbers are derived, never stored.** `PlayerStats.build()` - **A player's combat numbers are derived, never stored.** `PlayerStats.build()`
recomputes them from the character's upgrade ids every time, so a saved stat recomputes them from the character's upgrade ids every time, so a saved stat
cannot disagree with the upgrades that produced it. Upgrade *riders* (split cannot disagree with the upgrades that produced it. Upgrade *riders* (split
@@ -254,6 +262,12 @@ ticks in milliseconds with no SceneTree.
`Visibility` over them, then whatever must stay visible. That is what lets a `Visibility` over them, then whatever must stay visible. That is what lets a
tile or an enemy be half lit. Anything new that fog may hide goes *before* tile or an enemy be half lit. Anything new that fog may hide goes *before*
`_draw_darkness`; anything it must not goes after. `_draw_darkness`; anything it must not goes after.
- **No enemy bullet may land without having been drawn first.** Bullets fade
beyond the edge of sight, but `BulletRenderer.visibility()` stretches each
enemy bullet's draw distance with its speed so it is always fully on screen
`WARNING_TIME` before it could reach you. A faster bullet in new content is
covered automatically; `test_bullet_draw.gd` fails if it is not. Never hide
bullets by shadow alone — you can walk round a corner into one.
- **A telegraph must be visible through fog.** `WorldView._draw_telegraphs` - **A telegraph must be visible through fog.** `WorldView._draw_telegraphs`
is drawn after the darkness on purpose; everything else in the view is under it. A is drawn after the darkness on purpose; everything else in the view is under it. A
warning you cannot see is an unavoidable hit with extra steps. warning you cannot see is an unavoidable hit with extra steps.
+6 -5
View File
@@ -7,7 +7,7 @@ Godot 4.7, GDScript, no external runtime dependencies.
```bash ```bash
tools/server.sh # dedicated server on :27015 tools/server.sh # dedicated server on :27015
tools/client.sh --join --name ada # connect a client tools/client.sh --join # connect a client; you name a character on the roster screen
``` ```
Or run one client and press **Host and play** for a listen server. Or run one client and press **Host and play** for a listen server.
@@ -25,8 +25,9 @@ hitbox overlay. Every one of those is rebindable in Settings.
| **Authoritative multiplayer** | Clients send input and nothing else. Hits, damage, death, loot, upgrades and instance transfers are all server decisions. Prediction and reconciliation for the local player. | | **Authoritative multiplayer** | Clients send input and nothing else. Hits, damage, death, loot, upgrades and instance transfers are all server decisions. Prediction and reconciliation for the local player. |
| **Enemies** | Five readable movements (static, drift, orbit, approach, strafe) composed with bullet emitters. Nothing deals contact damage; every threat is a bullet you can see. | | **Enemies** | Five readable movements (static, drift, orbit, approach, strafe) composed with bullet emitters. Nothing deals contact damage; every threat is a bullet you can see. |
| **Two bosses** | The Warden of the Fold stands still through four phases. The Cantor of the Vault walks a circuit, chases, orbits, and marks the floor before it strikes. Both are pure data. | | **Two bosses** | The Warden of the Fold stands still through four phases. The Cantor of the Vault walks a circuit, chases, orbits, and marks the floor before it strikes. Both are pure data. |
| **Three dungeons** | Two real runs, one per boss, plus a Proving Grounds: the same content at a fraction of the health and ten times the drop rate, for testing by hand. | | **Three dungeons** | Two real runs, one per boss, plus a Proving Grounds: the same content at a fraction of the health and ten times the drop rate, for testing by hand — and not for release. Generated rooms joined by tunnels two to four tiles wide, which now and then hold a group of enemies. |
| **Characters** | Up to five living per account, permadeath, levels 1–15, experience shared across the party undivided. | | **Fog of war** | Lit by rays, so a shadow cuts straight through a tile and an enemy rounding a corner appears a slice at a time. Bullets fade past the edge of sight, but never so soon that one could reach you unseen. |
| **Characters** | Up to five living per account, permadeath, levels 1–15. Health carries between runs and the hub heals it. Experience is shared, undivided, among the players near enough to have seen the kill. |
| **Inventory and loot** | Four slots, always on screen. Loot is either world-shared or instanced per player, and the instanced kind is filtered on the wire rather than hidden in the client. | | **Inventory and loot** | Four slots, always on screen. Loot is either world-shared or instanced per player, and the instanced kind is filtered on the wire rather than hidden in the client. |
| **Upgrades** | Seven, drawn three at a time and spent at a hub NPC. Damage, fire rate, extra projectiles, splitting, poison, and a legendary that deletes enemy bullets. | | **Upgrades** | Seven, drawn three at a time and spent at a hub NPC. Damage, fire rate, extra projectiles, splitting, poison, and a legendary that deletes enemy bullets. |
| **Emergency escape** | A one-second channel back to the hub that damage does **not** interrupt — because if it did, quitting the process would be the better escape. Dropping your connection runs the same channel. | | **Emergency escape** | A one-second channel back to the hub that damage does **not** interrupt — because if it did, quitting the process would be the better escape. Dropping your connection runs the same channel. |
@@ -36,8 +37,8 @@ hitbox overlay. Every one of those is rebindable in Settings.
```bash ```bash
tools/check.sh # parse-check every script ~5s tools/check.sh # parse-check every script ~5s
tools/test.sh # 468 GUT tests, headless ~4s tools/test.sh # 576 GUT tests, headless ~4s
tools/smoke.sh # server + 4 bot clients over ENet ~40s tools/smoke.sh # server + 5 bot clients over ENet ~40s
``` ```
Read [CLAUDE.md](CLAUDE.md) first — it is short, and the Godot CLI gotchas in it Read [CLAUDE.md](CLAUDE.md) first — it is short, and the Godot CLI gotchas in it
+20 -2
View File
@@ -144,6 +144,8 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.**
| File | Role | | File | Role |
| --- | --- | | --- | --- |
| `src/sim/sim_world.gd` | The simulation. The authority flag decides what runs. | | `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/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/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. | | `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/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/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/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. | | `src/instances/instance.gd` | The hub and one dungeon run: a world plus a peer list. |
### Network ### 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/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/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/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/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/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/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/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/character_select.gd` | Roster screen: pick or create. |
| `src/ui/upgrade_screen.gd` | The quartermaster's three choices, and what you hold. | | `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. | | `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. |
+107 -2
View File
@@ -108,8 +108,21 @@ stopping at a circle. Nothing is remembered: still hard fog.
Two lines this deliberately does not cross: light stops the moment a ray steps Two lines this deliberately does not cross: light stops the moment a ray steps
back out of the wall it was in, so it never rounds a corner or reaches the far back out of the wall it was in, so it never rounds a corner or reaches the far
side of a one-tile wall; and two blocking tiles that touch only at a corner are side of a one-tile wall; and two blocking tiles that touch only at a corner are
closed, not a crack. Bullets are drawn over the darkness, as they always were — closed, not a crack. Bullets are drawn over the darkness — a shot out of the
a shot out of the black is still one you can dodge. black is still one you can dodge.
**Bullets fade out past the edge of sight, but never sooner than is safe.**
*(User asked to stop drawing bullets beyond sight, with the condition that
nobody is ever hit by a bullet they could not see.)* Every enemy bullet is
drawn at full strength out to the edge of sight or to however far it could close
in `BulletRenderer.WARNING_TIME` (0.75s) with you running straight at it,
whichever is further, then fades over 80 units. Today the edge of sight is always
further: the fastest enemy bullet is 300 u/s, so even head-on it is on screen
0.83s before it lands. `test_bullet_draw.gd` checks that for every emitter in the
content, and for bullets far faster. Player shots fade at the edge of sight
regardless, since they cannot hurt you. Bullets in shadow *within* sight range
are still drawn: you can step round a corner into one, and hiding it would be
exactly the invisible hit the condition rules out.
**Dungeon size scales with depth.** *(User, this session.)* `--depth N` is a dev **Dungeon size scales with depth.** *(User, this session.)* `--depth N` is a dev
flag; what raises depth in actual play is still open. flag; what raises depth in actual play is still open.
@@ -530,3 +543,95 @@ a different fight in a different room.
**A CHASE boss has a dead band around its preferred distance.** The correction **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 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. 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 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, 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 ## 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 about a bullet must always be told it died, or it keeps a phantom until the
lifetime expires. lifetime expires.
Bullet spawns use a much wider radius (`BULLET_INTEREST_RADIUS`, 2200u) than Bullet spawns are filtered **per bullet**: a spawn is sent if *that bullet*,
actors, because the failure modes are not symmetric. An enemy appearing at the at its speed and acceleration over its remaining life, could reach ground the
edge of sight is cosmetic; a bullet withheld at spawn that later flies into view player can see (`ServerRuntime._bullet_concerns`). The failure modes are not
is invisible damage. The floor is *longest bullet travel + fog radius* — 1500 + symmetric — an enemy appearing at the edge of sight is cosmetic, a bullet
460 for the Warden's "Collapse" snipe — and `test_interest.gd` recomputes that withheld at spawn that later flies into view is invisible damage — so the rule
from the live content, so adding a faster or longer-lived bullet fails a test errs long.
rather than producing bullets that wink into existence.
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 ## 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 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 | | 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 The respawn lockout lives in `SimWorld`, not only in the HUD's disabled button —
same three seconds, but that is presentation. A client that ignores its own UI but note what reaches it. A connected player is unbound on the tick it dies and
and holds the bit down still waits, because the check lives in `SimWorld`. 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 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()` 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 `Protocol.VERSION` is checked at handshake and mismatched clients are rejected
with a reason, rather than desyncing later. 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 ## Instances
One server process hosts the lobby hub plus every concurrent dungeon. An One server process hosts the lobby hub plus every concurrent dungeon. An
+481
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, the four stages that followed: characters and permadeath, inventory and loot,
upgrades, and a second boss that moves. 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 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.** 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). | | **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. | | **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 Each stage below also carries its own **Known gaps** section — those are smaller
and more specific than the table above. and more specific than the table above.
@@ -51,6 +80,458 @@ stopped. The bot now walks straight to its own entrance and presses interact
only on it (`ClientRuntime._bot_input`). If smoke fails again, push with only on it (`ClientRuntime._bot_input`). If smoke fails again, push with
`KEEP=1 git push` so the logs survive. `KEEP=1 git push` so the logs survive.
---
## 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` | 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 |
| `diag_upgrades.tscn` | level → banked choice → refused in a dungeon and away from the NPC → taken → new stats → persisted → **survives every zone change** | ~10s |
`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/` 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
track and an invisible scrollbar.
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()` |
| HUD: player panel, party health, message feed, low-health edge | done | [src/ui/hud.gd](../src/ui/hud.gd) — stacked by a running cursor, so nothing has a fixed row to collide on |
| Prompts name the keys actually bound | done | `Settings.short_label()` |
| Health bar: dark track, number inside, damage trail | done | `Hud._bar`, `Hud._watch_health` |
| Centre banners: level-ups, boss phases, leaving | done | `Hud.banner`, `Hud.track_boss` |
| Bullets fade out beyond sight, never sooner than is safe | done | `BulletRenderer.visibility()` — every enemy bullet is drawn at least 0.75s before it could land |
| Markers pointing to party members out of sight | done | `Hud._draw_party_markers` |
| Connect screen: live boss pattern behind it, port validation, credits | done | [src/ui/main_menu.gd](../src/ui/main_menu.gd), [src/ui/menu_backdrop.gd](../src/ui/menu_backdrop.gd) |
| Boss bar waits until the boss has been seen | done | `Hud._draw_boss_bar`, reading the fog's `Visibility` |
| A death is announced on the roster screen | done | `ClientRuntime.last_fallen`, `GameScene.fallen_line` |
| Occasional enemy groups in tunnels | done | `Instance._populate_tunnels()` — about one depth-1 run in three, never within aggro range of the entrance |
| Scrolling camera | done | [src/view/game_scene.gd](../src/view/game_scene.gd) |
| Hard fog of war, lit by rays rather than per tile | done | [src/view/visibility.gd](../src/view/visibility.gd), `WorldView._draw_darkness` |
| 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` | 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.
---
## Dungeon kinds · *done*
Three entrances stand side by side in the hub, labelled with the fight each one
ends in.
| Dungeon | `Dungeons` id | Arena / boss | Enemy HP | Boss HP | Loot chance |
| --- | --- | --- | --- | --- | --- |
| Warden's Descent | `warden_descent` | `warden_hall` → the Warden | ×1 | ×1 | ×1 (trash 8%) |
| The Choir Vault | `choir_vault` | `choir_vault` → the Cantor | ×1 | ×1 | ×1 |
| Proving Grounds | `proving_grounds` | *whichever the seed rolls* | ×0.2 | ×0.08 | ×10 (clamped) |
**A dungeon names its arena, and the arena decides its boss.** Which boss you
are about to fight is the one thing a player chooses before walking into a
dungeon, so it must not be rolled for them. Only the Proving Grounds leaves
`arena` empty — it exists to reach *a* fight fast, and runs there last a couple
of minutes, so the one you want is one re-entry away.
The Proving Grounds is a **test harness you can walk into**: same generator,
same rooms, same enemies, but everything dies far faster and drops far 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 it to a real run without restarting
the server.
**It is a debugging tool and does not ship.** It pays exactly the same
experience a real run does — `Progression.xp_for_boss` ignores its argument and
`xp_for_enemy` keys on the enemy id, which the difficulty multipliers do not
touch — while its boss has 8% of its health and its trash drops ten times as
often. That makes it strictly the best place to level and to farm, which is
correct for a harness and would be ruinous in a release. Remove it from
`Dungeons.ORDER` (and its `P` marker from the lobby stamp) before shipping, or
give `DungeonDef` an `xp_mult` alongside the three multipliers it already has.
Adding a 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, so the
two have to stay in the same order.
Two more 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 a real run. `accepts_new_party_member` compares
dungeon ids too, so walking into one entrance can never drop you into
another's forming run on timing alone.
---
## 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 | |
| Rebindable controls | done | [src/ui/settings_screen.gd](../src/ui/settings_screen.gd), [src/core/settings.gd](../src/core/settings.gd) |
| Themed controls from a UI pack | done | [src/view/ui_theme.gd](../src/view/ui_theme.gd), art in `assets/sprites/ui/` |
| Dialogs on panels, sliders, scrollbars | done | `UiTheme.dialog_panel`, `HSlider` / `VScrollBar` theme entries |
| Upgrade cards framed by rarity | done | `UiTheme.card_panel`, `UpgradeScreen._make_card` |
| Textured HUD bars and inventory slots | done | `HUD._bar`, `HUD._draw_inventory` |
| Audio buses and a volume setting | done | `Settings`, an SFX bus created at runtime, sliders in the settings screen |
| **In-game credits screen** | **done** | Settings → Credits, from [src/core/credits.gd](../src/core/credits.gd). Two packs 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).
---
## The scale of health and damage
Health and damage are an order of magnitude larger than they started. A shot is
60, a fresh character has 1000, the Warden has 36000. **Every ratio is
unchanged** — enemy health, boss health and every emitter's damage were scaled
together, so time to kill is exactly what it was.
The reason is rounding. At the old base of 6 damage, the +5% every upgrade
carries computed to 6.3 and rounded straight back to 6, so a player's first
upgrade visibly did nothing. At 60 it is +3, and every percentage in the game
now has somewhere to land.
`tests/unit/test_content.gd` pins both halves of that: that a single upgrade
changes damage, and that shots-to-kill for each enemy is what it was before.
Do not tidy these numbers back down without scaling `content.gd` with them.
The hub's practice dummy stopped relying on a huge health pool at the same
time: it is `indestructible` by flag now. The old 100000 was already past the
u16 the snapshot sends enemy health in, and after the rescale a patient player
could have destroyed the hub's only practice target for everyone.
---
## 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
- **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 · *done*
| Feature | State | Where |
| --- | --- | --- |
| Stationary phases | done | every Warden phase |
| Boss confined to its room | done | `SimWorld._step_boss` clamps after moving |
| Roaming / chasing within the boss room | done | `BossPhase.Move.CHASE`, holds a standoff |
| Phases that move to preset locations | done | `BossPhase.Move.WAYPOINTS`, room-relative |
| Orbiting the arena | done | `BossPhase.Move.ORBIT` |
| Attacks spawned at a distance with a telegraph | done | [telegraphed_strike_emitter.gd](../src/sim/patterns/telegraphed_strike_emitter.gd), `SimEvent.Type.TELEGRAPH` |
| A second boss | done | **Cantor of the Vault**, fights in `Rooms.choir_vault()` |
### Boss movement
Movement is a property of the **phase**, not of the boss: a fight that stands
still and then starts hunting you is one boss with two phases. Four modes —
`STATIC`, `ORBIT`, `CHASE`, `WAYPOINTS` — all handled generically in
`SimWorld._move_boss`, so adding a boss that moves is still writing data.
- **Waypoints are fractions of the arena**, not absolute positions, so one phase
works in the Warden's hall and the Choir Vault alike.
- **CHASE holds a distance rather than closing.** A boss standing on top of you
is a boss whose bullets cannot be read.
- **Every mode is speed-clamped in one place.** `ORBIT` computes an absolute
destination and would otherwise snap onto its circle on the first tick.
- **Movement slides against geometry**, so a boss cannot walk through the
pillars its own arena was designed around.
- **The room clamp is now load-bearing.** It was a no-op while every boss stood
still — which is exactly when an invariant is cheapest to establish. Boss
rooms deliberately do not lock, and that is only an escape if the boss cannot
follow you out.
### Telegraphed strikes
`TelegraphedStrikeEmitter` marks spots in the arena and fills them a moment
later. The moment in between is the whole feature: a burst that appears at your
feet is a coin flip, the same burst with a second of notice is a question.
- **The emitter is stateless**, like every other one — they are shared resources
and two bosses of the same kind must not stomp each other. Strike positions
are *derived* from the volley number, so the announcement and the strike
compute the same points with nothing stored between them.
- **`EmitContext` gained an `events` list**, the only thing an emitter can do
besides spawn bullets.
- **Markers are drawn through fog and through walls.** Everything else in the
view is covered by the darkness; a warning you cannot see is not a warning. See
`WorldView._draw_telegraphs`.
### The Cantor of the Vault
| Phase | Movement | Idea |
| --- | --- | --- |
| Call to Prayer | static | Teaches the marker, and nothing else. |
| Processional | waypoints, four corners | The safe half of the room keeps moving. |
| Antiphon | chase at 220u | It comes for you; walls punish running straight. |
| Final Cadence | orbit | Movement and telegraphs at once. |
**The Cantor has its own portal in the hub.** Which boss a run ends with follows
from the arena its dungeon names, not from the seed and certainly not from
`--depth`, which nothing in play raises.
`test_every_boss_has_a_dungeon_that_reaches_it` pins that adding a boss without
giving it a way in is a failing test rather than a boss nobody meets.
`Content.ALL_ENEMIES` / `ALL_BOSSES` exist because five places were
hand-maintaining their own copy of the content list, and `tools/export_content.gd`
had already gone stale and silently stopped writing the second boss.
### Known gaps
- **Nothing tests how the fight *looks*.** The boss was drawn straight from the
newest snapshot with no interpolation — a visible step at 20 Hz — and every
test passed for as long as bosses stood still. There is still no automated
check that a moving actor renders smoothly; the ones added cover the
interpolation call, not the impression.
- **No boss-specific music, intro or death sequence.** A boss dies like an
enemy, only louder.
- **Telegraph markers are one shape.** A circle is the only warning the client
can draw; a lane or a cone would need another event field.
- **`Rooms` has two arenas.** A third boss needs a third stamp, which is where
the generator's "boss arena on the right-hand side" assumption will be tested.
---
## 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 ## Open questions for the user
Genuinely unspecified. **Do not guess at these** — each changes the design, and Genuinely unspecified. **Do not guess at these** — each changes the design, and
+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 codecs and both had the same trailing lines. Check the match count when patching
by pattern, not by line. 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 ## CI
`.github/workflows/ci.yml` runs check → test → smoke on a container image with `.github/workflows/ci.yml` runs check → test → smoke on a container image with
+53 -3
View File
@@ -17,6 +17,21 @@ extends Node2D
## Used only in fallback mode, where every bullet is the same generated dot and ## Used only in fallback mode, where every bullet is the same generated dot and
## colour is the only thing distinguishing a kind. ## colour is the only thing distinguishing a kind.
## How long an enemy bullet is guaranteed to be drawn at full strength before
## it could possibly reach you. The whole point of fading bullets with distance
## is to stop drawing shots that cannot matter -- and the one thing that must
## never come of it is a hit from a bullet you were not shown.
##
## So each enemy bullet is drawn out to however far it, plus you running straight
## at it, could close in this long -- or to the edge of sight, whichever is
## further. With today's content the edge of sight is always the further: the
## fastest enemy bullet does 300 u/s, so even head-on it is on screen for 0.83s
## before it lands. The speed term is what keeps that true for anything faster.
const WARNING_TIME := 0.75
## Past the distance a bullet is drawn to, it fades out over this much more
## rather than vanishing on a line -- a hard edge reads as a rendering fault.
const FADE_OUT := 80.0
const _FALLBACK_COLOURS: Array[Color] = [ const _FALLBACK_COLOURS: Array[Color] = [
Color(0.45, 0.95, 1.0), # KIND_PLAYER_SHOT Color(0.45, 0.95, 1.0), # KIND_PLAYER_SHOT
Color(1.0, 0.35, 0.75), # KIND_ORB Color(1.0, 0.35, 0.75), # KIND_ORB
@@ -99,13 +114,47 @@ func _slice_still_frames(sheet: Texture2D, kinds: int) -> Array[Texture2D]:
return out return out
func render_pool(pool: BulletPool) -> void: ## How strongly to draw a bullet [param dist] from the eye: 1 is fully, 0 is not
## at all. See [constant WARNING_TIME] for why the distance depends on the bullet.
##
## Shots by players fade at the edge of sight and no further, however fast they
## are: they cannot hurt you, so there is nothing to warn about, and a Sniper's
## 1500 u/s shot was the most common thing streaking across the dark.
##
## Distance only -- not shadow. A bullet behind a wall within sight range is
## still drawn, over the darkness, because you can step round the corner into
## it; hiding it would be the invisible hit this is careful not to cause.
static func visibility(dist: float, speed: float, accel: float, team: int,
sight_radius: float) -> float:
var reach := sight_radius
if team == SimConfig.TEAM_ENEMY:
# Top speed over the warning window, times the window: an over-estimate
# of how far it can come, which is the safe direction to be wrong in.
var top := speed + maxf(accel, 0.0) * WARNING_TIME
reach = maxf(sight_radius, (top + SimConfig.PLAYER_SPEED) * WARNING_TIME)
if dist <= reach:
return 1.0
return clampf(1.0 - (dist - reach) / FADE_OUT, 0.0, 1.0)
## Push the pool into the MultiMeshes. With an [param eye], bullets far beyond
## [param sight_radius] are left out; without one -- the connect screen's
## backdrop -- everything is drawn.
func render_pool(pool: BulletPool, eye: Vector2 = Vector2.INF,
sight_radius: float = 0.0) -> void:
var counts := PackedInt32Array() var counts := PackedInt32Array()
counts.resize(_layers.size()) counts.resize(_layers.size())
counts.fill(0) counts.fill(0)
var cull := eye.is_finite()
for i in pool.high_water: for i in pool.high_water:
if pool.alive[i] == 0: if pool.alive[i] == 0:
continue continue
var alpha := 1.0
if cull:
alpha = visibility((pool.pos[i] as Vector2).distance_to(eye),
(pool.vel[i] as Vector2).length(), pool.accel[i], pool.team[i], sight_radius)
if alpha <= 0.0:
continue
var kind := clampi(pool.kind[i], 0, _layers.size() - 1) var kind := clampi(pool.kind[i], 0, _layers.size() - 1)
var n := counts[kind] var n := counts[kind]
if n >= SimConfig.MAX_BULLETS: if n >= SimConfig.MAX_BULLETS:
@@ -121,8 +170,9 @@ func render_pool(pool: BulletPool) -> void:
+ float(pool.uid[i] % 64) * (TAU / 64.0) + float(pool.uid[i] % 64) * (TAU / 64.0)
_layers[kind].multimesh.set_instance_transform_2d( _layers[kind].multimesh.set_instance_transform_2d(
n, Transform2D(angle, Vector2(d, d), 0.0, pool.pos[i])) n, Transform2D(angle, Vector2(d, d), 0.0, pool.pos[i]))
_layers[kind].multimesh.set_instance_color(n, var tint: Color = _FALLBACK_COLOURS[kind] if _fallback else Color.WHITE
_FALLBACK_COLOURS[kind] if _fallback else Color.WHITE) tint.a *= alpha
_layers[kind].multimesh.set_instance_color(n, tint)
counts[kind] = n + 1 counts[kind] = n + 1
for kind in _layers.size(): for kind in _layers.size():
_layers[kind].multimesh.visible_instance_count = counts[kind] _layers[kind].multimesh.visible_instance_count = counts[kind]
+5 -3
View File
@@ -57,7 +57,8 @@ func _process(delta: float) -> void:
_anim_time += delta _anim_time += delta
client = Net.client client = Net.client
if client != null: if client != null:
bullets.render_pool(client.world.pool) bullets.render_pool(client.world.pool, client.predicted_pos,
SimConfig.FOG_VIEW_RADIUS)
queue_redraw() queue_redraw()
@@ -70,8 +71,9 @@ func _process(delta: float) -> void:
## darkness because a warning you cannot see is no warning (see ## darkness because a warning you cannot see is no warning (see
## _draw_telegraphs). The local player goes on top because it is the eye. And ## _draw_telegraphs). The local player goes on top because it is the eye. And
## bullets are a child node, so they are drawn after all of this, over the dark ## bullets are a child node, so they are drawn after all of this, over the dark
## -- as they always were: a shot coming out of the black is a shot you can ## -- a shot coming out of the black is a shot you can still dodge. Out past the
## still dodge. ## edge of sight they fade away, but only as far as is safe: see
## BulletRenderer.visibility().
func _draw() -> void: func _draw() -> void:
if client == null: if client == null:
return return
+112
View File
@@ -0,0 +1,112 @@
extends GutTest
## Which bullets are drawn, and the promise that bounds it: no enemy bullet can
## hit you without having been on screen, at full strength, for at least
## BulletRenderer.WARNING_TIME first. Fading distant bullets is only worth doing
## if that holds for every bullet the game can fire.
const RENDERER := preload("res://src/view/bullet_renderer.gd")
const SIGHT := SimConfig.FOG_VIEW_RADIUS
## The worst case for warning: the bullet coming straight at you and you running
## straight at it. Stepped the way BulletPool integrates, from far enough out
## that it starts undrawn. Returns seconds it was fully drawn before it hit.
func _warning(speed: float, accel: float) -> float:
var dt := SimConfig.TICK_DELTA
var gap := 6000.0
var v := speed
var t := 0
var first_full := -1
var reach := SimConfig.PLAYER_RADIUS + 7.0
while gap > reach and t < 60 * 120:
if first_full < 0 and RENDERER.visibility(gap, v, accel,
SimConfig.TEAM_ENEMY, SIGHT) >= 1.0:
first_full = t
v = maxf(v + accel * dt, 0.0)
gap -= (v + SimConfig.PLAYER_SPEED) * dt
t += 1
assert_gte(first_full, 0, "the bullet was drawn at all before it arrived")
return float(t - first_full) * dt
func test_every_enemy_bullet_in_the_game_is_seen_in_time() -> void:
var emitters: Array[BulletEmitter] = []
for id in Content.ALL_ENEMIES:
emitters.append_array(Content.enemy(id).emitters)
for id in Content.ALL_BOSSES:
for phase in Content.boss(id).phases:
emitters.append_array(phase.emitters)
assert_gt(emitters.size(), 0)
for e in emitters:
var warned := _warning(e.speed, e.accel)
assert_gte(warned, RENDERER.WARNING_TIME - SimConfig.TICK_DELTA,
"a %.0f u/s bullet was on screen %.2fs before it could land" % [e.speed, warned])
func test_a_bullet_far_faster_than_anything_today_is_still_seen_in_time() -> void:
# The sight radius covers today's content on its own. This is the case the
# speed term exists for: content added later, faster than the radius allows.
for case in [[1200.0, 0.0], [600.0, 900.0], [2000.0, 0.0]]:
var warned := _warning(case[0], case[1])
assert_gte(warned, RENDERER.WARNING_TIME - SimConfig.TICK_DELTA,
"%.0f u/s, accelerating %.0f: on screen %.2fs" % [case[0], case[1], warned])
func test_inside_sight_everything_is_drawn_fully() -> void:
for team in [SimConfig.TEAM_ENEMY, SimConfig.TEAM_PLAYER]:
assert_eq(RENDERER.visibility(SIGHT - 1.0, 130.0, 0.0, team, SIGHT), 1.0)
func test_a_slow_shot_far_beyond_sight_is_not_drawn() -> void:
assert_eq(RENDERER.visibility(SIGHT + 400.0, 130.0, 0.0, SimConfig.TEAM_ENEMY, SIGHT),
0.0, "out in the black and nowhere near you")
func test_the_edge_fades_rather_than_cuts() -> void:
var mid := RENDERER.visibility(SIGHT + RENDERER.FADE_OUT * 0.5, 130.0, 0.0,
SimConfig.TEAM_ENEMY, SIGHT)
assert_between(mid, 0.3, 0.7)
func test_player_shots_fade_at_the_edge_of_sight_however_fast() -> void:
# They cannot hurt you, so there is nothing to warn about -- and a Sniper's
# shot streaking across the dark was the commonest case of all.
assert_eq(RENDERER.visibility(SIGHT + 200.0, SimConfig.MAX_BULLET_SPEED, 0.0,
SimConfig.TEAM_PLAYER, SIGHT), 0.0)
assert_gt(RENDERER.visibility(SIGHT + 200.0, SimConfig.MAX_BULLET_SPEED, 0.0,
SimConfig.TEAM_ENEMY, SIGHT), 0.0,
"an enemy bullet that fast is drawn further out, because it could reach you")
# --- The renderer actually applies it ---------------------------------------
func _drawn(r: Node2D) -> int:
var n := 0
for layer in r._layers:
n += (layer as MultiMeshInstance2D).multimesh.visible_instance_count
return n
func test_the_renderer_leaves_out_what_is_too_far_to_matter() -> void:
var r: Node2D = RENDERER.new()
add_child_autofree(r)
var pool := BulletPool.new()
var eye := Vector2(100.0, 100.0)
pool.spawn(eye + Vector2(200.0, 0.0), Vector2(-130.0, 0.0), 7.0, 300, 10,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
pool.spawn(eye + Vector2(SIGHT + 500.0, 0.0), Vector2(-130.0, 0.0), 7.0, 300, 10,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
r.render_pool(pool, eye, SIGHT)
assert_eq(_drawn(r), 1, "the near bullet only")
func test_with_no_eye_everything_is_drawn() -> void:
# The connect screen's backdrop has no player and wants the whole pattern.
var r: Node2D = RENDERER.new()
add_child_autofree(r)
var pool := BulletPool.new()
pool.spawn(Vector2.ZERO, Vector2.RIGHT, 7.0, 300, 10, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
pool.spawn(Vector2(5000.0, 0.0), Vector2.RIGHT, 7.0, 300, 10,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
r.render_pool(pool)
assert_eq(_drawn(r), 2)
+1
View File
@@ -0,0 +1 @@
uid://noaqo4x7so77
+3 -1
View File
@@ -105,7 +105,9 @@ func _process(_delta: float) -> void:
# High until just before the picture and low after, so the damage trail # High until just before the picture and low after, so the damage trail
# -- the part of the bar the drop took -- is still draining when it is taken. # -- the part of the bar the drop took -- is still draining when it is taken.
if _frame > TO_BOSS and not _left_shot: if _frame > TO_BOSS and not _left_shot:
_hold_health(0.62 if _frame < AT_BOSS - 10 else 0.22) # Forty frames, not ten: at a high frame rate ten is shorter than one
# snapshot, and the picture showed the health from before the drop.
_hold_health(0.62 if _frame < AT_BOSS - 40 else 0.22)
# Captured by progress rather than by frame count. The channel is one second # Captured by progress rather than by frame count. The channel is one second
# of server ticks and frames run as fast as the display allows, so a fixed # of server ticks and frames run as fast as the display allows, so a fixed
# frame number lands anywhere from a quarter of the way in to after it has # frame number lands anywhere from a quarter of the way in to after it has