61680d00d7
ci / verify (push) Successful in 48s
Buttons were the easy half. This is everything else. Dialogs sit on panels now rather than being text over a dimmed world -- the pause menu, character select, upgrades, settings and credits all root through UiTheme.dialog_panel(). The panel stylebox is the pack's frame MODULATED DARK. The set is cream throughout, which is right for buttons and wrong for a dialog over a dark dungeon; tinting keeps the pixel border and the corner shape and lets every label the game already draws in light colours stay readable, instead of recolouring every label in five screens to suit the art. Sliders and scrollbars are the pack's too, section headings sit on its banner ribbon, and an upgrade card's rarity is now its frame rather than a word on it -- three choices are compared at a glance and a colour reads faster than a label. Two bugs of the same shape, and neither was findable without looking at the screen. A Slider and a ScrollBar take their THICKNESS from the stylebox's minimum size, which for a StyleBoxTexture is its content margins. Mine were zero, so both resolved the correct stylebox, reported the correct texture, and drew a groove zero pixels tall. A probe confirmed the theme was resolving perfectly while the track was invisible. Tests now assert every slider and scrollbar stylebox has a non-zero minimum, and it is gotcha 7 in CLAUDE.md. The first attempt at the slider groove also used the pack's HOLLOW bar sprite, whose middle is transparent -- tinting it dark left an outline and nothing else. It uses the solid one. tools/screenshot.tscn gained the upgrade screen, which it has to stage: the game scene owns that screen's visibility and re-asserts it every frame, so setting `visible` lasted exactly one frame, and standing the player at the NPC client-side lasted until reconciliation pulled them back. It now moves the player on both sides and holds it until the shot. That tool has caught four bugs the automated gates all passed. check.sh clean, 468 tests, SMOKE PASS, all four diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
459 lines
25 KiB
Markdown
459 lines
25 KiB
Markdown
# Status and roadmap
|
||
|
||
**Read this first when picking up work.** It maps every feature in the design
|
||
brief to its current state and the files that implement it, so you can find the
|
||
relevant code without re-reading the whole project.
|
||
|
||
Art and audio source packs, and their licence status, are recorded in
|
||
[ASSETS.md](ASSETS.md) — the packs themselves are not in git.
|
||
|
||
Design decisions that are already settled — and the reasoning behind them — live
|
||
in [DECISIONS.md](DECISIONS.md). Check there before asking the user something
|
||
that may already have an answer.
|
||
|
||
Legend: **done** · **partial** (works, with a stated gap) · **todo** (not started)
|
||
|
||
## Verification surface
|
||
|
||
What "everything passes" currently means. Numbers move; the shape does not.
|
||
|
||
| Gate | Covers | Runtime |
|
||
| --- | --- | --- |
|
||
| `tools/check.sh` | every script parses and type-checks | ~5s |
|
||
| `tools/test.sh` | 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()` |
|
||
| Scrolling camera | done | [src/view/game_scene.gd](../src/view/game_scene.gd) |
|
||
| Hard fog of war | done | `WorldView._draw_terrain` / `_visible` |
|
||
| Per-peer map streaming (anti map-hack) | done | `ServerRuntime._stream_map` |
|
||
| Aggro: range **and** line of sight | done | `SimWorld._aggro_target` |
|
||
| Cursor-to-world aiming under a scrolling camera | done | `ClientRuntime.screen_to_world` |
|
||
| Boss confined to its room | done | `SimWorld._step_boss` clamps to `SimBoss.room`. A no-op while bosses are stationary — established now so Stage 5's movement cannot quietly break it. |
|
||
| Actor interest management | done | `NetCodec.encode_snapshot(world, countdown, for_peer)`; per-peer encode in `ServerRuntime`. Bullet spawns filtered separately, see below. |
|
||
|
||
### The invariants that matter most here
|
||
|
||
**Maps are streamed per peer, and the generation seed is never sent.** See
|
||
[NETCODE.md](NETCODE.md#maps-are-streamed-never-sent). `MAP_STREAM_RADIUS` must
|
||
stay wider than `FOG_VIEW_RADIUS`, or the client predicts movement against
|
||
terrain it does not have.
|
||
|
||
**Three radii, deliberately different, and each has a floor it must respect:**
|
||
|
||
| Radius | Value | Must exceed | Why |
|
||
| --- | --- | --- | --- |
|
||
| `FOG_VIEW_RADIUS` | 460 | — | What the player can see. |
|
||
| `ACTOR_INTEREST_RADIUS` | 800 | fog radius | Enemies beyond it are never sent. Above the fog radius so nothing pops in at the edge of sight. |
|
||
| `MAP_STREAM_RADIUS` | 900 | fog radius | Client predicts movement and simulates bullets against terrain it cannot see. |
|
||
| `BULLET_INTEREST_RADIUS` | 2200 | longest bullet travel + fog radius | A bullet is announced once at spawn. Withhold one that later flies into view and it becomes invisible damage. `test_interest.gd` computes the floor from real content, so a faster bullet fails a test instead. |
|
||
|
||
Fog is a *rendering* rule and defends nothing on its own — a modified client
|
||
draws whatever it holds. The defence is what the server declines to send.
|
||
|
||
---
|
||
|
||
## Dungeon kinds · *done*
|
||
|
||
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.
|
||
|
||
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 respects line of sight; 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
|
||
several have no obvious default.
|
||
|
||
### Blocking nothing yet
|
||
|
||
8. **What advances dungeon depth?** `--depth` is a dev flag; nothing raises it
|
||
in play. Depth drives map size and could drive difficulty and rewards.
|
||
9. **Where do items come from outside a dungeon?** Loot only drops from kills.
|
||
If the hub should sell potions, that is the Stage 4 NPC's second job — and
|
||
it needs a currency, which the game does not have.
|
||
10. **Should items stack?** Four potions currently take four slots, which makes
|
||
a 4-slot bag small. Stacking is a count byte per slot plus a rule for
|
||
splitting one; neither is hard, but both change the UI.
|
||
11. **Attribution for four asset packs.** See [ASSETS.md](ASSETS.md) — two are
|
||
non-redistributable and local-only, and there is no in-game credits screen
|
||
yet, which CC BY 4.0 requires for the audio.
|