Initial commit: Transcience MVP
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
This commit is contained in:
+156
@@ -0,0 +1,156 @@
|
||||
# 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_DESPAWN` goes 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. `ENetMultiplayerPeer` supports 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.
|
||||
Reference in New Issue
Block a user