005679f1b5
(1) Bullets appeared to trail the ship. Two independent causes, measured with
the new tools/diag_prediction.gd rather than guessed at:
- ServerRuntime ticked before ClientRuntime, so input sampled on frame N was
not consumed until frame N+1, leaving the drawn ship a constant one tick
(4.00px at 240 u/s) ahead of the authoritative one that bullets spawn from.
ClientRuntime now sets process_physics_priority = -10. Gap on a listen
server: 4.00px -> 0.10px mean, 0.30px worst.
- PLAYER_MUZZLE_OFFSET was PLAYER_RADIUS + 6 = 12px against a 13px drawn
ship, so bullets were born inside the sprite. Regression from the previous
commit's hitbox shrink; it now derives from PLAYER_VISUAL_RADIUS.
(2) No more timed respawn. A downed player stays down until they ask for the
hub (E), which is an ordinary input -- the server has no "revive me" message.
(3) Escape channel 3s -> 1s, and damage no longer cancels it. An interruptible
channel makes killing the process strictly better than using the button, so a
dropped connection now runs the same channel: the player stays in the world as
linkdead, still killable, and is only released once it completes. Instances
refuse to close while a linkdead body is resolving, or a solo drop would delete
it on the next tick and hand the exploit straight back.
(4) Escape opens an in-game menu: return to hub (routed through the same held-
escape channel, not a new message), disconnect, quit.
(5) Server pushes a roster so the hub shows who is online and which dungeon
they are in. Entering a dungeon grants 2s arrival protection -- invulnerable
AND weapons-cold, since invulnerability alone would make the spawn a free
firing position -- flagged in the snapshot and drawn on every protected ship.
(6) Cleared dungeons hold the party 30s (was 5s) with a visible countdown.
(7) The hub's grey circle was a 100k-HP target dummy that read as scenery. Now
drawn as a bullseye so its purpose is legible.
Protocol version 1 -> 2. 91 tests (was 78); smoke.sh gains a bot that is
SIGKILLed mid-dungeon to prove the disconnect path end to end. check.sh,
test.sh and smoke.sh all pass.
208 lines
10 KiB
Markdown
208 lines
10 KiB
Markdown
# 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.
|
||
|
||
## Leaving a run, and why disconnecting is not an escape
|
||
|
||
The emergency escape is a **one-second server-owned channel** that damage does
|
||
*not* interrupt. That combination is deliberate and the two halves depend on
|
||
each other.
|
||
|
||
An interruptible channel sounds like the right kind of risk until you follow it
|
||
through: a player about to die under fire can never finish the channel, so
|
||
killing the game process becomes strictly better than using the button. The
|
||
escape hatch turns into the exploit.
|
||
|
||
So a dropped connection runs the same channel. On disconnect the peer is removed
|
||
from the send list but its player **stays in the world** (`SimPlayer.linkdead`),
|
||
treated as holding the escape button down, still fully killable, for the same
|
||
second everyone else pays. Only when that resolves is the peer forgotten. Pulling
|
||
the plug is therefore never cheaper than pressing the key, and there is no
|
||
timing window where it is.
|
||
|
||
Two details that are easy to get wrong and are covered by tests:
|
||
|
||
- An instance must not close while a linkdead body is still resolving
|
||
(`Instance.has_linkdead()`), or a solo player's drop would delete their body
|
||
on the next tick and hand back the exact exploit.
|
||
- Dying still ends the channel. That is the one thing the escape cannot beat,
|
||
and it is what keeps the second a real risk rather than a formality.
|
||
|
||
`tools/smoke.sh` SIGKILLs a bot mid-dungeon and asserts the server channels it
|
||
out rather than dropping it instantly.
|
||
|
||
## Arriving in a run
|
||
|
||
Entering a dungeon grants `SimConfig.SPAWN_GRACE_TICKS` (2s) of **arrival
|
||
protection**: invulnerable *and* unable to shoot. Both halves matter —
|
||
invulnerability alone would make the spawn point a free firing position. It is
|
||
flagged in the snapshot (`Protocol.F_SPAWN_GRACE`) so every client can draw it
|
||
on every protected ship, not just its own.
|
||
|
||
## 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.
|
||
|
||
`ClientRuntime` sets `process_physics_priority = -10` so it samples and sends
|
||
input *before* `ServerRuntime` ticks. This matters only on a listen server,
|
||
where both live in one process: with the default tree order the server ran
|
||
first, so input sampled on frame N was not consumed until frame N+1, leaving the
|
||
drawn ship a permanent one tick (4px at 240 u/s) ahead of the authoritative one
|
||
— and bullets, which spawn at the authoritative position, visibly trailed behind
|
||
the ship. `tools/diag_prediction.gd` measures this gap; it is currently ~0.1px
|
||
mean, down from a constant 4.00px.
|
||
|
||
The other half of that fix is `SimConfig.PLAYER_MUZZLE_OFFSET`, derived from the
|
||
*visual* radius rather than the hitbox. A remote client still draws its ship a
|
||
tick or two ahead of the server, and that margin is what keeps bullets emerging
|
||
from the nose rather than the middle of the sprite.
|
||
|
||
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.
|