651c4ad94a
ci / verify (push) Successful in 1m57s
Top-down twin-stick bullet-hell, Godot 4.7, server-authoritative dedicated server with client-side prediction. Clients send input only; the server resolves every hit for both players and enemies (no PvP). - SimWorld: whole simulation as plain RefCounted objects (no nodes, no physics server), ~0.24ms/tick at peak load -- runs headless for free and drives 78 tests in under a second - BulletPool: struct-of-arrays bullet storage, replicated as spawn/despawn events rather than per-tick state - Emitter framework (Ring/AimedSpread/WallGap/ArcSweep) shared by trash enemies and bosses -- a new boss is data in src/content/content.gd, no simulation changes - The Warden of the Fold: stationary 4-phase boss built entirely on that format - Lobby hub with a portal into on-demand dungeon instances; one process hosts the hub plus every concurrent dungeon - Emergency escape: 3s server-owned channel, cancelled by damage - tools/check.sh, test.sh (GUT), smoke.sh (real server + bot clients over ENet), bench.gd; git hooks wired to the same scripts - docs/ARCHITECTURE.md, NETCODE.md, WORKFLOW.md, ROADMAP.md
104 lines
5.4 KiB
Markdown
104 lines
5.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 — no arbitrary collision shapes, no
|
|
tilemap collision. The arena is a rectangle and every hit is circle-vs-circle.
|
|
For a bullet hell that is not a limitation; hitboxes in the genre are circles by
|
|
convention anyway. A dungeon with real geometry would need a static collision
|
|
representation added to `SimWorld`, not a switch back to nodes.
|
|
|
|
## 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/view/bullet_renderer.gd` | The whole bullet field in one MultiMesh draw call. |
|