The gap Stage 1 left open: snapshots were encoded once and broadcast to every peer in an instance, so clients were handed enemies the fog then hid. Fog is a rendering rule -- a modified client draws whatever it holds -- so that was no defence at all. Snapshots are now encoded per peer, and actors beyond ACTOR_INTEREST_RADIUS (800u) are never sent. Measured cost of four filtered encodes against one shared: 95.6us vs 24.9us, or 0.032 ms/tick amortised over the snapshot interval. Two asymmetries worth keeping: - The observer's own player record is never filtered, however far out the arithmetic puts it. The client reconciles its prediction against that record, so dropping it breaks the player's own movement rather than hiding someone. - Only bullet SPAWNS are filtered, never despawns. A client told about a bullet must always be told it died, or it keeps a phantom. Bullet spawns use a much wider radius (2200u) than actors, deliberately. An enemy appearing at the edge of sight is cosmetic; a bullet withheld at spawn that later flies into view is invisible damage. The floor is longest bullet travel + fog radius -- 1500 for the Warden's Collapse snipe -- and test_interest.gd recomputes that from live content, so adding a faster or longer-lived bullet fails a test instead of producing bullets that wink into existence. Also clamped bosses to their room. A no-op today since every boss is stationary, which is exactly when the invariant is cheap to establish: boss rooms have no door that locks, so the only thing keeping a fight in the boss room is the boss, and Stage 5's movement work would otherwise break it quietly. 146 tests (was 137). check.sh, test.sh and smoke.sh pass. Stage 1 has no partials left; docs/ROADMAP.md now records the three interest radii and the floor each must respect.
8.1 KiB
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 — the packs themselves are not in git.
Design decisions that are already settled — and the reasoning behind them — live in 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)
Stage 1 — World and exploration · done
| Feature | State | Where |
|---|---|---|
| Tile grid: movement / bullet / sight blocking | done | src/sim/map_grid.gd |
| Dungeon generation from (seed, depth) | done | src/sim/map_gen.gd |
| Hand-authored boss arenas and hub | done | 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 |
| 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. 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.
Stage 2 — Characters, persistence, levels · todo, next
Depends on nothing in Stage 1 except a place to stand. Blocked only on the identity layer, which is decided but unbuilt.
| Feature | Notes |
|---|---|
| Identity abstraction | Shaped like Steamworks so it swaps out cleanly: opaque ticket from client → server validates → stable 64-bit account id (a SteamID64 stand-in). Local dev provider persists a generated id in user://. See DECISIONS.md. |
| Character store | Server-side, keyed by account id. Needs a schema and a file format; nothing persistent exists in the project today. |
| Up to 5 characters, random colour each | Colour is a placeholder for a later cosmetic system. |
| Last-played character auto-selected on join | |
| Permadeath → mark inactive, never delete | Archival/troubleshooting. Confirm whether the 5-character cap counts only active characters. |
| Death flow: pick another character or create one | Replaces today's "return to hub" button, which currently just revives you. |
| Levels 1–15, +10 max HP per level | Confirm whether level 1 is 100 HP (→ 240 at cap) or 110. |
| XP from kills, bosses worth much more | First full dungeon should give slightly more than one level. |
Current behaviour to replace: SimPlayer has no identity beyond a peer id;
ServerRuntime.peer_names is client-supplied and trusted for display only.
Stage 3 — Upgrades · todo
| Feature | Notes |
|---|---|
| Lobby NPC, 3 random choices per level gained | Confirm whether unclaimed level-ups queue. |
| Every upgrade also grants +5% damage, additive | Displayed on the choice alongside its specific effects. |
| Upgrade list | split shot, glass cannon, spread, sniper, doubleshot (rarer), poison (rare), eraser (legendary) |
| Rarity weights | Unspecified. Needs numbers. |
| Damage stacking formula | Unspecified. Sniper is explicitly multiplicative; the rest read as additive. Order needs pinning down before any of it is built. |
| "Chosen upgrades" screen |
Damage is currently the constant SimConfig.PLAYER_BULLET_DAMAGE; upgrades turn
it into a per-player computed stat, so SimWorld._fire_player_shot grows a
stats block. Several upgrades (split, spread, doubleshot) change how many
bullets spawn per shot, so they belong in the same place.
Stage 4 — Inventory and loot · todo
| Feature | Notes |
|---|---|
| Small always-on-screen inventory | Slot count unspecified. |
| Health potions: rare from trash, guaranteed from bosses | |
| World-shared loot | Player-instanced loot planned later. |
| A unique, useless food item from bosses | Exists specifically to test player-instanced loot. Confirm whether it should be instanced now or just marked for it. |
| Dropping items so others can pick them up |
Stage 5 — Boss features and new bosses · todo
| Feature | State |
|---|---|
| Stationary phases | done — every current phase |
| Roaming / chasing within the boss room | todo; needs SimBoss.room clamping (see Stage 1 partial) |
| Phases that move to preset locations | todo |
| Attacks spawned at a distance with a telegraph indicator | todo — new event type plus a renderer, and it must survive fog |
| More bosses | partial — Rooms.choir_vault() is authored but has no BossDef yet |
The boss format itself is proven: tests/unit/test_boss.gd builds a boss from
scratch and asserts the simulation needs no changes to run it. Movement will be
the first thing that format has not covered, so expect BossPhase to gain a
movement field rather than SimWorld gaining a branch.
Deliberate omissions
Not oversights — each was considered and rejected for now, with the reasoning in NETCODE.md or 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.
ENetMultiplayerPeersupports 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.
Open questions for the user
Genuinely unspecified; do not guess at these, they change the design:
- Damage stacking order — additive pool then multiplicative, or something else?
- Rarity weights for the four upgrade tiers.
- Split shot geometry: ±22.5° from the original heading, or 45° to each side?
- Poison: do applications stack, or refresh a single DoT?
- Eraser: does it delete enemy bullets it passes through?
- Inventory slot count.
- Do unclaimed level-ups queue at the NPC?
- Does the 5-character cap count only active characters?
- Glass cannon's −50% health: of base HP, or of levelled max HP?
- What advances dungeon depth?
--depthis a dev flag; nothing raises it in play.