ci / verify (push) Successful in 52s
The fog asked one question per tile -- can I see its centre? -- and drew or skipped the whole tile on the answer. Every shadow was a staircase, tiles snapped in and out as you walked, and a wall beside you vanished whenever its centre happened to sit behind its neighbour. Visibility casts rays from the eye instead: a fixed sweep, plus two aimed either side of every wall corner, which is what makes each shadow edge a straight line rather than a smear between rays. The view now draws terrain and actors first and then darkness over whatever the rays did not reach, so a shadow runs straight through a tile and an enemy rounding a corner appears a slice at a time. Bullets are a child node and are still drawn over the dark; telegraphs are drawn after it, as they must be. "A bit less strict" is three specific things. A wall face you can see is lit 26 units in, measured perpendicular to the face; shadows end in a short fade rather than a line; and sight fades over its last 64 units rather than stopping at a circle. Still hard fog -- nothing remembered. The first version measured that depth along the ray, and a screenshot caught what no test had: a wall you look along got a sliver, so the wall beside you in a tunnel looked missing -- the old fog's own complaint. Perpendicular depth fixes it, and light stops the instant a ray steps back into the open, so it never rounds a corner, never reaches the far side of a one-tile wall, and never leaves a stepped wall through its other face. Two tiles touching only at a corner are closed. About half a millisecond a frame. Measured against the old fog in the same scene: hub 68 fps against 62, dungeon 240 against 240. The hub's own low rate predates this. MapGrid gains a revision counter so the corner list is rebuilt only when the map changes or the eye crosses a tile. screenshot.tscn now captures a dungeon, a tunnel and the tunnel with F1's overlay, which draws where each ray stopped -- and makes SHOT_DIR rather than failing every capture when it does not exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
11 KiB
Markdown
203 lines
11 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/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/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. |
|
|
| `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. |
|