diff --git a/CLAUDE.md b/CLAUDE.md index c8100dc..1903aab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,12 @@ Top-down twin-stick bullet-hell with a dedicated, server-authoritative backend. Godot 4.7, GDScript only. One executable is both server and client. +**Starting a new session?** [docs/ROADMAP.md](docs/ROADMAP.md) says what is +built, what is next, and which file implements each feature. +[docs/DECISIONS.md](docs/DECISIONS.md) records what the user has already decided +and why — read it before asking a design question, several answers there are not +the obvious default. + ## Commands ```bash diff --git a/README.md b/README.md index ef0b093..c27a05b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ will otherwise cost an afternoon. ## Docs +- [docs/ROADMAP.md](docs/ROADMAP.md) — what is built, what is next, where each feature lives +- [docs/DECISIONS.md](docs/DECISIONS.md) — settled design decisions and their reasoning - [CLAUDE.md](CLAUDE.md) — commands, invariants, Godot CLI gotchas - [docs/WORKFLOW.md](docs/WORKFLOW.md) — agent-assisted Godot development, and why the setup looks like this - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — code map and the node-free simulation diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md new file mode 100644 index 0000000..c3029b7 --- /dev/null +++ b/docs/DECISIONS.md @@ -0,0 +1,128 @@ +# Settled decisions + +Design choices the user has already made, with the reasoning. **Check here +before asking** — re-litigating a settled decision wastes a round trip, and +several of these look like defaults you would otherwise pick differently. + +Ordered newest last. + +--- + +## Simulation shape + +**Game logic lives in plain `RefCounted` objects, not nodes.** No +`CharacterBody2D`, no `Area2D`, no physics server. Bullet counts make per-bullet +nodes unaffordable, the dedicated server allocates nothing, and the whole suite +runs with no `SceneTree`. Full reasoning in [ARCHITECTURE.md](ARCHITECTURE.md). + +**Content is GDScript, not `.tres`.** A boss is a readable diff, there are no +resource UIDs churning in version control, and a test can build content inline. +`tools/export_content.gd` writes `.tres` copies for inspector tuning, but code +is the source of truth — port inspector edits back. + +--- + +## Netcode + +**The client sends input and nothing else.** No message exists for position, +hits, damage, or a finished escape. Having no code path is strictly stronger +than validating one. + +**Bullets replicate as spawn events, not state.** ~36 bytes once per bullet; +both sides run the identical integration. Only early deaths (a hit, or a wall) +need announcing. Pinned by `tests/integration/test_replica_parity.gd`. + +**No lag compensation.** Rewinding the world to a shooter's view would mean a +player who dodged on their own screen still takes the hit. If it becomes a +complaint, lag-compensate *player bullets against enemies only* — never enemy +bullets against players. + +**`INPUT_MAX_LEAD` must stay well above `INPUT_LEAD_MAX`.** The server's input +acceptance window has to be wider than the band in which the client re-syncs its +own numbering. When it was not, drifting clocks landed in a dead zone where the +server silently rejected every input and the client never noticed. Pinned by +`tests/unit/test_input_lead.gd`. + +--- + +## Combat feel + +**Hitbox is smaller than the sprite** (`PLAYER_RADIUS` 6 vs +`PLAYER_VISUAL_RADIUS` 13), and `PLAYER_MUZZLE_OFFSET` derives from the visual +radius. A visible near-miss reads as fair; an invisible hit does not. + +**No invulnerability frames.** Every bullet that touches you lands. I-frames +make dense patterns *safer* than sparse ones, which inverts the genre. Measured +cost: ~13.6s stationary in the Warden's opening phase. `spawn_grace` on entering +a dungeon is the sole exception, and it is a transition, not a combat mechanic. + +**No contact damage.** Every threat is a bullet you can see and dodge. The +Stalker carries a point-blank shotgun rather than damaging you by touch. +`tests/unit/test_content.gd` asserts every hostile enemy has an emitter. + +--- + +## Leaving a run + +**Escape is a 1-second channel that damage does not interrupt.** An +interruptible channel makes killing the process better than pressing the button. + +**A disconnect runs the same channel.** The player stays in the world as +`linkdead`, still killable. All four exits (key, menu button, clean disconnect, +SIGKILL) converge on one server-side path keyed on the socket closing — there is +deliberately no "clean leave" message. `tools/smoke.sh` asserts both the hard +kill and the polite disconnect. + +**Boss rooms do not lock.** *(User, this session.)* You can always walk out of a +boss fight, and the boss cannot follow. The consequence: fights cannot rely on +trapping the player, and disengaging is always available. + +--- + +## World + +**Tile grid, generated layout, hand-authored boss arenas.** *(User, this +session.)* A grid because collision, line of sight and interest management all +become array lookups; authored arenas because a generated boss room is a bad one +about as often as a good one. + +**Hard fog.** *(User, this session.)* No remembered terrain — anything outside +current line of sight is not drawn, including ground already walked over. + +**Dungeon size scales with depth.** *(User, this session.)* `--depth N` is a dev +flag; what raises depth in actual play is still open. + +**Never send the map, or its seed.** *(User, this session — corrected an earlier +choice of mine.)* Sending `(seed, depth)` and regenerating client-side is far +cheaper on the wire and hands any modified client the entire floor plan. Tiles +stream per peer instead. The accepted trade, in the user's words: a cheater +seeing further than they should is tolerable; seeing the whole map is not. + +Consequence to preserve: the client holds *real* geometry it cannot see, because +it predicts movement against walls and simulates bullets that die on them. So +hard fog is a rendering rule, not secrecy. The secrecy is in what the server +declines to send. + +--- + +## Identity and persistence + +**Steam-shaped auth abstraction.** *(User, this session.)* The goal is +eventually Steam, so build the shape Steamworks uses and keep it swappable: +client presents an opaque ticket, server validates it and gets a stable 64-bit +account id (SteamID64's stand-in). A local dev provider persists a generated id +in `user://`, so **no Steam account is needed now**. + +Not integrating GodotSteam yet: it needs a running Steam client and an app ID, +which would break the "no Steam account" requirement. Swapping it in later +should be one provider class and no schema change. + +--- + +## Progression + +**XP from kills, bosses worth far more.** *(User, this session.)* A first full +dungeon should give a bit more than is needed for the first level-up. + +**Permadeath.** Death marks a character inactive — never deleted, for archival +and troubleshooting — and the player picks another character or creates one. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4e3f182..41a38ca 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,67 +1,139 @@ -# Roadmap +# Status and roadmap -## Built +**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. -| MVP requirement | State | +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) + +--- + +## 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 | **partial** | `SimBoss.room` exists and bounds curtain patterns, but nothing clamps the boss — every boss is stationary today. Enforcement belongs with boss movement in Stage 5. | +| **Actor interest management** | **todo** | The known gap. `ServerRuntime._physics_process` still encodes one snapshot and sends it to every peer in the instance, so clients are told about enemies the fog then hides. Terrain is properly withheld; actors are not. **Do this first.** | + +### The invariant that matters 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. + +--- + +## 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 | | --- | --- | -| Top-down 2D twin-stick bullet hell | Movement, aim, fire, i-frames, death and respawn; MultiMesh bullet rendering. | -| Multiplayer with server-side hit validation | Authoritative dedicated server; clients send input only; all hits resolved server-side for both players and enemies. No PvP. | -| Simple, predictable enemies | Drifter, Turret, Stalker, plus a lobby dummy. Five movement behaviours, all closed-form. | -| Stationary boss, adaptable format | The Warden of the Fold: four phases, data-defined. A new boss is one function and zero simulation changes. | -| Lobby hub with dungeon entry | Persistent lobby instance, portal, party forming window, dungeons opened on demand. | -| Emergency escape | One-second server-owned channel, cancelled by release but not by damage; a disconnect runs the same channel so quitting is never a cheaper exit. | -| Leaving and arriving | Downed players return to the hub on request (no timed respawn); entering a dungeon grants 2s of invulnerable, weapons-cold arrival protection. | -| Hub awareness | Server-pushed roster showing who is online and which dungeon they are in. | -| In-game menu | Escape opens return-to-hub / disconnect / quit. | +| 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](DECISIONS.md#identity). | +| 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. | -91 tests plus an end-to-end smoke test over a real socket, including a bot that -is SIGKILLed mid-dungeon to prove the disconnect path. +Current behaviour to replace: `SimPlayer` has no identity beyond a peer id; +`ServerRuntime.peer_names` is client-supplied and trusted for display only. -## Next, in rough order of value +--- -**1. Make the dungeon a place.** Right now it is one arena and three stages. -Rooms, doors, and a `SimWorld` with static geometry — which means adding a -collision representation for walls (segment-vs-circle for players, segment -crossing for bullets) since there is no physics engine to lean on. +## Stage 3 — Upgrades · *todo* -**1b. Progression, and what it turns on.** Several things are currently sized -for "no persistence yet" and should be revisited together with it: death sends -you to the hub rather than costing anything, a linkdead body is simply deleted -once it channels out (there is no hub state to put it in), and `peer_names` is -client-supplied and trusted for display. All three are fine now and none of them -are once a character has anything worth losing. +| 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 | | -**2. Art and feel.** Everything is drawn with `draw_circle` and a generated dot -texture. Sprites, hit flashes, screen shake, muzzle flashes, death effects, and -sound. None of it touches the simulation — this is entirely `src/view/`. +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. -**3. A second boss.** The format claims to be reusable; the way to find out is -to use it. A mobile boss will exercise `BossDef.stationary`, which the runtime -already reads but no content uses yet. +--- -**4. Progression.** Loot, character stats, persistence. Needs a decision on -storage: for a dedicated server, player state belongs server-side in a database, -not in a client save file. +## Stage 4 — Inventory and loot · *todo* -**5. Netcode hardening.** In the order they will actually matter: - - DTLS on `ENetMultiplayerPeer` before any public server. - - Authentication; `peer_names` is currently client-supplied and trusted for - display only, which is fine now and will not be once there is progression. - - Snapshot delta compression, once enemy counts grow. - - Interest management inside an instance, once arenas are larger than a - screen. +| 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 | | -**6. Scale.** The simulation costs ~1.4% of a core per instance, so the ceiling -is bandwidth and process supervision, not CPU. A shard manager that runs several -server processes behind a lobby-of-lobbies is the shape, but it is premature -until there is a game to fill it. +--- -## Deliberately not done +## Stage 5 — Boss features and new bosses · *todo* -- **Lag compensation.** Discussed in [NETCODE.md](NETCODE.md): rewinding for a - shooter's view would mean a player who dodged still gets hit, which is the - wrong trade for this genre. -- **Pattern-level bullet replication.** A large bandwidth win that couples the - client to emitter behaviour. Not worth it until bandwidth binds. +| 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](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). + for a bullet hell — see [ARCHITECTURE.md](ARCHITECTURE.md). + +--- + +## Open questions for the user + +Genuinely unspecified; do not guess at these, they change the design: + +1. Damage stacking order — additive pool then multiplicative, or something else? +2. Rarity weights for the four upgrade tiers. +3. Split shot geometry: ±22.5° from the original heading, or 45° to each side? +4. Poison: do applications stack, or refresh a single DoT? +5. Eraser: does it delete *enemy bullets* it passes through? +6. Inventory slot count. +7. Do unclaimed level-ups queue at the NPC? +8. Does the 5-character cap count only active characters? +9. Glass cannon's −50% health: of base HP, or of levelled max HP? +10. What advances dungeon depth? `--depth` is a dev flag; nothing raises it in play. diff --git a/src/net/client_runtime.gd b/src/net/client_runtime.gd index 114a4ac..3a7cde6 100644 --- a/src/net/client_runtime.gd +++ b/src/net/client_runtime.gd @@ -57,6 +57,19 @@ var cleared_countdown: int = Protocol.COUNTDOWN_NONE ## Where this world's dungeon portal is. Per-map now, so it has to be told. var portal_pos := Vector2.ZERO +## Offset the view applies when drawing the world: screen = world + this. +## Published by the game scene every frame, rather than assumed, so aiming +## stays correct if the camera ever stops being exactly centred on the player +## (clamping at map edges, screen shake, a look-ahead offset). +var camera_offset := Vector2.ZERO + + +## Screen point to world point. The camera scrolls now, so this is no longer +## "relative to the middle of the screen" -- treating it as such made the ship +## aim at a fixed world location instead of at the cursor. +static func screen_to_world(screen: Vector2, offset: Vector2) -> Vector2: + return screen - offset + ## Backstop for input-numbering drift: if the server stops acknowledging new ## inputs, our tick numbering has fallen outside its acceptance window and no ## amount of waiting fixes it. Counted in snapshots, not ticks. @@ -133,8 +146,8 @@ func _sample_input() -> InputFrame: var dead_buttons := InputFrame.BTN_INTERACT if request_respawn else 0 return InputFrame.make(input_tick, Vector2.ZERO, aim, dead_buttons) var move := Input.get_vector("move_left", "move_right", "move_up", "move_down") - var mouse := get_viewport().get_mouse_position() - get_viewport().get_visible_rect().size * 0.5 - var to_mouse := mouse - predicted_pos + var world_mouse := screen_to_world(get_viewport().get_mouse_position(), camera_offset) + var to_mouse := world_mouse - predicted_pos if to_mouse.length_squared() > 1.0: aim = to_mouse.angle() var buttons := 0 diff --git a/src/view/game_scene.gd b/src/view/game_scene.gd index debc5f6..99494a6 100644 --- a/src/view/game_scene.gd +++ b/src/view/game_scene.gd @@ -32,6 +32,11 @@ func _follow_camera() -> void: if _bound != null: focus = _bound.predicted_pos world_view.position = _screen_centre - focus + if _bound != null: + # The client converts cursor position to world space with this, so it + # has to come from the transform actually used to draw, not from a + # second assumption about where the camera is. + _bound.camera_offset = world_view.position func _process(_delta: float) -> void: diff --git a/tests/unit/test_aim.gd b/tests/unit/test_aim.gd new file mode 100644 index 0000000..c1d661a --- /dev/null +++ b/tests/unit/test_aim.gd @@ -0,0 +1,61 @@ +extends GutTest +## Cursor-to-world conversion. +## +## This broke the moment the camera started scrolling: the old code treated +## "mouse relative to the centre of the screen" as a world position, which was +## true only while the world was drawn fixed at the origin. Afterwards the ship +## aimed at a fixed world location regardless of where the cursor was. + +const SCREEN := Vector2(1280.0, 720.0) +const CENTRE := Vector2(640.0, 360.0) + + +## The camera the game actually uses: world_view.position = centre - player. +func _offset_for(player: Vector2) -> Vector2: + return CENTRE - player + + +func _aim_from(mouse_screen: Vector2, player: Vector2) -> Vector2: + var world_mouse := ClientRuntime.screen_to_world(mouse_screen, _offset_for(player)) + return world_mouse - player + + +func test_screen_to_world_inverts_the_draw_transform() -> void: + # The view draws at screen = world + offset, so this must be its inverse or + # everything derived from the cursor is off by the camera position. + var offset := _offset_for(Vector2(900.0, -400.0)) + var world := Vector2(123.0, -456.0) + var screen := world + offset + assert_eq(ClientRuntime.screen_to_world(screen, offset), world) + + +func test_aim_follows_the_cursor_not_a_world_location() -> void: + # Same cursor position, two very different player positions: the aim + # direction must be identical, because the player is drawn centred. + var mouse := CENTRE + Vector2(100.0, 0.0) + var near_origin := _aim_from(mouse, Vector2.ZERO) + var far_away := _aim_from(mouse, Vector2(4000.0, -2500.0)) + assert_almost_eq(near_origin.angle(), far_away.angle(), 0.0001, + "aim must depend on the cursor, not on where in the map you stand") + assert_almost_eq(near_origin.angle(), 0.0, 0.0001, "cursor right of centre aims right") + + +func test_the_four_cardinal_directions() -> void: + var player := Vector2(3000.0, 1500.0) + assert_almost_eq(_aim_from(CENTRE + Vector2(50.0, 0.0), player).angle(), 0.0, 0.001) + assert_almost_eq(_aim_from(CENTRE + Vector2(0.0, 50.0), player).angle(), PI * 0.5, 0.001) + assert_almost_eq(_aim_from(CENTRE + Vector2(0.0, -50.0), player).angle(), -PI * 0.5, 0.001) + assert_almost_eq(absf(_aim_from(CENTRE + Vector2(-50.0, 0.0), player).angle()), PI, 0.001) + + +## The regression itself, stated directly: with the old expression the aim +## vector picked up the player's position, so walking around the map swung the +## crosshair even with the mouse held still. +func test_walking_does_not_swing_the_aim() -> void: + var mouse := CENTRE + Vector2(0.0, -120.0) + var angles: Array[float] = [] + for step in 5: + angles.append(_aim_from(mouse, Vector2(step * 500.0, step * -300.0)).angle()) + for a in angles: + assert_almost_eq(a, angles[0], 0.0001, + "the crosshair must not move when only the player moves") diff --git a/tests/unit/test_aim.gd.uid b/tests/unit/test_aim.gd.uid new file mode 100644 index 0000000..11148be --- /dev/null +++ b/tests/unit/test_aim.gd.uid @@ -0,0 +1 @@ +uid://b7wqk5agkkso2