050b8251a7
ci / verify (push) Successful in 48s
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>
138 lines
7.5 KiB
Markdown
138 lines
7.5 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/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/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. |
|