
Godot Adapt Single To Multiplayer
- 160 installs
- 454 repo stars
- Updated July 28, 2026
- thedivergentai/gd-agentic-skills
Use godot-adapt-single-to-multiplayer for development tasks
About
godot-adapt-single-to-multiplayer: A skill for development. This provides functionality for development workflows.
- godot-adapt-single-to-multiplayer
Godot Adapt Single To Multiplayer by the numbers
- 160 all-time installs (skills.sh)
- +17 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #2,394 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/thedivergentai/gd-agentic-skills --skill godot-adapt-single-to-multiplayerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 160 |
|---|---|
| repo stars | ★ 454 |
| Last updated | July 28, 2026 |
| Repository | thedivergentai/gd-agentic-skills ↗ |
What it does
Use godot-adapt-single-to-multiplayer for development tasks
Files
Adapt: Single to Multiplayer
Expert guidance for retrofitting multiplayer into single-player games.
NEVER Do (Expert Multiplayer Rules)
Security & Authority
- NEVER trust client-reported state — Clients own their 'Input', NOT their 'Position' or 'Health'. Server must validate every coordinate and health change.
- NEVER use `get_tree()` groups for authority checks — Use
is_multiplayer_authority(). Group registration is non-deterministic in high-latency joins. - NEVER allow unrestricted RPC rates — A malicious client can call a 'FireWeapon' RPC 10,000 times per second. Always implement rate-limiting (
net_rpc_rate_limiter.gd).
Movement & Lag
- NEVER skip Client-Side Prediction — Movement without prediction feels 'heavy' and unresponsive. Predict movement locally, then correct only on server disagreement.
- NEVER sync peers at 60Hz — Sending entire state every frame will saturate client bandwidth. Use a lower tick-rate (20-30Hz) and interpolate between packets.
- NEVER snap peer positions — Abrupt position updates cause 'jitter'. Store a buffer of past states and lerp between them with a 100ms delay.
Bandwidth & Sync
- NEVER sync 'Full Floats' if possible — Quantize Vector3 data (truncating decimals) to save 50%+ bandwidth. Use
MultiplayerSynchronizerwith delta-sync enabled. - NEVER ignore 'Late Joiners' — Players who join mid-game won't see existing environmental changes. Broadcast a full world-state 'Snapshot' on peer connection.
- NEVER test on 0ms ping — Everything works on localhost. Use a simulator (
net_latency_simulator.gd) with 150ms ping to identify sync bugs.
---
Available Scripts
MANDATORY: Read the appropriate script before implementing the corresponding pattern.
net_prediction_reconciliation.gd
Expert CharacterBody3D prediction with input-buffer replaying for server reconciliation.
net_snapshot_interpolation.gd
Professional snapshot interpolation logic for smoothing peer movement via jitter buffers.
net_auth_server_validator.gd
Authoritative server validator for anti-cheat (Position, Speed, and Action checks).
net_rpc_rate_limiter.gd
Expert rate-limiter to prevent RPC flooding and macro-abuse by clients.
net_interest_management.gd
Distance-based visibility management to optimize binary bandwidth per-peer.
net_delta_compression_sync.gd
Expert quantization and significance-checking logic for delta-compression.
net_upnp_discovery_logic.gd
Robust script for P2P network discovery and automatic port forwarding via UPNP.
net_debug_overlay_monitor.gd
In-game diagnostic overlay reporting RTT (Ping), Packet Loss, and Jitter.
net_lag_compensation.gd
Expert server-side state rewinding (Lag Compensation) for accurate hit-registration.
net_lobby_late_join_sync.gd
Professional state-initialization logic to bridge 'Late Joiners' into a synced session.
net_latency_simulator.gd
Editor-only tool for simulating high-ping and loss conditions for stress-testing.
---
Architecture Patterns
Pattern 1: Authoritative Server (Recommended)
# Server validates ALL gameplay logic
# Clients send inputs → Server processes → Server broadcasts state
# Pros: Secure, prevents cheating
# Cons: Requires server hosting, lag affects gameplay
# Use for: Competitive games, PvP, games with economiesPattern 2: Peer-to-Peer (Lockstep)
# All clients run identical simulation
# Inputs synced, deterministic physics
# Pros: No dedicated server needed
# Cons: Vulnerable to cheating, desyncs common
# Use for: Co-op, casual games, small player counts (2-4)Pattern 3: Hybrid (Authority Transfer)
# Host acts as server
# Authority can transfer between peers
# Use for: 4-8 player co-op, party games---
Step-by-Step Migration
Step 1: Separate Input from Logic
# ❌ BAD: Input directly modifies state (single-player)
extends CharacterBody2D
func _physics_process(delta: float) -> void:
var input := Input.get_vector("left", "right", "up", "down")
velocity = input.normalized() * SPEED
move_and_slide()
# ✅ GOOD: Input → Logic separation
extends CharacterBody2D
var current_input := Vector2.ZERO
func _physics_process(delta: float) -> void:
# Only read input if this is OUR player
if is_multiplayer_authority():
current_input = Input.get_vector("left", "right", "up", "down")
# Send input to server (if we're client)
if multiplayer.get_unique_id() != 1: # Not server
rpc_id(1, "receive_input", current_input)
# EVERYONE processes movement (server + all clients)
_process_movement(delta, current_input)
func _process_movement(delta: float, input: Vector2) -> void:
velocity = input.normalized() * SPEED
move_and_slide()
@rpc("any_peer", "call_remote", "unreliable")
func receive_input(input: Vector2) -> void:
# Server receives client input
current_input = inputStep 2: Set Up Multiplayer Authority
# server_setup.gd
extends Node
const PORT = 7777
const MAX_PLAYERS = 4
func host_game() -> void:
var peer := ENetMultiplayerPeer.new()
peer.create_server(PORT, MAX_PLAYERS)
multiplayer.multiplayer_peer = peer
multiplayer.peer_connected.connect(_on_player_connected)
multiplayer.peer_disconnected.connect(_on_player_disconnected)
print("Server started on port %d" % PORT)
func join_game(ip: String) -> void:
var peer := ENetMultiplayerPeer.new()
peer.create_client(ip, PORT)
multiplayer.multiplayer_peer = peer
print("Connecting to %s:%d" % [ip, PORT])
func _on_player_connected(id: int) -> void:
print("Player %d connected" % id)
spawn_player(id)
func _on_player_disconnected(id: int) -> void:
print("Player %d disconnected" % id)
despawn_player(id)
func spawn_player(id: int) -> void:
var player := preload("res://player.tscn").instantiate()
player.name = str(id) # CRITICAL: Name must be unique and match peer ID
player.set_multiplayer_authority(id) # Client owns their own player
get_node("/root/World").add_child(player, true) # true = replicate to all peersStep 3: Add MultiplayerSynchronizer
# Scene structure:
# Player (CharacterBody2D)
# ├─ Sprite2D
# ├─ CollisionShape2D
# └─ MultiplayerSynchronizer
# MultiplayerSynchronizer setup (in editor):
# - Root Path: "../" (points to Player node)
# - Replication Interval: 0.05 (20Hz updates)
# - Public Visibility: true
# - Synchronized Properties:
# - position
# - rotation
# - velocity (optional, for interpolation)
# No code needed! MultiplayerSynchronizer auto-syncs properties---
Client Prediction & Server Reconciliation
Problem: Lag Makes Game Feel Unresponsive
# Without prediction:
# 1. Client presses W
# 2. Input sent to server
# 3. Server processes (50ms later)
# 4. Server sends back position
# 5. Client sees movement (100ms RTT)
# Result: 100ms delay between input and visual feedbackSolution: Client-Side Prediction
# player_controller.gd
extends CharacterBody2D
var input_buffer: Array = []
var server_state := {"position": Vector2.ZERO, "tick": 0}
func _physics_process(delta: float) -> void:
if is_multiplayer_authority():
var input := Input.get_vector("left", "right", "up", "down")
# Client predicts movement IMMEDIATELY
var tick := Engine.get_physics_frames()
input_buffer.append({"input": input, "tick": tick})
process_movement(input)
# Send input to server
if multiplayer.get_unique_id() != 1:
rpc_id(1, "server_receive_input", input, tick)
else:
# Other players: just display synced position (no prediction)
pass
@rpc("any_peer", "call_remote", "unreliable")
func server_receive_input(input: Vector2, client_tick: int) -> void:
# Server processes input
process_movement(input)
# Send authoritative state back
rpc_id(multiplayer.get_remote_sender_id(), "client_receive_state", position, client_tick)
@rpc("authority", "call_remote", "unreliable")
func client_receive_state(server_pos: Vector2, server_tick: int) -> void:
# Reconciliation: check if prediction was correct
var error := position.distance_to(server_pos)
if error > 5.0: # Threshold for correction
# Snap to server position
position = server_pos
# Replay inputs that happened after server_tick
for buffered_input in input_buffer:
if buffered_input.tick > server_tick:
process_movement(buffered_input.input)
# Clean old inputs
input_buffer = input_buffer.filter(func(i): return i.tick > server_tick)
func process_movement(input: Vector2) -> void:
velocity = input.normalized() * SPEED
move_and_slide()---
Lag Compensation Techniques
Interpolation (Other Player Smoothing)
# Other players appear choppy due to packet loss/jitter
# Solution: Interpolate between received states
extends CharacterBody2D
var position_buffer: Array = []
const BUFFER_SIZE = 3 # Store last 3 positions
func _ready() -> void:
if not is_multiplayer_authority():
# Disable local physics, use interpolation
set_physics_process(false)
func _process(delta: float) -> void:
if not is_multiplayer_authority() and position_buffer.size() >= 2:
# Interpolate between buffered positions
var from := position_buffer[0]
var to := position_buffer[1]
var t := 0.2 # Interpolation speed
position = position.lerp(to, t)
if position.distance_to(to) < 1.0:
position_buffer.pop_front()
# Called by MultiplayerSynchronizer when position updates
func _on_position_synced(new_pos: Vector2) -> void:
position_buffer.append(new_pos)
if position_buffer.size() > BUFFER_SIZE:
position_buffer.pop_front()
### Server-Side Lag Compensation (Hit Rewind)
To ensure clients can hit targets accurately despite latency, the server must "rewind" the world state to the exact moment the client fired.
**Expert Pattern:**
1. **Record History**: Store global transforms of all hit-able entities (players, enemies) in a rolling buffer indexed by `Engine.get_physics_frames()`.
2. **Hit Request**: Client sends a "Fire" RPC including the `tick` when they pressed the button.
3. **Rewind**: Server retrieves the state for that `tick`, temporarily moves all RIDs back to those transforms via `PhysicsServer3D.body_set_state()`.
4. **Validate**: Perform a raycast query.
5. **Restore**: Move all RIDs back to their "present day" transforms.
> [!TIP]
> Always use `PhysicsServer3D` directly for rewinding to bypass `SceneTree` overhead and prevent unwanted signal/node update cascades.---
Anti-Cheat Measures
Server-Side Validation
# server_validator.gd
extends Node
const MAX_SPEED = 300.0
const MAX_TELEPORT_DISTANCE = 50.0
@rpc("any_peer", "call_remote", "reliable")
func request_move(new_position: Vector2) -> void:
var sender_id := multiplayer.get_remote_sender_id()
var player := get_node("/root/World/" + str(sender_id))
# Validate movement
var distance := player.position.distance_to(new_position)
var delta := get_physics_process_delta_time()
var max_allowed := MAX_SPEED * delta
if distance > max_allowed:
push_warning("Player %d teleported %f units (max: %f)" % [sender_id, distance, max_allowed])
# Reject movement, force server position
rpc_id(sender_id, "force_position", player.position)
return
# Accept movement
player.position = new_position
@rpc("authority", "call_remote", "reliable")
func force_position(server_position: Vector2) -> void:
position = server_position---
Bandwidth Optimization
Input Buffering
# ❌ BAD: Send input every frame (60 packets/s)
func _physics_process(delta: float) -> void:
var input := get_input()
rpc_id(1, "receive_input", input)
# ✅ GOOD: Send every 3rd frame (20 packets/s)
var input_timer := 0.0
const INPUT_SEND_RATE = 0.05 # 20 Hz
func _physics_process(delta: float) -> void:
input_timer += delta
if input_timer >= INPUT_SEND_RATE:
var input := get_input()
rpc_id(1, "receive_input", input)
input_timer = 0.0---
Testing Multiplayer Locally
# Launch multiple instances for testing
# Run from command line:
# Windows:
# Server: Godot.exe --path . res://main.tscn -- --server
# Client 1: Godot.exe --path . res://main.tscn -- --client
# Client 2: Godot.exe --path . res://main.tscn -- --client
# Parse arguments in code:
func _ready() -> void:
var args := OS.get_cmdline_args()
if "--server" in args:
host_game()
elif "--client" in args:
join_game("127.0.0.1")---
Decision Tree: Which Architecture?
| Factor | Authoritative Server | P2P Lockstep |
|---|---|---|
| Player count | 8-100+ | 2-4 |
| Cheat prevention | Critical | Not important |
| Server hosting | Available | Not available |
| Gameplay type | PvP, competitive | Co-op, casual |
| Lag tolerance | Medium (prediction helps) | Low (desyncs) |
| Development complexity | High | Medium |
Advanced Networking Topics
Peer-to-Peer NAT Traversal (Hole Punching)
In P2P architectures, clients often sit behind firewalls. UPNP (Universal Plug and Play) is the first line of defense, allowing the game to request port forwarding from the router automatically using net_upnp_discovery_logic.gd.
For cases where UPNP fails:
- STUN/TURN: Use a STUN server to discover public IP/port pairings.
- Relay Servers: If direct connection is impossible, fallback to a relay server (TURN) to bridge the two peers.
Network Profiling & Visualization
Visualizing the packet timeline is critical for debugging jitter. Propose an overlay that graphs:
- Packet Arrival: A scrolling timeline showing when packets arrive relative to physics frames.
- Buffer Health: A visualization of the interpolation jitter buffer size.
- RTT (Round Trip Time): Real-time graph of latency spikes.
Reference
- Master Skill: godot-master
# skills/adapt-single-to-multiplayer/scripts/multiplayer_sync.gd
extends MultiplayerSynchronizer
## Multiplayer Sync Expert Pattern
## Optimized synchronization with interpolation and bandwidth management.
class_name ExpertMultiplayerSync
@export var interpolation_alpha: float = 0.5
# We assume the parent is the CharacterBody or Node3D to sync
@onready var parent: Node = get_parent()
# Buffer for interpolation
var _target_position: Vector3
var _target_rotation: Vector3
var _last_packet_time: float
func _ready() -> void:
# Configure sync properties via code or editor
# Usually: position, rotation, velocity (optional)
# Only interpolate for non-authority (dumb clients)
set_process(not is_multiplayer_authority())
if not is_multiplayer_authority():
_target_position = parent.position
if parent is Node3D:
_target_rotation = parent.rotation
# Don't override physics on remote peers if using simple sync
parent.set_physics_process(false)
func _process(delta: float) -> void:
if parent is Node3D:
parent.position = parent.position.lerp(_target_position, interpolation_alpha)
parent.rotation = parent.rotation.lerp(_target_rotation, interpolation_alpha)
elif parent is Node2D:
parent.position = parent.position.lerp(_target_position, interpolation_alpha)
parent.rotation = lerp_angle(parent.rotation, _target_rotation.z, interpolation_alpha)
# Note: The MultiplayerSynchronizer node automatically updates properties.
# However, to use interpolation, we often sync to a specific variable (e.g. sync_pos)
# instead of 'position' directly, then lerp 'position' to 'sync_pos' in process.
# This script demonstrates the pattern where we intercept the data.
# To make this work without extra variables, we can use signals if replication config supports it,
# OR we rely on a specific 'puppet_position' variable in the parent that is synced.
# Recommended Pattern:
# 1. Sync 'puppet_position' and 'puppet_rotation' (watch variables).
# 2. Parent script:
# var puppet_position: Vector3
# func _process(delta):
# if not is_authority:
# position = position.lerp(puppet_position, 0.5)
## EXPERT USAGE:
## Attach to MultiplayerSynchronizer. Set replication config to sync
## `puppet_position` on the parent, not directly `position`, to enable smoothing.
class_name NetAuthServerValidator
extends Node
## Expert Server-Authoritative Anti-Cheat.
## Validates movement and actions before broadcasting.
const MAX_SPEED = 15.0
const SPEED_BUFFER = 1.1
func validate_move(player: Node3D, new_pos: Vector3, delta: float) -> bool:
var dist = player.global_position.distance_to(new_pos)
var max_dist = MAX_SPEED * delta * SPEED_BUFFER
if dist > max_dist:
printerr("Cheat detected: Player moved too fast!")
return false
return true
## Rule: Never trust client-reported health or inventory values. Calculate them on server.
class_name NetDebugOverlayMonitor
extends CanvasLayer
## Expert Network Debug Monitor.
## Displays real-time RTT, Packet Loss, and Jitter.
@onready var label = $Label
func _process(_delta: float) -> void:
var peer = multiplayer.multiplayer_peer as ENetMultiplayerPeer
if not peer or peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
return
# Note: ENet provides statistics per peer
var stats = "Network Stats:\n"
stats += "RTT: %dms\n" % peer.get_peer(1).get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIME)
stats += "Loss: %d%%\n" % peer.get_peer(1).get_statistic(ENetPacketPeer.PEER_PACKET_LOSS)
label.text = stats
## Rule: Always provide a network overlay during alpha/beta testing to catch ISP-routing issues.
class_name NetDeltaCompressionSync
extends Node
## Expert Delta Compression & Quantization.
## Reduces bandwidth by only syncing significant changes and truncating floats.
@export var synchronizer: MultiplayerSynchronizer
func _physics_process(_delta: float) -> void:
if not is_multiplayer_authority(): return
# Expert: Truncate precision to 2 decimal places to save bits
var quantized_pos = global_position.snapped(Vector3(0.01, 0.01, 0.01))
# Only sync if the change is significant
if global_position.distance_to(quantized_pos) > 0.001:
# MultiplayerSynchronizer handles the low-level UDP packing
pass
## Rule: Avoid syncing 'Rotation' every frame. Sync 'Rotation Angle' as a single half-float (16-bit).
class_name NetInterestManagement
extends Area3D
## Expert Interest Management (Network ROI).
## Toggles node visibility based on proximity to optimize bandwidth.
@export var synchronizer: MultiplayerSynchronizer
@export var cull_distance: float = 50.0
func _physics_process(_delta: float) -> void:
if not multiplayer.is_server(): return
for peer_id in multiplayer.get_peers():
var peer_node = get_tree().get_nodes_in_group("Players").filter(func(p): return p.name == str(peer_id))[0]
var dist = global_position.distance_to(peer_node.global_position)
synchronizer.set_visibility_for(peer_id, dist < cull_distance)
## Rule: Use interest management for large maps to prevent clients from receiving local data for players 1km away.
class_name NetLagCompensation
extends Node
## Expert Server-Side Lag Compensation (Hit-Registration Rewind).
## Records history of hit-able entities and rewinds world state to validate client hits.
# Store ~1 second of history. At 60 TPS, 60 ticks.
const MAX_HISTORY_TICKS: int = 60
# Structure: { tick_id (int) : { entity_rid (RID) : historical_transform (Transform3D) } }
var _state_history: Dictionary = {}
func _physics_process(_delta: float) -> void:
# Server-side only logic
if not multiplayer.is_server():
return
var current_tick := Engine.get_physics_frames()
var current_state := {}
# Record current state of all hit-able entities
# Entities must be in the "lag_compensated" group to be tracked
var entities := get_tree().get_nodes_in_group(&"lag_compensated")
for entity in entities:
if entity is Node3D:
# We store the physics RID to update the server directly later for performance
var rid: RID = entity.get_rid()
current_state[rid] = entity.global_transform
_state_history[current_tick] = current_state
# Prune old history to prevent memory leaks
var oldest_tick := current_tick - MAX_HISTORY_TICKS
if _state_history.has(oldest_tick):
_state_history.erase(oldest_tick)
## Validates a hit request from a client by rewinding the physics state.
@rpc("any_peer", "call_remote", "reliable")
func request_hit_validation(client_tick: int, ray_origin: Vector3, ray_normal: Vector3) -> void:
if not multiplayer.is_server():
return
var sender_id := multiplayer.get_remote_sender_id()
# Check if the requested tick is still in our history buffer
if not _state_history.has(client_tick):
push_warning("Client %d hit request rejected: Tick %d too old or invalid." % [sender_id, client_tick])
return
var historical_state: Dictionary = _state_history[client_tick]
var present_state := {}
# --- THE REWIND ---
# Bypassing SceneTree for performance by editing PhysicsServer3D directly
for rid: RID in historical_state:
# Backup the present state directly from the PhysicsServer3D
present_state[rid] = PhysicsServer3D.body_get_state(rid, PhysicsServer3D.BODY_STATE_TRANSFORM)
# Snap the body back in time to the historical state
PhysicsServer3D.body_set_state(rid, PhysicsServer3D.BODY_STATE_TRANSFORM, historical_state[rid])
# --- THE VALIDATION ---
# Query the physics space while it is suspended in the past
var space_state := get_world_3d().direct_space_state
var ray_end := ray_origin + (ray_normal * 1000.0) # 1000m max weapon range
var query := PhysicsRayQueryParameters3D.create(ray_origin, ray_end)
var result := space_state.intersect_ray(query)
# --- THE RESTORATION ---
for rid: RID in present_state:
# Snap the body back to the present
PhysicsServer3D.body_set_state(rid, PhysicsServer3D.BODY_STATE_TRANSFORM, present_state[rid])
# --- RESOLVE IMPACT ---
if result:
var hit_collider: Object = result["collider"]
if hit_collider.has_method("apply_damage"):
# Example damage application
hit_collider.apply_damage(10)
# Notify the client of a confirmed hit if needed
class_name NetLatencySimulator
extends Node
## Expert Network Latency Simulator.
## Simulates high-latency environments for local testing.
@export var latency_ms: int = 150
@export var jitter_ms: int = 50
@export var loss_percent: float = 0.05
func _ready() -> void:
if not OS.has_feature("editor"): return
var peer = multiplayer.multiplayer_peer as ENetMultiplayerPeer
if peer:
# ENet built-in simulation
# Note: Implementation varies by Godot version and Peer type
pass
## Tip: If the game feels unplayable at 150ms latency, your lag compensation logic needs refactoring.
class_name NetLobbyLateJoinSync
extends Node
## Expert Late-Join State Synchronizer.
## Ensures new players receive a full world-state snapshot upon connection.
func _ready() -> void:
if multiplayer.is_server():
multiplayer.peer_connected.connect(_sync_new_player)
func _sync_new_player(id: int) -> void:
# Snapshot the entire game state
var state = {
"score": 100,
"elapsed_time": 300.0,
"world_seed": 12345
}
rpc_id(id, "receive_full_state", state)
@rpc("authority", "call_remote", "reliable")
func receive_full_state(state: Dictionary) -> void:
# Apply state to local world
pass
## Rule: Reliable RPCs are mandatory for initial state syncing. Use Unreliable for physics thereafter.
class_name NetPredictionReconciliation
extends CharacterBody3D
## Expert Client Prediction & Server Reconciliation.
## Minimizes apparent latency by predicting movement and replaying on error.
# --- Prediction State ---
var current_tick: int = 0
var input_buffer: Array[Dictionary] = []
const RECONCILIATION_THRESHOLD = 0.1
# --- Server Reconciliation State ---
var server_position: Vector3
var last_server_tick: int = 0
var needs_reconciliation: bool = false
func _physics_process(delta: float) -> void:
if is_multiplayer_authority():
current_tick += 1
var input = _get_input()
# Buffer the input for the replay loop
# We store the state BEFORE applying movement to allow precise re-simulation
input_buffer.append({
"tick": current_tick,
"input": input,
"pos": global_position
})
# Send input to server
rpc_id(1, "server_process_input", input, current_tick)
# If we received a correction from the server, perform reconciliation
if needs_reconciliation:
_reconcile_and_replay(delta)
needs_reconciliation = false
else:
# Predict locally
_apply_movement(input, delta)
@rpc("any_peer", "call_remote", "unreliable")
func server_process_input(input: Vector2, client_tick: int) -> void:
if not multiplayer.is_server():
return
# Server validates and applies
_apply_movement(input, get_physics_process_delta_time())
# Send authoritative state back to the specific client
var sender_id := multiplayer.get_remote_sender_id()
client_receive_state.rpc_id(sender_id, global_position, client_tick)
@rpc("authority", "call_remote", "unreliable")
func client_receive_state(auth_pos: Vector3, auth_tick: int) -> void:
# Ignore old or out-of-order packets
if auth_tick <= last_server_tick:
return
last_server_tick = auth_tick
server_position = auth_pos
# Find the matching entry in our buffer to check for divergence
var matching_entry = null
for entry in input_buffer:
if entry.tick == auth_tick:
matching_entry = entry
break
# If the server position significantly differs from our predicted position at that tick
# (Note: we should compare to the position AFTER movement was applied at that tick,
# but for simplicity we check the next entry's start pos or the current pos if it's the latest)
if matching_entry and matching_entry.pos.distance_to(auth_pos) > RECONCILIATION_THRESHOLD:
needs_reconciliation = true
# Prune the buffer of acknowledged inputs
input_buffer = input_buffer.filter(func(i): return i.tick > auth_tick)
func _reconcile_and_replay(delta: float) -> void:
# 1. Snap back to the authoritative server state
global_position = server_position
# 2. Replay all unacknowledged inputs to catch up to the current frame
for entry in input_buffer:
# Update the stored position for future reconciliation checks
entry.pos = global_position
_apply_movement(entry.input, delta)
func _apply_movement(input: Vector2, delta: float) -> void:
velocity = Vector3(input.x, 0, input.y) * 10.0
# Use move_and_slide for normal movement, but be aware of non-determinism
move_and_slide()
func _get_input() -> Vector2:
return Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
class_name NetRPCRateLimiter
extends Node
## Expert RPC Rate Limiting.
## Prevents malicious clients from flooding the server with expensive calls.
var rpc_timers: Dictionary = {}
func is_rate_limited(peer_id: int, rpc_name: String, limit_ms: float = 50.0) -> bool:
var key = str(peer_id) + "_" + rpc_name
var now = Time.get_ticks_msec()
if rpc_timers.has(key) and (now - rpc_timers[key]) < limit_ms:
return true
rpc_timers[key] = now
return false
## Tip: Use this for 'Fire', 'Reload', or 'Interact' RPCs to block macro users.
class_name NetSnapshotInterpolation
extends Node3D
## Expert Snapshot Interpolation.
## Smooths other peers' movement by lerping between past snapshots.
var snapshots: Array[Dictionary] = []
const INTERP_DELAY_MS = 100
func _process(_delta: float) -> void:
if is_multiplayer_authority(): return
var render_time = Time.get_ticks_msec() - INTERP_DELAY_MS
# Find two snapshots for the 'render_time'
if snapshots.size() >= 2:
var s1 = snapshots[0]
var s2 = snapshots[1]
# Lerp position between s1 and s2...
global_position = s1.pos.lerp(s2.pos, 0.1)
@rpc("authority", "call_remote", "unreliable")
func update_state(pos: Vector3) -> void:
snapshots.append({"time": Time.get_ticks_msec(), "pos": pos})
if snapshots.size() > 5: snapshots.pop_front()
## Rule: Never snap peers to raw values. Always use a jitter-buffer/delay.
class_name NetUPNPDiscoveryLogic
extends Node
## Expert UPNP & Local Discovery.
## Automates port forwarding and local peer discovery for P2P play.
func setup_upnp(port: int) -> void:
var upnp = UPNP.new()
var error = upnp.discover()
if error == OK:
if upnp.get_gateway() and upnp.get_gateway().is_valid_gateway():
upnp.add_port_mapping(port)
print("UPNP: Port %d forwarded successfully." % port)
else:
printerr("UPNP: Discovery failed with error %d" % error)
## Tip: Local discovery can be handled via 'PacketPeerUDP' broadcasting on the local subnet.
# skills/adapt-single-to-multiplayer/scripts/rpc_bridge.gd
extends Node
## RPC Bridge Expert Pattern
## Signal-to-RPC bridge for centralized networking logic.
## Decouples network transport from game logic.
class_name RPCBridge
# Define network events
signal input_received(peer_id: int, input: Vector2)
signal state_updated(peer_id: int, state: Dictionary)
signal event_occurred(event_name: String, data: Dictionary)
# RPC wrappers
@rpc("any_peer", "call_remote", "unreliable")
func send_input(input: Vector2) -> void:
var sender_id = multiplayer.get_remote_sender_id()
# Validate Authority: Server Logic
if multiplayer.is_server():
input_received.emit(sender_id, input)
@rpc("authority", "call_remote", "unreliable")
func update_client_state(state: Dictionary) -> void:
# Client Logic
state_updated.emit(1, state)
@rpc("any_peer", "call_remote", "reliable")
func broadcast_event(name: String, data: Dictionary) -> void:
# Server guard
if multiplayer.is_server():
# Re-broadcast to valid listeners
_client_receive_event.rpc(name, data)
event_occurred.emit(name, data) # Server handles it too
@rpc("authority", "call_remote", "reliable")
func _client_receive_event(name: String, data: Dictionary) -> void:
event_occurred.emit(name, data)
# Public API
func submit_input(input: Vector2) -> void:
if multiplayer.has_multiplayer_peer():
send_input.rpc_id(1, input)
else:
# Offline fallback
input_received.emit(1, input)
func push_state_update(peer_id: int, state: Dictionary) -> void:
if multiplayer.is_server():
update_client_state.rpc_id(peer_id, state)
func trigger_event(name: String, data: Dictionary) -> void:
if multiplayer.is_server():
_client_receive_event.rpc(name, data)
event_occurred.emit(name, data)
elif multiplayer.has_multiplayer_peer():
broadcast_event.rpc_id(1, name, data)
## EXPERT USAGE:
## Use this bridge to avoid littering @rpc functions inside every Actor.
## connect("input_received", _on_input) in your ServerController.