godot-adapt-single-to-multiplayer

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Adapt: 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
    is_multiplayer_authority()
    on individual nodes. Group iteration is unreliable for network identity.
  • 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
undefined
gdscript
undefined

Server 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

undefined
undefined

Pattern 2: Peer-to-Peer (Lockstep)

模式2:对等网络(Lockstep)

gdscript
undefined
gdscript
undefined

All 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)

undefined
undefined

Pattern 3: Hybrid (Authority Transfer)

模式3:混合架构(权限转移)

gdscript
undefined
gdscript
undefined

Host 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
undefined
gdscript
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
undefined
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
undefined

Step 2: Set Up Multiplayer Authority

步骤2:设置多人联机权限

gdscript
undefined
gdscript
undefined

server_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
undefined
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
undefined

Step 3: Add MultiplayerSynchronizer

步骤3:添加MultiplayerSynchronizer

gdscript
undefined
gdscript
undefined

Scene 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
undefined
gdscript
undefined

Without 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

undefined
undefined

Solution: Client-Side Prediction

解决方案:客户端侧预测

gdscript
undefined
gdscript
undefined

player_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
undefined
gdscript
undefined

Other 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
undefined
gdscript
undefined

server_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
undefined
gdscript
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
undefined
gdscript
undefined

Launch 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?

决策树:选择哪种架构?

FactorAuthoritative ServerP2P Lockstep
Player count8-100+2-4
Cheat preventionCriticalNot important
Server hostingAvailableNot available
Gameplay typePvP, competitiveCo-op, casual
Lag toleranceMedium (prediction helps)Low (desyncs)
Development complexityHighMedium
因素权威服务器P2P Lockstep
玩家数量8-100+2-4
反作弊需求关键不重要
服务器托管可用不可用
游戏类型PvP、竞技类合作类、休闲类
延迟容忍度中等(预测可改善)低(易不同步)
开发复杂度中等

Reference

参考

  • Master Skill: godot-master
  • 核心技能:godot-master