Last active
August 10, 2024 10:55
-
-
Save apples/2cdafa0dd2d7e5a400ff8d2e204471ed to your computer and use it in GitHub Desktop.
Godot multiplayer lobby system with simple client verification.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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