Close Stage 1: per-peer actor interest, boss room confinement
ci / verify (push) Successful in 46s

The gap Stage 1 left open: snapshots were encoded once and broadcast to every
peer in an instance, so clients were handed enemies the fog then hid. Fog is a
rendering rule -- a modified client draws whatever it holds -- so that was no
defence at all. Snapshots are now encoded per peer, and actors beyond
ACTOR_INTEREST_RADIUS (800u) are never sent. Measured cost of four filtered
encodes against one shared: 95.6us vs 24.9us, or 0.032 ms/tick amortised over
the snapshot interval.

Two asymmetries worth keeping:

- The observer's own player record is never filtered, however far out the
  arithmetic puts it. The client reconciles its prediction against that record,
  so dropping it breaks the player's own movement rather than hiding someone.
- Only bullet SPAWNS are filtered, never despawns. A client told about a bullet
  must always be told it died, or it keeps a phantom.

Bullet spawns use a much wider radius (2200u) than actors, deliberately. An
enemy appearing at the edge of sight is cosmetic; a bullet withheld at spawn
that later flies into view is invisible damage. The floor is longest bullet
travel + fog radius -- 1500 for the Warden's Collapse snipe -- and
test_interest.gd recomputes that from live content, so adding a faster or
longer-lived bullet fails a test instead of producing bullets that wink into
existence.

Also clamped bosses to their room. A no-op today since every boss is
stationary, which is exactly when the invariant is cheap to establish: boss
rooms have no door that locks, so the only thing keeping a fight in the boss
room is the boss, and Stage 5's movement work would otherwise break it quietly.

146 tests (was 137). check.sh, test.sh and smoke.sh pass. Stage 1 has no
partials left; docs/ROADMAP.md now records the three interest radii and the
floor each must respect.
This commit is contained in:
2026-09-03 23:33:32 +02:00
parent 20dd6bc502
commit 7ee6c8e761
9 changed files with 267 additions and 15 deletions
+29
View File
@@ -158,6 +158,35 @@ Hard fog is therefore a *rendering* rule, not a secrecy mechanism. The secrecy
lives in what the server declines to send: terrain outside the stream radius,
and (next stage) actors outside it.
## Actors are scoped per peer
Snapshots are encoded **once per peer**, not once and broadcast: an actor
further than `ACTOR_INTEREST_RADIUS` (800u) from a player is never sent to that
player. Before this, fog was hiding enemies the client had already been handed,
which is no defence at all against a modified client.
Measured cost of encoding four filtered snapshots instead of one shared:
95.6µs against 24.9µs, which amortised over the 3-tick snapshot interval is
0.032 ms/tick — 0.2% of the frame budget.
Two rules fall out of it:
- **The observer's own player record is never filtered**, however far outside
the radius the arithmetic puts it. The client reconciles its prediction
against that record; dropping it would break the player's own movement rather
than merely hide someone.
- **Bullet despawns are not filtered, only spawns.** A client that was told
about a bullet must always be told it died, or it keeps a phantom until the
lifetime expires.
Bullet spawns use a much wider radius (`BULLET_INTEREST_RADIUS`, 2200u) than
actors, because the failure modes are not symmetric. An enemy appearing at the
edge of sight is cosmetic; a bullet withheld at spawn that later flies into view
is invisible damage. The floor is *longest bullet travel + fog radius* — 1500 +
460 for the Warden's "Collapse" snipe — and `test_interest.gd` recomputes that
from the live content, so adding a faster or longer-lived bullet fails a test
rather than producing bullets that wink into existence.
## No contact damage
Nothing hurts you by touching it. Every threat is a bullet you can see and
+16 -4
View File
@@ -29,16 +29,28 @@ Legend: **done** · **partial** (works, with a stated gap) · **todo** (not star
| Per-peer map streaming (anti map-hack) | done | `ServerRuntime._stream_map` |
| Aggro: range **and** line of sight | done | `SimWorld._aggro_target` |
| Cursor-to-world aiming under a scrolling camera | done | `ClientRuntime.screen_to_world` |
| Boss confined to its room | **partial** | `SimBoss.room` exists and bounds curtain patterns, but nothing clamps the boss — every boss is stationary today. Enforcement belongs with boss movement in Stage 5. |
| **Actor interest management** | **todo** | The known gap. `ServerRuntime._physics_process` still encodes one snapshot and sends it to every peer in the instance, so clients are told about enemies the fog then hides. Terrain is properly withheld; actors are not. **Do this first.** |
| Boss confined to its room | done | `SimWorld._step_boss` clamps to `SimBoss.room`. A no-op while bosses are stationary — established now so Stage 5's movement cannot quietly break it. |
| Actor interest management | done | `NetCodec.encode_snapshot(world, countdown, for_peer)`; per-peer encode in `ServerRuntime`. Bullet spawns filtered separately, see below. |
### The invariant that matters most here
### The invariants that matter most here
Maps are streamed per peer, and the generation seed is **never** sent. See
**Maps are streamed per peer, and the generation seed is never sent.** See
[NETCODE.md](NETCODE.md#maps-are-streamed-never-sent). `MAP_STREAM_RADIUS` must
stay wider than `FOG_VIEW_RADIUS`, or the client predicts movement against
terrain it does not have.
**Three radii, deliberately different, and each has a floor it must respect:**
| Radius | Value | Must exceed | Why |
| --- | --- | --- | --- |
| `FOG_VIEW_RADIUS` | 460 | — | What the player can see. |
| `ACTOR_INTEREST_RADIUS` | 800 | fog radius | Enemies beyond it are never sent. Above the fog radius so nothing pops in at the edge of sight. |
| `MAP_STREAM_RADIUS` | 900 | fog radius | Client predicts movement and simulates bullets against terrain it cannot see. |
| `BULLET_INTEREST_RADIUS` | 2200 | longest bullet travel + fog radius | A bullet is announced once at spawn. Withhold one that later flies into view and it becomes invisible damage. `test_interest.gd` computes the floor from real content, so a faster bullet fails a test instead. |
Fog is a *rendering* rule and defends nothing on its own — a modified client
draws whatever it holds. The defence is what the server declines to send.
---
## Stage 2 — Characters, persistence, levels · *todo, next*