4a98cf4b0e
ci / verify (push) Successful in 48s
A dungeon now names the boss arena it ends in, and the arena decides the boss. Three entrances in the hub: Warden's Descent, The Choir Vault, Proving Grounds. That replaces the seed coin-flip from the last commit, which was the wrong call. Which boss you are about to fight is the single thing a player decides before walking into a dungeon, and rolling it for them makes that decision unavailable. Only the Proving Grounds still leaves its arena open, because a harness you re-enter every couple of minutes wants whichever fight comes up first -- and it is the one place where "either will do" is true. test_every_boss_has_a_dungeon_that_reaches_it is the check that matters here: adding a boss and forgetting to give it a way in is now a failing test rather than a boss nobody meets. The smoke test asserts all three portals open over a real socket -- bots pick theirs by account id, so a run exercises each. Bullets no longer animate. The sheet's eight frames are a COLOUR cycle rather than a shape change, so running it had every bullet on screen strobing through a palette in unison, which with a few hundred in the air is exactly as hard to look at as it sounds. Each bullet holds one frame and turns slowly instead, offset by its own id so a ring of twenty does not rotate as one rigid wheel. The rate is slow enough that nothing completes a full turn inside its own lifetime, so it reads as drift rather than spin, and a test pins that against the longest-lived bullet in the game. The renderer slices four textures at startup now instead of thirty-two. check.sh clean, 414 tests, SMOKE PASS (22 assertions), all four diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
438 lines
24 KiB
Markdown
438 lines
24 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` | 414 GUT tests, no SceneTree | ~4s |
|
||
| `tools/smoke.sh` | 22 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 |
|
||
|
||
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 | |
|
||
| Audio buses and a volume setting | todo | Everything plays on Master at hardcoded dB |
|
||
| **In-game credits screen** | **todo** | Not cosmetic: the SFX 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
|
||
|
||
- **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.
|