godot-adapt-single-to-multiplayer
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseAdapt: Single to Multiplayer
适配:从单人到多人游戏
Expert guidance for retrofitting multiplayer into single-player games.
为单人游戏改造多人联机功能的专家指南。
NEVER Do
绝对不要做的事
- NEVER trust client input — Always validate on server. Clients can send fake position/health/inventory data.
- NEVER use get_tree().get_nodes_in_group() for authority checks — Use on individual nodes. Group iteration is unreliable for network identity.
is_multiplayer_authority() - NEVER forget to set multiplayer_authority — Nodes without authority assignment will desync. Server should own world objects, clients own their player.
- NEVER run physics on both client and server identically — Leads to double-speed movement. Use client prediction with server reconciliation OR server-only physics.
- NEVER send raw input every frame — Buffer inputs client-side, send in batches (every 3-5 frames). Reduces bandwidth 60-80%.
- 绝对不要信任客户端输入 — 始终在服务器端进行验证。客户端可能发送伪造的位置、生命值或物品栏数据。
- 绝对不要使用get_tree().get_nodes_in_group()进行权限校验 — 对单个节点使用。遍历组的方式对于网络身份校验不可靠。
is_multiplayer_authority() - 绝对不要忘记设置multiplayer_authority — 未分配权限的节点会导致不同步。服务器应拥有世界对象的权限,客户端拥有自己玩家的权限。
- 绝对不要在客户端和服务器上完全相同地运行物理逻辑 — 这会导致移动速度翻倍。应使用带server reconciliation的客户端预测,或者仅在服务器端运行物理逻辑。
- 绝对不要每帧发送原始输入 — 在客户端缓冲输入,批量发送(每3-5帧一次)。可减少60-80%的带宽占用。
Available Scripts
可用脚本
MANDATORY: Read the appropriate script before implementing the corresponding pattern.
强制要求:在实现对应模式前,请先阅读相应的脚本。
multiplayer_sync.gd
multiplayer_sync.gd
Latency-aware synchronization with MultiplayerSynchronizer. Demonstrates peer interpolation (lerp to network position) and authority-based update logic.
基于MultiplayerSynchronizer的延迟感知同步。演示了对等体插值(lerp到网络位置)以及基于权限的更新逻辑。
rpc_bridge.gd
rpc_bridge.gd
Signal-to-RPC bridge pattern. Shows authority guard pattern: client requests → server validates → server broadcasts. Essential for cheat prevention.
信号转RPC的桥接模式。展示了权限守卫模式:客户端请求 → 服务器验证 → 服务器广播。这是防止作弊的关键。
Architecture Patterns
架构模式
Pattern 1: Authoritative Server (Recommended)
模式1:权威服务器(推荐)
gdscript
undefinedgdscript
undefinedServer validates ALL gameplay logic
Server validates ALL gameplay logic
Clients send inputs → Server processes → Server broadcasts state
Clients send inputs → Server processes → Server broadcasts state
Pros: Secure, prevents cheating
Pros: Secure, prevents cheating
Cons: Requires server hosting, lag affects gameplay
Cons: Requires server hosting, lag affects gameplay
Use for: Competitive games, PvP, games with economies
Use for: Competitive games, PvP, games with economies
undefinedundefinedPattern 2: Peer-to-Peer (Lockstep)
模式2:对等网络(Lockstep)
gdscript
undefinedgdscript
undefinedAll clients run identical simulation
All clients run identical simulation
Inputs synced, deterministic physics
Inputs synced, deterministic physics
Pros: No dedicated server needed
Pros: No dedicated server needed
Cons: Vulnerable to cheating, desyncs common
Cons: Vulnerable to cheating, desyncs common
Use for: Co-op, casual games, small player counts (2-4)
Use for: Co-op, casual games, small player counts (2-4)
undefinedundefinedPattern 3: Hybrid (Authority Transfer)
模式3:混合架构(权限转移)
gdscript
undefinedgdscript
undefinedHost acts as server
Host acts as server
Authority can transfer between peers
Authority can transfer between peers
Use for: 4-8 player co-op, party games
Use for: 4-8 player co-op, party games
---
---Step-by-Step Migration
分步迁移指南
Step 1: Separate Input from Logic
步骤1:分离输入与逻辑
gdscript
undefinedgdscript
undefined❌ BAD: Input directly modifies state (single-player)
❌ 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()
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
✅ 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 = input
undefinedextends 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 = input
undefinedStep 2: Set Up Multiplayer Authority
步骤2:设置多人联机权限
gdscript
undefinedgdscript
undefinedserver_setup.gd
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 peers
undefinedextends 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 peers
undefinedStep 3: Add MultiplayerSynchronizer
步骤3:添加MultiplayerSynchronizer
gdscript
undefinedgdscript
undefinedScene structure:
Scene structure:
Player (CharacterBody2D)
Player (CharacterBody2D)
├─ Sprite2D
├─ Sprite2D
├─ CollisionShape2D
├─ CollisionShape2D
└─ MultiplayerSynchronizer
└─ MultiplayerSynchronizer
MultiplayerSynchronizer setup (in editor):
MultiplayerSynchronizer setup (in editor):
- Root Path: "../" (points to Player node)
- Root Path: "../" (points to Player node)
- Replication Interval: 0.05 (20Hz updates)
- Replication Interval: 0.05 (20Hz updates)
- Public Visibility: true
- Public Visibility: true
- Synchronized Properties:
- Synchronized Properties:
- position
- position
- rotation
- rotation
- velocity (optional, for interpolation)
- velocity (optional, for interpolation)
No code needed! MultiplayerSynchronizer auto-syncs properties
No code needed! MultiplayerSynchronizer auto-syncs properties
---
---Client Prediction & Server Reconciliation
客户端预测与Server Reconciliation
Problem: Lag Makes Game Feel Unresponsive
问题:延迟导致游戏手感卡顿
gdscript
undefinedgdscript
undefinedWithout prediction:
Without prediction:
1. Client presses W
1. Client presses W
2. Input sent to server
2. Input sent to server
3. Server processes (50ms later)
3. Server processes (50ms later)
4. Server sends back position
4. Server sends back position
5. Client sees movement (100ms RTT)
5. Client sees movement (100ms RTT)
Result: 100ms delay between input and visual feedback
Result: 100ms delay between input and visual feedback
undefinedundefinedSolution: Client-Side Prediction
解决方案:客户端侧预测
gdscript
undefinedgdscript
undefinedplayer_controller.gd
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()
---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)
插值(其他玩家平滑显示)
gdscript
undefinedgdscript
undefinedOther players appear choppy due to packet loss/jitter
Other players appear choppy due to packet loss/jitter
Solution: Interpolate between received states
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()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
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()
---func _on_position_synced(new_pos: Vector2) -> void:
position_buffer.append(new_pos)
if position_buffer.size() > BUFFER_SIZE:
position_buffer.pop_front()
---Anti-Cheat Measures
反作弊措施
Server-Side Validation
服务器端验证
gdscript
undefinedgdscript
undefinedserver_validator.gd
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
---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
输入缓冲
gdscript
undefinedgdscript
undefined❌ BAD: Send input every frame (60 packets/s)
❌ BAD: Send input every frame (60 packets/s)
func _physics_process(delta: float) -> void:
var input := get_input()
rpc_id(1, "receive_input", input)
func _physics_process(delta: float) -> void:
var input := get_input()
rpc_id(1, "receive_input", input)
✅ GOOD: Send every 3rd frame (20 packets/s)
✅ 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
---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
本地测试多人联机
gdscript
undefinedgdscript
undefinedLaunch multiple instances for testing
Launch multiple instances for testing
Run from command line:
Run from command line:
Windows:
Windows:
Server: Godot.exe --path . res://main.tscn -- --server
Server: Godot.exe --path . res://main.tscn -- --server
Client 1: Godot.exe --path . res://main.tscn -- --client
Client 1: Godot.exe --path . res://main.tscn -- --client
Client 2: Godot.exe --path . res://main.tscn -- --client
Client 2: Godot.exe --path . res://main.tscn -- --client
Parse arguments in code:
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")
---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 |
| 因素 | 权威服务器 | P2P Lockstep |
|---|---|---|
| 玩家数量 | 8-100+ | 2-4 |
| 反作弊需求 | 关键 | 不重要 |
| 服务器托管 | 可用 | 不可用 |
| 游戏类型 | PvP、竞技类 | 合作类、休闲类 |
| 延迟容忍度 | 中等(预测可改善) | 低(易不同步) |
| 开发复杂度 | 高 | 中等 |
Reference
参考
- Master Skill: godot-master
- 核心技能:godot-master