| name | host-authoritative-state |
| description | Use when designing the state layer of a multiplayer Godot game — deciding what's host-owned, how clients request changes, how the host validates and broadcasts. Pairs with `/peer-to-peer-multiplayer`. Trigger on "host authority", "authoritative state", "state ownership", "MP cheating", "client validation", "RPC patterns". |
| license | MIT |
| compatibility | ["Cursor","Claude Code","Windsurf","Codex"] |
| category | multiplayer-and-networking |
| user-invocable | true |
| allowed-tools | Read Grep summer_get_scene_tree summer_inspect_node summer_save_scene summer_get_script_errors |
/host-authoritative-state — Designing the State Layer
Overview
This is Layer 2 of multiplayer architecture. /peer-to-peer-multiplayer covers all four layers; this skill zooms in on the one that produces 90% of shipping-quality MP bugs. For every piece of state in your game, decide: does the host own it, or does the client own it? Get this wrong and you ship a cheat-vulnerable, desync-prone game where players "teleport," items dupe, and hit-detection lies. Get it right and the rest of multiplayer falls into place.
The fundamental question
For every state field, ask one question:
If a malicious client lies about this, does it break the game?
| Answer | Owner | Notes |
|---|
| Yes | Host-authoritative. | Clients send intent (RPC request); host validates and broadcasts. |
| No | Client-owned. | Cosmetic. Replicated for visual sync only. |
| Sometimes | Host-authoritative with client prediction. | Position is the canonical example: client predicts to feel responsive; host reconciles to prevent teleport-cheats. |
This question is the entire decision tree. Apply it to every field, no exceptions.
The decision matrix
The canonical state-ownership table for a typical 3D action game. Use this as your starting point and extend per game.
| State field | Owner | Why | What an attacker could do if you got it wrong |
|---|
health | host | core combat integrity | edit memory → infinite HP, ignore damage |
mana / stamina | host | gates ability/sprint use | infinite-cast spam, infinite-sprint |
score / kills / objectives | host | competitive integrity | self-award kills, fake leaderboard |
inventory contents | host | items must not dupe | broadcast "I picked it up" twice → duped item |
currency (gold, gems) | host | trade exploits | mint currency on the client |
ability_cooldowns | host | gates damage rate | spam-cast every frame |
current_weapon equipped | host | tied to inventory and damage tables | swap to "best gun" without owning it |
team_assignment | host | balance + friendly fire | switch sides mid-match |
ready_state (lobby) | host | match-start gating | force-start with one player |
chat_messages | host-relayed | moderation hooks, profanity filter | bypass mute, broadcast to muted peers |
position | client-predicted, host-reconciled | input latency feels awful otherwise | speed-hack, teleport — reconciliation catches both |
velocity | client-predicted, host-reconciled | same as position | same as position |
look_direction (yaw/pitch) | client | cosmetic; host doesn't need it for hit-reg if you raycast on host | cheating here doesn't help |
animation_state | client | visual; can lead the truth | wrong anim = visual glitch, not exploit |
footstep_audio | client | host doesn't care | spam → mild annoyance, not exploit |
particle_fx / muzzle_flash | client | host doesn't care | same |
aim_assist_target | client | local UX | spoofing it only hurts the spoofer |
If a row is missing, run the fundamental question on it. When in doubt, host-own it — false positives are cheap, false negatives ship cheats.
The Manager pattern
Every host-authoritative state field lives in a Manager autoload. Naming convention: {Domain}Manager — HealthManager, ScoreManager, InventoryManager, CooldownManager. This is the canonical state-ownership pattern across shipping-quality MP architectures.
Each Manager has a fixed shape:
class_name HealthManager
extends Node
# ── Signals (UI / floating bars / scoreboard listen here) ──
signal player_health_updated(peer_id: int, hp: int, max_hp: int)
signal player_died(peer_id: int, killer_id: int)
const DEFAULT_MAX_HEALTH := 100
# Host-authoritative dictionary, keyed by peer_id.
var _player_health: Dictionary = {} # peer_id -> { hp, max_hp, is_dead }
func _ready() -> void:
NetworkManager.peer_joined.connect(_on_peer_joined)
NetworkManager.peer_left.connect(_on_peer_left)
# ── HOST-ONLY MUTATORS (state writes) ──────────────────────────
func _host_apply_damage(target: int, amount: int, attacker: int) -> void:
if not NetworkManager.is_host: return
var entry = _player_health.get(target)
if entry == null or entry.is_dead: return
var new_hp = max(0, entry.hp - amount)
entry.hp = new_hp
_broadcast_health(target, new_hp, entry.max_hp)
if new_hp == 0:
entry.is_dead = true
_broadcast_death(target, attacker)
# ── CLIENT REQUEST HANDLERS (validate then mutate) ─────────────
@rpc("any_peer", "call_remote", "reliable")
func _client_request_damage(target: int, amount: int) -> void:
if not NetworkManager.is_host: return
var sender := multiplayer.get_remote_sender_id()
if not _validate_damage_request(sender, target, amount): return
_host_apply_damage(target, amount, sender)
# ── HOST-TO-CLIENT BROADCASTS (state reads) ────────────────────
@rpc("authority", "call_remote", "reliable")
func _broadcast_health(peer_id: int, hp: int, max_hp: int) -> void:
var entry = _player_health.get(peer_id, { "hp": hp, "max_hp": max_hp, "is_dead": false })
entry.hp = hp
entry.max_hp = max_hp
_player_health[peer_id] = entry
player_health_updated.emit(peer_id, hp, max_hp)
@rpc("authority", "call_remote", "reliable")
func _broadcast_death(peer_id: int, killer_id: int) -> void:
player_died.emit(peer_id, killer_id)
The shape:
- Data lives in a private
Dictionary keyed by peer_id.
_host_apply_*() mutators are the only functions that write the dictionary. First line: if not NetworkManager.is_host: return.
_client_request_*() RPC handlers validate, then call the mutator. Never write directly.
_broadcast_*() RPC functions push state to clients (@rpc("authority", ...)) so only host can call.
- Lifecycle via
NetworkManager.peer_joined / peer_left — host registers and cleans up entries.
Build one Manager per state domain. Don't put health and inventory in the same autoload — when one needs a refactor the other gets dragged along.
The four RPC flavors
/peer-to-peer-multiplayer covers three; this skill goes deeper on a fourth (query/response). Every networked function maps to one of these. Pick the wrong one and you'll see the bug listed.
Flavor 1: Host → Client broadcast
@rpc("authority", "call_remote", "reliable")
func _broadcast_score(peer: int, value: int) -> void:
_scores[peer] = value
score_updated.emit(peer, value)
authority = only host can call this. Anyone else's call is dropped by Godot.
call_remote = doesn't run on the caller. Host already wrote the dictionary; no need to re-run locally.
reliable = state must arrive. Lost packets retry.
- Bug if wrong: if you mark this
any_peer, a client can fake a score broadcast → desync.
Flavor 2: Client → Host request
@rpc("any_peer", "call_remote", "reliable")
func _client_request_use_item(item_id: String) -> void:
if not NetworkManager.is_host: return
var sender := multiplayer.get_remote_sender_id()
if not _validate_item_use(sender, item_id): return
_host_apply_item_use(sender, item_id)
any_peer = any client can call.
- First line is always
if not NetworkManager.is_host: return. Defensive — Godot already routes correctly, this guards against misconfiguration.
multiplayer.get_remote_sender_id() is trustworthy — clients can't lie about who sent the request.
- Bug if wrong: missing the
is_host guard means peers process each other's requests and corrupt local state.
Flavor 3: Client → Client cosmetic relay
@rpc("any_peer", "call_remote", "unreliable")
func _peer_play_emote(emote_id: String) -> void:
var sender := multiplayer.get_remote_sender_id()
_play_emote_visual(sender, emote_id)
unreliable = fire-and-forget, lossy is fine for cosmetics.
- No validation — worst case a player's emote desyncs by a frame.
- Bug if wrong: marking this
reliable for high-frequency cosmetic events (footsteps, particle spawns) blows up bandwidth.
Flavor 4: Query / response (the late-join flavor)
The flavor not covered in /peer-to-peer-multiplayer. When one specific client needs the host to send them state — e.g. a peer just joined and needs the full game state — use rpc_id to target a single peer.
# Client side — peer asks the host for current state
func client_request_full_state() -> void:
rpc_id(1, "_host_send_full_state_to_caller")
@rpc("any_peer", "call_remote", "reliable")
func _host_send_full_state_to_caller() -> void:
if not NetworkManager.is_host: return
var caller := multiplayer.get_remote_sender_id()
# send state ONLY to the caller, not all peers
for peer_id in _player_health:
var entry = _player_health[peer_id]
rpc_id(caller, "_broadcast_health", peer_id, entry.hp, entry.max_hp)
# Host side — actually a flavor 1 broadcast, but targeted via rpc_id
@rpc("authority", "call_remote", "reliable")
func _broadcast_health(peer_id: int, hp: int, max_hp: int) -> void:
# same handler as flavor 1, but called via rpc_id(caller, ...) for targeting
pass
Use this for: late-join state replay, lobby info on connect, post-respawn loadout. Always reliable — partial state replay is worse than no replay.
Validation patterns
Every _client_request_*() must validate before applying. The validation is the only thing standing between you and a cheat.
func _validate_damage_request(attacker: int, target: int, amount: int) -> bool:
# 1. Authority check — does this client own the attacker?
if attacker != target_attacker_owner(attacker): return false
# 2. Existence check
if not _player_health.has(target): return false
if _player_health[target].is_dead: return false
# 3. Resource check — does attacker have a weapon equipped?
if not InventoryManager.has_weapon_equipped(attacker): return false
# 4. Range check — within weapon's max range?
var weapon := InventoryManager.get_equipped_weapon(attacker)
var dist := _get_player_distance(attacker, target)
if dist > weapon.max_range + 0.5: # epsilon for float compare
return false
# 5. Line-of-sight check — physics raycast on the host
if not _has_line_of_sight(attacker, target): return false
# 6. Cooldown check — is the weapon off cooldown?
if not CooldownManager.is_ready(attacker, weapon.id): return false
# 7. Damage cap — clamp `amount` to weapon.max_damage to defeat magnitude inflation
if amount > weapon.max_damage: return false
return true
Six common validators, in priority order:
- Authority — does this client own the entity they're modifying? (You can attack other players, but you can only modify your own inventory.)
- Existence — does the target still exist on the host? (Race vs. peer disconnect.)
- Resource — does the requester have the resources? (Mana, ammo, weapon.)
- Range / distance — physics-plausible? (Prevents shooting across the map.)
- Line-of-sight — raycast on the host. (Prevents shooting through walls.)
- Cooldown — within rate limits? (Prevents spam-cast.)
- Magnitude — is the requested amount within game-defined max? (Prevents inflation: clamp damage to weapon's
max_damage, etc.)
When validation fails: drop silently or log + drop. Never tell the client they tried to cheat — that just lets them probe. No error response, no friendly "you can't do that" message. The request simply has no effect.
Float epsilon: range / distance / cooldown checks involve floats. Always allow a small epsilon (+ 0.5 for distance, > -0.05 for cooldown remaining). Strict equality on floats fails for legitimate clients due to physics jitter.
Late-join state sync
When a peer joins mid-session, the host must replay current state to them. Without this, the new peer's HUD shows zeros and they think they have full HP.
The pattern: every Manager exposes a _send_full_state(peer_id) method. Connect it to NetworkManager.peer_joined.
# In each Manager
func _on_peer_joined(id: int) -> void:
if not NetworkManager.is_host: return
# Register the new peer
_player_health[id] = { "hp": DEFAULT_MAX_HEALTH, "max_hp": DEFAULT_MAX_HEALTH, "is_dead": false }
# Replay all existing state to them
_send_full_state(id)
func _send_full_state(target_peer: int) -> void:
for peer_id in _player_health:
var entry = _player_health[peer_id]
rpc_id(target_peer, "_broadcast_health", peer_id, entry.hp, entry.max_hp)
if entry.is_dead:
rpc_id(target_peer, "_broadcast_death", peer_id, 0)
Use flavor 4 (rpc_id) so only the joining peer gets the replay. Broadcasting to all peers re-confirms state they already have — wastes bandwidth and may trigger "received update" hooks on UIs.
Idempotency: _send_full_state may run a fraction of a second after _broadcast_health for the same peer. Make sure your Manager's broadcast handlers are idempotent — receiving the same state twice should be a no-op.
Common mistakes
| Mistake | What goes wrong | Fix |
|---|
| Trusting client-supplied damage values | Client edits memory → 99999 damage one-shots everyone | Host computes damage from weapon resource; client supplies only target and weapon_id. |
| Validating after applying instead of before | Cheat already took effect for a frame; reverting causes visual rubber-banding | Validate first, then mutate. The mutator never runs if validation fails. |
Broadcasting state changes via signal.emit() on host | Signals fire only on the emitter — clients never see the update | Use @rpc("authority", ...) broadcast functions. Signals are for local listeners (UI on the host). |
Forgetting peer_left cleanup | Dictionaries grow forever as players cycle through; eventual memory leak + ghost entries | Every Manager listens to NetworkManager.peer_left and erase()s its entry. |
Using reliable for high-frequency cosmetic state (footsteps, particles) | Bandwidth blowup; reliable retransmits on packet loss | unreliable for cosmetic; reliable only for state that must arrive. |
| Comparing floats without epsilon in validation | Legitimate clients fail validation due to physics-step jitter; their hits get silently dropped | dist > max_range + 0.5, never dist > max_range. |
| Writing to host dictionary from a non-host code path | One peer accidentally becomes "second host" → desyncs | First line of every mutator: if not NetworkManager.is_host: return. Treat as boilerplate. |
Anti-pattern: the "synced var" temptation
Godot 4 ships a MultiplayerSynchronizer node that automatically replicates a list of properties across the network. It's tempting because it removes RPC boilerplate. It is wrong for anything game-logic-relevant.
MultiplayerSynchronizer:
- Has no validation hook. Writes go through unconditionally.
- Has no concept of "host owns this, client requests changes."
- Replicates whatever value the configured authority node sets — and the authority is per-node, not per-property.
- Provides no audit trail when a value changes (compared to a Manager method you can breakpoint).
It is the right tool for cosmetic state only:
- Animation node parameters (
AnimationTree blend values).
- Look direction / camera yaw on remote peer's visuals.
- Particle emission state on a non-gameplay decoration.
Game-logic state — health, score, inventory, cooldowns, position-of-truth — goes through Managers with explicit validate-mutate-broadcast. The five extra lines of boilerplate per field are what keeps the game shippable.
Putting it together
When you start a new MP game, the order is:
- Build
NetworkManager (covered by /peer-to-peer-multiplayer Layer 1).
- List every piece of game state on a sheet.
- Run "if a malicious client lies, does it break?" on each.
- Group host-owned fields by domain → one Manager per domain.
- For each Manager: data dict,
_host_apply_*() mutators, _client_request_*() RPCs with validators, _broadcast_*() RPCs, peer_joined / peer_left lifecycle, _send_full_state() for late-join.
- Cosmetic state goes through
MultiplayerSynchronizer or a flavor-3 RPC.
- Position/velocity get host-reconciled prediction (covered by
/peer-to-peer-multiplayer Layer 4).
Collaborative protocol
This skill writes new autoload files (one per Manager). Always ask before each:
May I create autoloads/HealthManager.gd and register it as an autoload?
May I add the validation function to HealthManager? Here's what I'll check: [list].
May I wire HealthManager's peer_joined lifecycle?
Don't bulk-create five Managers in one shot. Walk one Manager end-to-end (data → mutators → requests → broadcasts → lifecycle) so the user can verify the shape, then repeat for the next domain.
After each Manager file lands, call summer_get_script_errors to confirm clean compile. See references/collaborative-protocol.md.
See also