ajhahn.de
← Theria commits

Commit

Theria

feat: server-authoritative fog of war — radius vision, per-team snapshots

ajhahnde · Jun 2026 · ea92e799c8b980dc31524c631e6280627f49edaa · parent: 192d6e2 · view on GitHub →

modified CHANGELOG.md
@@ -48,6 +48,12 @@ protocol version.
walking it into a wall, threading the gank gaps and rounding the towers — and the bots approach
through the same routing. Pathing runs on a deterministic grid in the simulation, so an online
hero's predicted route still reconciles exactly with the server and a bot match replays identically.
- **Fog of war**: a team now sees only what its own units light up. Each hero, creep, and structure
reveals a radius around itself; the rest of the map sits under fog and the enemies in it are
hidden — they appear the moment they step into your team's vision and vanish when they leave. The
reveal is **authoritative**: an unseen enemy is never sent to a player's client, so it cannot be
read off the wire. *(First pass: vision is a plain radius — walls do not yet block line of sight —
and there is no remembered "last seen" terrain; both are follow-up slices.)*
## [v0.3.4] — 2026-06-16
added src/client/fog.gdshader
@@ -0,0 +1,43 @@
shader_type spatial;
render_mode blend_mix, unshaded, cull_disabled, depth_draw_never, shadows_disabled;
// The fog of war: a dark sheet laid just over the playfield that dims everywhere the player's
// team has no vision, with a clear hole punched around each of the team's sight sources (its
// heroes, creeps, and structures). It is a presentation tint only — enemy units in fog are
// hidden by the renderer and never sent over the wire (Vision / NetProtocol), so this draws no
// units, only the darkened ground. World-space and unshaded so the tint reads flat and even
// across the arena regardless of where the plane sits; depth-tested (it does not write depth) so
// the 3D bodies standing in a lit circle rise in front of it rather than being dimmed.
// The reveal circles, packed (center.x, center.z, radius, _) in world units — one per sight
// source the driver feeds each frame, up to MAX_SOURCES. Only the first `source_count` are read,
// so the unused tail never reveals.
const int MAX_SOURCES = 64;
uniform vec4 fog_sources[MAX_SOURCES];
uniform int source_count = 0;
// The fog tint and how opaque it sits over unseen ground, plus the soft inner band: the sheet
// fades from clear at a circle's edge to full fog `fog_edge` units outside it, so the reveal has
// a feathered rim rather than a hard ring.
uniform vec3 fog_color : source_color = vec3(0.02, 0.03, 0.06);
uniform float fog_alpha : hint_range(0.0, 1.0) = 0.82;
uniform float fog_edge = 220.0;
varying vec3 world_pos;
void vertex() {
world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
}
void fragment() {
// The signed distance to the nearest reveal edge: negative inside a circle, positive in fog.
float nearest = 1.0e9;
for (int i = 0; i < source_count; i++) {
nearest = min(nearest, distance(world_pos.xz, fog_sources[i].xy) - fog_sources[i].z);
}
// No sources yet (pre-match, before a hero spawns) means no vision data — leave the field
// clear rather than blacking it out, so the menu backdrop and the first frame read normally.
float alpha = source_count == 0 ? 0.0 : fog_alpha * smoothstep(0.0, fog_edge, nearest);
ALBEDO = fog_color;
ALPHA = alpha;
}
added src/client/fog.gdshader.uid
@@ -0,0 +1 @@
uid://dc0gdh7mchclj
added src/client/fog_overlay.gd
@@ -0,0 +1,74 @@
class_name FogOverlay
extends RefCounted
## The fog-of-war sheet drawn over the playfield: a dark plane spanning the arena that dims
## everywhere the player's team cannot see, cleared in a circle around each of the team's sight
## sources. It is presentation only — which enemies are hidden, and which never reach a remote
## client at all, is decided authoritatively by Vision and the snapshot filter; this just tints
## the unseen ground so the fog reads. Built once with the scene (skipped on a headless run, which
## has no display) and fed the live reveal circles each tick by `main.gd`.
##
## The reveal set comes straight from Vision.sight_sources, the same circles the server's snapshot
## filter uses, so the lit ground matches exactly which units are sent — a unit appears the instant
## its position enters a lit circle.
const FOG_SHADER: Shader = preload("res://src/client/fog.gdshader")
## Must match the fog shader's MAX_SOURCES. A 3v3 team fields well under this — up to three heroes,
## a dozen lane creeps, and five structures, ~20 sources — so the cap only ever clips a pathological
## case, in which the surplus sources simply go undrawn (a touch more fog), never an error.
const MAX_SOURCES := 64
## The sheet's height above the ground: above MapView's flat lane/river/bridge decor so the field
## dims under it, but well below the 3D bodies (heroes, towers) so a unit standing in a lit circle
## rises in front of the fog rather than being tinted by it.
const FOG_Y := 10.0
var _material: ShaderMaterial
## Builds the fog plane under `parent` and returns the overlay holding its material. Call once,
## after the ground and map decor exist; the plane covers the whole arena at a fixed lift.
static func build(parent: Node3D) -> FogOverlay:
var fog := FogOverlay.new()
fog._material = ShaderMaterial.new()
fog._material.shader = FOG_SHADER
var mesh := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = MapData.BOUNDS.size
mesh.mesh = plane
var center := MapData.BOUNDS.get_center()
mesh.position = Vector3(center.x, FOG_Y, center.y)
mesh.material_override = fog._material
# Span the whole map, so a reveal circle near the rim is never clipped by the camera frustum
# culling the plane when the hero is across the arena.
mesh.extra_cull_margin = MapData.BOUNDS.size.length()
parent.add_child(mesh)
return fog
## Applies fog of war to the drawn world for `team`: dims the ground that team cannot see (every
## mode) and hides the enemy bodies standing in that fog (`hide_fogged`, set only with local
## authority — a pure CLIENT already receives a snapshot filtered to its team, so every entity it
## holds is one it can see). `views` is the renderer's id->view pool. Friendlies are always visible,
## so the hide pass only ever drops enemies, layered over the renderer's own dead-hero hide.
func apply(state: SimState, team: int, views: Dictionary, hide_fogged: bool) -> void:
if hide_fogged:
var visible := Vision.visible_ids(state, team)
for id in state.entities:
if not visible.has(id):
(views[id]["root"] as Node3D).visible = false
update(Vision.sight_sources(state, team))
## Feeds this tick's reveal circles to the shader: each `{center: Vector2, radius: float}` from
## Vision.sight_sources, packed as (center.x, center.y, radius, 0) in world units. Capped at
## MAX_SOURCES; an empty set leaves the field clear (no vision data yet).
func update(sources: Array) -> void:
var packed := PackedVector4Array()
for source in sources:
if packed.size() >= MAX_SOURCES:
break
var center: Vector2 = source["center"]
packed.append(Vector4(center.x, center.y, source["radius"], 0.0))
_material.set_shader_parameter("fog_sources", packed)
_material.set_shader_parameter("source_count", packed.size())
added src/client/fog_overlay.gd.uid
@@ -0,0 +1 @@
uid://cwnkacigt6c1c
modified src/client/main.gd
@@ -205,6 +205,9 @@ var _views: Dictionary = {}
## screen — built and driven as one layer by `MatchOverlays`, reconciled each tick in
## `_sync_world`. Null on a headless run (no display to draw it on).
var _overlays: MatchOverlays = null
## The fog-of-war sheet over the playfield, fed the player team's reveal circles each tick in
## `_sync_world`. Null on a headless run (no display to draw it on).
var _fog: FogOverlay = null
func _ready() -> void:
@@ -478,7 +481,9 @@ func _tick_host() -> void:
else:
team1_command = _bot.decide(_sim.state, _bot_id)
_sim.step({_hero_id: _sample_player_input(), _bot_id: team1_command})
_net.broadcast_snapshot(_sim.state, ack)
# Fog of war: the client only ever receives what its team (the remote team) can see, so an
# enemy in fog never crosses the wire — the filter is authoritative, not a render dim.
_net.broadcast_snapshot(_sim.state, ack, Vision.visible_ids(_sim.state, NetSession.REMOTE_TEAM))
## Samples local input, sends it up stamped with a sequence number, buffers it as pending,
@@ -652,6 +657,7 @@ func _build_world() -> void:
if not _is_headless():
_overlays = MatchOverlays.new()
add_child(_overlays)
_fog = FogOverlay.build(self)
## Reconciles the view pool against the live state, then trails the camera. Called each
@@ -670,6 +676,10 @@ func _sync_world() -> void:
if not state.entities.has(id):
(_views[id]["root"] as Node3D).queue_free()
_views.erase(id)
if _fog != null:
# Fog of war: dim the unseen ground and hide enemies in it. A pure CLIENT's snapshot is
# already filtered to its team, so only a local-authority world needs the enemy-hiding pass.
_fog.apply(state, _player_team(), _views, _mode != Mode.CLIENT)
for event in state.fx_events:
MatchFx.play(self, event)
for attack in state.attack_events:
modified src/net/net_protocol.gd
@@ -49,22 +49,35 @@ static func decode_input(data: Array) -> InputCommand:
return command
## Encodes the full authoritative world into a snapshot byte record: an 11-byte
## Encodes the authoritative world into a snapshot byte record: an 11-byte
## header — tick (u32), `ack` (i32), winner (i8), entity count (u16) — followed by one
## fixed entity record per entity in insertion order. `ack` is the last client input
## fixed entity record per encoded entity in insertion order. `ack` is the last client input
## sequence the server has applied (`-1` when none); the client reads it to prune and
## replay its pending inputs, and `decode_snapshot` ignores it (a transport marker, not
## world state) — `decode_snapshot_ack` reads it alone, straight from the header,
## without decoding the entities. Insertion order is preserved so the decoded world
## iterates identically to the server's — deterministic rendering. Packing the world
## this tight keeps a full creep wave inside one unreliable datagram.
static func encode_snapshot(state: SimState, ack: int = -1) -> PackedByteArray:
##
## `visible_ids` is the fog-of-war filter: when non-empty, only entities whose id is in it are
## written (and the count reflects that), so an enemy a team cannot see never crosses the wire —
## the fog is authoritative, not a client dim. Empty (the default) writes the whole world, the
## pre-fog behaviour every other caller and the round-trip tests rely on. The wire shape is
## unchanged — a filtered snapshot is just a smaller entity count — so PROTOCOL_VERSION is not
## affected: a filtered server and an unfiltered one differ only in how many rows they send.
static func encode_snapshot(
state: SimState, ack: int = -1, visible_ids: Dictionary = {}
) -> PackedByteArray:
var ids: Array = []
for id in state.entities:
if visible_ids.is_empty() or visible_ids.has(id):
ids.append(id)
var buf := StreamPeerBuffer.new()
buf.put_u32(state.tick)
buf.put_32(ack)
buf.put_8(state.winner)
buf.put_u16(state.entities.size())
for id in state.entities:
buf.put_u16(ids.size())
for id in ids:
_encode_entity(buf, state.entities[id])
return buf.data_array
modified src/net/net_session.gd
@@ -97,8 +97,12 @@ func close() -> void:
## Broadcasts the authoritative world to every client. Called once per tick by the
## host driver, after the simulation has stepped. `ack` is the sequence number of
## the remote input applied this tick, so the client can reconcile against it.
func broadcast_snapshot(state: SimState, ack: int = -1) -> void:
_push_snapshot.rpc(NetProtocol.encode_snapshot(state, ack))
## `visible_ids` is the receiving team's fog-of-war filter (see NetProtocol.encode_snapshot):
## when non-empty, only the entities that team can see are sent, so an enemy in fog never crosses
## the wire. The walking skeleton seats a single client (team 1), so the host passes that team's
## visible set; an empty filter sends the whole world.
func broadcast_snapshot(state: SimState, ack: int = -1, visible_ids: Dictionary = {}) -> void:
_push_snapshot.rpc(NetProtocol.encode_snapshot(state, ack, visible_ids))
## The last input received from `peer_id`, or null if none has arrived yet.
added src/sim/vision.gd
@@ -0,0 +1,71 @@
class_name Vision
extends RefCounted
## Per-team fog-of-war vision over the authoritative world.
##
## A team sees its own units always, plus any unit standing within the sight radius of one of
## those units — a plain radius reveal, no terrain occlusion (a wall does not block sight in v1;
## that is a later slice that can read MapData.obstacles()). Pure data over a SimState, like
## MapData and the simulation core: no engine, render, or global-state coupling, so it is shared
## by the server's snapshot filter, the client's render, and the headless tests, and replays
## identically.
##
## The server runs `visible_ids` per receiving team and only ever sends that team the entities it
## can see (NetProtocol.encode_snapshot's filter), so an enemy in fog never crosses the wire — the
## fog is authoritative, not a client dim a maphack could peel back. The renderer feeds
## `sight_sources` to the fog overlay so the lit reveal matches exactly which units are sent.
## How far each kind of unit sees, in world units. A hero scouts widest; a tower/nexus holds a
## fixed ward over its approach; a lane creep lights only its immediate front. Tuned lighter than a
## full MOBA's wards (there are none yet) so map control still rewards moving a hero up.
const HERO_SIGHT := 1400.0
const CREEP_SIGHT := 900.0
const STRUCTURE_SIGHT := 1300.0
## How far `entity` sees, or 0 for a unit that grants no vision (a pure mover, or a downed hero —
## a dead unit's ward goes dark until it respawns). The one place the per-kind radii are resolved,
## read by both `sight_sources` and `visible_ids`.
static func sight_radius(entity: SimEntity) -> float:
if entity.is_dead():
return 0.0
if entity.is_hero:
return HERO_SIGHT
if entity.is_creep:
return CREEP_SIGHT
if entity.is_structure:
return STRUCTURE_SIGHT
return 0.0
## The reveal set for `team`: one `{center, radius}` per living friendly unit that grants vision,
## in entity insertion order. Shared by the fog render (the lit circles) and `visible_ids` (the
## membership test), so what is drawn lit is exactly what is sent.
static func sight_sources(state: SimState, team: int) -> Array:
var sources: Array = []
for id in state.entities:
var entity: SimEntity = state.entities[id]
if entity.team != team:
continue
var radius := sight_radius(entity)
if radius > 0.0:
sources.append({"center": entity.position, "radius": radius})
return sources
## The ids `team` can see, as an id->true set for O(1) membership: every own-team entity always
## (you never lose sight of your own units, even a downed hero on the respawn clock), plus any
## entity whose centre lies within the radius of one of the team's sight sources. Pure and
## insertion-ordered, so the server filters every client's snapshot deterministically.
static func visible_ids(state: SimState, team: int) -> Dictionary:
var sources := sight_sources(state, team)
var visible: Dictionary = {}
for id in state.entities:
var entity: SimEntity = state.entities[id]
if entity.team == team:
visible[id] = true
continue
for source in sources:
if entity.position.distance_to(source["center"]) <= source["radius"]:
visible[id] = true
break
return visible
added src/sim/vision.gd.uid
@@ -0,0 +1 @@
uid://dkrw5noi1dwoa
added test/unit/test_fog_overlay.gd
@@ -0,0 +1,20 @@
extends GutTest
## Smoke for the fog overlay's build/update path. In the game the overlay is display-gated (skipped
## on a headless run), and its visual tint is only judged in a windowed playtest — so this proves
## the cheap, headless-safe half: the class registers, its shader resource loads, the plane is added
## to the scene, and packing the reveal circles into the shader runs without error.
func test_build_adds_the_fog_plane_and_update_runs() -> void:
var root := Node3D.new()
add_child_autofree(root)
var fog := FogOverlay.build(root)
assert_not_null(fog, "the overlay builds")
assert_eq(root.get_child_count(), 1, "the fog plane is added to the scene")
# Feed it a couple of reveal circles (a hero and a creep source) — the source-packing path.
fog.update([
{"center": Vector2(120.0, -50.0), "radius": Vision.HERO_SIGHT},
{"center": Vector2(0.0, 0.0), "radius": Vision.CREEP_SIGHT},
])
# An empty set is the pre-match case (no sight sources yet) — it must not error.
fog.update([])
added test/unit/test_fog_overlay.gd.uid
@@ -0,0 +1 @@
uid://b67nplh5uprrm
modified test/unit/test_net_protocol.gd
@@ -106,6 +106,30 @@ func test_snapshot_preserves_entity_order() -> void:
assert_eq(restored.entities.keys(), state.entities.keys())
func test_a_visibility_filter_encodes_only_the_listed_entities() -> void:
# Fog of war: the server filters each client's snapshot to the entities that team can see, so an
# enemy in fog never crosses the wire. A non-empty filter writes only its ids; the rest vanish.
var state := _populated_state()
var kept := state.entities.keys().slice(0, 2)
var visible := {}
for id in kept:
visible[id] = true
var restored := NetProtocol.decode_snapshot(NetProtocol.encode_snapshot(state, -1, visible))
assert_eq(restored.entities.size(), 2, "only the visible entities are encoded")
assert_eq(restored.entities.keys(), kept, "the kept ids survive, in order")
for id in state.entities:
if not visible.has(id):
assert_null(restored.get_entity(id), "an entity in fog is absent from the snapshot")
func test_an_empty_visibility_filter_encodes_the_whole_world() -> void:
# The default (no fog filter) must stay the pre-fog behaviour: every entity is sent, so an
# unfiltered server and the round-trip tests are unaffected.
var state := _populated_state()
var restored := NetProtocol.decode_snapshot(NetProtocol.encode_snapshot(state, -1, {}))
assert_eq(restored.entities.size(), state.entities.size(), "an empty filter sends the full world")
func test_snapshot_carries_the_winner() -> void:
var state := SimState.new()
state.winner = 1
added test/unit/test_vision.gd
@@ -0,0 +1,92 @@
extends GutTest
## Contracts for the per-team fog-of-war vision. Vision is pure data over a SimState — no engine,
## socket, or render coupling — so these run headless and deterministically, exactly like the
## simulation and map-data tests. They pin the two properties the netcode and the renderer lean on:
## a team always sees its own units, and an enemy is seen only when it stands inside a friendly
## sight source's radius — and that the rule is team-fair (mirror-symmetric).
## A bare world with the wave schedule off, so a test seats exactly the units it asserts on.
func _world() -> SimCore:
var sim := SimCore.new()
sim.spawn_creeps = false
return sim
func test_own_team_is_always_visible_even_out_of_sight_range() -> void:
var sim := _world()
var near_hero := sim.add_hero(0, Vector2.ZERO, 320.0)
# A second friendly well beyond the first's sight radius — own units are seen regardless.
var far_hero := sim.add_hero(0, Vector2(Vision.HERO_SIGHT * 4.0, 0.0), 320.0)
var visible := Vision.visible_ids(sim.state, 0)
assert_true(visible.has(near_hero), "a team always sees its own hero")
assert_true(visible.has(far_hero), "a team sees its own hero even out of every sight radius")
func test_an_enemy_is_seen_only_inside_a_sight_radius() -> void:
var sim := _world()
sim.add_hero(0, Vector2.ZERO, 320.0)
var seen := sim.add_entity(1, Vector2(Vision.HERO_SIGHT - 1.0, 0.0), 320.0, 100)
var hidden := sim.add_entity(1, Vector2(Vision.HERO_SIGHT + 1.0, 0.0), 320.0, 100)
var visible := Vision.visible_ids(sim.state, 0)
assert_true(visible.has(seen), "an enemy just inside a hero's sight is visible")
assert_false(visible.has(hidden), "an enemy just outside every sight radius stays in fog")
func test_a_creep_grants_vision() -> void:
var sim := _world()
sim.add_creep(0, 0, Vector2.ZERO)
var enemy := sim.add_entity(1, Vector2(Vision.CREEP_SIGHT - 1.0, 0.0), 320.0, 100)
assert_true(Vision.visible_ids(sim.state, 0).has(enemy), "a lane creep lights its front")
func test_a_structure_grants_vision() -> void:
var sim := _world()
sim.add_structure(0, Vector2.ZERO, 1000, 0, 0.0, 0)
var enemy := sim.add_entity(1, Vector2(Vision.STRUCTURE_SIGHT - 1.0, 0.0), 320.0, 100)
assert_true(Vision.visible_ids(sim.state, 0).has(enemy), "a tower holds a ward over its approach")
func test_a_downed_hero_grants_no_vision() -> void:
var sim := _world()
var hero := sim.add_hero(0, Vector2.ZERO, 320.0)
sim.state.get_entity(hero).respawn_ticks = 100 # downed and on the respawn clock
var enemy := sim.add_entity(1, Vector2(Vision.HERO_SIGHT - 1.0, 0.0), 320.0, 100)
var visible := Vision.visible_ids(sim.state, 0)
assert_true(visible.has(hero), "a team still sees its own downed hero")
assert_false(visible.has(enemy), "a dead hero's ward goes dark — the enemy by its body is unseen")
assert_eq(Vision.sight_sources(sim.state, 0).size(), 0, "a downed hero is not a sight source")
func test_sight_sources_lists_living_friendly_units_only() -> void:
var sim := _world()
var living := sim.add_hero(0, Vector2(120.0, -40.0), 320.0)
var downed := sim.add_hero(0, Vector2(900.0, 0.0), 320.0)
sim.state.get_entity(downed).respawn_ticks = 100
sim.add_hero(1, Vector2.ZERO, 320.0) # an enemy — never our source
var sources := Vision.sight_sources(sim.state, 0)
assert_eq(sources.size(), 1, "only the living friendly hero is a source")
var pos := sim.state.get_entity(living).position
assert_eq(sources[0]["center"], pos, "the source sits on the unit")
assert_eq(sources[0]["radius"], Vision.HERO_SIGHT, "a hero's source carries the sight radius")
func test_vision_is_team_fair_under_the_map_mirror() -> void:
# The same encounter mirrored across the y = x axis and with the teams swapped must resolve the
# same way — neither team sees farther, so fog never favours a side.
var hero_pos := Vector2(0.0, 0.0)
var enemy_pos := Vector2(500.0, 0.0) # inside HERO_SIGHT
var a := _world()
a.add_hero(0, hero_pos, 320.0)
var enemy_a := a.add_entity(1, enemy_pos, 320.0, 100)
var b := _world()
b.add_hero(1, MapData.mirror(hero_pos), 320.0)
var enemy_b := b.add_entity(0, MapData.mirror(enemy_pos), 320.0, 100)
assert_true(Vision.visible_ids(a.state, 0).has(enemy_a), "team 0 sees the enemy in range")
assert_true(
Vision.visible_ids(b.state, 1).has(enemy_b),
"the mirrored encounter resolves identically for the swapped team",
)
added test/unit/test_vision.gd.uid
@@ -0,0 +1 @@
uid://d1pyhqd5vg0qc