Skip to content

Instantly share code, notes, and snippets.

@apples
Last active August 10, 2024 10:55
Show Gist options
  • Save apples/2cdafa0dd2d7e5a400ff8d2e204471ed to your computer and use it in GitHub Desktop.
Save apples/2cdafa0dd2d7e5a400ff8d2e204471ed to your computer and use it in GitHub Desktop.
Godot multiplayer lobby system with simple client verification.
extends Node
##
## Manages multiplayer connections.
##
## Emitted when a client player joins the session. Only emitted on the server.
## Also emitted for the local player when the server starts.
signal player_joined(unique_id: int)
## Emitted when a client player leaves the session (or is kicked). Only emitted on the server.
signal player_disconnected(unique_id: int, reason: LeaveReason)
## Emitted when this client is connected to the server. Only emitted on the client.
signal connected_to_server()
## Emitted when this client is disconnected from the server. Only emitted on the client
signal disconnected_from_server()
## Emitted when this client fails to join the server. Only emitted on the client
signal failed_to_join()
## Reason that a player left the session.
enum LeaveReason {
## The player intentionally quit the game.
PLAYER_QUIT,
## The player was unexpectedly disconnected for an unknown reason.
SUDDEN_DISCONNECT,
## The host kicked the player.
KICKED,
}
enum _DiffOp {
REPLACE,
DELETE,
}
## Default port number.
const PORT = 23456
## The maximum number of remote players allowed.
const MAX_PLAYERS = 4
## The time in seconds that a new player has to complete the handshake.
const AUTH_TIMEOUT = 10.0
## Time interval between pings for measuring latency.
const PING_INTERVAL_MSEC = 200.0
## Number of pings to keep buffered. Larger values result in smoother measurements.
const PING_FRAMES = 20
## Time interval between player custom_data synchronizations.
## When set to 0.0, synchronizations happen every process frame.
const CUSTOM_DATA_INTERVAL = 0.0
## A dictionary of all players. { [multiplayer_id: int]: PlayerSlot }
var players: Dictionary = {}
var _pending_players: Dictionary = {}
var _ping_buffers: Dictionary = {} # { [multiplayer_id: int]: PackedInt64Array }
var _ping_frame: int = 0
var _ping_frame_timestamps: PackedInt64Array
var _next_ping: float = INF
var _custom_data_caches: Dictionary = {} # { [multiplayer_id: int]: Dictionary }
var _next_custom_data_sync: float = INF
@onready var scene_multiplayer: SceneMultiplayer = multiplayer as SceneMultiplayer
#region Built-in virtual methods
func _ready() -> void:
scene_multiplayer.peer_connected.connect(_on_peer_connected)
scene_multiplayer.peer_disconnected.connect(_on_peer_disconnected)
scene_multiplayer.server_disconnected.connect(_on_server_disconnected)
scene_multiplayer.connected_to_server.connect(_on_connected_to_server)
scene_multiplayer.connection_failed.connect(_on_connection_failed)
scene_multiplayer.peer_authenticating.connect(_on_peer_authenticating)
scene_multiplayer.peer_authentication_failed.connect(_on_peer_authentication_failed)
scene_multiplayer.auth_timeout = AUTH_TIMEOUT
scene_multiplayer.auth_callback = _auth_callback
set_process(false)
func _process(delta: float) -> void:
assert(scene_multiplayer.has_multiplayer_peer())
assert(scene_multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.ConnectionStatus.CONNECTION_CONNECTED)
assert(scene_multiplayer.is_server())
# Process pings.
_next_ping -= delta
if _next_ping <= 0.0:
var idx := _ping_frame % PING_FRAMES
for p: PlayerSlot in players.values():
_update_player_ping_rpc.rpc(p.multiplayer_id, p.ping_msec, p.ping_quality)
if p.multiplayer_id != 1:
_update_ping(p.multiplayer_id, idx, 0)
_ping_frame_timestamps[idx] = Time.get_ticks_usec()
var frame := _ping_frame
_ping_frame += 1 # We need to increment _ping_frame before calling the rpc, since the rpc will be synchronously called locally.
_ping_rpc.rpc(frame)
_next_ping += PING_INTERVAL_MSEC / 1000.0
# Process custom_data replication.
_next_custom_data_sync -= delta
if _next_custom_data_sync <= 0.0:
for p: PlayerSlot in players.values():
assert(p.multiplayer_id in _custom_data_caches)
var cached: Dictionary = _custom_data_caches[p.multiplayer_id]
var diff := _shallow_diff(cached, p.custom_data)
_custom_data_caches[p.multiplayer_id] = p.custom_data.duplicate(false)
if not diff.is_empty():
_patch_player_custom_data_rpc.rpc(p.multiplayer_id, diff)
if CUSTOM_DATA_INTERVAL == 0.0:
_next_custom_data_sync = 0.0
else:
_next_custom_data_sync += CUSTOM_DATA_INTERVAL
#endregion Built-in virtual methods
#region Public methods
## Starts the server on PORT.
func start_server():
var peer = ENetMultiplayerPeer.new()
peer.create_server(PORT)
multiplayer.multiplayer_peer = peer
var player_slot := _setup_player(1)
player_slot.player_profile = ProfileManager.player_profile
player_slot.player_name = ProfileManager.player_name
player_slot.ping_quality = 4
player_joined.emit.call_deferred(1)
_ping_frame_timestamps = PackedInt64Array()
_ping_frame_timestamps.resize(PING_FRAMES)
_next_ping = PING_INTERVAL_MSEC / 1000.0
_next_custom_data_sync = 0.0
set_process(true)
## Starts the client, connects to the specified server address on PORT.
func start_client(server_address: String):
var peer = ENetMultiplayerPeer.new()
peer.create_client(server_address, PORT)
multiplayer.multiplayer_peer = peer
## Returns the player slot for the given multiplayer unique id.
func get_player_by_multiplayer_id(multiplayer_id: int) -> PlayerSlot:
return players.get(multiplayer_id, null)
## Forcibly disconnects a player.
func kick_player(multiplayer_id: int, emit_disconnect_signal: bool = true):
multiplayer.multiplayer_peer.disconnect_peer(multiplayer_id, true)
var slot := get_player_by_multiplayer_id(multiplayer_id)
if not slot:
return
if emit_disconnect_signal:
player_disconnected.emit(multiplayer_id, LeaveReason.KICKED)
_teardown_player(multiplayer_id)
## (async) Disconnects this peer from the network or shuts down the server.
func close():
set_process(false)
if not multiplayer.is_server():
_client_quit_rpc.rpc_id(1)
await get_tree().create_timer(0.05).timeout
if multiplayer.has_multiplayer_peer():
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
## Prints a message and includes a tag indicating which client this is.
func debug_print(text: String) -> void:
if not scene_multiplayer.is_server():
text = "(client %s) %s" % [scene_multiplayer.get_unique_id(), text]
else:
text = "(server) %s" % [text]
print(text)
#endregion Public methods
#region Private methods
func _setup_player(id: int, pending: bool = false) -> PlayerSlot:
debug_print("Setup player: %s" % id)
var player_pool: Dictionary
if pending:
player_pool = _pending_players
else:
player_pool = players
if player_pool.has(id):
push_error("Player already setup for id %s." % id)
return null
var player_slot := PlayerSlot.new()
player_slot.multiplayer_id = id
player_pool[id] = player_slot
if scene_multiplayer.is_server():
_ping_buffers[id] = PackedInt64Array()
_ping_buffers[id].resize(PING_FRAMES)
_custom_data_caches[id] = {}
return player_slot
func _teardown_player(id: int) -> void:
assert(players.has(id) or _pending_players.has(id))
debug_print("Teardown player: %s" % id)
players.erase(id)
_pending_players.erase(id)
_ping_buffers.erase(id)
_custom_data_caches.erase(id)
func _on_peer_authenticating(id: int):
if scene_multiplayer.is_server():
return
assert(id == 1)
debug_print("Sending authentication")
var client_info = {
player_profile = ProfileManager.player_profile,
player_name = ProfileManager.player_name,
}
scene_multiplayer.send_auth(1, var_to_bytes(client_info))
scene_multiplayer.complete_auth(1)
func _on_peer_authentication_failed(id: int):
debug_print("Authentication failed: %s" % id)
if _pending_players.has(id):
_teardown_player(id)
assert(!players.has(id))
func _auth_callback(multiplayer_id: int, data: PackedByteArray):
debug_print("Received authentication: %s" % multiplayer_id)
if players.size() >= MAX_PLAYERS:
push_error("Player limit reached.")
scene_multiplayer.disconnect_peer(multiplayer_id)
return
var client_info = bytes_to_var(data)
if not client_info:
push_error("Client client_info is empty.")
scene_multiplayer.disconnect_peer(multiplayer_id)
return
if not "player_profile" in client_info or client_info.player_profile is not String or client_info.player_profile == "":
push_error("Client client_info is missing fields.")
scene_multiplayer.disconnect_peer(multiplayer_id)
return
if not "player_name" in client_info or client_info.player_name is not String or client_info.player_name == "":
push_error("Client client_info is missing fields.")
scene_multiplayer.disconnect_peer(multiplayer_id)
return
for slot in players.values():
if slot.player_profile == client_info.player_profile:
push_error("Client with player_profile = \"%s\" tried to join, but another player is already using that profile!" % [client_info.player_profile])
scene_multiplayer.disconnect_peer(multiplayer_id)
return
var player_slot := _setup_player(multiplayer_id, true)
player_slot.player_profile = client_info.player_profile
player_slot.player_name = client_info.player_name
scene_multiplayer.complete_auth(multiplayer_id)
func _on_peer_connected(id: int):
if scene_multiplayer.is_server(): debug_print("Client connected: %s" % id)
if not scene_multiplayer.is_server():
_setup_player(id)
return
var player_slot: PlayerSlot = _pending_players[id]
assert(player_slot)
_pending_players.erase(id)
players[id] = player_slot
player_joined.emit(id)
_custom_data_caches[id] = player_slot.custom_data.duplicate(false)
for p: PlayerSlot in players.values():
_put_player_custom_data_rpc.rpc_id(id, p.multiplayer_id, p.custom_data)
if p.multiplayer_id != id and p.multiplayer_id != scene_multiplayer.get_unique_id():
_put_player_custom_data_rpc.rpc_id(p.multiplayer_id, id, player_slot.custom_data)
func _on_peer_disconnected(id: int):
if scene_multiplayer.is_server(): debug_print("Client disconnected: %s" % id)
var slot := get_player_by_multiplayer_id(id)
if slot:
if scene_multiplayer.is_server():
player_disconnected.emit(id, LeaveReason.SUDDEN_DISCONNECT)
_teardown_player(id)
func _on_connected_to_server():
debug_print("Connected to server")
_setup_player(scene_multiplayer.get_unique_id())
connected_to_server.emit()
func _on_server_disconnected():
debug_print("Disconnected from server.")
set_process(false)
disconnected_from_server.emit()
for id: int in players.keys():
_teardown_player(id)
func _on_connection_failed():
debug_print("Failed to connect to server.")
failed_to_join.emit()
func _update_ping(id: int, frame: int, time_usec: int):
assert(scene_multiplayer.is_server())
var player_slot := get_player_by_multiplayer_id(id)
assert(player_slot)
var ping_times_usec: PackedInt64Array = _ping_buffers[id]
ping_times_usec[frame] = time_usec
var total_time: int = 0
var count: int = 0
var missing_count: int = 0
for i in PING_FRAMES:
if ping_times_usec[i] != 0:
total_time += ping_times_usec[i]
count += 1
else:
missing_count += 1
if count == 0:
player_slot.ping_msec = 0.0
else:
player_slot.ping_msec = float(total_time) / float(count) / 1000.0 / 2.0
player_slot.ping_quality = 4 - ((4 * max(0, missing_count - 1)) / PING_FRAMES)
func _shallow_diff(old: Dictionary, new: Dictionary) -> Array:
var diff := []
for key in new:
if key in old:
if new[key] != old[key]:
# Added.
diff.append_array([_DiffOp.REPLACE, key, new[key]])
else:
# Updated.
diff.append_array([_DiffOp.REPLACE, key, new[key]])
for key in old:
if not key in new:
# Removed.
diff.append_array([_DiffOp.DELETE, key, null])
return diff
#endregion Private methods
#region Private RPCs
@rpc("authority", "call_remote", "unreliable")
func _ping_rpc(frame: int):
_pong_rpc.rpc_id(multiplayer.get_remote_sender_id(), frame)
@rpc("any_peer", "call_remote", "unreliable")
func _pong_rpc(frame: int):
var recv := Time.get_ticks_usec()
if not is_multiplayer_authority():
push_error("Pong received, but I'm not the authority!")
return
if frame < 0 or frame >= _ping_frame:
push_error("Pong received, but frame index %s is invalid. Current frame: %s." % [frame, _ping_frame])
kick_player(multiplayer.get_remote_sender_id())
return
if frame < _ping_frame - PING_FRAMES:
return
var player_slot: PlayerSlot = get_player_by_multiplayer_id(multiplayer.get_remote_sender_id())
if not player_slot:
return
var idx := frame % PING_FRAMES
_update_ping(multiplayer.get_remote_sender_id(), idx, recv - _ping_frame_timestamps[idx])
@rpc("authority", "call_remote", "unreliable_ordered")
func _update_player_ping_rpc(player_id: int, ping_msec: float, ping_quality: int):
var player_slot := get_player_by_multiplayer_id(player_id)
if not player_slot:
return
player_slot.ping_msec = ping_msec
player_slot.ping_quality = ping_quality
@rpc("authority", "call_remote", "reliable")
func _put_player_custom_data_rpc(player_id: int, custom_data: Dictionary):
var player_slot := get_player_by_multiplayer_id(player_id)
if not player_slot:
return
player_slot.custom_data.merge(custom_data, true)
@rpc("authority", "call_remote", "reliable")
func _patch_player_custom_data_rpc(player_id: int, patch: Array):
var player_slot := get_player_by_multiplayer_id(player_id)
if not player_slot:
return
if patch.size() % 3 != 0:
push_error("Received custom_player_data patch of improper size.")
return
for i: int in range(0, patch.size(), 3):
match patch[i]:
_DiffOp.REPLACE:
player_slot.custom_data[patch[i + 1]] = patch[i + 2]
_DiffOp.DELETE:
player_slot.custom_data.erase(patch[i + 1])
_:
push_error("Invalid diff op.")
return
@rpc("any_peer", "call_remote", "reliable")
func _client_quit_rpc():
if not multiplayer.is_server():
push_error("_client_quit_rpc received, but I am not the server.")
return
var multiplayer_id := multiplayer.get_remote_sender_id()
var slot := get_player_by_multiplayer_id(multiplayer_id)
if not slot:
return
player_disconnected.emit(multiplayer_id, LeaveReason.PLAYER_QUIT)
_teardown_player(multiplayer_id)
#endregion Private RPCs
#region Subclasses
## Represents a client player connected to the server.
class PlayerSlot extends RefCounted:
## The client's multiplayer unique id.
var multiplayer_id: int
## The player's unique profile id (e.g. Steam id). Not replicated to clients.
var player_profile: String
## The player's chosen nickname or Steam name. Not replicated to clients.
var player_name: String
## The player's average ping to the server.
var ping_msec: float
## The player's network connection score, ranging from 0 (total loss) to 4 (perfect connection).
var ping_quality: int
## A bag of data to use however you want, which will be replicated from the server to clients.
## Clients can write to this as well, but values may be overwritten by the server.
## Cannot replicate Objects.
## Note: This is intended for lightweight, non-time-sensitive data, e.g. player colors, scoreboards, etc.
var custom_data: Dictionary
#endregion Subclasses
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment