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
7.4 KiB
Netcode
Model
Authoritative dedicated server, clients send input only, with client-side prediction and reconciliation for the local player.
client server (60 Hz)
| |
|-- InputFrame (60 Hz, x3) ---->| queue_input(): validate, drop, sort
| move, aim, 3 button bits | SimWorld.step(): move, AI, emitters,
| | integrate bullets, resolve hits
|<-- events (reliable, ch 3) ---| bullet spawns/despawns, hits, deaths
|<-- snapshot (unrel., ch 2) ---| players, enemies, boss @ 20 Hz
| |
predict locally, reconcile authoritative for everything
The client has no message that expresses a position, a hit, damage taken, or a completed escape. This is deliberate and stronger than validating such messages after the fact: there is no code path to exploit, only intent to interpret.
Why bullets are not replicated as state
A bullet hell has hundreds of live bullets — this project peaks at ~350 with one boss and four players. Sending a position per bullet per snapshot would be roughly 350 × 8 bytes × 20 Hz ≈ 56 KB/s per client and would grow with the pattern density, which is the one thing you want freedom to increase.
Instead the server sends one spawn event per bullet, once: uid, position,
velocity, radius, lifetime, kind, team, acceleration, turn rate (~36 bytes).
Both sides then run the identical integration in BulletPool.step(). A bullet
that expires or leaves the arena dies on both sides with no traffic at all.
The only bullets that need a despawn message are the ones killed early by a hit, because that is the one outcome a client cannot derive.
Cost at the same peak: a few KB/s, dominated by whatever the patterns are actually firing rather than by how much is in the air.
tests/integration/test_replica_parity.gd is the test that keeps this honest.
It runs the server and a replica side by side for 900 ticks of a boss fight —
once in-process, once with every event encoded and decoded through the real wire
format — and asserts that every live bullet matches within 0.01 px (1 px through
float32 on the wire). If that test fails, the bullets on screen are no longer
the bullets that can kill you.
Note what this does not require: lockstep determinism. Clients simulate bullets for display only. The server never reads a client's bullet state, so float drift between machines is a cosmetic concern, not a correctness one.
Hit validation
All in SimWorld._resolve_bullet_hits() and _resolve_contact_damage(), server
only, circle-vs-circle:
- Enemy bullet → player: skipped entirely while the player has i-frames, so
a dense pattern costs one hit, not thirty. The bullet is consumed and a
BULLET_DESPAWNgoes out. - Player bullet → boss, then enemies: first overlap consumes the bullet.
Boss damage is scaled by the current phase's
damage_taken_mult. - Enemy body → player: contact damage, same i-frame gate.
There is no lag compensation and no rewind. For a bullet hell that is the right call: what the player is dodging is their own position against a bullet field, and rewinding the world to a shooter's view would mean a player who dodged on their screen still gets hit. The cost is that a high-ping player's shots at a moving enemy land where the enemy was on the server. Enemies are large and slow relative to bullet speed, so this reads as latency rather than unfairness. If it becomes a complaint, the fix is to lag-compensate player bullets against enemies only, never enemy bullets against players.
Input validation
SimWorld.queue_input() is the single audit point. It drops:
| Case | Guard |
|---|---|
| Replay of a consumed tick | f.tick <= p.last_input_tick |
| Stale input | f.tick < tick - INPUT_MAX_AGE |
| Input claiming the future | f.tick > tick + INPUT_MAX_LEAD |
| Flood | queue capped at INPUT_MAX_AGE, oldest dropped |
Beyond that, the wire format itself constrains the cheat surface: the move
vector is two signed bytes at 1/100 precision, and Movement.sanitize_move()
clamps to the unit disc, so no packet can express extra speed. Fire rate is a
server-side cooldown; holding the button every tick fires at exactly the same
rate as tapping it correctly.
A client that stops sending coasts on its last input for INPUT_MAX_AGE ticks
and then stops, so a dropped connection does not leave a player sliding.
Prediction and reconciliation
ClientRuntime keeps every unacknowledged InputFrame. Each snapshot echoes
last_input_tick per player; on receipt the client discards acknowledged
frames, takes the server's authoritative position, and replays the rest through
the same Movement.step_player() the server used. Divergence over 24 px snaps;
smaller errors lerp at 0.3 so ordinary jitter does not read as rubber-banding.
Remote players, enemies and the boss are interpolated between the last two
snapshots. Bullets are not interpolated — they are simulated, and a spawn event
carries the server tick it was generated at so the client fast-forwards the
bullet by the packet's flight time (BulletPool.advance_slot()) instead of
popping it in at the muzzle a round-trip late.
Channels
ENet channels keep a burst of reliable bullet events from head-of-line blocking the snapshot stream:
| Channel | Traffic | Mode |
|---|---|---|
| 1 | handshake, instance transitions | reliable |
| 2 | snapshots | unreliable, newest wins |
| 3 | bullet spawns/despawns, hits | reliable ordered |
| 4 | client input | unreliable ordered |
Protocol.VERSION is checked at handshake and mismatched clients are rejected
with a reason, rather than desyncing later.
Instances
One server process hosts the lobby hub plus every concurrent dungeon. An
Instance is a SimWorld and a peer list; messages are addressed to the peers
of one instance, so a player never receives traffic for a world they are not in.
At ~0.24 ms/tick per instance, the limit is bandwidth and player count long
before it is CPU.
Transfers (portal in, escape out, dungeon cleared) are driven by server-only
events that NetCodec refuses to serialise, so the decision cannot leak to a
client as something it might try to send back.
The listen server
"Host and play" runs a ServerRuntime and a ClientRuntime in one process. The
client is peer 1 and goes through the identical code path — it still sends input
and still learns outcomes from snapshots. Only the transport is short-circuited,
in Net.send_* / Net.send_input, which check for the local peer and call the
handler directly instead of going through ENet. No simulation code knows the
difference, so a bug cannot hide in a "host-only" branch.
Known gaps
- No lag compensation (see above — deliberate, with a stated escape hatch).
- No snapshot delta compression. Full state at 20 Hz is fine at this actor count; delta-encoding against the last acknowledged snapshot is the next step if enemy counts grow.
- No encryption or authentication.
ENetMultiplayerPeersupports DTLS; wire it up before exposing a server to the internet. - Bullet spawn events are per-bullet. Sending pattern events instead (emitter id + tick + seed, clients regenerate) would cut bullet traffic by roughly the count of each emitter's shot. Worth doing only if bandwidth becomes the binding constraint — it couples the client to emitter behaviour, which the current design deliberately avoids.