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
5.4 KiB
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:
- Bullet count. Hundreds of live bullets. A node and an
Area2Deach would cost more than the entire simulation does.BulletPoolis parallel packed arrays; a tick over ~350 bullets costs 0.24 ms including AI and hit checks. - Headless cost. The dedicated server allocates no nodes and touches no physics server.
- Testability. A
SimWorldisSimWorld.new(). The suite drives thousands of ticks in ~2 s with no SceneTree, no awaits and no frame timing. - Replication control.
MultiplayerSynchronizeris excellent for prototyping and wrong for this shape: it would replicate per-bullet state and saturate the link. See 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_fpspinned 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. |