From summer
Guides designing the state layer of a multiplayer Godot game: deciding host vs. client ownership, RPC request/validation patterns, and anti-cheat state design.
How this skill is triggered — by the user, by Claude, or both
Slash command
/summer:host-authoritative-stateThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
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-detect...
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.
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 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.
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:
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.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.
/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.
@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.any_peer, a client can fake a score broadcast → desync.@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.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.is_host guard means peers process each other's requests and corrupt local state.@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.reliable for high-frequency cosmetic events (footsteps, particle spawns) blows up bandwidth.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.
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:
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.
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.
| 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. |
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:
It is the right tool for cosmetic state only:
AnimationTree blend values).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.
When you start a new MP game, the order is:
NetworkManager (covered by /peer-to-peer-multiplayer Layer 1)._host_apply_*() mutators, _client_request_*() RPCs with validators, _broadcast_*() RPCs, peer_joined / peer_left lifecycle, _send_full_state() for late-join.MultiplayerSynchronizer or a flavor-3 RPC./peer-to-peer-multiplayer Layer 4).This skill writes new autoload files (one per Manager). Always ask before each:
May I create
autoloads/HealthManager.gdand register it as an autoload? May I add the validation function to HealthManager? Here's what I'll check: [list]. May I wire HealthManager'speer_joinedlifecycle?
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.
peer-to-peer-multiplayer — the four-layer architecture overview. Read this first if you haven't.setup-multiplayer — lighter intro that just gets a session running. Use that one if the user just wants two players to see each other.references/godot-version.md — Godot 4.5 multiplayer API stability notes.references/gd-style.md — typed-GDScript conventions used in the examples above.references/mcp-tools-reference.md — summer_get_script_errors for compile verification.npx claudepluginhub summerengine/summer-engine-agent --plugin summerGuides building peer-to-peer multiplayer architecture in Godot from scratch, covering network manager, authoritative state, routing, and real-time rendering. Activates on keywords like 'multiplayer', 'p2p', 'host'.
Implements multiplayer basics in Godot 4.3+ using MultiplayerAPI, ENet/WebSocket peers, RPCs, and authority model for client-server architecture.
Defines ownership, authority, replication, prediction, rollback, and reconciliation strategies for multiplayer systems. Useful when dealing with latency, authority issues, or networked state sync.