diff --git a/CLAUDE.md b/CLAUDE.md index 294cc5e..8fbfb30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ the obvious default. ```bash tools/check.sh # parse-check every script (~5s) -- run after every edit -tools/test.sh # 551 GUT tests, headless (~4s) +tools/test.sh # 568 GUT tests, headless (~4s) tools/smoke.sh # real server + 4 bot clients over ENet (~40s) tools/server.sh # dedicated server tools/client.sh --listen # host and play, no menu diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8879ccc..4f29945 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -51,6 +51,451 @@ 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 `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` | 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 | +| `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/`. 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. + +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` | +| 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` | 2200 | longest bullet travel + fog radius | A bullet is announced once at spawn. Withhold one that later flies into view and it becomes invisible damage. `test_interest.gd` computes the floor from real content, so a faster bullet fails a test instead. | + +Fog is a *rendering* rule and defends nothing on its own — a modified client +draws whatever it holds. The defence is what the server declines to send. + +--- + +## Dungeon kinds · *done* + +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 Genuinely unspecified. **Do not guess at these** — each changes the design, and