class_name KeetGameState
extends Node
## Durable game state manager for keet-daemon.
## Reads/writes game state via the room metadata Autobase (meta.get/meta.set).
## All game keys are prefixed with "game:" in the metadata namespace.
##
## Role-aware: checks local member role before allowing writes.
## Supports watching for remote changes via polling.
signal state_loaded(room_id: String, entries: Dictionary)
signal state_updated(room_id: String, key: String, value: Variant)
signal role_loaded(room_id: String, role: String)
signal error_occurred(message: String)
## Emitted once per watch() after the membership/writability probe completes.
## `info` keys: room, daemon, me, member(bool), role, writable(bool), members(int),
## state_base_key, has_bootstrap(bool), verdict(String).
signal membership_checked(room_id: String, info: Dictionary)
var client: KeetUdsClient
## On-screen diagnostic overlay (top-left). Shows whether this peer is an
## accepted room member + can durably write game-state. Toggle with F9.
## This is the answer to "were we added as a member?" without log-grepping.
var _diag_overlay: CanvasLayer
var _diag_label: Label
var _mesh_label: Label # live Godot-mesh readout (peer count + room) — updated per-frame
var _last_membership: Dictionary = {}
@export var auto_poll: bool = true
@export_range(1.0, 30.0) var poll_interval: float = 3.0
var current_room: String = ""
var current_role: String = "peer" # admin, mod, peer, read-only
var _poll_timer: Timer
var _state_cache: Dictionary = {} # key (without game: prefix) → value
var _known_keys: Dictionary = {} # key → true (for change detection)
var _raw_cache: Dictionary = {} # key → the stored TEXT last parsed (a poll skips unchanged text)
## True from watch() until one state.list answers. That answer is the room's LOAD
## (one state_loaded, the whole snapshot); every later answer is a POLL and emits
## only keys that changed. See _fetch_all.
var _initial_load_pending: bool = false
## A POLL'S SNAPSHOT CAN PREDATE A LOCAL WRITE (hyper-games #108, 2026-10-07).
## state.list is sent, then a carve's state.set lands and is confirmed, then the
## list's answer -- taken before the write -- arrives without the key. Read as a
## deletion, that emitted null, and TerrainEditManager re-folded: clear_edits, a
## FULL terrain rebuild, the whole journal replayed, after the player's own carve.
## _fetch_gen numbers each state.list sent; _local_gen[key] is the _fetch_gen at
## which a local set/erase of that key was confirmed. An answer to fetch G says
## nothing about a key with _local_gen >= G -- the local cache is newer. A later
## fetch (sent after the confirmation) is authoritative again, so a real remote
## delete still arrives one poll later. Gate: gate_poll_no_reemit step 4.
var _fetch_gen: int = 0
var _local_gen: Dictionary = {} # key → _fetch_gen when its local write/erase was confirmed
func _ready() -> void:
_poll_timer = Timer.new()
_poll_timer.wait_time = poll_interval
_poll_timer.timeout.connect(_poll_state)
add_child(_poll_timer)
_build_diag_overlay()
## Build the persistent on-screen membership overlay (top-left corner).
func _build_diag_overlay() -> void:
_diag_overlay = CanvasLayer.new()
_diag_overlay.layer = 128 # above gameplay UI
add_child(_diag_overlay)
_diag_label = Label.new()
_diag_label.position = Vector2(10, 56) # just below HUD PeerLabel(10-30)+FPSLabel(30-50)
_diag_label.add_theme_color_override("font_color", Color.YELLOW)
_diag_label.add_theme_color_override("font_outline_color", Color.BLACK)
_diag_label.add_theme_constant_override("outline_size", 4)
_diag_label.text = "[membership] probing…"
_diag_overlay.add_child(_diag_label)
# Live mesh readout (ephemeral transport): peer count + room topic source.
# Updated every frame in _process so it reflects connect/disconnect live —
# unlike the membership probe above, which only runs on an RPC round-trip.
_mesh_label = Label.new()
_mesh_label.position = Vector2(10, 128) # below the 3-4 line membership block
_mesh_label.add_theme_color_override("font_outline_color", Color.BLACK)
_mesh_label.add_theme_constant_override("outline_size", 4)
_mesh_label.text = "[mesh] …"
_diag_overlay.add_child(_mesh_label)
func _unhandled_input(event: InputEvent) -> void:
# Ctrl+Shift+F9 toggles the membership overlay (shown by default while
# debugging). Was bare F9 matched WITHOUT checking modifiers, so map_core's
# Ctrl+F9 (LOD colours) toggled this too.
# Looked up, not named: this addon must still parse in a project without the
# game's Keymap autoload.
var km: Node = get_node_or_null(^"/root/Keymap")
if km != null and km.tapped(event, "debug.room_overlay"):
if _diag_overlay != null:
_diag_overlay.visible = not _diag_overlay.visible
## Live mesh readout: how many peers we're actually meshing with (Godot
## MultiplayerPeer, the EPHEMERAL transport) + the room id the mesh topic derives
## from. peers=0 while member=YES means we're in the room (durable) but not
## meshing (ephemeral). Compare `room=` across two clients — they only see each
## other if it matches (mesh topic = sha256("hyper-game:room:<room_id>")).
func _process(_delta: float) -> void:
if _mesh_label == null or _diag_overlay == null or not _diag_overlay.visible:
return
# IN BUILD MODE THE READOUT LIVES IN THE 3D VIEW'S CORNER (hyper-games #124, user
# 2026-10-18: "membership daemon info still overwrites the ui"). The editor's areas
# fill the window's edges now, and this layer (128) drew over them. BuildDock's
# view_rect() is the free 3D rectangle in build mode and the whole window otherwise.
# Looked up, not named: this addon must still parse without the game's autoloads.
var dock: Node = get_node_or_null(^"/root/BuildDock")
_diag_overlay.offset = (dock.view_rect() as Rect2).position if dock != null and dock.has_method("view_rect") else Vector2.ZERO
var peers: int = 0
var active := false
var mp := multiplayer.multiplayer_peer
if mp != null and not (mp is OfflineMultiplayerPeer):
active = true
peers = multiplayer.get_peers().size()
var room := ""
var ks := get_node_or_null("/root/KeetService")
if ks != null and ks.has_method("get_current_room"):
var full := str(ks.get_current_room())
room = full.substr(0, 12)
# Say when it is HOME (docs/home-world-design.md), keeping the id: the id is
# what two clients compare, and a guest in your home sees the same one.
if ks.has_method("is_home") and ks.is_home(full):
room = "home(%s)" % room
_mesh_label.add_theme_color_override("font_color", Color.LIME_GREEN if peers > 0 else Color.YELLOW)
_mesh_label.text = "MESH active=%s peers=%d players=%d room=%s" % [
("YES" if active else "NO"), peers, (peers + 1 if active else 0), room]
## Start watching a room's game state.
func watch(room_id: String) -> void:
_kgs_diag("watch room_id=%s client_null=%s auto_poll=%s" % [room_id, str(client == null), str(auto_poll)])
current_room = room_id
_state_cache.clear()
_known_keys.clear()
_raw_cache.clear()
_local_gen.clear()
_initial_load_pending = true
if room_id.is_empty():
_poll_timer.stop()
return
_fetch_role(room_id)
_fetch_all(room_id)
_diag_membership(room_id)
if auto_poll:
_poll_timer.start()
_kgs_diag("poll_timer started, interval=%ss" % str(poll_interval))
## Stop watching.
func unwatch() -> void:
current_room = ""
current_role = "peer"
_state_cache.clear()
_known_keys.clear()
_raw_cache.clear()
_local_gen.clear()
_initial_load_pending = false
_poll_timer.stop()
## Get a value from the durable cache.
func get_value(key: String, default: Variant = null) -> Variant:
return _state_cache.get(key, default)
## Get all cached state.
func get_all() -> Dictionary:
return _state_cache.duplicate()
## Set a game state value (writes to Autobase via meta.set).
func set_value(key: String, value: Variant, callback: Callable = Callable()) -> void:
if current_room.is_empty():
error_occurred.emit("set_value: no room selected")
return
if not _can_write():
error_occurred.emit("set_value: role '%s' cannot write" % current_role)
return
_require_client()
var serialized: String = var_to_str(value)
# state.set writes to the per-room StateBase Autobase. Every peer in
# the room converges on the same state-base via the announce-swarm
# (see keet-daemon/lib/state-base.js).
_kgs_diag("set_value room=%s key=%s val_preview=%s" % [current_room, key, serialized.substr(0, 120)])
client.call_rpc("state.set", {
"roomId": current_room,
"key": key,
"value": serialized
}, func(result: Variant, err: Variant) -> void:
if err != null:
error_occurred.emit("set_value(%s): %s" % [key, str(err)])
return
_state_cache[key] = value
_known_keys[key] = true
_local_gen[key] = _fetch_gen
state_updated.emit(current_room, key, value)
if callback.is_valid():
callback.call(result)
)
## Delete a game state key.
## The callback ALWAYS answers: the result on success, {"error": why} on every failure path
## (no room, no write role, RPC error). It used to answer only on success, so a caller
## waiting for N erases (HomeReset.reset_all) waited forever on the first refusal.
func erase_value(key: String, callback: Callable = Callable()) -> void:
if current_room.is_empty():
if callback.is_valid():
callback.call({"error": "erase_value: no room selected"})
return
if not _can_write():
error_occurred.emit("erase_value: role '%s' cannot write" % current_role)
if callback.is_valid():
callback.call({"error": "erase_value: role '%s' cannot write" % current_role})
return
_require_client()
client.call_rpc("state.del", {
"roomId": current_room,
"key": key
}, func(result: Variant, err: Variant) -> void:
if err != null:
error_occurred.emit("erase_value(%s): %s" % [key, str(err)])
if callback.is_valid():
callback.call({"error": str(err)})
return
_state_cache.erase(key)
_known_keys.erase(key)
# The text cache too: left behind, a later re-write of the SAME text (by any
# peer) was skipped as "unchanged" and never re-emitted.
_raw_cache.erase(key)
_local_gen[key] = _fetch_gen
if callback.is_valid():
callback.call(result)
)
## Batch-set multiple values.
func set_batch(entries: Dictionary, callback: Callable = Callable()) -> void:
var remaining: int = entries.size()
if remaining == 0:
if callback.is_valid():
callback.call(null)
return
for key: String in entries:
set_value(key, entries[key], func(_r: Variant) -> void:
remaining -= 1
if remaining <= 0 and callback.is_valid():
callback.call(null)
)
## Check if local user can write game state.
func can_write() -> bool:
return _can_write()
## Return the most recent membership probe result (see membership_checked).
func get_membership() -> Dictionary:
return _last_membership.duplicate()
# ---- Membership / writability diagnostic ----
#
# Answers "were we added as a member of this room?" — the gate that decides
# whether our game-state writes durably land. Runs ONCE per watch() (not in the
# poll loop, so it never spams). Chains four daemon RPCs:
# version → which keet-daemon/StateBase is actually running (catches a stale
# v2 daemon being run when v3 was expected)
# identity → our own memberId
# members → the room's keet membership (we are a member iff our id is present)
# state.info → whether the per-room StateBase is writable for us
# Emits membership_checked + updates the on-screen overlay + logs ONE line.
func _diag_membership(room_id: String) -> void:
if client == null:
return
var info: Dictionary = {
"room": room_id, "daemon": "?", "me": "", "member": false,
"role": "?", "writable": false, "members": 0,
"state_base_key": "", "has_bootstrap": false, "verdict": "probing"
}
client.call_rpc("version", {}, func(ver: Variant, _e1: Variant) -> void:
if ver is Dictionary:
info["daemon"] = str(ver.get("daemonVersion", ver.get("version", "?")))
elif ver is String:
info["daemon"] = ver
client.call_rpc("identity", {}, func(ident: Variant, _e2: Variant) -> void:
if ident is Dictionary:
info["me"] = str(ident.get("memberId", ""))
client.call_rpc("members", {"roomId": room_id}, func(mem: Variant, _e3: Variant) -> void:
if mem is Array:
info["members"] = mem.size()
for m: Variant in mem:
if m is Dictionary and str(m.get("memberId", "")) == info["me"]:
info["member"] = true
info["role"] = str(m.get("role", "peer"))
client.call_rpc("state.info", {"roomId": room_id}, func(si: Variant, _e4: Variant) -> void:
if si is Dictionary:
info["writable"] = bool(si.get("writable", false))
info["state_base_key"] = str(si.get("stateBaseKey", ""))
info["has_bootstrap"] = bool(si.get("hasBootstrap", false))
_emit_membership(room_id, info)
)
)
)
)
func _emit_membership(room_id: String, info: Dictionary) -> void:
var ok: bool = bool(info.get("member", false)) and bool(info.get("writable", false))
if ok:
info["verdict"] = "OK — accepted member, writes will persist"
elif not bool(info.get("member", false)):
info["verdict"] = "NOT A MEMBER — writes will NOT persist (not in room membership)"
else:
info["verdict"] = "MEMBER but NOT WRITABLE — StateBase not admitting our writer yet"
_last_membership = info
_kgs_diag("[membership] daemon=%s me=%s member=%s writable=%s role=%s members=%d sbKey=%s :: %s" % [
str(info.get("daemon")), str(info.get("me")).substr(0, 12),
str(info.get("member")), str(info.get("writable")), str(info.get("role")),
int(info.get("members")), str(info.get("state_base_key")).substr(0, 12),
str(info.get("verdict"))
])
if _diag_label != null:
_diag_label.add_theme_color_override("font_color", Color.LIME_GREEN if ok else Color.RED)
_diag_label.text = "MEMBERSHIP daemon=%s\nme=%s member=%s writable=%s role=%s members=%d\n%s" % [
str(info.get("daemon")), str(info.get("me")).substr(0, 16),
("YES" if info.get("member") else "NO"), ("YES" if info.get("writable") else "NO"),
str(info.get("role")), int(info.get("members")), str(info.get("verdict"))
]
membership_checked.emit(room_id, info)
# ---- Role Management ----
func _fetch_role(room_id: String) -> void:
_require_client()
# Get our own identity first
client.call_rpc("identity", {}, func(ident: Variant, err: Variant) -> void:
if err != null or not ident is Dictionary:
return
var my_member_id: String = ident.get("memberId", "")
if my_member_id.is_empty():
return
# Then fetch member list to find our role
client.call_rpc("members", {"roomId": room_id}, func(result: Variant, merr: Variant) -> void:
if merr != null or not result is Array:
return
for member: Variant in result:
if member is Dictionary:
var mid: String = member.get("memberId", "")
if mid == my_member_id:
current_role = member.get("role", "peer")
role_loaded.emit(room_id, current_role)
return
)
)
func _can_write() -> bool:
return current_role != "read-only"
# ---- State Fetching ----
func _fetch_all(room_id: String) -> void:
_require_client()
# state.list returns { entries: { key: { value, ts, _by } } } from the
# room's StateBase. Each entry's `.value` is the var_to_str-encoded
# Variant that set_value stored.
_kgs_diag("_fetch_all start room=%s" % room_id)
_fetch_gen += 1
client.call_rpc("state.list", {
"roomId": room_id
}, _apply_list.bind(room_id, _fetch_gen))
## state.list's answer, applied (a named method rather than an inline lambda so a gate
## can drive and time it — gate_poll_cost).
func _apply_list(result: Variant, err: Variant, room_id: String, gen: int = -1) -> void:
# A reply for a room we have since left (or re-watched) describes a world
# that is no longer this one; applying it would emit another room's keys.
if room_id != current_room:
return
if err != null:
_kgs_diag("state.list err=%s" % str(err))
return
if not result is Dictionary:
_kgs_diag("state.list result_type=%d" % typeof(result))
return
var raw_entries: Variant = result.get("entries", {})
if not raw_entries is Dictionary:
_kgs_diag("state.list entries_type=%d (expected Dict)" % typeof(raw_entries))
return
# Always log, including the zero-entry case.
_kgs_diag("state.list got=%d keys=%s" % [raw_entries.size(), str(raw_entries.keys()).substr(0, 300)])
# LOAD vs POLL — hyper-games #88 Tier 1 #1.
#
# This used to end EVERY answer with state_loaded(all entries), and
# GameState._on_durable_loaded answers that with state_changed for every
# key. So every 3 s, on every peer in a room, TerrainEditManager saw each
# older edit arrive "out of order" and re-folded the whole journal
# (clear_edits + replay, every edited chunk remeshed), and LevelPersistence
# refolded the level — with nothing having changed.
#
# The first answer had the opposite fault: each key also went out as
# state_updated, which GameState stores and emits ONE AT A TIME in
# Dictionary order, one signal ahead of the atomic bulk load #77 item 5
# depends on (gate_durable_load_order).
#
# Now: the first answer after watch() is the load — cache filled, ONE
# state_loaded, no per-key emits. Every later answer is a poll — only keys
# whose value changed or vanished, as state_updated. Gate: gate_poll_no_reemit.
var initial: bool = _initial_load_pending
var entries: Dictionary = {}
var fresh_keys: Dictionary = {}
for game_key_v in raw_entries.keys():
var game_key: String = str(game_key_v)
# Written/erased locally since this fetch was sent: the cache is newer.
if gen >= 0 and int(_local_gen.get(game_key, -1)) >= gen:
fresh_keys[game_key] = true
continue
var entry: Variant = raw_entries[game_key_v]
# v3 state.list returns FLAT {key: value}; v2 returned {key: {value, ts, _by}}.
# Support both: nested dict with a "value" field, else the entry IS the value
# (a serialized string, or already a Variant). Treating the flat string as
# "no value" (the old code) made EVERY loaded value null → nothing restored.
var raw_val: Variant
if entry is Dictionary and entry.has("value"):
raw_val = entry["value"]
else:
raw_val = entry
fresh_keys[game_key] = true
# UNCHANGED TEXT = UNCHANGED VALUE: skip the parse and the deep compare. Every
# poll used to str_to_var + compare EVERY entry — 51 ms per 2,500 records,
# every 3 s on every map (gate_poll_cost; the "scripts" hitch in the HITCH log).
# A key whose text changed (or a local set_value since) still parses and
# compares, so a value equal to the cache still emits nothing.
if not initial and raw_val is String and _raw_cache.get(game_key) == raw_val:
continue
if raw_val is String:
_raw_cache[game_key] = raw_val
var value: Variant = _deserialize(raw_val)
var prior: Variant = _state_cache.get(game_key, null)
if initial or prior != value:
_state_cache[game_key] = value
_known_keys[game_key] = true
if not initial:
state_updated.emit(room_id, game_key, value)
entries[game_key] = value
for k in _known_keys.keys():
if gen >= 0 and int(_local_gen.get(k, -1)) >= gen:
continue # absent only because this snapshot predates our own write
if not fresh_keys.has(k):
_state_cache.erase(k)
_known_keys.erase(k)
_raw_cache.erase(k)
if not initial:
state_updated.emit(room_id, k, null)
# A snapshot from a fetch sent AFTER a key's local write covers it: drop the mark.
if gen >= 0:
for k in _local_gen.keys():
if int(_local_gen[k]) < gen:
_local_gen.erase(k)
if initial:
_initial_load_pending = false
state_loaded.emit(room_id, entries)
var _consecutive_disconnect_polls: int = 0
const _MAX_DISCONNECT_POLLS := 5 # 5 * 3s = 15s before we self-terminate
func _poll_state() -> void:
if current_room.is_empty():
_kgs_diag("poll skip: current_room empty")
return
if client == null:
_kgs_diag("poll skip: client null")
return
if not client.is_connected:
_consecutive_disconnect_polls += 1
_kgs_diag("poll skip: client not connected (room=%s) count=%d" % [current_room, _consecutive_disconnect_polls])
# If the launcher (and thus daemon) has died, we're an orphan. After
# enough failed polls, terminate ourselves so we don't sit around
# as a zombie process holding the room storage lock.
if _consecutive_disconnect_polls >= _MAX_DISCONNECT_POLLS:
_kgs_diag("daemon disconnected too long — terminating game to avoid orphan")
push_warning("[KeetGameState] daemon offline %d polls — quitting" % _consecutive_disconnect_polls)
var tree := get_tree()
if tree != null:
tree.quit(1)
return
_consecutive_disconnect_polls = 0
_fetch_all(current_room)
func _deserialize(raw: Variant) -> Variant:
if raw is String and raw != "":
var parsed: Variant = str_to_var(raw)
if parsed != null:
return parsed
return raw
func _kgs_diag(msg: String) -> void:
if not OS.has_environment("HTPS_VERBOSE"):
return # gated — set HTPS_VERBOSE=1 to re-enable diagnostic logging
var line := "[%s] [kgs] %s" % [Time.get_datetime_string_from_system(), msg]
printerr("HTPSDIAG " + line)
# READ_WRITE preserves existing content; WRITE_READ truncates (Godot bug-bait).
var paths: Array[String] = ["/tmp/hyper-tps-diag.log",
OS.get_user_data_dir().path_join("hyper-tps-diag.log")]
for p in paths:
var f := FileAccess.open(p, FileAccess.READ_WRITE)
if f == null:
f = FileAccess.open(p, FileAccess.WRITE)
if f != null:
f.seek_end()
f.store_line(line)
f.close()
return
func _require_client() -> void:
if client == null:
push_error("KeetGameState: client not set. Assign a KeetUdsClient node.")