Files
transcience/docs/ROADMAP.md
T
claude 050b8251a7
ci / verify (push) Successful in 48s
Stage 3: inventory, ground loot, and two loot visibilities
Four always-on-screen slots, items as data, and loot tables on enemies and
bosses. Health potions drop rarely from trash and always from the Warden;
the Warden also drops a Warden's Ration, one per living player, which does
nothing at all.

The ration is not filler. Player-instanced loot is a separate code path from
shared loot -- a distinct entity per owner, filtered per peer in the snapshot
encoder -- and the cheapest way to keep that path honest is to have something
in the game that exercises it on every boss kill.

Item actions ride the input frame rather than becoming new client messages.
InputFrame gained BTN_USE, BTN_DROP and a slot byte, which buys the packet-loss
redundancy, the replay guard on last_input_tick, ordering against movement on
the same tick, and a rate limit of one action per tick -- all of which a
separate RPC would have needed bolted back on. The cost is that anything in
the frame which must not repeat has to be edge-triggered, since frames are
resent and a starved server coasts on the last one it holds.

Instanced loot is enforced in NetCodec.encode_snapshot, beside the actor
interest radius: a peer is never told another player's copy exists. Hiding it
client-side would have been the same mistake as relying on fog to hide enemies.

Inventories live on the character and are written to the store on every
transaction, so a crash between "picked it up" and "wrote it down" cannot lose
or duplicate an item. Anything dropped becomes world-shared whatever it was
before, and a potion used at full health is refused rather than spent.

tools/diag_loot.tscn covers drop -> snapshot -> pick up -> persist -> use ->
drop plus both visibilities on the wire, for the same reason diag_progression
exists: bots are poor shots and almost never produce a drop. It asserts each
input frame was actually consumed, after an early version silently dropped its
first press and every later check passed for the wrong reason.

check.sh clean, 266 tests, SMOKE PASS, all three diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:16:15 +02:00

17 KiB
Raw Blame History

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)

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 266 GUT tests, no SceneTree ~3s
tools/smoke.sh 16 assertions over a real ENet socket: handshake, auth, character creation and persistence, portal, 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

The three diagnostics exist because the smoke test structurally cannot reach what they cover: bots are poor shots (so they neither level up nor produce drops), 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
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.


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
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
SFX pool driven by server events done 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 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.

Stage 2 — Characters, persistence, levels · done

Feature State Where
Steam-shaped identity abstraction done src/meta/auth_provider.gd, local_auth_provider.gd
Character store, JSON, survives restart done 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
Levels 115, +10 max HP each done 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/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, 14 use a slot, shift+14 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.

Stage 4 — Upgrades · todo, blocked on decisions

Do not start this without answering the open questions below. The damage formula in particular determines the shape of every upgrade.

The mechanism

  • An NPC in the hub. Each level gained grants one choice.
  • The choice offers 3 random upgrades.
  • Every upgrade also carries a +5% damage buff, additive ("adaptively scaling" in the brief — read as additive, confirm if wrong).
  • The choice screen must show that buff and all the upgrade's other effects.
  • A separate screen lists the upgrades already taken.

The upgrades, as specified

Upgrade Rarity Effect
Split shot common Hitting an enemy spawns 2 of the same bullet at a 45° angle behind the enemy. A shot cannot split twice unless the upgrade is taken again.
Glass cannon common +100% damage, 50% health.
Spread common Adds 2 side projectiles in a cone. 10% damage.
Sniper common 2× damage (multiplicative, not additive), 0.5× fire rate, 2× bullet speed.
Doubleshot rarer than common Adds 1 projectile firing parallel to the others. 50% damage.
Poison rare Each projectile deals an additional 50% of its damage over the next 10 seconds.
Eraser legendary Shots have a 1% chance to delete a projectile they pass through.

Upgrades stack — "cannot split twice unless upgraded again" says so directly.

What this implies

Damage is currently the constant SimConfig.PLAYER_BULLET_DAMAGE. It becomes a per-player computed stat, so SimWorld._fire_player_shot grows a stats block. Split, spread and doubleshot all change how many bullets a shot produces, so they belong in the same place.

Two constraints already pinned by tests that upgrades will collide with:

  • Sniper doubles bullet speed. test_bullet_speeds_stay_below_the_tunnelling_threshold asserts that even at 2× a bullet stays under one tile per tick. Stacking two snipers would break wall collision, so the multiplier needs a ceiling.
  • Longer/faster bullets widen BULLET_INTEREST_RADIUS. test_interest.gd recomputes the floor from live content; upgrades change bullet travel per player, which that test does not currently model.

Stage 5 — Boss features and new bosses · todo

Feature State
Stationary phases done — every current phase
Boss confined to its room done — SimWorld._step_boss clamps to SimBoss.room
Roaming / chasing within the boss room todo
Phases that move to preset locations todo
Attacks spawned at a distance with a telegraph indicator todo — a new event type plus a renderer, and it must survive fog
More bosses partial — Rooms.choir_vault() is authored but has no BossDef

The boss format is proven: tests/unit/test_boss.gd builds one from scratch and asserts the simulation needs no changes to run it. Movement is the first thing that format has not covered, so expect BossPhase to gain a movement field rather than SimWorld gaining a per-boss branch.

Remember boss rooms do not lock (a settled decision): a player can always walk out, and the boss cannot follow. Fights cannot rely on trapping anyone.


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. 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.

Open questions for the user

Genuinely unspecified. Do not guess at these — each changes the design, and several have no obvious default.

Blocking Stage 4 (upgrades)

  1. Damage stacking order. Sniper is explicitly multiplicative; the +5% per upgrade and the ± percentages read as additive. Is it base × (1 + Σ additive) × Π multiplicative, or something else?
  2. Rarity weights for common / rarer / rare / legendary.
  3. Split shot geometry — ±22.5° from the original heading (45° total), or 45° to each side (90° total)?
  4. Poison stacking — do applications stack, or does a new hit refresh one damage-over-time effect?
  5. Eraser's target — does it delete enemy bullets it passes through?
  6. Do unclaimed level-ups queue? Reaching level 4 and 5 inside one run: two pending choices at the NPC, or one?
  7. Glass cannon's 50% health — of base HP, or of the character's levelled maximum?

Blocking nothing yet

  1. What advances dungeon depth? --depth is a dev flag; nothing raises it in play. Depth drives map size and could drive difficulty and rewards.
  2. 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.
  3. 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.
  4. Attribution for four asset packs. See 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.