Files
transcience/docs/ARCHITECTURE.md
T
claudeandClaude Opus 5.5 08f58af1a8
ci / verify (push) Successful in 53s
Documentation pass before a compact
Audited rather than appended. What had drifted, now corrected:

- ROADMAP's opening still described the project before this session: no
  word of the anti-cheat fixes, 468 tests, 23 smoke assertions, and a
  screenshot list half its current size. It now summarises the session,
  lists seven open items raised with the user and not yet decided (the
  boss-kill experience edge, the hub frame rate, aggro through corner
  cracks, the Warden-hall alcoves, one-tile arena doors, the Proving
  Grounds before release, hit feedback), and quotes current numbers.
  Its radius table still gave BULLET_INTEREST_RADIUS as the bullet cull
  at 2200; it is a 2900 ceiling over a per-bullet check.
- NETCODE described the flat bullet radius as the design, claimed the
  respawn lockout gates connected players (they are unbound on death),
  and called actor scoping "next stage". All corrected, and a new section
  covers what the handshake and server now refuse: duplicate sessions,
  reasons that now arrive, paced control messages, coalesced store
  writes, bounds-checked hot decoders.
- DECISIONS gains the audit's rules, the Proving Grounds' status, the
  tunnel and entrance rules, and the interface calls -- each flagged as
  the user's or as a judgement call.
- ARCHITECTURE's file map lacked the actor classes, the dev auth
  provider, the menu backdrop and any tools section.
- README told people to start a client with --name, which now only names
  bot characters, and its feature list predated fog, tunnels and carried
  health. CLAUDE.md gains three invariants (health on the record, one
  session per account, tools that host use scratch data); WORKFLOW gains
  the four traps this session fell into or nearly did.

Every doc was checked for unintended deletions against the session's
start, and every relative link resolves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:05:56 +02:00

221 lines
13 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
for aggro. Actor-vs-actor hits stay circle-vs-circle, which is the genre
convention anyway. The client's fog no longer shares that line test: it casts
exact rays through the same grid ([Visibility](../src/view/visibility.gd)), so
shadows cut through tiles rather than snapping to them.
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
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, 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
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.
`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
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
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/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/sim_player.gd`, `sim_enemy.gd`, `sim_boss.gd` | Actor state. A player's current health is restored from its character on arrival, never reset. |
| `src/sim/sim_events.gd` | Every event type. Order is a wire format; `NetCodec.event_payload_size` must know each one's length. |
| `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/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. **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/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. |
| `src/meta/auth_provider.gd` | Identity, shaped like Steamworks so it swaps out. |
| `src/meta/local_auth_provider.gd` | The development provider. **Insecure on purpose** — any client can claim any account. Do not ship it. |
| `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. Draw order is the fog's mechanism: everything it may hide, then the darkness, then what it must not. |
| `src/view/visibility.gd` | The fog's geometry: rays from the eye, wall corners, the shadow mesh. Pure, so `test_visibility.gd` asks it which points are lit. |
| `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. Fades bullets past the edge of sight, never sooner than `WARNING_TIME` before one could land (`visibility()`). |
| `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` | Player panel, party, boss bar, banners, message feed, inventory, prompts. Drawn, not built from controls; stacked by a running cursor so nothing collides. |
| `src/ui/main_menu.gd`, `game_menu.gd` | Connect screen and the in-game menu. |
| `src/ui/menu_backdrop.gd` | The real boss patterns playing behind the connect screen, primed so the first frame is already full. |
| `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. |
### Tools
| File | Role |
| --- | --- |
| `tools/check.sh`, `check_scripts.gd` | Parse and type-check every script; refreshes the class cache first. |
| `tools/test.sh` | The GUT suite, headless. |
| `tools/smoke.sh` | A real server and five bots over ENet, asserting the whole loop. |
| `tools/server.sh`, `client.sh` | Run a dedicated server, or a client. |
| `tools/install-hooks.sh` | Points git at `.githooks/`: check and test on commit, smoke on push. |
| `tools/diag_*.gd` | Scene-based end-to-end checks the bots cannot reach: prediction, progression, loot, upgrades. |
| `tools/bench.gd` | Simulation cost per tick. |
| `tools/screenshot.gd` | Every screen, staged. Needs a display. **Its own store and account.** |
| `tools/export_content.gd`, `setup_input_map.gd` | Content to `.tres` for the inspector; the input map, generated correctly. |