diff --git a/CLAUDE.md b/CLAUDE.md index 1a2bb47..e18ead3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ the obvious default. ```bash tools/check.sh # parse-check every script (~5s) -- run after every edit -tools/test.sh # GUT suite, headless (~3s) +tools/test.sh # 468 GUT tests, headless (~4s) tools/smoke.sh # real server + 4 bot clients over ENet (~40s) tools/server.sh # dedicated server tools/client.sh --listen # host and play, no menu @@ -37,6 +37,12 @@ test cannot cover any of them: bots are poor shots, so they rarely kill anything, which means they neither earn levels, produce drops, nor ever reach the quartermaster. +**Nothing automated can see the screen.** `check.sh`, the suite and `smoke.sh` +have all passed with the entire interface rendering unstyled, an invisible +slider and an invisible scrollbar. After touching anything under `src/ui/` or +`src/view/`, run `screenshot.tscn` and open the files. It needs a display, so it +is not a gate. + Everything after `--` goes to `GameOpts.parse()`: | Flag | Effect | @@ -69,12 +75,22 @@ push (~35s, skip deliberately with `SKIP_SMOKE_HOOK=1 git push`). **The server decides everything; the client only sends intent.** -A client can send exactly two things: an [InputFrame] (move vector, aim angle, -five button bits, an inventory slot) and a handshake — plus the two low-rate -character-roster requests, which are also pure intent. There is no message for -"I moved here", "I hit that", "I took damage", "my escape finished" or "I now -own this item". Adding one would collapse the whole security model, so don't — -validate-after-the-fact is strictly weaker than having no code path at all. +There are exactly **five** client -> server messages, and that number is worth +watching: + +| Message | Carries | +| --- | --- | +| `c_hello` | protocol version + an opaque auth ticket | +| `c_input` | an [InputFrame]: move vector, aim angle, five button bits, an inventory slot | +| `c_select_character` | a character id, checked against *that account's* list | +| `c_create_character` | a name, sanitised at the boundary | +| `c_choose_upgrade` | an index into the three options the SERVER put on the table | + +Every one is pure intent. There is no message for "I moved here", "I hit that", +"I took damage", "my escape finished", "I now own this item", "I am in this +dungeon" or "my damage is X". Adding one would collapse the whole security +model, so don't — validate-after-the-fact is strictly weaker than having no code +path at all. When a new player action needs a message, look at whether it fits in the input frame first. Item use and drop did, and got the redundancy, the replay guard and @@ -95,7 +111,7 @@ and `tests/integration/test_replica_parity.gd` pin this down. | `src/actors/` | Data-only `Resource` definitions: `EnemyDef`, `BossDef`, `ItemDef`, `LootDrop`, `DungeonDef`, `UpgradeDef`. Shapes, not instances. | | `src/sim/patterns/` | Bullet emitters — the authoring surface for every enemy and boss. Emitters are stateless: they are shared resources, and two bosses of the same kind must not stomp each other. | | `src/sim/map_grid.gd` | Tile grid: collision, line of sight, chunk streaming. | -| `src/sim/map_gen.gd` | Dungeon generation; `build()` is the only entry point. | +| `src/sim/map_gen.gd` | Dungeon generation. **Server-side only** — handing a client the seed would be a map hack with no work required. | | `src/content/rooms.gd` | Hand-authored room stamps (hub, boss arenas) as text. | | `src/meta/` | Accounts, characters, persistence, XP curve. Server-owned. | | `src/content/content.gd` | All enemies and bosses, defined in code. Source of truth. | diff --git a/README.md b/README.md index 92bb5b9..c4427ea 100644 --- a/README.md +++ b/README.md @@ -12,39 +12,51 @@ tools/client.sh --join --name ada # connect a client Or run one client and press **Host and play** for a listen server. -**Controls** — WASD move, mouse aim, LMB fire, **E** on the ring in the hub to -enter a dungeon, **hold F** for three seconds to escape back to the hub. - -## Develop - -```bash -tools/check.sh # parse-check every script ~5s -tools/test.sh # GUT suite, headless ~2s -tools/smoke.sh # server + 2 bot clients, ENet ~35s -godot --headless --path . --script tools/bench.gd -``` - -Read [CLAUDE.md](CLAUDE.md) first — it is short, and the Godot CLI gotchas in it -will otherwise cost an afternoon. +**Controls** — WASD move, mouse aim, LMB fire, **E** to pick up loot and to use +the hub's portals and quartermaster, **1–4** use an inventory slot, **shift+1–4** +drop one, **hold F** for one second to escape a dungeon, **Esc** menu, **F1** +hitbox overlay. Every one of those is rebindable in Settings. ## What is here | | | | --- | --- | -| **Twin-stick bullet hell** | Server-simulated bullet field, ~350 concurrent bullets at 0.24 ms/tick. | -| **Authoritative multiplayer** | Clients send input only. Hits, damage, death and instance transfers are server decisions. Prediction + reconciliation for the local player. | -| **Enemies** | Four readable behaviours (static, drift, orbit, approach, strafe) composed with bullet emitters. | -| **Boss** | The Warden of the Fold: stationary, four phases, each adding one idea. Defined entirely as data. | -| **Lobby hub** | Shared persistent instance with a portal into dungeon runs. | -| **Emergency escape** | Three-second channel back to the hub, cancelled by damage. | +| **Twin-stick bullet hell** | Server-simulated bullet field, ~300 concurrent bullets at 0.28 ms/tick — around 60x headroom against the 60 Hz budget. | +| **Authoritative multiplayer** | Clients send input and nothing else. Hits, damage, death, loot, upgrades and instance transfers are all server decisions. Prediction and reconciliation for the local player. | +| **Enemies** | Five readable movements (static, drift, orbit, approach, strafe) composed with bullet emitters. Nothing deals contact damage; every threat is a bullet you can see. | +| **Two bosses** | The Warden of the Fold stands still through four phases. The Cantor of the Vault walks a circuit, chases, orbits, and marks the floor before it strikes. Both are pure data. | +| **Three dungeons** | Two real runs, one per boss, plus a Proving Grounds: the same content at a fraction of the health and ten times the drop rate, for testing by hand. | +| **Characters** | Up to five living per account, permadeath, levels 1–15, experience shared across the party undivided. | +| **Inventory and loot** | Four slots, always on screen. Loot is either world-shared or instanced per player, and the instanced kind is filtered on the wire rather than hidden in the client. | +| **Upgrades** | Seven, drawn three at a time and spent at a hub NPC. Damage, fire rate, extra projectiles, splitting, poison, and a legendary that deletes enemy bullets. | +| **Emergency escape** | A one-second channel back to the hub that damage does **not** interrupt — because if it did, quitting the process would be the better escape. Dropping your connection runs the same channel. | +| **Settings** | Rebindable controls and volumes, and an in-game credits screen. | + +## Develop + +```bash +tools/check.sh # parse-check every script ~5s +tools/test.sh # 468 GUT tests, headless ~4s +tools/smoke.sh # server + 4 bot clients over ENet ~40s +``` + +Read [CLAUDE.md](CLAUDE.md) first — it is short, and the Godot CLI gotchas in it +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 -- [CREDITS.md](CREDITS.md) — third-party asset attribution -- [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 - [docs/NETCODE.md](docs/NETCODE.md) — replication model, hit validation, known gaps -- [docs/ROADMAP.md](docs/ROADMAP.md) — what is built and what is next +- [docs/WORKFLOW.md](docs/WORKFLOW.md) — agent-assisted Godot development, and the verification traps this project has actually hit +- [docs/ASSETS.md](docs/ASSETS.md) — asset packs and their licence findings +- [CREDITS.md](CREDITS.md) — third-party attribution +- [CLAUDE.md](CLAUDE.md) — commands, invariants, Godot CLI gotchas + +## Not ready for the internet + +`LocalAuthProvider` lets any client claim any account id. It exists to have the +same shape as Steamworks so swapping is one class, and it must be replaced +before this is reachable from anywhere untrusted. There is no transport +encryption either. See [docs/ROADMAP.md](docs/ROADMAP.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eed9d07..d7b9ae4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -62,15 +62,24 @@ travel less than one tile per tick or the point test steps over walls (pinned by ## Where the layers sit now -Stage 1 and 2 added two things worth knowing before reading any file: +Four things are worth knowing before reading any file: - **Geometry is per-world.** There is no global arena. `SimWorld.map` is a [MapGrid](../src/sim/map_grid.gd); the hub and every dungeon have their own, and the client holds a *partial* copy streamed to it in chunks. -- **`src/meta/` is server-only.** Accounts, characters, levels and experience - live there. The simulation reads a player's level and maximum health; it never - writes progression. One writer means a level cannot disagree with the +- **`src/meta/` is server-only.** Accounts, characters, levels, experience, + inventories and upgrades live there. The simulation reads a player's level and + maximum health and moves items between the ground and a bag; it never writes + progression or touches the store. It announces what happened and + `ServerRuntime` banks it. One writer means a level cannot disagree with the experience that earned it. +- **Derived, never stored.** Level comes from lifetime experience; combat + numbers come from the upgrade list ([PlayerStats](../src/sim/player_stats.gd)); + maximum health comes from the level and the upgrades. Nothing that can be + recomputed is persisted, so nothing saved can disagree with what produced it. +- **`src/core/settings.gd` is the exception to "shared".** Everything else in + `src/core/` is agreed by both sides; settings are the player's own machine + and never reach the server. ## Tick @@ -96,6 +105,13 @@ BossDef = stats + ordered phases `SimWorld._run_emitters()` is shared by enemies and bosses, so any pattern can be dropped on either. A boss is four phases layering one idea at a time; a new boss is a new function in `src/content/content.gd` and zero simulation changes. +`SimWorld._move_boss()` is the same idea for movement: the mode is a field on +the phase, and adding a boss that walks needs no code. + +Emitters are **stateless**. They are shared resources — two bosses of the same +kind would otherwise stomp each other's timers — so anything an emitter needs to +remember between two ticks has to be derived instead. `TelegraphedStrikeEmitter` +computes its strike positions from the volley number for exactly this reason. ## Entry point @@ -108,30 +124,76 @@ after `--`: ## File map +Grouped by layer. The rule the whole thing hangs on: **`src/sim/` imports +nothing from `net`, `view` or `ui`, and never touches `Net`.** + +### Shared constants and helpers + | File | Role | | --- | --- | | `src/core/sim_config.gd` | Every constant server and client must agree on. | | `src/core/movement.gd` | Pure movement + overlap helpers. Shared by prediction. | | `src/core/game_log.gd`, `game_opts.gd` | Static; usable from tools and tests. | -| `src/sim/sim_world.gd` | The simulation. Authority flag decides what runs. | +| `src/core/settings.gd` | Client-local preferences. Never reaches the server. | +| `src/core/credits.gd` | Third-party assets and their licences. Two are CC BY, so this is a legal requirement. | + +### The simulation + +| File | Role | +| --- | --- | +| `src/sim/sim_world.gd` | The simulation. The authority flag decides what runs. | | `src/sim/bullet_pool.gd` | Struct-of-arrays bullet storage and integration. | | `src/sim/input_frame.gd` | The only thing a client may assert about itself. | -| `src/net/net_codec.gd` | Snapshot / event / input binary codecs. | -| `src/net/server_runtime.gd` | Instances, ticking, transfers, broadcast. | -| `src/net/client_runtime.gd` | Prediction, reconciliation, interpolation, bot input. | -| `src/instances/instance.gd` | Lobby hub and dungeon progression. | -| `src/autoload/net.gd` | ENet lifecycle, RPCs, local loopback for listen servers. | +| `src/sim/player_stats.gd` | Combat numbers derived from a character's upgrades. | +| `src/sim/poison_track.gd` | Damage over time. O(1) per actor per tick however many doses. | +| `src/sim/sim_loot.gd` | An item on the ground: world-shared, or owned by one peer. | +| `src/sim/sim_portal.gd` | A dungeon entrance, and which dungeon it opens. | | `src/sim/map_grid.gd` | Tile grid: collision, line of sight, chunked streaming. | -| `src/sim/map_gen.gd` | Dungeon generation. `build()` is the only entry point both sides use. | -| `src/content/rooms.gd` | Hand-authored stamps: the hub and each boss arena, as text. | +| `src/sim/map_gen.gd` | Dungeon generation. **Server-side only** — the client is never given the seed. | +| `src/sim/patterns/` | Bullet emitters. Stateless; the authoring surface for every fight. | + +### Content (data, in code) + +| File | Role | +| --- | --- | +| `src/content/content.gd` | Every enemy and boss. `ALL_ENEMIES` / `ALL_BOSSES` are what the tools and tests iterate. | | `src/content/items.gd` | Every item. `Items.ORDER` doubles as the wire format. | -| `src/sim/sim_loot.gd` | An item on the ground. World-shared, or owned by one peer. | +| `src/content/upgrades.gd` | The seven upgrades and their draw weights. | +| `src/content/dungeons.gd` | The kinds of run. `ORDER` is a wire format *and* the hub's portal order. | +| `src/content/rooms.gd` | Hand-authored stamps: the hub and each boss arena, as text. | +| `src/actors/` | The `Resource` definitions those tables build: enemy, boss, item, loot, upgrade, dungeon. | + +### Server-owned state + +| File | Role | +| --- | --- | | `src/meta/progression.gd` | XP curve and what a level is worth. Pure functions. | -| `src/meta/character.gd`, `character_store.gd` | Characters and their JSON persistence. Server-owned. | +| `src/meta/character.gd`, `character_store.gd` | Characters and their JSON persistence. | | `src/meta/auth_provider.gd` | Identity, shaped like Steamworks so it swaps out. | -| `src/net/net_codec.gd` | Snapshot / event / input / roster / character / map-chunk codecs. | -| `src/net/server_runtime.gd` | Instances, ticking, transfers, interest, progression, map streaming. | +| `src/instances/instance.gd` | The hub and one dungeon run: a world plus a peer list. | + +### Network + +| File | Role | +| --- | --- | +| `src/net/net_codec.gd` | Every binary codec: snapshot, events, input, roster, characters, map chunks, portals, upgrade state. | +| `src/net/server_runtime.gd` | Instances, ticking, transfers, interest, progression, persistence, map streaming. | +| `src/net/client_runtime.gd` | Prediction, reconciliation, interpolation, bot input. | +| `src/net/protocol.gd` | Wire version and constants. Bump `VERSION` whenever a layout changes. | +| `src/autoload/net.gd` | ENet lifecycle, RPCs, local loopback for listen servers. The only autoload. | + +### View and UI (read-only; decides nothing) + +| File | Role | +| --- | --- | +| `src/view/world_view.gd` | Everything that is not a bullet. Fog, actors, loot, telegraphs. | | `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. | | `src/view/art.gd` | Every atlas rect and sound path, in one table. | +| `src/view/ui_theme.gd` | The control theme, built in code from the UI pack. | | `src/view/debug_draw.gd` | F1 overlay: what the simulation collides against. | +| `src/view/game_scene.gd`, `sfx.gd` | Wiring the view to whatever client `Net` currently has. | +| `src/ui/hud.gd` | Bars, inventory, prompts. Drawn, not built from controls. | +| `src/ui/main_menu.gd`, `game_menu.gd` | Connect screen and the in-game menu. | | `src/ui/character_select.gd` | Roster screen: pick or create. | +| `src/ui/upgrade_screen.gd` | The quartermaster's three choices, and what you hold. | +| `src/ui/settings_screen.gd`, `credits_screen.gd` | Controls, volumes, attribution. | diff --git a/docs/ASSETS.md b/docs/ASSETS.md index 314315d..37c58de 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -51,9 +51,11 @@ modification. So they live in `assets/local/`, which is gitignored. `Art.bullets_texture()` and `Art.impact_texture()` load them at runtime if present and return null if -not; the bullet renderer falls back to a generated dot. Verified both ways: with -the files present the game uses them, and with them hidden the suite still -passes 156/156 and the game runs. +not; the bullet renderer falls back to a generated dot. Verified both ways: with the files +present the game uses them, and with them hidden the whole suite still passes +and the game runs — `tests/unit/test_art.gd` has an explicit case for their +absence, because a clean clone is the configuration most contributors will +actually have. This is also why nothing there may be `preload()`ed — `preload` resolves at compile time and would fail the build on every machine that lacks the files. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d26e367..f516461 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -13,6 +13,41 @@ that may already have an answer. Legend: **done** · **partial** (works, with a stated gap) · **todo** (not started) +--- + +## Where things stand + +**Every stage in the original brief is built.** The MVP asked for a twin-stick +bullet hell, server-side hit validation, predictable enemies, an adaptable boss, +a lobby hub with dungeon runs, and an emergency escape. All of that exists, plus +the four stages that followed: characters and permadeath, inventory and loot, +upgrades, and a second boss that moves. + +So there is no obvious "next ticket". What follows is the honest list of +candidates, none of which the user has chosen — **ask before starting one.** + +| Candidate | Why it might be next | What it needs first | +| --- | --- | --- | +| **Replace `LocalAuthProvider`** | It is the one thing standing between this and a server anyone else can reach. Any client can claim any account id. | Nothing technical — it is one `AuthProvider` subclass. Needs a decision about Steam vs something else. | +| **A reason to keep playing past level 15** | Levels cap, `--depth` never rises in play, and a cleared dungeon gives experience and a potion. The long loop is missing. | Open question 1 below. | +| **An economy** | Loot only drops from kills; nothing sells anything. The quartermaster is the obvious shop and has no currency to take. | Open questions 2 and 3. | +| **Impact and hit feedback** | `Art.IMPACT` is loaded and validated and nothing plays it. No hit flash, no screen shake, no death animation. | Nothing. This is the cheapest visible win. | +| **Replace the two non-redistributable packs** | Bullet and FX art is local-only and non-commercial, which blocks both a public repo copy and a commercial release. | CC0 or CC BY replacements. See [ASSETS.md](ASSETS.md). | +| **Transport encryption** | `ENetMultiplayerPeer` supports DTLS. Required before a public server, not before then. | Follows the auth decision. | + +Each stage below also carries its own **Known gaps** section — those are smaller +and more specific than the table above. + +### Known flake + +`tools/smoke.sh` failed once in a pre-push hook and then passed on four +consecutive runs with no stray processes. It was not reproduced and is not +diagnosed. The suspicion is machine load — the smoke test has fixed tick budgets +and was competing with a full suite run — but that is a guess. If it recurs, +that is the first thing to check. + +--- + ## Verification surface What "everything passes" currently means. Numbers move; the shape does not. @@ -441,18 +476,24 @@ Not oversights — each was considered and rejected for now, with the reasoning ## Open questions for the user Genuinely unspecified. **Do not guess at these** — each changes the design, and -several have no obvious default. +several have no obvious default. The seven questions that blocked Stage 4 were +answered and are recorded in [DECISIONS.md](DECISIONS.md#upgrades); these are +what is left. -### 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. +1. **What advances dungeon depth?** `--depth` is a dev flag and nothing raises + it in play. Depth already drives map size and could drive difficulty and + rewards. Without an answer there is no progression past level 15. +2. **Where do items come from outside a dungeon?** Loot only drops from kills. + If the hub should sell potions, that is the quartermaster's second job. +3. **Is there a currency?** Nothing drops money and nothing costs anything. A + shop needs one; so does any reward for a cleared run beyond experience. +4. **Should items stack?** Four potions currently take four slots, which makes a + four-slot bag small. Stacking is a count byte per slot plus a rule for + splitting one — neither is hard, and both change the UI. +5. **Does a dungeon ever get harder within a run?** Enemies are placed once at + generation and never reinforce. That is a deliberate decision + ([DECISIONS.md](DECISIONS.md#world)), but it means a slow party is never + punished for it. +6. **What happens to a party when one member dies?** Permadeath removes them to + the roster screen while the run continues without them. Nobody has said + whether that is right.