extends Node3D
const PlayerScene := preload("res://scenes/player/player.tscn")
const PlayerProceduralScene := preload("res://scenes/player/player_procedural.tscn")
## Toggle between classic and procedural (Overgrowth-style) player controller.
## Set via editor or at runtime before spawning.
@export var use_procedural_player: bool = true
@onready var maps_node: Node3D = $Maps
@onready var players_node: Node3D = $Players
@onready var player_spawner: MultiplayerSpawner = $PlayerSpawner
@onready var peer_label: Label = $HUD/PeerLabel
@onready var fps_label: Label = $HUD/FPSLabel
@onready var menu: Control = $MenuUI/MenuMain
@onready var lobby_camera: Camera3D = $LobbyCamera
var sky3d: Sky3DExtended = null
var current_map: Node3D = null
## Path of the currently-loaded map. Persisted to world:settings:current_map so a
## rejoining peer restores the SAME world (level) — each map is a contained world
## (maps = pages, teleporters = links). Defaults to the scene's hardcoded map.
const DEFAULT_MAP_PATH := "res://scenes/maps/map_test.tscn"
## Where a headless peer starts when nothing is declared. Separate from
## DEFAULT_MAP_PATH because the two answer different questions: one is the world
## a player opens into, the other is the cheapest scene a peer can host from.
const DEFAULT_HEADLESS_MAP_PATH := "res://scenes/maps/map_server.tscn"
## HYPER_TPS_MAP — start on a given map instead of map_test, for looking at one.
##
## ⚠ THIS EXISTS BECAUSE LOADING A MAP SCENE DIRECTLY DOES NOT WORK, and the
## failure is silent and total: the map is GREY. Sky3DExtended -- the
## WorldEnvironment, the SunLight, the MoonLight, the SkyDome -- lives in
## game.tscn, NOT in the map. Map scenes carry no environment BY DESIGN
## (map_dc_erosion.tscn is four nodes: terrain, spawn, teleporter). So
##
## dev-run.sh res://scenes/maps/map_dc_erosion.tscn <- no sky, no sun
##
## renders an unlit world against a default clear colour and looks broken when
## nothing is. Going through the shell is the only way to see a map as it is:
##
## HYPER_TPS_MAP=res://scenes/maps/map_dc_erosion.tscn dev-run.sh
##
## It only seeds the STARTING map; teleporters and the persisted
## world:settings:current_map behave exactly as before.
static func _initial_map_path() -> String:
var override := OS.get_environment("HYPER_TPS_MAP")
if override != "":
if not ResourceLoader.exists(override):
push_error("[Game] HYPER_TPS_MAP=%s does not exist -- starting on %s"
% [override, DEFAULT_MAP_PATH])
return DEFAULT_MAP_PATH
print("[Game] HYPER_TPS_MAP -> %s" % override)
return override
return DEFAULT_MAP_PATH
## The map a HEADLESS peer starts in.
##
## Same declaration as the graphical path -- HYPER_TPS_MAP -- deliberately, and
## not a second variable: two ways to say the same thing is how the headless and
## graphical branches drifted apart in the first place. Undeclared, this is the
## lightweight server map, so an existing headless peer behaves exactly as it did.
##
## A declared map that does not exist falls back rather than loading nothing: a
## peer-bot with no map is a peer that cannot host the bots it was started for,
## and the typo should not be discovered as an empty world.
static func _headless_map_path() -> String:
var declared := OS.get_environment("HYPER_TPS_MAP")
if declared != "":
if ResourceLoader.exists(declared):
return declared
push_error("[Game] HYPER_TPS_MAP=%s does not exist -- headless starting on %s"
% [declared, DEFAULT_HEADLESS_MAP_PATH])
return DEFAULT_HEADLESS_MAP_PATH
var current_map_path: String = _initial_map_path()
var spawn_points: Array[Node3D] = []
## The spawn points that exist NOW, read from the group at the moment of use. Spawn
## points are objects since 2026-10-05 (scene_objects.gd base objects): deleting one
## detaches it from the tree and so from the group, and placing one adds it — the
## `spawn_points` array above is a snapshot from game start / the last map change and
## would keep spawning players at a deleted point.
func _live_spawn_points() -> Array[Node3D]:
var out: Array[Node3D] = []
if not is_inside_tree():
return out
for n in get_tree().get_nodes_in_group("spawn_points"):
if n is Node3D and (n as Node3D).is_inside_tree():
out.append(n as Node3D)
return out
var _spawn_index: int = 0
var _game_active := false
var build_version: String = "dev"
var _net_overlay: RichTextLabel = null
var _net_timer: Timer = null
var _log_file: FileAccess = null
var _peer_keys: Dictionary = {} # peer_id → PackedByteArray (public key)
## dhtKey hex → true, for peers already handed to swarm_join_peer. Log bookkeeping
## only: the GDExtension is idempotent, this just keeps one line per peer instead
## of one per presence change.
var _armed_peer_keys: Dictionary = {}
## The daemon's most recent error, quoted verbatim if room state times out.
var _last_state_error: String = ""
var _sky_debug_panel: PanelContainer = null
var _screenshot_label: Label = null
## Benchmark stats — per-peer RTT samples and position error measurements.
var benchmark_active: bool = false
var _benchmark_start_time: float = 0.0
var _benchmark_stats: Dictionary = {} # peer_id → {rtt_samples: Array, pos_errors: Array, sync_count: int}
## Interest management — tiered update rates based on distance
## Each tier: [max_distance, replication_interval]
var interest_tiers: Array = [
[500.0, 0.05], # Near: full rate (20Hz)
[1500.0, 0.2], # Medium: 5Hz
[3000.0, 0.5], # Far: 2Hz
]
var interest_max_range: float = 3000.0 # Beyond this = hidden
var interest_enabled: bool = true
func _log(msg: String) -> void:
print(msg)
if not _log_file:
if FileAccess.file_exists("/tmp/hypertps-game.log"):
_log_file = FileAccess.open("/tmp/hypertps-game.log", FileAccess.READ_WRITE)
if _log_file:
_log_file.seek_end()
else:
_log_file = FileAccess.open("/tmp/hypertps-game.log", FileAccess.WRITE)
if _log_file:
_log_file.seek_end()
_log_file.store_line("%s %s" % [Time.get_datetime_string_from_system(), msg])
_log_file.flush()
func _ready() -> void:
# Set default offline peer so MultiplayerSpawner/Synchronizer don't error
# before a real peer is assigned via Host/Join/Home
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
# Says when edits are NOT being saved (daemon gone, or a write refused) instead
# of losing them silently on quit. docs/home-world-design.md §4.
$HUD.add_child(preload("res://scenes/game/save_banner.gd").new())
# Read build version. HYPER_TPS_BUILD wins, because a dev run has no
# version.txt and reported a bare "dev" -- which cannot answer the only
# question anyone asks of it: am I looking at the code I just changed?
# scripts/dev-run.sh sets it to the git HEAD plus a -dirty marker, and it also
# reaches the Network HUD, so a screenshot names the commit it was taken on.
var vf := FileAccess.open("res://version.txt", FileAccess.READ)
if vf:
build_version = vf.get_as_text().strip_edges()
if OS.has_environment("HYPER_TPS_BUILD"):
build_version = OS.get_environment("HYPER_TPS_BUILD")
print("[HyperTPS] Build: %s HTPS_MODELLOG=%s" % [build_version,
"ON" if OS.has_environment("HTPS_MODELLOG") else "off"])
# Long frames, timed by phase, to user://hitches.log (scenes/game/hitch_log.gd).
# On by default: it writes only when a frame is a hitch, plus a 10 s summary.
if OS.get_environment("HTPS_HITCHLOG") != "0" and get_tree().root.get_node_or_null(^"HitchLog") == null:
get_tree().root.add_child.call_deferred(preload("res://scenes/game/hitch_log.gd").new())
# Headless: load the DECLARED map, defaulting to the lightweight server map.
#
# This used to hardcode map_server, which spec/bot-testbed.md (e1) names as an
# obligation of the containment work: a peer-bot's map must be a declaration
# rather than a branch, or the process that runs every owned bot cannot say
# which world it is in, and reaches map_flat only if a map change happens to
# replicate to it.
if DisplayServer.get_name() == "headless":
var headless_path := _headless_map_path()
for child in maps_node.get_children():
child.queue_free()
var headless_map := load(headless_path)
if headless_map:
current_map = headless_map.instantiate()
maps_node.add_child(current_map)
# ⚠ THE NAMESPACE FOLLOWS THE LOADED MAP. This assignment was missing
# and its absence was silent: current_map_path still held
# _initial_map_path() while map_server sat in the tree, and the
# GameState.current_map_id seeded a dozen lines below is derived from
# it. Every map-scoped key on a headless peer -- world:bots: and
# world:creatures: among them -- was namespaced to a map that was not
# loaded. Bot containment is map-scoped, so this landed on exactly the
# peer the bots run on.
current_map_path = headless_path
print("[Game] Headless mode — loaded %s (no rendering)" % headless_path)
else:
push_error("[Game] headless map %s failed to load" % headless_path)
# Track current map and collect spawn points
if not current_map and maps_node.get_child_count() > 0:
current_map = maps_node.get_child(0)
# Seed the per-map state namespace with the default/hardcoded map.
GameState.current_map_id = GameState.map_id_from_path(current_map_path)
spawn_points.assign(get_tree().get_nodes_in_group("spawn_points"))
# Connect multiplayer signals
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
# Durable membership drives dialing. Presence replicates, so an entry can land
# for a member this peer has never connected to -- which is the whole point:
# a restarted peer has met nobody and must still be able to dial out.
if Presence:
Presence.presence_changed.connect(_on_presence_changed)
# A peer announcing a map change has to reach us as an event: world:settings:
# is read once at game start and nothing subscribes to it, which is why a live
# peer never followed a map change in the first place.
GameState.state_changed.connect(_on_player_map_changed)
# Keep the daemon's last complaint, so a timeout can quote it instead of
# guessing. One string: this is for a diagnosis, not a history.
if KeetService != null and KeetService.game_state != null:
KeetService.game_state.error_occurred.connect(
func(msg: String) -> void: _last_state_error = msg)
# Define world state routes — Keet room roles gate durable writes, and
# author-scoped content adds a per-peer ownership gate (GameState.can_write).
#
# ONE LINE, because the table is the declaration now. This used to be fifteen
# route() calls whose channels and roles had to agree with MAP_SCOPED_PREFIXES
# and AUTHOR_SCOPED_PREFIXES in game_state.gd, with nothing checking that they
# did — and both ways of disagreeing are silent. Adding world:sky: meant
# remembering all three; world:level: was wrong in two of them at once. The
# prose that lived here now sits beside the row it explains, in
# GameState.STATE_ROUTES, and state_routes_gate asserts the rows are complete.
GameState.install_routes()
# Admin-controllable subsystem wiring. Each subsystem opts in via a
# small registration block here rather than touching addon source.
# Format: pick the node, add to "admin_controllable" group, set
# admin_name (short label) and admin_exposed_methods (opt-in methods).
# @export properties are auto-exposed; everything else requires
# explicit set_meta("admin_exposed:<name>", true).
var sky_node: Node = get_node_or_null("Sky3DExtended")
if sky_node != null:
sky_node.add_to_group("admin_controllable")
sky_node.set_meta("admin_name", "sky")
# Sky3D bug workaround: TimeOfDay.current_time's setter doesn't
# call _update_celestial_coords() — the sun/moon position only
# recomputes via _process() when game_time_enabled is true, OR
# when day/month/year setters fire. Our scene has both
# game_time_enabled=false and editor_time_enabled=false, so a
# direct current_time = X via AdminFacade emits time_changed
# but doesn't visually move the sun. Hook time_changed here to
# force the recompute on every set.
var tod: Node = sky_node.get_node_or_null("TimeOfDay")
if tod != null and tod.has_signal("time_changed") and tod.has_method("_update_celestial_coords"):
if not tod.time_changed.is_connected(_on_sky_time_changed.bind(tod)):
tod.time_changed.connect(_on_sky_time_changed.bind(tod))
print("[game] registered sky node for admin control")
# Creatures are bots (#114 phase 5): BotRegistry builds, ticks and draws their bodies,
# map-bound, so nothing per-map is attached here any more.
# Terrain edit manager — routes edits through GameState for persistence + sync
var terrain_mgr := TerrainEditManager.new()
terrain_mgr.name = "TerrainEditManager"
add_child(terrain_mgr)
# HUD starts showing placeholder
peer_label.text = "Peer: ---"
# Load chat overlay
var ChatScene := load("res://packs/keet_chat/chat_overlay.tscn") as PackedScene
if ChatScene:
var chat_overlay := ChatScene.instantiate()
$HUD.add_child(chat_overlay)
print("[Game] Loaded Keet Chat overlay")
# Network debug overlay (top-right, always visible)
_create_net_overlay()
# Interest management timer (evaluate every 2 seconds)
interest_enabled = Settings.interest_management
var interest_timer := Timer.new()
interest_timer.wait_time = 2.0
interest_timer.timeout.connect(_update_interest)
add_child(interest_timer)
interest_timer.start()
# Set up Sky3DExtended (day/night cycle, clouds, stars)
_setup_sky()
# Apply saved graphics settings
Settings.apply_settings()
func _setup_sky() -> void:
# Reuse the Sky3DExtended that's been placed in the scene via the editor.
# game.gd does NOT create one at runtime — that path caused duplicate
# SunLights when an editor-added Sky3DExtended also existed.
var existing := get_node_or_null("Sky3DExtended")
if existing is Sky3DExtended:
sky3d = existing
else:
push_warning("[Game] No Sky3DExtended in scene — sky/lighting disabled. Add a Sky3DExtended node to game.tscn.")
return
# In headless --dedicated mode, Sky3DExtended's shader children allocate
# Vulkan RIDs against a null GPU backend, producing endless
# "Attempted to free invalid ID" double-free errors that crash the
# engine (and the parent launcher with it). KEEP the parent node so
# AdminFacade can index it and /world set sky.current_time still
# routes state through state-base — but kill the heavy children
# (Atmosphere/SkyDome/Clouds/SunLight/MoonLight) that own the RIDs.
# TimeOfDay (a logical timer) stays so current_time has somewhere to live.
if DisplayServer.get_name() == "headless":
var doomed_children: Array[String] = [
"Atmosphere", "SkyDome", "Sky", "Clouds", "CloudVolume",
"SunLight", "MoonLight", "Sun", "Moon", "Environment",
"WorldEnvironment", "DirectionalLight3D"
]
for cname in doomed_children:
var c := existing.get_node_or_null(cname)
if c != null:
c.queue_free()
# Also strip the env on the Sky3DExtended itself so apply_environment
# won't recreate sky shaders.
if "environment" in existing:
existing.environment = null
push_warning("[Game] Headless mode — Sky3DExtended kept (TimeOfDay only); rendering children freed to prevent RID leak")
return
# All Sky3D properties (current_time, minutes_per_day, update_interval,
# environment.sky.process_mode) are taken from the editor-configured
# Sky3DExtended node. game.gd does not override them.
print("[Game] Sky3DExtended found (editor-configured)")
# The dock is built and the sky resolved — register the atmosphere panel now
# rather than on first entry to build mode, so the section exists the moment
# you get there instead of appearing a frame late.
_register_editor_panels()
func _unhandled_input(event: InputEvent) -> void:
if Keymap.tapped(event, "global.screenshot"):
_take_screenshot()
## Put the atmosphere controls in the editor dock, once, the first time build mode
## is entered.
##
## WAS a raw `KEY_F2` match in _unhandled_input, which had three problems and only
## the first was visible: F2 is `global.mode_play` in the keymap, so opening the
## sky panel ALSO dropped you out of build mode — undeclared, so the boot conflict
## check never saw it. It anchored itself top-left over the viewport. And it flipped
## Input.mouse_mode by hand, which is now GameMode's job.
##
## As a dock section it needs no key at all: it is there while building, and its
## header collapses it. That hands F2 back to mode_play, whole.
func _register_editor_panels() -> void:
if BuildDock.has_panel("atmosphere"):
return
# LOUD, not silent. Every step here can fail in a way that leaves the dock
# simply missing a section, which is indistinguishable from a dock that never
# rendered — and that cost two round trips of "I don't see the panel".
if sky3d == null:
push_warning("[Game] no Sky3DExtended — no Atmosphere panel. _setup_sky() " +
"returns early in headless AND when the node is absent from game.tscn.")
return
var SkyDebugPanel := load("res://scenes/game/sky_debug_panel.gd")
if SkyDebugPanel == null:
push_error("[Game] sky_debug_panel.gd failed to LOAD — no Atmosphere panel.")
return
_sky_debug_panel = PanelContainer.new()
_sky_debug_panel.set_script(SkyDebugPanel)
# Build the UI BEFORE it enters the tree — OptionButton popups emit signals on
# entry and error if their handlers are not wired yet.
_sky_debug_panel.setup(sky3d)
# OPEN by default. It is a parametric panel — the thing you came to the editor
# to turn — and a collapsed one reads as absent. The dock caps the column
# against the window and scrolls the overflow, so "tall" is no longer a reason
# to hide it. Reference material (Hotkeys) is what starts collapsed.
BuildDock.register("atmosphere", "Atmosphere", _sky_debug_panel, true)
func _take_screenshot() -> void:
var img := get_viewport().get_texture().get_image()
var dir_path := "user://screenshots"
DirAccess.make_dir_recursive_absolute(dir_path)
var timestamp := Time.get_datetime_string_from_system().replace(":", "-").replace("T", "_")
var path := dir_path + "/screenshot_" + timestamp + ".jpg"
img.save_jpg(path, 0.9)
var global_path := ProjectSettings.globalize_path(path)
print("[Screenshot] Saved: %s" % global_path)
_show_screenshot_notification(global_path)
func _show_screenshot_notification(path: String) -> void:
if _screenshot_label:
_screenshot_label.queue_free()
_screenshot_label = Label.new()
_screenshot_label.text = "Screenshot saved"
_screenshot_label.add_theme_font_size_override("font_size", 18)
_screenshot_label.add_theme_color_override("font_color", Color(1, 1, 1))
_screenshot_label.anchor_left = 0.5
_screenshot_label.anchor_right = 0.5
_screenshot_label.anchor_top = 0.85
_screenshot_label.grow_horizontal = Control.GROW_DIRECTION_BOTH
_screenshot_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
$HUD.add_child(_screenshot_label)
var tween := create_tween()
tween.tween_interval(1.5)
tween.tween_property(_screenshot_label, "modulate:a", 0.0, 0.5)
tween.tween_callback(_screenshot_label.queue_free)
func _process(_delta: float) -> void:
fps_label.text = "FPS: %d" % Engine.get_frames_per_second()
# In build mode the peer / FPS lines sit in the 3D view's corner, not over the editor's
# areas (hyper-games #124; BuildDock.view_rect() is the whole window in play mode).
# Only these two move: the HUD layer also holds the crosshair, which stays centred.
var corner: Vector2 = BuildDock.view_rect().position if BuildDock != null else Vector2.ZERO
peer_label.position = corner + Vector2(10, 10)
fps_label.position = corner + Vector2(10, 30)
if benchmark_active:
_collect_benchmark_stats()
## Called by menu_main.gd when the player starts the game (Resume, Host, or Join).
func on_game_started() -> void:
if _game_active:
return
_game_active = true
var my_id := multiplayer.get_unique_id()
peer_label.text = "Peer: %d" % my_id
_log("[Game] on_game_started id=%d" % my_id)
# Load persistent world state from Autobase (terrain edits, creature registry,
# world settings, saved positions). Listeners process state_changed as it arrives.
GameState.reset_loaded()
GameState.load_room_state()
# Arm the swarm against everyone the room knows about, before any spawn logic
# and before the read-only return below — a spectator still has to mesh.
_rearm_known_peers()
# Say which map we are in, and take stock of who else is where. Before the
# read-only return: a spectator still needs to not see bodies from another map.
_publish_my_map()
_update_map_visibility()
# Read-only users are spectators — no player spawn, keep lobby camera.
var role: String = KeetService.current_role if KeetService else "peer"
if role == "read-only":
_log("[Game] read-only mode — spectating (no player spawn)")
return
# Spawn the LOCAL player IMMEDIATELY so a joiner is never frozen on the lobby
# camera while durable room state loads. A cold daemon's first state.list can
# take ~25s, and blocking the spawn behind it made the joining peer look stuck
# (player never appears, lobby camera frozen). The player lands on the current
# (default) map; the world restore below moves it to the saved map + position
# once state arrives — change_map relocates existing players, so this is safe.
# Mesh mode: each peer spawns only its own player (no centralized authority).
_spawn_player_for(my_id)
lobby_camera.current = false
# Announce to any already-connected peers so they create our player.
for id in multiplayer.get_peers():
_announce_player.rpc_id(id, my_id)
# Restore the WORLD asynchronously (does NOT block spawn): wait (bounded) for
# durable state, switch to the saved map, then re-arm a one-shot position
# restore. The map switch fires map_changing, which PlayerPosition would
# otherwise treat as a teleport and suppress — so a rejoining peer still lands
# at its saved position once the correct map is active.
await _await_room_state()
await _restore_saved_map()
PlayerPosition.restore_after_cold_join()
# A SLOW room (#131: 16.4 s to open): the world is restored once its state arrives.
if not bool(GameState.room_state_loaded):
_restore_when_loaded()
## The late half of the world restore: wait (bounded, 120 s) for the room's state, then
## switch to its saved map and position — or seed a default only now that the setting is
## KNOWN to be absent (_may_seed_world).
func _restore_when_loaded() -> void:
var deadline := Time.get_unix_time_from_system() + 120.0
while Time.get_unix_time_from_system() < deadline:
if not is_inside_tree():
return
if bool(GameState.room_state_loaded):
_log("[Game] room state arrived late — restoring the world now")
await _restore_saved_map()
PlayerPosition.restore_after_cold_join()
return
await get_tree().process_frame
_log("[Game] room state never loaded (120 s) — the world is NOT restored and NOTHING was seeded")
## Wait (bounded) for the first durable room-state load so saved map + position
## are available before spawn. Proceeds on timeout (fresh world / daemon offline).
func _await_room_state() -> void:
var gs := get_node_or_null("/root/GameState")
if gs == null:
return
# MEASURED, not guessed. The previous budget was 25 s, justified by a comment
# claiming a cold room open takes 15-20 s. Timed against a real daemon and a
# 1014-key room:
#
# cold daemon boot -> UDS socket 1.40 s
# first state-list (opens the room) 1.18 s
# subsequent state-list (warm) 0.05 s
#
# So the operation is ~2.6 s cold, and 8 s is already 3x headroom. The old
# figure was not merely generous, it was harmful: a load that is genuinely
# BROKEN sat for 25 s looking like a load that was merely slow, and the only
# signal was a line saying it had given up.
#
# Report how long it actually took, so drift shows up as a number rather than
# as a feeling that startup got worse.
# Only errors from THIS wait count: a stale one (a set_value refused before any room
# was selected) was reported as the fault (#131).
_last_state_error = ""
var t0 := Time.get_unix_time_from_system()
var deadline := t0 + 8.0
while Time.get_unix_time_from_system() < deadline:
if gs.room_state_loaded:
var dt := Time.get_unix_time_from_system() - t0
if dt > 3.0:
_log("[Game] room state loaded in %.1fs — slower than the ~2.6s cold path" % dt)
return
await get_tree().process_frame
_log("[Game] room-state load TIMED OUT after 8s — proceeding with defaults. " +
"The cold path measures ~2.6s, so this is a fault, not slowness. " +
_observe_room_state_fault())
## What the daemon looks like right now, in one line.
##
## Gathers the observations and hands them to the pure describer below, so the
## rule that turns them into a diagnosis can be gated without standing up a
## daemon — that rule is the only part of this that can be wrong in a way that
## costs anything.
func _observe_room_state_fault() -> String:
var ks := get_node_or_null("/root/KeetService")
if ks == null:
return "KeetService autoload absent — nothing could have loaded room state."
var uds: bool = ks.client != null and bool(ks.client.is_connected)
var room: String = String(ks.get_current_room()) if ks.has_method("get_current_room") else ""
var rooms: int = (ks.get_rooms() as Dictionary).size() if ks.has_method("get_rooms") else -1
var polls: int = 0
if ks.game_state != null:
polls = int(ks.game_state.get("_consecutive_disconnect_polls"))
return _describe_room_state_fault(uds, room, rooms, polls, _last_state_error)
## Turn those observations into a specific diagnosis.
##
## ⛔ THE MESSAGE USED TO NAME TWO CANDIDATES AND DISTINGUISH NEITHER: "(daemon
## down, room not opening)". It fired once on stortron after a pgh app update and
## four controlled attempts failed to reproduce it — three cold restarts and one
## under heavy tree-read I/O all loaded in under 3s — so the single occurrence had
## to carry its own evidence, and it did not.
##
## The empty-room case is listed first because it is the cheapest thing to be
## wrong and the old message did not mention it at all: KeetGameState._poll_state
## returns immediately when current_room is empty, so state_loaded can never fire
## and the wait is guaranteed to burn its whole budget.
static func _describe_room_state_fault(uds_connected: bool, room_id: String,
room_count: int, disconnect_polls: int, last_error: String) -> String:
if not uds_connected:
return ("FAULT: the keet-daemon UDS is not connected%s — the daemon is down "
+ "or never finished booting.") % (
"" if disconnect_polls <= 0 else " (%d polls skipped while disconnected)" % disconnect_polls)
if room_id == "":
return ("FAULT: no room is selected, so _poll_state returns before it ever "
+ "calls state.list — room state could not load, and would not have "
+ "loaded given any amount of time.")
if room_count == 0:
return ("FAULT: the daemon answered but lists 0 rooms, so room '%s' is not "
+ "open on it.") % room_id.left(16)
if last_error != "":
return "FAULT: the daemon reported an error: %s" % last_error
return ("FAULT: UDS up, room '%s' selected, %d rooms listed and NO ERROR "
+ "reported — state.list was issued and never answered. Suspect the "
+ "room's autobase not opening.") % [room_id.left(16), room_count]
## MAY THE DEFAULT WORLD BE SEEDED? (hyper-games #131) Only when the room's state has
## LOADED and so the setting is known to be absent — or with no room at all (offline:
## local state, nothing shared to overwrite). A timed-out load is UNKNOWN, not absent:
## seeding then overwrote a slow room's saved map, as admin, for everyone.
static func _may_seed_world(room_selected: bool, state_loaded: bool) -> bool:
return not room_selected or state_loaded
## Switch to the saved map (world:settings:current_map) before spawning, so a
## rejoining peer lands in the same world (level) with its terrain edits.
func _restore_saved_map() -> void:
var saved: Variant = GameState.get_state("world:settings:current_map")
if not (saved is Dictionary and saved.has("path")):
# Fresh room (no saved map yet): SEED the default world ONCE so the room is
# self-describing from its first session — a later joiner, a headless
# --daemon-only peer, or an agent probing over UDS reads
# world:settings:current_map without a prior map change/teleport. This is
# the world-state INIT and it happens exactly once: admin-gated (only the
# creator can write world:settings:*), and every joiner already has a saved
# map synced from the shared StateBase so they never reach this branch.
# We do NOT reload (we're already on current_map_path) — only persist the
# setting. Idempotent by construction: runs solely while the setting is
# ABSENT, so accumulated changes are never re-initialized (joins sync the
# persistent state; they do not re-seed a default).
var ks := get_node_or_null("/root/KeetService")
var room_selected: bool = ks != null and ks.has_method("get_current_room") and String(ks.get_current_room()) != ""
if not _may_seed_world(room_selected, bool(GameState.room_state_loaded)):
_log("[Game] NOT seeding the default world: the room's state has not loaded, so its saved map is unknown, not absent (#131)")
return
if GameState.can_write("world:settings:current_map"):
GameState.set_state("world:settings:current_map", {"path": current_map_path, "ts": Time.get_unix_time_from_system()})
_log("[Game] seeded default world (current_map=%s)" % current_map_path)
return
var path: String = str(saved["path"])
if path == "" or path == current_map_path:
return
# ⚠ HYPER_TPS_MAP WINS OVER THE SAVED MAP. Without this the override seeds
# current_map_path and is then immediately overridden by the room's persisted
# world:settings:current_map -- measured: the game logged
# "HYPER_TPS_MAP -> map_dc_erosion" and then loaded map_test anyway, which is
# a flag that looks like it worked and did not. It is a dev affordance for
# LOOKING at a map, so it outranks the restore for this session only; nothing
# is written back, so the room's saved map is untouched.
if OS.get_environment("HYPER_TPS_MAP") != "":
_log("[Game] HYPER_TPS_MAP set -- not restoring saved map %s" % path)
return
_log("[Game] restoring saved map: %s" % path)
# ZERO = land on that map's spawn point (see change_map), not its origin —
# (0,2,0) is inside the terrain on map_mountain. restore_after_cold_join then
# moves us to our saved position if we have one.
await change_map(path, Vector3.ZERO)
## Reset game state so it can be restarted (e.g., when yielding host to another peer).
func reset_for_rejoin() -> void:
PlayerPosition.detach_local_player()
for child in players_node.get_children():
child.queue_free()
_game_active = false
_spawn_index = 0
# Safe to drop now. This clear used to be the third way a peer forgot how to
# dial (alongside the in-memory table and process restart) because _peer_keys
# was the ONLY record of anyone's key. Durable presence is that record now, and
# on_game_started re-arms from it, so this is back to being what it reads as:
# dropping a live peer_id mapping that no longer means anything.
_peer_keys.clear()
# Cleared as well so the re-arm logs honestly against whatever peer object the
# next session builds — the explicit marks live in the GDExtension, and a new
# peer instance has none of them.
_armed_peer_keys.clear()
lobby_camera.current = true
peer_label.text = "Peer: ---"
## Sky3D addon bug workaround: TimeOfDay.current_time's setter doesn't
## call _update_celestial_coords() on its own (only via _process when
## game_time_enabled is true). Hook time_changed so any direct set —
## including from AdminFacade — visibly moves the sun.
func _on_sky_time_changed(_time: float, tod: Node) -> void:
if tod != null and tod.has_method("_update_celestial_coords"):
tod._update_celestial_coords()
func _on_peer_connected(id: int) -> void:
_log("[Game] peer_connected id=%d game_active=%s peer_count=%d" % [id, _game_active, multiplayer.get_peers().size()])
# Store peer's public key for event-driven reconnection.
var mp = multiplayer.multiplayer_peer
if mp and mp.has_method("get_peer_public_key"):
var pk: PackedByteArray = mp.get_peer_public_key(id)
if pk.size() == 32:
_peer_keys[id] = pk
_log("[Game] stored pk for peer %d: %s" % [id, pk.hex_encode().substr(0, 8)])
if _game_active:
# Announce ourselves so the new peer creates a player for us.
_announce_player.rpc_id(id, multiplayer.get_unique_id())
## Any peer announces itself — all recipients create that player locally.
@rpc("any_peer", "reliable")
func _announce_player(peer_id: int) -> void:
_log("[Game] _announce_player peer_id=%d" % peer_id)
if _game_active:
_spawn_player_for(peer_id)
## Where each peer is, so a body is only drawn in the map its owner is in.
const KEY_PLAYER_MAP := "player:map:"
## Announce which map WE are in. Called wherever current_map_path changes.
func _publish_my_map() -> void:
if not _game_active:
return
GameState.set_state(KEY_PLAYER_MAP + str(multiplayer.get_unique_id()),
GameState.map_id_from_path(current_map_path))
## Is this peer in the same map as us?
##
## ⚠ AN UNKNOWN MAP COUNTS AS SHARED. A peer running a build that does not
## publish player:map: would otherwise become invisible to everyone the day this
## ships — a worse failure than the one being fixed, and a silent one.
func _peer_shares_our_map(peer_id: int, my_map: String = "") -> bool:
if my_map == "":
my_map = GameState.map_id_from_path(current_map_path)
var theirs: Variant = GameState.get_state(KEY_PLAYER_MAP + str(peer_id))
if theirs == null or String(theirs) == "":
return true
return String(theirs) == my_map
## Hide the bodies whose owners are somewhere else.
##
## Separate from _update_interest, and deliberately: interest is a DISTANCE
## optimisation that can be switched off with interest_enabled, and "you are in
## another world" must not be switchable with it. Interest asks this before it
## shows anyone, so the two cannot contradict each other.
func _update_map_visibility() -> void:
if players_node == null or not is_instance_valid(players_node):
return
var my_id := multiplayer.get_unique_id()
var my_map := GameState.map_id_from_path(current_map_path)
for child in players_node.get_children():
var peer_id: int = child.name.to_int()
if peer_id == my_id:
continue
var shares := _peer_shares_our_map(peer_id, my_map)
if child is Node3D:
(child as Node3D).visible = shares
# A body you cannot see must not be one you can walk into.
if child is CollisionObject3D:
(child as CollisionObject3D).process_mode = \
Node.PROCESS_MODE_INHERIT if shares else Node.PROCESS_MODE_DISABLED
func _on_player_map_changed(key: String, _value: Variant, _channel: int) -> void:
if key.begins_with(KEY_PLAYER_MAP):
_update_map_visibility()
## Presence changed — a member joined, left, or published a key. Re-arm.
func _on_presence_changed(_presence_map: Dictionary) -> void:
_rearm_known_peers()
## Mark every known room member as an EXPLICIT swarm peer.
##
## ⛔ THIS IS NOT A RETRY LOOP, AND IT MUST NOT BECOME ONE. The retry already
## exists one layer down, in the GDExtension. hyperswarm-peer's
## SwarmManager::join_peer() sets is_explicit, and _select_timer() routes an
## explicit peer whose attempts exceed 3 to the long retry timer while a
## NON-explicit peer gets nullptr and is dropped. update_priority() then pins an
## explicit peer at attempts = 3 for ever, so it retries indefinitely. Calling
## swarm_join_peer once per peer is the correct idiom — it is JS hyperswarm's
## joinPeer, which is also called once.
##
## hyper-games #77 item 2 reads the single call in _on_peer_disconnected as the
## bug ("nothing retries"). It is not. What was missing was the KEY: public keys
## were learned only in _on_peer_connected, held in the in-memory _peer_keys, and
## cleared by reset_for_rejoin, so after a restart there was nothing to call
## swarm_join_peer WITH and the retry ladder was never armed for anybody.
##
## Idempotent by construction: join_peer skips a peer that is already connected
## or connecting, so this is safe to call on every presence change.
func _rearm_known_peers() -> void:
if not Presence:
return
var mp := multiplayer.multiplayer_peer
if mp == null or not mp.has_method("swarm_join_peer"):
return
var keys: Array[String] = Presence.get_peer_dht_keys()
for pk_hex: String in keys:
var pk: PackedByteArray = pk_hex.hex_decode()
if pk.size() != 32:
continue
mp.swarm_join_peer(pk)
# Log only the first time we arm a key, or a busy room repeats this line
# on every presence change for every member.
if not _armed_peer_keys.has(pk_hex):
_armed_peer_keys[pk_hex] = true
_log("[Game] armed swarm peer from presence: %s" % pk_hex.substr(0, 8))
func _on_peer_disconnected(id: int) -> void:
_log("[Game] peer_disconnected id=%d" % id)
_remove_player(id)
# Event-driven reconnection: immediately tell swarm to reconnect to this peer.
if _game_active and _peer_keys.has(id):
var pk: PackedByteArray = _peer_keys[id]
var mp = multiplayer.multiplayer_peer
if mp and mp.has_method("swarm_join_peer"):
_log("[Game] swarm_join_peer for reconnect: %s" % pk.hex_encode().substr(0, 8))
mp.swarm_join_peer(pk)
## Spawn a player node directly (bypasses MultiplayerSpawner — mesh mode).
## Each peer is authority over its own player; MultiplayerSynchronizer handles sync.
func _spawn_player_for(id: int) -> void:
if players_node.has_node(str(id)):
return # Already exists
var scene: PackedScene = PlayerProceduralScene if use_procedural_player else PlayerScene
var player: CharacterBody3D = scene.instantiate()
player.name = str(id)
player.set_multiplayer_authority(id)
_log("[Game] _spawn_player_for id=%d local=%s procedural=%s" % [id, id == multiplayer.get_unique_id(), use_procedural_player])
var is_local := id == multiplayer.get_unique_id()
var saved_pos: Variant = PlayerPosition.get_saved_local_position() if is_local else null
if saved_pos != null:
player.position = saved_pos
_log("[Game] spawning local player at SAVED pos %s" % str(saved_pos))
elif not _live_spawn_points().is_empty():
var live := _live_spawn_points()
player.position = live[_spawn_index % live.size()].global_position
_spawn_index += 1
else:
player.position = Vector3(0, 2, 0)
players_node.add_child(player, true)
if is_local:
# ARRIVE, don't just appear: held until the ground under the point is real
# (spawn points sit 17-121 m up or 12 m inside rock on the terrain maps, and
# the terrain's collision lags its arrival). A SAVED position keeps its own
# rules (PlayerPosition reconciles it), so it is not snapped here.
if player.has_method("arrive"):
player.call("arrive", player.position, saved_pos == null)
lobby_camera.current = false
# Hand the local player to PlayerPosition so it snapshots as we move and
# can still restore if the saved pos arrives after spawn.
PlayerPosition.attach_local_player(player)
func _add_player(id: int) -> void:
_spawn_player_for(id)
## Ensure all connected players (including self) are spawned.
func ensure_all_spawned() -> void:
if not _game_active:
return
var my_id := multiplayer.get_unique_id()
_reap_orphan_players(my_id)
if not players_node.has_node(str(my_id)):
_spawn_player_for(my_id)
for id in multiplayer.get_peers():
if not players_node.has_node(str(id)):
_spawn_player_for(id)
## Free player nodes that belong to no live peer.
##
## ⛔ THE OFFLINE PEER LEAVES ONE BEHIND EVERY SESSION. on_game_started spawns the
## local player immediately so a joiner is never frozen on the lobby camera, but at
## that moment multiplayer.multiplayer_peer is still the OfflineMultiplayerPeer and
## get_unique_id() returns 1 -- so the node is named "1". When the mesh comes up we
## are assigned a real id and ensure_all_spawned, seeing no node by that name,
## spawns a SECOND local player. Nothing ever removed the first.
##
## Measured from a real ashatron join:
## [Game] on_game_started id=1
## [Game] _spawn_player_for id=1 local=true procedural=true
## [Menu] authority elected: peer_id=576519484
## [Game] _spawn_player_for id=576519484 local=true procedural=true
##
## The orphan is not inert. It carries two MultiplayerSynchronizers, and it is
## still iterated by _update_interest, which reads child.name.to_int() -> 1 and
## calls set_visibility_for(1, ...) for a peer that does not exist -- the
## "Condition !peers_info.has(p_peer) is true" spam at _update_sync_visibility. It
## is also the most likely source of the "Node 'InputSync:wants_jump' not found"
## flood, which begins the instant a remote peer appears and there is finally
## someone to sync to.
##
## Reaped by IDENTITY, not by a special case for 1: any player node whose name is
## neither our id nor a connected peer's belongs to nobody. A peer mid-handshake is
## already in get_peers() before _add_player spawns for it, so this cannot race a
## legitimate arrival.
func _reap_orphan_players(my_id: int) -> void:
var live: Dictionary = {my_id: true}
for id in multiplayer.get_peers():
live[id] = true
for child in players_node.get_children():
var id: int = child.name.to_int()
if live.has(id):
continue
_log("[Game] reaping orphan player '%s' (no live peer owns it)" % child.name)
# `_local_player`, with the underscore: the property is private by
# convention and get("local_player") silently returns null, which would
# leave PlayerPosition holding a freed node.
if child == PlayerPosition.get("_local_player"):
PlayerPosition.detach_local_player()
child.queue_free()
func _remove_player(id: int) -> void:
if id == multiplayer.get_unique_id():
PlayerPosition.detach_local_player()
var player_node := players_node.get_node_or_null(str(id))
if player_node:
player_node.queue_free()
func change_map(map_path: String, spawn_pos: Vector3) -> void:
# Remove current map
if current_map:
current_map.queue_free()
current_map = null
# Load and add new map
var scene := load(map_path) as PackedScene
if not scene:
push_error("[Game] Failed to load map: %s" % map_path)
return
# ENTER THE MAP BEFORE INSTANTIATING IT, for two reasons.
#
# 1. The per-map namespace has to be right while the map LOADS, not after.
# add_child runs the map's _ready, which defers an import that writes
# map-scoped state (the level source announcement among it). With this call
# below the instantiation, current_map_id was still the map we just LEFT for
# that whole window, so those writes were scoped to the wrong map.
#
# 2. It is what the sky was waiting for. Timestamped, the import and shell
# subtract take 1.5 s and 2.2 s; enter_map emits map_changing, which is what
# tells the panels to switch. Announcing the change after the load meant you
# watched the OUTGOING map's sky for ~3.7 s and it flipped at the end.
# Announced first, the level loads into a world already set to its own sky.
current_map_path = map_path
GameState.enter_map(GameState.map_id_from_path(map_path))
current_map = scene.instantiate()
maps_node.add_child(current_map)
# Remove any WorldEnvironment embedded in the map scene — Sky3DExtended provides
# the global environment and would conflict with a second WorldEnvironment node.
_remove_map_world_environment(current_map)
# Wait a frame for spawn points to register
await get_tree().process_frame
# Re-collect spawn points from new map
spawn_points.assign(get_tree().get_nodes_in_group("spawn_points"))
# NO DESTINATION = THE MAP'S OWN SPAWN POINT. A pad that hard-codes a point
# inside ANOTHER scene holds a copy that goes stale when that map changes:
# map_test's Mountain pad kept y=5 after map_mountain's ground plane was
# removed and its spawns moved to y=246.5, and landed players 240 m inside the
# mountain. Pads that leave `destination` unset (ZERO) follow the target map.
# Pads that set one (far-out heightmap/diffusion, erosion's 5200 m) keep it.
if spawn_pos == Vector3.ZERO and not _live_spawn_points().is_empty():
spawn_pos = _live_spawn_points()[0].global_position
# ⛔ MOVE OUR OWN PLAYER, NOT EVERY PLAYER. This used to teleport every body in
# players_node to the destination, which was coherent only while the whole room
# changed map together — and it never did: world:settings:current_map is read
# once at game start and nothing subscribes to it, so a live peer stays where
# it is. Dragging their body along made a peer in another map appear beside you
# at your spawn, which is the visible half of the same wrong assumption.
var my_player := players_node.get_node_or_null(str(multiplayer.get_unique_id()))
if my_player != null:
if my_player.has_method("arrive"):
my_player.call("arrive", spawn_pos) # held until the new map's ground is real
else:
my_player.global_position = spawn_pos
my_player.velocity = Vector3.ZERO
if "grapple_hooked" in my_player:
my_player.grapple_hooked = false
# Re-apply graphics settings for new WorldEnvironment
Settings.apply_settings()
# current_map_path / enter_map moved ABOVE the instantiation — see there.
# Persist the current world (room-global) so a rejoining peer restores this
# level (not the default). world:settings:* is admin-gated.
GameState.set_state("world:settings:current_map", {"path": map_path, "ts": Time.get_unix_time_from_system()})
# We have moved worlds: tell the room, and re-evaluate who we can see. Both
# halves matter — without the publish nobody hides US, without the refresh we
# keep drawing the peers we just left behind.
_publish_my_map()
_update_map_visibility()
print("[Game] Loaded map: %s" % map_path)
## Coordinate atmosphere ownership between game.gd's Sky3DExtended and the loaded map.
## - If the map has its own top-level WorldEnvironment (e.g. map_dc_terrain has its
## Sky3DExtended + Sunshine driver wired to its own sun/moon paths), DETACH the
## game.gd Sky3D from the tree so the map's atmosphere takes over without duplication.
## - If the map has no WorldEnvironment, ensure game.gd's Sky3D is in the tree (re-add
## it if previously detached) — the canonical fallback for plain maps like map_test.
## - Standalone DirectionalLight3D nodes at map root are stripped only when game.gd
## owns the atmosphere; if the map owns it, those lights are part of the map's
## Sky3D wiring and stay put.
func _remove_map_world_environment(root: Node) -> void:
var map_has_world_env := false
for child in root.get_children():
if child is WorldEnvironment:
map_has_world_env = true
break
if map_has_world_env:
# Map owns the atmosphere — detach game.gd's Sky3D from the tree.
# (WorldEnvironment is a Node, not a Node3D, so it has no `visible` flag —
# tree detachment is the standard way to suspend it. Reference is preserved
# on `sky3d` so we can re-add it on the next map without one.)
if sky3d and sky3d.is_inside_tree():
remove_child(sky3d)
print("[Game] Map has own WorldEnvironment — detached game.gd Sky3D")
else:
# game.gd owns the atmosphere — re-attach Sky3D if it was previously detached.
if sky3d and not sky3d.is_inside_tree():
add_child(sky3d)
print("[Game] Re-attached game.gd Sky3D")
for child in root.get_children():
if child is DirectionalLight3D:
print("[Game] Removing map DirectionalLight3D: %s" % child.name)
child.queue_free()
## RPC to sync map changes across the mesh. Authority calls this to change
## the map on all peers. Only admin/mod roles can trigger map changes.
@rpc("any_peer", "call_local", "reliable")
func _sync_map_change(map_path: String, spawn_x: float, spawn_y: float, spawn_z: float) -> void:
var sender_id := multiplayer.get_remote_sender_id()
# Allow local calls (sender_id == 0) and authority
if sender_id != 0 and sender_id != get_multiplayer_authority():
push_warning("[Game] _sync_map_change rejected from non-authority peer %d" % sender_id)
return
change_map(map_path, Vector3(spawn_x, spawn_y, spawn_z))
func _spawn_player(data: Variant) -> Node:
var id: int = data as int
var scene: PackedScene = PlayerProceduralScene if use_procedural_player else PlayerScene
var player: CharacterBody3D = scene.instantiate()
player.name = str(id)
player.set_multiplayer_authority(id)
_log("[Game] _spawn_player id=%d local=%s procedural=%s" % [id, id == multiplayer.get_unique_id(), use_procedural_player])
DebugLog.log("game", "SPAWNING PLAYER id=%d local=%s procedural=%s" % [id, id == multiplayer.get_unique_id(), use_procedural_player])
if not _live_spawn_points().is_empty():
var live2 := _live_spawn_points()
player.position = live2[_spawn_index % live2.size()].global_position
_spawn_index += 1
else:
player.position = Vector3(0, 2, 0)
# When the local player is spawned, switch from lobby camera to player camera
if id == multiplayer.get_unique_id():
lobby_camera.current = false
PlayerPosition.attach_local_player(player)
return player
# ---- Benchmark Stats ----
func start_benchmark() -> void:
benchmark_active = true
_benchmark_start_time = Time.get_ticks_msec() / 1000.0
_benchmark_stats.clear()
func _collect_benchmark_stats() -> void:
var mp = multiplayer.multiplayer_peer
if mp == null or mp is OfflineMultiplayerPeer:
return
var my_id: int = multiplayer.get_unique_id()
var my_player: Node = players_node.get_node_or_null(str(my_id))
if not my_player:
return
for child in players_node.get_children():
if child == my_player:
continue
var pid: int = child.name.to_int()
if not _benchmark_stats.has(pid):
_benchmark_stats[pid] = {"rtt_samples": [], "pos_errors": [], "sync_count": 0}
var entry: Dictionary = _benchmark_stats[pid]
# RTT from GDExtension
if mp.has_method("get_peer_rtt"):
var rtt: Dictionary = mp.get_peer_rtt(pid)
if not rtt.is_empty():
var srtt: int = rtt.get("srtt", 0)
if srtt > 0:
entry["rtt_samples"].append(srtt)
# Position error: interpolation lag between decoded sync position and lerped position.
# Low values mean the remote is tracking the authority closely.
var decoded_pos: Variant = child.get("_decoded_position")
var actual_pos: Vector3 = child.global_position
if decoded_pos is Vector3 and (decoded_pos as Vector3) != Vector3.ZERO:
var error_m: float = (decoded_pos as Vector3).distance_to(actual_pos)
entry["pos_errors"].append(error_m)
entry["sync_count"] += 1
func get_benchmark_summary() -> Dictionary:
var elapsed: float = (Time.get_ticks_msec() / 1000.0) - _benchmark_start_time
var peers := {}
for pid in _benchmark_stats:
var entry: Dictionary = _benchmark_stats[pid]
var rtt_arr: Array = entry["rtt_samples"]
var pos_arr: Array = entry["pos_errors"]
var peer_data := {}
if not rtt_arr.is_empty():
var rtt_sum: int = 0
var rtt_min: int = rtt_arr[0]
var rtt_max: int = rtt_arr[0]
for v in rtt_arr:
rtt_sum += v
if v < rtt_min:
rtt_min = v
if v > rtt_max:
rtt_max = v
peer_data["rtt_avg_ms"] = rtt_sum / rtt_arr.size()
peer_data["rtt_min_ms"] = rtt_min
peer_data["rtt_max_ms"] = rtt_max
peer_data["rtt_sample_count"] = rtt_arr.size()
# Jitter: mean absolute deviation from average
var avg: float = float(rtt_sum) / rtt_arr.size()
var jitter_sum: float = 0.0
for v in rtt_arr:
jitter_sum += absf(float(v) - avg)
peer_data["rtt_jitter_ms"] = roundi(jitter_sum / rtt_arr.size())
if not pos_arr.is_empty():
var pos_sum: float = 0.0
var pos_max: float = 0.0
for v in pos_arr:
pos_sum += v
if v > pos_max:
pos_max = v
peer_data["position_error_avg_m"] = snappedf(pos_sum / pos_arr.size(), 0.001)
peer_data["position_error_max_m"] = snappedf(pos_max, 0.001)
peer_data["sync_count"] = entry["sync_count"]
peers[str(pid)] = peer_data
return {
"running": benchmark_active,
"elapsed_s": snappedf(elapsed, 0.1),
"peers": peers,
}
# ---- Interest Management ----
## Tiered interest management: adjust what we send to each peer based on distance.
## On our own player's synchronizers, set_visibility_for() controls per-peer sending.
## replication_interval is set based on closest peer distance (global, not per-peer).
func _update_interest() -> void:
if not _game_active or not interest_enabled:
return
var my_id := multiplayer.get_unique_id()
var my_player := players_node.get_node_or_null(str(my_id))
if not my_player:
return
var my_pos: Vector3 = my_player.global_position
# Get our own synchronizers (we're authority for these)
var my_player_sync := my_player.get_node_or_null("PlayerSync") as MultiplayerSynchronizer
var my_input_sync := my_player.get_node_or_null("InputSync/InputSynchronizer") as MultiplayerSynchronizer
var min_dist: float = INF
for child in players_node.get_children():
if child == my_player:
continue
var peer_id: int = child.name.to_int()
var dist: float = my_pos.distance_to(child.global_position)
if dist < min_dist:
min_dist = dist
# Another world outranks distance. Interest is an optimisation that
# interest_enabled can switch off; "they are not here" must not be.
if not _peer_shares_our_map(peer_id):
if my_player_sync:
my_player_sync.set_visibility_for(peer_id, false)
if my_input_sync:
my_input_sync.set_visibility_for(peer_id, false)
child.visible = false
continue
if dist > interest_max_range:
# Out of range — stop sending to this peer and hide them locally
if my_player_sync:
my_player_sync.set_visibility_for(peer_id, false)
if my_input_sync:
my_input_sync.set_visibility_for(peer_id, false)
child.visible = false
else:
# In range — ensure we send to this peer and show them
if my_player_sync:
my_player_sync.set_visibility_for(peer_id, true)
if my_input_sync:
my_input_sync.set_visibility_for(peer_id, true)
child.visible = true
# Set our own replication interval based on closest peer distance
if my_player_sync and min_dist < INF:
for tier in interest_tiers:
if min_dist < tier[0]:
my_player_sync.replication_interval = tier[1]
if my_input_sync:
my_input_sync.replication_interval = tier[1]
break
# ---- Network Debug Overlay ----
func _create_net_overlay() -> void:
# Background panel
var panel := PanelContainer.new()
panel.name = "NetOverlay"
panel.anchor_left = 1.0
panel.anchor_right = 1.0
panel.offset_left = -280.0
panel.offset_top = 10.0
panel.offset_right = -10.0
panel.offset_bottom = 220.0
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
var style := StyleBoxFlat.new()
style.bg_color = Color(0, 0, 0, 0.5)
style.corner_radius_top_left = 4
style.corner_radius_top_right = 4
style.corner_radius_bottom_left = 4
style.corner_radius_bottom_right = 4
style.content_margin_left = 8
style.content_margin_right = 8
style.content_margin_top = 6
style.content_margin_bottom = 6
panel.add_theme_stylebox_override("panel", style)
_net_overlay = RichTextLabel.new()
_net_overlay.bbcode_enabled = true
_net_overlay.scroll_active = false
_net_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
_net_overlay.add_theme_font_size_override("normal_font_size", 11)
_net_overlay.add_theme_color_override("default_color", Color(0.85, 0.85, 0.85))
panel.add_child(_net_overlay)
$HUD.add_child(panel)
# Poll native GDExtension stats every 3 seconds
_net_timer = Timer.new()
_net_timer.wait_time = 3.0
_net_timer.timeout.connect(_update_net_overlay)
add_child(_net_timer)
_net_timer.start()
_update_net_overlay()
func _update_net_overlay() -> void:
if not _net_overlay:
return
var lines := PackedStringArray()
lines.append("[b][color=#66ccff]Network[/color][/b] build: %s" % build_version)
var mp = multiplayer.multiplayer_peer
if mp and not mp is OfflineMultiplayerPeer and mp.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED:
# DHT status from GDExtension
if mp.has_method("get_dht_status"):
var dht: Dictionary = mp.get_dht_status()
var port: int = dht.get("bound_port", 0)
lines.append("DHT: port %d active: %s" % [port, "yes" if dht.get("active", false) else "no"])
# Swarm status from GDExtension
if mp.has_method("get_swarm_status"):
var sw: Dictionary = mp.get_swarm_status()
var conns: int = sw.get("peers_connected", 0)
var known: int = sw.get("peers_known", 0)
lines.append("Swarm: %d/%d peers (connected/known)" % [conns, known])
# Game players
var my_id := multiplayer.get_unique_id()
var peer_ids := multiplayer.get_peers()
var total := 1 + peer_ids.size()
var role := "authority" if is_multiplayer_authority() else "peer"
lines.append("[b]Players: %d[/b] (you=%d %s)" % [total, my_id, role])
for pid in peer_ids:
var rtt_str := ""
if mp.has_method("get_peer_rtt"):
var rtt: Dictionary = mp.get_peer_rtt(pid)
if not rtt.is_empty():
rtt_str = " rtt=%dms min=%dms" % [rtt.get("srtt", 0), rtt.get("min_rtt", 0)]
lines.append(" peer %d [color=#66ff66]connected[/color]%s" % [pid, rtt_str])
if peer_ids.is_empty():
lines.append(" [color=#999999]no other players yet[/color]")
else:
lines.append("[b]Players:[/b] offline")
# Keet daemon status
if KeetService.client.is_connected:
lines.append("Keet: [color=#66ff66]ok[/color]")
else:
lines.append("Keet: [color=#ff6666]offline[/color]")
# Headless mode: log stats to stdout for remote monitoring
if DisplayServer.get_name() == "headless":
var plain := PackedStringArray()
for line in lines:
# Strip BBCode tags for plain-text output
var stripped := line
while stripped.find("[") != -1:
var open := stripped.find("[")
var close := stripped.find("]", open)
if close == -1:
break
stripped = stripped.substr(0, open) + stripped.substr(close + 1)
stripped = stripped.strip_edges()
if not stripped.is_empty():
plain.append(stripped)
DebugLog.log("network", " | ".join(plain))
_net_overlay.text = "\n".join(lines)