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*
+20
View File
@@ -117,6 +117,26 @@ const DUNGEON_CLEARED_EXIT_TICKS := 1800 # 30 seconds
## always holds the terrain it is about to reveal.
const FOG_VIEW_RADIUS := 460.0
# --- Interest management ----------------------------------------------------
## Actors further than this from a player are not sent to that player at all.
## Comfortably beyond FOG_VIEW_RADIUS so nothing visibly pops in at the edge of
## sight, but far short of a dungeon, so a modified client cannot read enemy
## positions across the map.
const ACTOR_INTEREST_RADIUS := 800.0
## Bullet spawns are filtered far more generously than actors, and deliberately
## so. A bullet is announced once, at spawn, and then simulated locally -- if we
## withhold it and it later flies into view, it is invisible damage, which is a
## gameplay bug rather than a cosmetic one. An enemy appearing at the edge of
## sight is neither.
##
## The floor is (longest bullet travel + FOG_VIEW_RADIUS): a bullet spawned
## further away than that cannot reach visible ground before it expires.
## test_interest.gd computes that from the real content and asserts this covers
## it, so adding a faster or longer-lived bullet fails a test instead of
## producing bullets that wink into existence.
const BULLET_INTEREST_RADIUS := 2200.0
# --- Map streaming ----------------------------------------------------------
## How far around a player the server streams map tiles. Comfortably wider than
## the viewport so movement prediction and bullet simulation always run against
+31 -4
View File
@@ -10,15 +10,36 @@ extends RefCounted
## [param cleared_countdown] is whole seconds until a cleared dungeon returns
## its party, or Protocol.COUNTDOWN_NONE when that does not apply.
## [param for_peer] scopes the snapshot to what that player may know: actors
## beyond SimConfig.ACTOR_INTEREST_RADIUS are omitted entirely, so fog is not
## the only thing hiding them. Pass 0 to encode the whole world (tests, and the
## safe fallback if the observer cannot be found).
static func encode_snapshot(world: SimWorld,
cleared_countdown: int = Protocol.COUNTDOWN_NONE) -> PackedByteArray:
cleared_countdown: int = Protocol.COUNTDOWN_NONE,
for_peer: int = 0) -> PackedByteArray:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.put_u32(world.tick)
b.put_u8(clampi(cleared_countdown, 0, Protocol.COUNTDOWN_NONE))
b.put_u8(mini(world.players.size(), 255))
var observer: SimPlayer = world.players.get(for_peer) if for_peer != 0 else null
var eye := Vector2.ZERO
var cull_sq := 0.0
if observer != null:
eye = observer.pos
cull_sq = SimConfig.ACTOR_INTEREST_RADIUS * SimConfig.ACTOR_INTEREST_RADIUS
var visible_players: Array[SimPlayer] = []
for p in world.players.values():
# Always include the observer's own player, whatever the distance
# arithmetic says: the client reconciles its prediction against this
# record, and dropping it would break its own movement.
if observer == null or p.peer_id == for_peer \
or eye.distance_squared_to(p.pos) <= cull_sq:
visible_players.append(p)
b.put_u8(mini(visible_players.size(), 255))
for p in visible_players:
b.put_u32(p.peer_id)
b.put_float(p.pos.x)
b.put_float(p.pos.y)
@@ -43,8 +64,11 @@ static func encode_snapshot(world: SimWorld,
var live_enemies: Array[SimEnemy] = []
for e in world.enemies.values():
if e.alive:
live_enemies.append(e)
if not e.alive:
continue
if observer != null and eye.distance_squared_to(e.pos) > cull_sq:
continue
live_enemies.append(e)
b.put_u16(mini(live_enemies.size(), 65535))
for e in live_enemies:
b.put_u32(e.id)
@@ -57,6 +81,9 @@ static func encode_snapshot(world: SimWorld,
b.put_u8(clampi(e.def.visual, 0, 255))
var has_boss := world.boss != null and world.boss.alive
if has_boss and observer != null \
and eye.distance_squared_to(world.boss.pos) > cull_sq:
has_boss = false
b.put_u8(1 if has_boss else 0)
if has_boss:
b.put_u32(world.boss.id)
+47 -7
View File
@@ -41,9 +41,13 @@ func _physics_process(_delta: float) -> void:
inst.step()
_dispatch_events(inst)
if send_snapshot and not inst.peers.is_empty():
var snap := NetCodec.encode_snapshot(inst.world, inst.exit_countdown_seconds())
# Encoded per peer, not once and broadcast: each player is told only
# about actors near them. Fog alone was hiding enemies the client
# had already been handed, which is no defence against a modified
# client at all.
for peer in inst.peers:
Net.send_snapshot(peer, snap)
Net.send_snapshot(peer, NetCodec.encode_snapshot(
inst.world, inst.exit_countdown_seconds(), peer))
_stream_map(peer, inst)
if inst.kind != Protocol.InstanceKind.DUNGEON:
continue
@@ -83,9 +87,25 @@ func _dispatch_events(inst: Instance) -> void:
_:
pass
var payload := NetCodec.encode_events(inst.world.tick, events)
# Bullet spawns are scoped per peer too; everything else (hits, deaths,
# phase changes) is low volume and mostly concerns the recipient, so it goes
# to everyone. Despawns are deliberately NOT filtered -- a client that was
# told about a bullet must always be told it died, or it keeps a phantom.
var shared: Array[Dictionary] = []
var spawns: Array[Dictionary] = []
for ev in events:
if int(ev["t"]) == SimEvent.Type.BULLET_SPAWN:
spawns.append(ev)
else:
shared.append(ev)
for peer in inst.peers:
Net.send_events(peer, payload)
var for_peer := shared
if not spawns.is_empty():
for_peer = shared + _spawns_near(inst, peer, spawns)
if for_peer.is_empty():
continue
Net.send_events(peer, NetCodec.encode_events(inst.world.tick, for_peer))
for peer in to_lobby:
_send_to_lobby(peer)
@@ -184,11 +204,11 @@ func _place(peer_id: int, inst: Instance) -> void:
_stream_map(peer_id, inst)
# A player arriving mid-fight has no idea what is already in the air, so
# replay the live bullets as spawn events before the next snapshot lands.
var backlog := _live_bullet_events(inst.world)
var backlog := _live_bullet_events(inst.world, peer_id)
if not backlog.is_empty():
Net.send_events(peer_id, NetCodec.encode_events(inst.world.tick, backlog))
Net.send_snapshot(peer_id, NetCodec.encode_snapshot(inst.world,
inst.exit_countdown_seconds()))
inst.exit_countdown_seconds(), peer_id))
func _transfer(peer_id: int, to: Instance) -> void:
@@ -290,11 +310,31 @@ func _broadcast_roster() -> void:
Net.send_roster(peer, payload)
func _live_bullet_events(world: SimWorld) -> Array[Dictionary]:
## Bullet spawns within this peer's interest radius. See
## SimConfig.BULLET_INTEREST_RADIUS for why this radius is much wider than the
## one used for actors.
func _spawns_near(inst: Instance, peer_id: int, spawns: Array[Dictionary]) -> Array[Dictionary]:
var p: SimPlayer = inst.world.players.get(peer_id)
if p == null:
return spawns
var cull_sq := SimConfig.BULLET_INTEREST_RADIUS * SimConfig.BULLET_INTEREST_RADIUS
var out: Array[Dictionary] = []
for ev in spawns:
if p.pos.distance_squared_to(ev["pos"]) <= cull_sq:
out.append(ev)
return out
func _live_bullet_events(world: SimWorld, for_peer: int = 0) -> Array[Dictionary]:
var out: Array[Dictionary] = []
var observer: SimPlayer = world.players.get(for_peer) if for_peer != 0 else null
var cull_sq := SimConfig.BULLET_INTEREST_RADIUS * SimConfig.BULLET_INTEREST_RADIUS
for i in world.pool.high_water:
if world.pool.alive[i] == 0:
continue
if observer != null \
and observer.pos.distance_squared_to(world.pool.pos[i]) > cull_sq:
continue
out.append({
"t": SimEvent.Type.BULLET_SPAWN,
"uid": world.pool.uid[i],
+10
View File
@@ -334,6 +334,16 @@ func _step_boss() -> void:
var phase := boss.current_phase()
if phase == null:
return
# A boss never leaves its arena. Enforced here rather than left to each
# boss's movement code, because boss rooms deliberately do not lock: the
# player can always walk out, and the fight only stays a fight if the boss
# cannot follow. Currently a no-op (every boss is stationary), which is
# exactly when an invariant is cheapest to establish.
if boss.room.size != Vector2.ZERO:
boss.pos = boss.room.position + Vector2(
clampf(boss.pos.x - boss.room.position.x, 0.0, boss.room.size.x),
clampf(boss.pos.y - boss.room.position.y, 0.0, boss.room.size.y))
if boss.phase_tick >= phase.telegraph_ticks:
var local := posmod(boss.phase_tick - phase.telegraph_ticks, maxi(phase.loop_ticks, 1))
_run_emitters(phase.emitters, boss.pos, local, boss.room)
+16
View File
@@ -29,6 +29,22 @@ func test_the_boss_starts_inside_its_own_room() -> void:
"the boss has to start inside the arena it is confined to")
## Boss rooms have no door that locks, so the only thing keeping a boss fight in
## the boss room is the boss itself. Bosses are stationary today, which makes
## this the right moment to pin the invariant -- before movement arrives and
## quietly breaks it.
func test_a_boss_cannot_leave_its_room() -> void:
_step(SimConfig.DUNGEON_FORMING_TICKS + 5)
var boss := inst.world.boss
var room: Rect2 = boss.room
# Shove it far outside, the way a future chase behaviour might.
boss.pos = room.position + room.size + Vector2(2000.0, 2000.0)
inst.step()
assert_true(room.grow(1.0).has_point(boss.pos),
"a boss that can be pushed out of its arena could chase a player who " +
"walked away, and walking away is always allowed")
func test_nothing_spawns_inside_geometry() -> void:
for e in inst.world.enemies.values():
assert_false(inst.world.map.circle_blocked(e.pos, e.def.radius),
+97
View File
@@ -0,0 +1,97 @@
extends GutTest
## Interest management: what the server declines to send.
##
## Fog is a rendering rule and stops nothing — a modified client draws whatever
## it was handed. These tests cover the part that is actually a defence: actors
## and bullet spawns the server never puts on the wire.
var world: SimWorld
const ME := 1
const OTHER := 2
func before_each() -> void:
var map := MapGrid.new(200, 60, MapGrid.Kind.WALL)
map.fill_rect(Rect2i(1, 1, 198, 58), MapGrid.Kind.FLOOR)
map.centre_on_origin()
world = SimWorld.new(1)
world.set_map(map)
var me := world.add_player(ME, "me")
me.pos = Vector2.ZERO
func _snap_for(peer: int) -> Dictionary:
return NetCodec.decode_snapshot(
NetCodec.encode_snapshot(world, Protocol.COUNTDOWN_NONE, peer))
func test_a_distant_enemy_is_not_sent_at_all() -> void:
world.spawn_enemy(Content.turret(), Vector2(SimConfig.ACTOR_INTEREST_RADIUS + 300.0, 0.0))
assert_eq((_snap_for(ME)["enemies"] as Array).size(), 0,
"an enemy across the map must never reach the client")
func test_a_nearby_enemy_is_sent() -> void:
world.spawn_enemy(Content.turret(), Vector2(200.0, 0.0))
assert_eq((_snap_for(ME)["enemies"] as Array).size(), 1)
func test_enemies_are_visible_well_beyond_what_can_be_seen() -> void:
# The interest radius has to exceed the fog radius, or enemies would appear
# out of nowhere at the exact edge of sight.
assert_gt(SimConfig.ACTOR_INTEREST_RADIUS, SimConfig.FOG_VIEW_RADIUS)
world.spawn_enemy(Content.turret(), Vector2(SimConfig.FOG_VIEW_RADIUS + 50.0, 0.0))
assert_eq((_snap_for(ME)["enemies"] as Array).size(), 1,
"just out of sight must still be known, so it does not pop in")
func test_a_distant_player_is_not_sent() -> void:
var them := world.add_player(OTHER, "them")
them.pos = Vector2(SimConfig.ACTOR_INTEREST_RADIUS + 400.0, 0.0)
var peers: Array = _snap_for(ME)["players"]
assert_eq(peers.size(), 1)
assert_eq(int(peers[0]["peer"]), ME)
## The one record that can never be filtered: the client reconciles its own
## prediction against it, so dropping it would break the player's own movement
## rather than merely hiding someone.
func test_your_own_player_is_always_sent_however_far_out_you_are() -> void:
world.players[ME].pos = Vector2(1.0e5, 1.0e5)
var peers: Array = _snap_for(ME)["players"]
assert_eq(peers.size(), 1)
assert_eq(int(peers[0]["peer"]), ME)
func test_a_distant_boss_is_not_sent() -> void:
var boss := world.spawn_boss(Content.warden())
boss.pos = Vector2(SimConfig.ACTOR_INTEREST_RADIUS + 500.0, 0.0)
assert_null(_snap_for(ME)["boss"], "you should not see a boss room you are nowhere near")
boss.pos = Vector2(100.0, 0.0)
assert_not_null(_snap_for(ME)["boss"])
func test_omitting_the_observer_encodes_the_whole_world() -> void:
# Peer 0 means "no filtering" — used by tests and as the safe fallback when
# the observer cannot be found.
world.spawn_enemy(Content.turret(), Vector2(9000.0, 0.0))
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
assert_eq((snap["enemies"] as Array).size(), 1)
## The radius that stops bullets winking into existence, checked against the
## real content rather than guessed. Adding a faster or longer-lived bullet
## should fail here rather than produce invisible damage in play.
func test_the_bullet_radius_covers_the_longest_shot_in_the_game() -> void:
var worst := SimConfig.PLAYER_BULLET_SPEED \
* float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA
var emitters: Array[BulletEmitter] = []
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET, Content.ENEMY_STALKER]:
emitters.append_array(Content.enemy(id).emitters)
for phase in Content.warden().phases:
emitters.append_array(phase.emitters)
for e in emitters:
worst = maxf(worst, e.speed * float(e.lifetime) * SimConfig.TICK_DELTA)
assert_gte(SimConfig.BULLET_INTEREST_RADIUS, worst + SimConfig.FOG_VIEW_RADIUS,
"a bullet spawned just outside the radius must not be able to reach " +
"ground the player can see before it expires (worst travel %.0f)" % worst)
+1
View File
@@ -0,0 +1 @@
uid://c156uloui1reg