ded7bf96d5
ci / verify (push) Successful in 47s
The roadmap was actively misleading: an earlier stage renumbering left Stage 2's completed work sitting under a "Stage 3 -- todo" heading, and the inventory table orphaned with no heading at all. Since that file is the primary handoff document, a fresh session would have started by re-implementing accounts and characters. Rewritten. Captured in full, from the original brief rather than from memory, the two stages not yet built: - Stage 3 (inventory and loot): 4 slots, potions rare from trash and guaranteed from bosses, world-shared loot, and the player-instanced food item -- with a note that two loot visibilities must exist from the start, because proving the instanced path works is the food item's entire purpose. - Stage 4 (upgrades): every upgrade with its exact stated effect, plus the two constraints it will collide with -- sniper's 2x bullet speed against the tunnelling threshold, and per-player bullet travel against the interest radius that test_interest.gd currently derives from static content. Ten open questions are listed as explicitly do-not-guess, seven of them blocking Stage 4. ARCHITECTURE.md still claimed "the arena is a rectangle" and "no tilemap collision", both untrue since Stage 1. Rewritten around MapGrid, with what the grid costs (axis-aligned, 32px-quantised, bullets under a tile per tick) rather than only what it buys. Added a "verification traps" section to WORKFLOW.md recording six mistakes made during this work, each of which cost a round trip of reporting something fixed that was not: verifying the artefact rather than the behaviour, dumping the wrong channel, measuring a configuration where the bug cannot exist, a test whose setup silently invalidated it, git checkout reverting real work alongside a probe, and a pattern edit matching in two files. They are specific enough to be actionable. Also documented the diagnostics in CLAUDE.md -- they were undiscoverable -- and recorded the current verification surface so "everything passes" has a stated meaning. 206 tests, 15 smoke assertions, both diagnostics pass.
136 lines
7.4 KiB
Markdown
136 lines
7.4 KiB
Markdown
# Architecture
|
|
|
|
## The shape
|
|
|
|
```
|
|
┌──────────────────────────────────────────┐
|
|
│ src/sim/ -- plain RefCounted objects │
|
|
authoritative │ SimWorld, SimPlayer, SimEnemy, SimBoss, │ replica
|
|
(server) │ BulletPool, emitters │ (client)
|
|
│ no nodes · no physics · no rendering │
|
|
└───────────────┬──────────────────────────┘
|
|
│
|
|
┌────────────────────────┼────────────────────────┐
|
|
│ │ │
|
|
┌───────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
|
|
│ src/instances/ │ │ src/net/ │ │ src/view/ ui/ │
|
|
│ lobby, dungeon │ │ codec, server & │ │ read-only draw │
|
|
│ progression │ │ client runtimes │ │ │
|
|
└────────────────┘ └────────┬────────┘ └─────────────────┘
|
|
│
|
|
┌────────▼────────┐
|
|
│ src/autoload/ │
|
|
│ net.gd (RPC) │
|
|
└─────────────────┘
|
|
```
|
|
|
|
Dependencies point inwards only. `src/sim/` imports nothing from `net`, `view`,
|
|
`ui` or `instances`, and never touches the `Net` autoload. That is what makes
|
|
the simulation runnable in a unit test, in a headless server and inside a client
|
|
without a single conditional.
|
|
|
|
## Why the simulation is not made of nodes
|
|
|
|
Godot's instinct is `CharacterBody2D` + `Area2D` + `MultiplayerSynchronizer`.
|
|
This project deliberately does not, for four reasons:
|
|
|
|
1. **Bullet count.** Hundreds of live bullets. A node and an `Area2D` each would
|
|
cost more than the entire simulation does. `BulletPool` is parallel packed
|
|
arrays; a tick over ~350 bullets costs 0.24 ms including AI and hit checks.
|
|
2. **Headless cost.** The dedicated server allocates no nodes and touches no
|
|
physics server.
|
|
3. **Testability.** A `SimWorld` is `SimWorld.new()`. The suite drives thousands
|
|
of ticks in ~2 s with no SceneTree, no awaits and no frame timing.
|
|
4. **Replication control.** `MultiplayerSynchronizer` is excellent for
|
|
prototyping and wrong for this shape: it would replicate per-bullet state and
|
|
saturate the link. See [NETCODE.md](NETCODE.md).
|
|
|
|
The trade is that there is no physics engine, so collision is hand-written.
|
|
Since Stage 1 that means a tile grid ([MapGrid](../src/sim/map_grid.gd)):
|
|
circle-vs-tile for actors, a point test for bullets, and Bresenham line of sight
|
|
shared by fog and aggro. Actor-vs-actor hits stay circle-vs-circle, which is the
|
|
genre convention anyway.
|
|
|
|
That was the right call rather than a compromise: the same grid answers
|
|
collision, sight, and per-peer interest management as array lookups. On arbitrary
|
|
polygons all three become intersection tests, and the fog query in particular
|
|
stops being cheap enough to run every frame.
|
|
|
|
What it costs: geometry is axis-aligned and 32px-quantised, and bullets must
|
|
travel less than one tile per tick or the point test steps over walls (pinned by
|
|
`test_bullet_speeds_stay_below_the_tunnelling_threshold`).
|
|
|
|
## Where the layers sit now
|
|
|
|
Stage 1 and 2 added two things 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
|
|
experience that earned it.
|
|
|
|
## Tick
|
|
|
|
Everything runs on Godot's physics tick, pinned to 60 Hz in `src/main.gd`
|
|
(asserted in code, because `project.godot` drops settings equal to the engine
|
|
default). `SimConfig` holds every constant both sides must agree on; per-machine
|
|
options live in `GameOpts` and must never affect the simulation.
|
|
|
|
## Content pipeline
|
|
|
|
```
|
|
BulletEmitter (Resource, abstract)
|
|
├── RingEmitter count, arc, spin per shot -> rings and spirals
|
|
├── AimedSpreadEmitter fan at nearest player -> punishes standing still
|
|
├── WallGapEmitter curtain with a sliding gap -> forces a committed dodge
|
|
└── ArcSweepEmitter rotating arms -> forces tracking
|
|
|
|
EnemyDef = stats + movement enum + emitters
|
|
BossPhase = hp threshold + telegraph + looping timeline of emitters
|
|
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.
|
|
|
|
## Entry point
|
|
|
|
One executable, one `src/main.tscn`. `GameOpts.parse()` reads the command line
|
|
after `--`:
|
|
|
|
- `--server` → `ServerRuntime`, no view, `max_fps` pinned to the tick rate.
|
|
- `--join` / `--bot` → connect immediately, skipping the menu.
|
|
- otherwise → the connect menu, which can also start a listen server.
|
|
|
|
## File map
|
|
|
|
| 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/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/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/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/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/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/debug_draw.gd` | F1 overlay: what the simulation collides against. |
|
|
| `src/ui/character_select.gd` | Roster screen: pick or create. |
|