Add direct-connect multiplayer foundation

This commit is contained in:
Alexander Sellite 2026-07-29 15:01:46 -04:00
parent 9e9850dd20
commit 24f8263175
36 changed files with 2633 additions and 45 deletions

View file

@ -0,0 +1,12 @@
class_name ConnectionEndpoint
extends RefCounted
var host: String = ""
var port: int = 7777
var normalized_display: String = ""
var original_display: String = ""
var error_message: String = ""
func is_valid() -> bool:
return error_message.is_empty() and not host.is_empty() and port in range(1, 65536)

View file

@ -0,0 +1 @@
uid://c0ssdkdd35qgf

View file

@ -0,0 +1,24 @@
class_name ConnectionRoute
extends RefCounted
enum Kind {
DIRECT,
}
var kind: Kind = Kind.DIRECT
var direct_endpoint: ConnectionEndpoint
var display_description: String = ""
static func direct(endpoint: ConnectionEndpoint) -> ConnectionRoute:
var route := ConnectionRoute.new()
route.kind = Kind.DIRECT
route.direct_endpoint = endpoint
route.display_description = (
endpoint.normalized_display if endpoint != null else ""
)
return route
func is_valid() -> bool:
return kind == Kind.DIRECT and direct_endpoint != null and direct_endpoint.is_valid()

View file

@ -0,0 +1 @@
uid://dquhcebc57bg8

View file

@ -0,0 +1,38 @@
class_name DirectEnetTransport
extends NetworkTransport
func start_host(
port: int,
max_clients: int,
bind_address: String = "*",
) -> Error:
disconnect_transport()
var enet_peer := ENetMultiplayerPeer.new()
if not bind_address.is_empty() and bind_address != "*":
enet_peer.set_bind_ip(bind_address)
var error: Error = enet_peer.create_server(port, max_clients, 3)
if error != OK:
transport_error.emit("Unable to host UDP port %d." % port)
return error
_peer = enet_peer
_route_description = "UDP *:%d" % port
return OK
func connect_to_route(route: ConnectionRoute) -> Error:
disconnect_transport()
if route == null or not route.is_valid():
transport_error.emit("The direct connection route is invalid.")
return ERR_INVALID_PARAMETER
var endpoint: ConnectionEndpoint = route.direct_endpoint
var enet_peer := ENetMultiplayerPeer.new()
var error: Error = enet_peer.create_client(endpoint.host, endpoint.port, 3)
if error != OK:
transport_error.emit(
"Unable to connect to %s." % endpoint.normalized_display
)
return error
_peer = enet_peer
_route_description = endpoint.normalized_display
return OK

View file

@ -0,0 +1 @@
uid://d4jn8ubxqkqa4

View file

@ -0,0 +1,69 @@
class_name EndpointParser
extends RefCounted
const DEFAULT_PORT: int = 7777
static func parse(value: String, default_port: int = DEFAULT_PORT) -> ConnectionEndpoint:
var result := ConnectionEndpoint.new()
result.original_display = value.strip_edges()
result.port = default_port
if default_port < 1 or default_port > 65535:
result.error_message = "The configured default port is invalid."
return result
var input: String = result.original_display
if input.is_empty():
result.error_message = "Enter a hostname or IP address."
return result
var host: String = ""
var port_text: String = ""
if input.begins_with("["):
var closing: int = input.find("]")
if closing < 0:
result.error_message = "The IPv6 address is missing a closing bracket."
return result
host = input.substr(1, closing - 1).strip_edges()
var suffix: String = input.substr(closing + 1).strip_edges()
if not suffix.is_empty():
if not suffix.begins_with(":") or suffix.length() == 1:
result.error_message = "Use [IPv6]:port for an IPv6 port."
return result
port_text = suffix.substr(1).strip_edges()
elif input.contains("[") or input.contains("]"):
result.error_message = "IPv6 brackets are malformed."
return result
else:
var colon_count: int = input.count(":")
if colon_count == 1:
var separator: int = input.rfind(":")
host = input.substr(0, separator).strip_edges()
port_text = input.substr(separator + 1).strip_edges()
else:
# Zero colons is a hostname/IPv4 host. More than one is a raw
# IPv6 host; an explicit port for it must use brackets.
host = input.strip_edges()
if host.is_empty():
result.error_message = "The hostname or IP address is empty."
return result
if host.contains(" ") or host.contains("\t") or host.contains("\n"):
result.error_message = "The hostname or IP address contains whitespace."
return result
if not port_text.is_empty():
if not port_text.is_valid_int():
result.error_message = "The port must be a number from 1 to 65535."
return result
result.port = int(port_text)
if result.port < 1 or result.port > 65535:
result.error_message = "The port must be from 1 to 65535."
return result
result.host = host.to_lower()
var is_ipv6: bool = result.host.contains(":")
result.normalized_display = (
"[%s]:%d" % [result.host, result.port]
if is_ipv6
else "%s:%d" % [result.host, result.port]
)
return result

View file

@ -0,0 +1 @@
uid://2drengcleq7e

View file

@ -0,0 +1,120 @@
class_name NetworkProfilePreferences
extends Node
const FORMAT_VERSION: int = 1
const PROFILE_PATH: String = "user://network_profile.json"
const TEMP_PATH: String = "user://network_profile.json.tmp"
const BACKUP_PATH: String = "user://network_profile.json.backup"
var profile_id: String = ""
var display_name: String = "Player"
var created_at_unix: int = 0
func load_or_create() -> bool:
_recover_interrupted_write()
if FileAccess.file_exists(PROFILE_PATH):
if _load_existing():
return true
var corrupt_path: String = (
"user://network_profile.corrupt.%d.json"
% int(Time.get_unix_time_from_system())
)
if not _rename(PROFILE_PATH, corrupt_path):
push_warning("Invalid network profile was preserved and not overwritten.")
return false
profile_id = Crypto.new().generate_random_bytes(16).hex_encode()
if profile_id.is_empty():
profile_id = "%d-%d" % [
int(Time.get_unix_time_from_system()),
Time.get_ticks_usec(),
]
display_name = "Player"
created_at_unix = int(Time.get_unix_time_from_system())
return _save_atomic()
func _load_existing() -> bool:
var file := FileAccess.open(PROFILE_PATH, FileAccess.READ)
if file == null:
return false
var json := JSON.new()
var error: Error = json.parse(file.get_as_text())
file.close()
if error != OK or typeof(json.data) != TYPE_DICTIONARY:
return false
var data: Dictionary = json.data
if (
data.get("format_version") != FORMAT_VERSION
or typeof(data.get("profile_id")) != TYPE_STRING
or typeof(data.get("display_name")) != TYPE_STRING
or typeof(data.get("created_at_unix")) not in [TYPE_INT, TYPE_FLOAT]
):
return false
var loaded_id: String = data["profile_id"]
var loaded_name: String = data["display_name"]
if (
loaded_id.is_empty()
or loaded_id.length() > NetworkProtocol.MAX_PROFILE_ID_LENGTH
or loaded_name.is_empty()
or loaded_name.length() > NetworkProtocol.MAX_DISPLAY_NAME_LENGTH
):
return false
profile_id = loaded_id
display_name = loaded_name
created_at_unix = int(data["created_at_unix"])
return true
func _save_atomic() -> bool:
var data: Dictionary = {
"format_version": FORMAT_VERSION,
"profile_id": profile_id,
"display_name": display_name,
"created_at_unix": created_at_unix,
}
var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify(data, "\t"))
file.flush()
var error: Error = file.get_error()
file.close()
if error != OK:
_remove_if_present(TEMP_PATH)
return false
_remove_if_present(BACKUP_PATH)
var had_primary: bool = FileAccess.file_exists(PROFILE_PATH)
if had_primary and not _rename(PROFILE_PATH, BACKUP_PATH):
_remove_if_present(TEMP_PATH)
return false
if not _rename(TEMP_PATH, PROFILE_PATH):
if had_primary:
_rename(BACKUP_PATH, PROFILE_PATH)
return false
_remove_if_present(BACKUP_PATH)
return true
func _recover_interrupted_write() -> void:
if FileAccess.file_exists(PROFILE_PATH):
_remove_if_present(TEMP_PATH)
_remove_if_present(BACKUP_PATH)
return
if FileAccess.file_exists(BACKUP_PATH):
_rename(BACKUP_PATH, PROFILE_PATH)
_remove_if_present(TEMP_PATH)
func _rename(from_path: String, to_path: String) -> bool:
return DirAccess.rename_absolute(
ProjectSettings.globalize_path(from_path),
ProjectSettings.globalize_path(to_path)
) == OK
func _remove_if_present(path: String) -> bool:
return (
not FileAccess.file_exists(path)
or DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) == OK
)

View file

@ -0,0 +1 @@
uid://db8ua5psoql0w

121
network/network_protocol.gd Normal file
View file

@ -0,0 +1,121 @@
class_name NetworkProtocol
extends RefCounted
const PROTOCOL_VERSION: int = 1
const GAME_BUILD: String = "prealpha"
const MAX_DISPLAY_NAME_LENGTH: int = 48
const MAX_PROFILE_ID_LENGTH: int = 96
const MAX_NONCE_LENGTH: int = 96
enum RejectionCode {
NONE,
MALFORMED_HANDSHAKE,
PROTOCOL_MISMATCH,
SERVER_FULL,
DUPLICATE_PROFILE,
AUTHENTICATION_TIMEOUT,
SERVER_SHUTTING_DOWN,
UNSUPPORTED_CLIENT,
}
static func make_client_hello(
profile_id: String,
display_name: String,
client_nonce: String,
) -> Dictionary:
return {
"protocol_version": PROTOCOL_VERSION,
"game_build": GAME_BUILD,
"local_profile_id": profile_id,
"display_name": display_name,
"client_nonce": client_nonce,
"capability_flags": PackedStringArray(),
"cosmetic_snapshot": {},
}
static func validate_client_hello(data: Variant) -> String:
if typeof(data) != TYPE_DICTIONARY:
return "Handshake payload is not a dictionary."
var payload: Dictionary = data
for key: String in [
"protocol_version",
"game_build",
"local_profile_id",
"display_name",
"client_nonce",
"capability_flags",
"cosmetic_snapshot",
]:
if not payload.has(key):
return "Handshake is missing %s." % key
if typeof(payload["protocol_version"]) != TYPE_INT:
return "Protocol version is invalid."
if typeof(payload["game_build"]) != TYPE_STRING:
return "Game build is invalid."
if typeof(payload["local_profile_id"]) != TYPE_STRING:
return "Profile ID is invalid."
if typeof(payload["display_name"]) != TYPE_STRING:
return "Display name is invalid."
if typeof(payload["client_nonce"]) != TYPE_STRING:
return "Client nonce is invalid."
if typeof(payload["capability_flags"]) not in [
TYPE_PACKED_STRING_ARRAY,
TYPE_ARRAY,
]:
return "Capabilities are invalid."
if typeof(payload["cosmetic_snapshot"]) != TYPE_DICTIONARY:
return "Cosmetic snapshot is invalid."
var profile_id: String = payload["local_profile_id"]
var display_name: String = payload["display_name"]
var nonce: String = payload["client_nonce"]
if (
profile_id.is_empty()
or profile_id.length() > MAX_PROFILE_ID_LENGTH
or display_name.is_empty()
or display_name.length() > MAX_DISPLAY_NAME_LENGTH
or nonce.is_empty()
or nonce.length() > MAX_NONCE_LENGTH
):
return "Handshake strings are outside allowed limits."
return ""
static func make_server_hello(
accepted: bool,
rejection_code: RejectionCode,
session_id: String,
assigned_peer_id: int,
player_count: int,
max_players: int,
) -> Dictionary:
return {
"accepted": accepted,
"rejection_code": int(rejection_code),
"protocol_version": PROTOCOL_VERSION,
"session_id": session_id,
"assigned_peer_id": assigned_peer_id,
"server_display_name": "NETFISHING",
"player_count": player_count,
"max_players": max_players,
"capability_flags": PackedStringArray(["movement_v1"]),
}
static func rejection_text(code: int) -> String:
match code:
RejectionCode.PROTOCOL_MISMATCH:
return "The server uses a different network protocol."
RejectionCode.SERVER_FULL:
return "The server is full."
RejectionCode.DUPLICATE_PROFILE:
return "This local profile is already connected."
RejectionCode.AUTHENTICATION_TIMEOUT:
return "The server did not finish authentication."
RejectionCode.SERVER_SHUTTING_DOWN:
return "The server is shutting down."
RejectionCode.UNSUPPORTED_CLIENT:
return "This game build is not supported by the server."
_:
return "The server rejected the connection."

View file

@ -0,0 +1 @@
uid://dghw7u3pdrpsf

816
network/network_session.gd Normal file
View file

@ -0,0 +1,816 @@
class_name NetworkSession
extends Node
const DEFAULT_PORT: int = 7777
const DEFAULT_SESSION_MAX_PLAYERS: int = 8
const DEFAULT_TRANSPORT_MAX_CLIENTS: int = 31
const CONNECTION_TIMEOUT_SECONDS: float = 10.0
const AUTHENTICATION_TIMEOUT_SECONDS: float = 8.0
const INPUT_INTERVAL: float = 1.0 / 25.0
const SNAPSHOT_INTERVAL: float = 1.0 / 15.0
signal state_changed(state: State)
signal status_message_changed(message: String)
signal connection_error(message: String)
signal peer_authenticated(peer_id: int, display_name: String)
signal peer_removed(peer_id: int)
signal host_openness_changed(is_open: bool)
signal peer_count_changed(player_count: int, max_players: int)
signal join_authenticated
signal server_lost
signal remote_recovery_requested(peer_id: int, entry_position: Vector3)
enum State {
INACTIVE,
STARTING_PRIVATE_HOST,
PRIVATE_HOST,
OPEN_HOST,
CONNECTING,
AUTHENTICATING,
JOINED_CLIENT,
DISCONNECTING,
CONNECTION_FAILED,
SERVER_LOST,
}
@export_range(2, 128, 1) var session_max_players: int = (
DEFAULT_SESSION_MAX_PLAYERS
)
@export_range(1, 256, 1) var transport_max_clients: int = (
DEFAULT_TRANSPORT_MAX_CLIENTS
)
var state: State = State.INACTIVE
var _transport: DirectEnetTransport
var _profile: NetworkProfilePreferences
var _saved_servers: SavedServerStore
var _spawn_service: PlayerSpawnService
var _registry := PeerRegistry.new()
var _pending_authentication: Dictionary[int, float] = {}
var _session_id: String = ""
var _operation_generation: int = 0
var _connection_deadline: float = 0.0
var _client_nonce: String = ""
var _current_route: ConnectionRoute
var _input_sequence: int = 0
var _input_accumulator: float = 0.0
var _snapshot_accumulator: float = 0.0
var _last_server_max_players: int = DEFAULT_SESSION_MAX_PLAYERS
var _profile_ready: bool = false
func _ready() -> void:
transport_max_clients = maxi(
transport_max_clients,
session_max_players - 1
)
_connect_multiplayer_signals()
func setup(
profile: NetworkProfilePreferences,
saved_servers: SavedServerStore,
spawn_service: PlayerSpawnService,
) -> void:
_profile = profile
_saved_servers = saved_servers
_spawn_service = spawn_service
if _profile != null:
_profile_ready = _profile.load_or_create()
func start_private_host(port: int = DEFAULT_PORT) -> bool:
if (
state != State.INACTIVE
or not _profile_ready
or _profile == null
or _spawn_service == null
):
return false
if port < 1 or port > 65535:
_fail("The hosting port must be from 1 to 65535.")
return false
_operation_generation += 1
_set_state(State.STARTING_PRIVATE_HOST, "Starting private game...")
_replace_transport()
var error: Error = _transport.start_host(
port,
transport_max_clients,
)
if error != OK:
_fail("Could not start a private game on UDP port %d." % port)
return false
var peer: MultiplayerPeer = _transport.get_multiplayer_peer()
peer.refuse_new_connections = true
multiplayer.multiplayer_peer = peer
_session_id = Crypto.new().generate_random_bytes(16).hex_encode()
_registry.clear()
_registry.add_peer(
1,
_profile.profile_id,
_profile.display_name,
NetworkProtocol.PROTOCOL_VERSION
)
_spawn_service.clear_remote_players()
_spawn_service.register_local_player(1)
_set_state(State.PRIVATE_HOST, "Private game • UDP %d" % port)
host_openness_changed.emit(false)
_emit_peer_count()
return true
func join_direct(endpoint_text: String) -> bool:
if (
state != State.INACTIVE
or not _profile_ready
or _profile == null
or _spawn_service == null
):
return false
var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text)
if not endpoint.is_valid():
_fail(endpoint.error_message)
return false
_operation_generation += 1
var generation: int = _operation_generation
_current_route = ConnectionRoute.direct(endpoint)
_set_state(
State.CONNECTING,
"Connecting to %s..." % endpoint.normalized_display
)
_replace_transport()
var error: Error = _transport.connect_to_route(_current_route)
if error != OK:
_fail("Could not begin the direct connection.")
return false
multiplayer.multiplayer_peer = _transport.get_multiplayer_peer()
_connection_deadline = (
Time.get_ticks_msec() / 1000.0 + CONNECTION_TIMEOUT_SECONDS
)
# The generation is checked by the process timeout and all state-gated
# multiplayer callbacks.
if generation != _operation_generation:
return false
return true
func cancel_connection() -> void:
if state not in [State.CONNECTING, State.AUTHENTICATING]:
return
_operation_generation += 1
_teardown_peer()
_set_state(State.INACTIVE, "Connection cancelled.")
func reset_failure() -> void:
if state not in [State.CONNECTION_FAILED, State.SERVER_LOST]:
return
_teardown_peer()
_set_state(State.INACTIVE, "Ready for a direct connection.")
func disconnect_session(message: String = "Disconnected.") -> void:
if state == State.INACTIVE:
return
if state in [State.CONNECTION_FAILED, State.SERVER_LOST]:
_teardown_peer()
_set_state(State.INACTIVE, message)
return
_operation_generation += 1
_set_state(State.DISCONNECTING, "Disconnecting...")
_teardown_peer()
_set_state(State.INACTIVE, message)
func set_host_open(is_open: bool) -> bool:
if state not in [State.PRIVATE_HOST, State.OPEN_HOST]:
return false
var peer: MultiplayerPeer = multiplayer.multiplayer_peer
if peer == null:
return false
peer.refuse_new_connections = not is_open
_set_state(
State.OPEN_HOST if is_open else State.PRIVATE_HOST,
(
"Open game • %d / %d players"
if is_open
else "Private game • %d / %d players"
) % [_registry.size(), session_max_players]
)
host_openness_changed.emit(is_open)
return true
func is_host() -> bool:
return state in [State.PRIVATE_HOST, State.OPEN_HOST]
func is_open_host() -> bool:
return state == State.OPEN_HOST
func is_joined_client() -> bool:
return state == State.JOINED_CLIENT
func get_player_count() -> int:
return _registry.size()
func get_session_max_players() -> int:
return session_max_players
func get_current_route_display() -> String:
return (
_current_route.display_description
if _current_route != null
else _transport.get_route_description() if _transport != null else ""
)
func can_use_host_gameplay() -> bool:
return is_host()
func _process(delta: float) -> void:
var now: float = Time.get_ticks_msec() / 1000.0
if (
state in [State.CONNECTING, State.AUTHENTICATING]
and _connection_deadline > 0.0
and now >= _connection_deadline
):
_fail("Connection timed out.")
_teardown_peer()
return
if is_host():
_expire_pending_authentication(now)
if state not in [State.OPEN_HOST, State.PRIVATE_HOST, State.JOINED_CLIENT]:
return
_input_accumulator += delta
_snapshot_accumulator += delta
if state == State.JOINED_CLIENT and _input_accumulator >= INPUT_INTERVAL:
_input_accumulator = fmod(_input_accumulator, INPUT_INTERVAL)
_send_local_input()
if is_host() and _snapshot_accumulator >= SNAPSHOT_INTERVAL:
_snapshot_accumulator = fmod(_snapshot_accumulator, SNAPSHOT_INTERVAL)
_broadcast_movement_snapshots()
func _connect_multiplayer_signals() -> void:
if not multiplayer.peer_connected.is_connected(_on_peer_connected):
multiplayer.peer_connected.connect(_on_peer_connected)
if not multiplayer.peer_disconnected.is_connected(_on_peer_disconnected):
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
if not multiplayer.connected_to_server.is_connected(
_on_connected_to_server
):
multiplayer.connected_to_server.connect(_on_connected_to_server)
if not multiplayer.connection_failed.is_connected(_on_connection_failed):
multiplayer.connection_failed.connect(_on_connection_failed)
if not multiplayer.server_disconnected.is_connected(
_on_server_disconnected
):
multiplayer.server_disconnected.connect(_on_server_disconnected)
func _replace_transport() -> void:
if _transport != null:
_transport.disconnect_transport()
_transport.queue_free()
_transport = DirectEnetTransport.new()
add_child(_transport)
_transport.transport_error.connect(connection_error.emit)
func _on_peer_connected(peer_id: int) -> void:
if not is_host():
return
if state != State.OPEN_HOST:
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
return
_pending_authentication[peer_id] = (
Time.get_ticks_msec() / 1000.0 + AUTHENTICATION_TIMEOUT_SECONDS
)
func _on_connected_to_server() -> void:
if state != State.CONNECTING:
return
_set_state(State.AUTHENTICATING, "Authenticating...")
_client_nonce = Crypto.new().generate_random_bytes(16).hex_encode()
var hello: Dictionary = NetworkProtocol.make_client_hello(
_profile.profile_id,
_profile.display_name,
_client_nonce
)
submit_client_hello.rpc_id(1, hello)
func _on_connection_failed() -> void:
if state not in [State.CONNECTING, State.AUTHENTICATING]:
return
_teardown_peer()
_fail("Could not connect. The server may be private, unavailable, or unreachable.")
func _on_server_disconnected() -> void:
if state in [State.INACTIVE, State.DISCONNECTING]:
return
_operation_generation += 1
_teardown_peer()
_set_state(State.SERVER_LOST, "The server connection was lost.")
server_lost.emit()
connection_error.emit("The server connection was lost.")
func _on_peer_disconnected(peer_id: int) -> void:
_pending_authentication.erase(peer_id)
if _registry.has_peer(peer_id):
_registry.remove_peer(peer_id)
_spawn_service.remove_peer(peer_id)
if is_host():
receive_peer_despawn.rpc(peer_id)
peer_removed.emit(peer_id)
_emit_peer_count()
@rpc("any_peer", "call_remote", "reliable", 0)
func submit_client_hello(data: Dictionary) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not is_host() or sender_id <= 1 or not _pending_authentication.has(sender_id):
return
var validation_error: String = NetworkProtocol.validate_client_hello(data)
if not validation_error.is_empty():
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE
)
return
if int(data["protocol_version"]) != NetworkProtocol.PROTOCOL_VERSION:
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.PROTOCOL_MISMATCH
)
return
if _registry.size() >= session_max_players:
_reject_peer(sender_id, NetworkProtocol.RejectionCode.SERVER_FULL)
return
var profile_id: String = data["local_profile_id"]
if _registry.has_profile(profile_id):
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.DUPLICATE_PROFILE
)
return
var display_name: String = data["display_name"]
if not _registry.add_peer(
sender_id,
profile_id,
display_name,
NetworkProtocol.PROTOCOL_VERSION
):
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE
)
return
_pending_authentication.erase(sender_id)
var spawn_index: int = _registry.get_peer_ids().find(sender_id)
var spawn_transform: Transform3D = (
_spawn_service.get_spawn_transform_for_index(spawn_index)
)
_spawn_service.spawn_remote_player(sender_id, spawn_transform, true)
receive_server_hello.rpc_id(
sender_id,
NetworkProtocol.make_server_hello(
true,
NetworkProtocol.RejectionCode.NONE,
_session_id,
sender_id,
_registry.size(),
session_max_players
)
)
receive_spawn_list.rpc_id(sender_id, _build_spawn_list())
receive_peer_spawn.rpc(
_make_spawn_entry(sender_id, display_name, spawn_transform)
)
peer_authenticated.emit(sender_id, display_name)
_emit_peer_count()
func _reject_peer(
peer_id: int,
code: NetworkProtocol.RejectionCode,
) -> void:
receive_server_hello.rpc_id(
peer_id,
NetworkProtocol.make_server_hello(
false,
code,
_session_id,
peer_id,
_registry.size(),
session_max_players
)
)
_pending_authentication.erase(peer_id)
call_deferred("_disconnect_rejected_peer", peer_id)
func _disconnect_rejected_peer(peer_id: int) -> void:
if (
is_host()
and multiplayer.multiplayer_peer != null
and multiplayer.multiplayer_peer.get_connection_status()
== MultiplayerPeer.CONNECTION_CONNECTED
):
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
@rpc("authority", "call_remote", "reliable", 0)
func receive_server_hello(data: Dictionary) -> void:
if state != State.AUTHENTICATING:
return
if (
typeof(data.get("accepted")) != TYPE_BOOL
or typeof(data.get("protocol_version")) != TYPE_INT
or typeof(data.get("rejection_code")) != TYPE_INT
):
_teardown_peer()
_fail("The server sent an invalid handshake response.")
return
if not bool(data["accepted"]):
var message: String = NetworkProtocol.rejection_text(
int(data["rejection_code"])
)
_teardown_peer()
_fail(message)
return
if int(data["protocol_version"]) != NetworkProtocol.PROTOCOL_VERSION:
_teardown_peer()
_fail("The server uses a different network protocol.")
return
_session_id = str(data.get("session_id", ""))
_last_server_max_players = int(data.get(
"max_players",
DEFAULT_SESSION_MAX_PLAYERS
))
var local_peer_id: int = multiplayer.get_unique_id()
_registry.clear()
_registry.add_peer(
local_peer_id,
_profile.profile_id,
_profile.display_name,
NetworkProtocol.PROTOCOL_VERSION
)
_spawn_service.clear_remote_players()
_spawn_service.register_local_player(local_peer_id)
_connection_deadline = 0.0
_set_state(
State.JOINED_CLIENT,
"Connected • %d / %d players" % [
int(data.get("player_count", 1)),
_last_server_max_players,
]
)
if _current_route != null and _saved_servers != null:
_saved_servers.record_successful_connection(
_current_route.direct_endpoint,
_last_server_max_players
)
join_authenticated.emit()
@rpc("authority", "call_remote", "reliable", 0)
func receive_spawn_list(entries: Array) -> void:
if state != State.JOINED_CLIENT:
return
for value: Variant in entries:
if typeof(value) == TYPE_DICTIONARY:
_apply_spawn_entry(value)
_emit_peer_count()
@rpc("authority", "call_remote", "reliable", 0)
func receive_peer_spawn(entry: Dictionary) -> void:
if state != State.JOINED_CLIENT:
return
_apply_spawn_entry(entry)
_emit_peer_count()
@rpc("authority", "call_remote", "reliable", 0)
func receive_peer_despawn(peer_id: int) -> void:
if state != State.JOINED_CLIENT:
return
_registry.remove_peer(peer_id)
_spawn_service.remove_peer(peer_id)
peer_removed.emit(peer_id)
_emit_peer_count()
func _apply_spawn_entry(entry: Dictionary) -> void:
if (
typeof(entry.get("peer_id")) != TYPE_INT
or typeof(entry.get("profile_id")) != TYPE_STRING
or typeof(entry.get("display_name")) != TYPE_STRING
or typeof(entry.get("position")) != TYPE_ARRAY
or typeof(entry.get("yaw")) not in [TYPE_FLOAT, TYPE_INT]
):
return
var peer_id: int = entry["peer_id"]
if peer_id == multiplayer.get_unique_id():
var own_position: Array = entry["position"]
if own_position.size() == 3:
var own_avatar: Player = _spawn_service.get_avatar(peer_id)
if own_avatar != null:
var own_snapshot: Dictionary = own_avatar.make_network_snapshot(
peer_id
)
own_snapshot["position"] = own_position
own_snapshot["visual_yaw"] = float(entry["yaw"])
own_avatar.apply_network_teleport(own_snapshot)
return
if not _registry.has_peer(peer_id):
_registry.add_peer(
peer_id,
entry["profile_id"],
entry["display_name"],
NetworkProtocol.PROTOCOL_VERSION
)
var transform: Transform3D = _spawn_service.get_spawn_transform_for_index(0)
var position: Array = entry["position"]
if position.size() != 3:
return
transform.origin = Vector3(
float(position[0]),
float(position[1]),
float(position[2])
)
transform.basis = Basis(Vector3.UP, float(entry["yaw"]))
_spawn_service.spawn_remote_player(peer_id, transform, false)
func _build_spawn_list() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
for peer_id: int in _registry.get_peer_ids():
var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id)
var avatar: Player = _spawn_service.get_avatar(peer_id)
if record != null and avatar != null:
entries.append(_make_spawn_entry(
peer_id,
record.display_name,
avatar.global_transform
))
return entries
func _make_spawn_entry(
peer_id: int,
display_name: String,
transform: Transform3D,
) -> Dictionary:
var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id)
return {
"peer_id": peer_id,
"profile_id": record.profile_id if record != null else "",
"display_name": display_name,
"position": [
transform.origin.x,
transform.origin.y,
transform.origin.z,
],
"yaw": transform.basis.get_euler().y,
}
func _send_local_input() -> void:
var peer_id: int = multiplayer.get_unique_id()
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar == null:
return
_input_sequence += 1
var input: Dictionary = avatar.capture_network_input(_input_sequence)
submit_movement_input.rpc_id(1, input)
@rpc("any_peer", "call_remote", "unreliable_ordered", 1)
func submit_movement_input(data: Dictionary) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not is_host() or not _registry.has_peer(sender_id):
return
var avatar: Player = _spawn_service.get_avatar(sender_id)
if avatar == null or not _is_valid_movement_input(data):
return
avatar.apply_authoritative_network_input(data)
func _is_valid_movement_input(data: Dictionary) -> bool:
if (
typeof(data.get("sequence")) != TYPE_INT
or typeof(data.get("axis")) != TYPE_ARRAY
or typeof(data.get("jump")) != TYPE_BOOL
or typeof(data.get("sprint")) != TYPE_BOOL
or typeof(data.get("sneak")) != TYPE_BOOL
or typeof(data.get("slow_walk")) != TYPE_BOOL
or typeof(data.get("camera_yaw")) not in [TYPE_FLOAT, TYPE_INT]
):
return false
var axis: Array = data["axis"]
if axis.size() != 2:
return false
var x: float = float(axis[0])
var y: float = float(axis[1])
var camera_yaw: float = float(data["camera_yaw"])
return (
is_finite(x)
and is_finite(y)
and is_finite(camera_yaw)
and absf(x) <= 1.01
and absf(y) <= 1.01
and absf(camera_yaw) <= TAU * 100.0
)
func _broadcast_movement_snapshots() -> void:
var snapshots: Array[Dictionary] = []
for peer_id: int in _registry.get_peer_ids():
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar == null:
continue
snapshots.append(avatar.make_network_snapshot(peer_id))
receive_movement_snapshots.rpc(snapshots)
@rpc("authority", "call_remote", "unreliable_ordered", 2)
func receive_movement_snapshots(snapshots: Array) -> void:
if state != State.JOINED_CLIENT:
return
var local_peer_id: int = multiplayer.get_unique_id()
for value: Variant in snapshots:
if typeof(value) != TYPE_DICTIONARY:
continue
var snapshot: Dictionary = value
if typeof(snapshot.get("peer_id")) != TYPE_INT:
continue
var peer_id: int = snapshot["peer_id"]
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar == null:
continue
if peer_id == local_peer_id:
avatar.apply_local_prediction_correction(snapshot)
else:
avatar.push_network_snapshot(snapshot)
func publish_authoritative_teleport(peer_id: int) -> void:
if not is_host():
return
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar == null:
return
receive_authoritative_teleport.rpc(
avatar.make_network_snapshot(peer_id)
)
func request_safe_respawn(entry_position: Vector3) -> void:
if not entry_position.is_finite():
return
if is_host():
remote_recovery_requested.emit(1, entry_position)
elif state == State.JOINED_CLIENT:
submit_safe_respawn_request.rpc_id(
1,
[entry_position.x, entry_position.y, entry_position.z]
)
@rpc("any_peer", "call_remote", "reliable", 0)
func submit_safe_respawn_request(position_data: Array) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if (
not is_host()
or not _registry.has_peer(sender_id)
or position_data.size() != 3
):
return
var entry_position := Vector3(
float(position_data[0]),
float(position_data[1]),
float(position_data[2])
)
if not entry_position.is_finite():
return
remote_recovery_requested.emit(sender_id, entry_position)
@rpc("authority", "call_remote", "reliable", 0)
func receive_authoritative_teleport(snapshot: Dictionary) -> void:
if state != State.JOINED_CLIENT:
return
var peer_id: int = int(snapshot.get("peer_id", 0))
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar != null:
avatar.apply_network_teleport(snapshot)
func _expire_pending_authentication(now: float) -> void:
for peer_id: int in _pending_authentication.keys():
if now >= float(_pending_authentication[peer_id]):
_reject_peer(
peer_id,
NetworkProtocol.RejectionCode.AUTHENTICATION_TIMEOUT
)
func _emit_peer_count() -> void:
peer_count_changed.emit(_registry.size(), session_max_players)
func _set_state(new_state: State, message: String) -> void:
if not _is_transition_allowed(state, new_state):
push_warning(
"Rejected invalid network state transition %s -> %s."
% [State.keys()[state], State.keys()[new_state]]
)
return
state = new_state
state_changed.emit(state)
status_message_changed.emit(message)
func _is_transition_allowed(from_state: State, to_state: State) -> bool:
if from_state == to_state:
return true
var allowed: Dictionary[State, Array] = {
State.INACTIVE: [
State.STARTING_PRIVATE_HOST,
State.CONNECTING,
State.CONNECTION_FAILED,
],
State.STARTING_PRIVATE_HOST: [
State.PRIVATE_HOST,
State.CONNECTION_FAILED,
State.DISCONNECTING,
],
State.PRIVATE_HOST: [
State.OPEN_HOST,
State.DISCONNECTING,
],
State.OPEN_HOST: [
State.PRIVATE_HOST,
State.DISCONNECTING,
],
State.CONNECTING: [
State.AUTHENTICATING,
State.CONNECTION_FAILED,
State.DISCONNECTING,
State.INACTIVE,
],
State.AUTHENTICATING: [
State.JOINED_CLIENT,
State.CONNECTION_FAILED,
State.DISCONNECTING,
State.INACTIVE,
],
State.JOINED_CLIENT: [
State.DISCONNECTING,
State.SERVER_LOST,
],
State.DISCONNECTING: [
State.INACTIVE,
State.CONNECTING,
],
State.CONNECTION_FAILED: [
State.INACTIVE,
State.CONNECTING,
State.DISCONNECTING,
],
State.SERVER_LOST: [
State.INACTIVE,
State.CONNECTING,
State.DISCONNECTING,
],
}
return to_state in allowed.get(from_state, [])
func _fail(message: String) -> void:
_set_state(State.CONNECTION_FAILED, message)
connection_error.emit(message)
func _teardown_peer() -> void:
_pending_authentication.clear()
_registry.clear()
if _spawn_service != null:
_spawn_service.clear_remote_players()
if _transport != null:
_transport.disconnect_transport()
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
_connection_deadline = 0.0
_input_accumulator = 0.0
_snapshot_accumulator = 0.0

View file

@ -0,0 +1 @@
uid://u62a32ktf0y5

View file

@ -0,0 +1,34 @@
class_name NetworkTransport
extends Node
signal transport_error(message: String)
var _peer: MultiplayerPeer
var _route_description: String = ""
func start_host(
_port: int,
_max_clients: int,
_bind_address: String = "*",
) -> Error:
return ERR_UNAVAILABLE
func connect_to_route(_route: ConnectionRoute) -> Error:
return ERR_UNAVAILABLE
func disconnect_transport() -> void:
if _peer != null:
_peer.close()
_peer = null
_route_description = ""
func get_multiplayer_peer() -> MultiplayerPeer:
return _peer
func get_route_description() -> String:
return _route_description

View file

@ -0,0 +1 @@
uid://pcqgk35ifhxc

69
network/peer_registry.gd Normal file
View file

@ -0,0 +1,69 @@
class_name PeerRegistry
extends RefCounted
class PeerRecord:
extends RefCounted
var peer_id: int = 0
var profile_id: String = ""
var display_name: String = ""
var protocol_version: int = 0
var joined_at_unix: int = 0
var _records: Dictionary[int, PeerRecord] = {}
func add_peer(
peer_id: int,
profile_id: String,
display_name: String,
protocol_version: int,
) -> bool:
if peer_id <= 0 or profile_id.is_empty() or has_profile(profile_id):
return false
var record := PeerRecord.new()
record.peer_id = peer_id
record.profile_id = profile_id
record.display_name = display_name
record.protocol_version = protocol_version
record.joined_at_unix = int(Time.get_unix_time_from_system())
_records[peer_id] = record
return true
func remove_peer(peer_id: int) -> PeerRecord:
var record: PeerRecord = _records.get(peer_id)
_records.erase(peer_id)
return record
func get_peer(peer_id: int) -> PeerRecord:
return _records.get(peer_id)
func has_peer(peer_id: int) -> bool:
return _records.has(peer_id)
func has_profile(profile_id: String) -> bool:
for record: PeerRecord in _records.values():
if record.profile_id == profile_id:
return true
return false
func get_peer_ids() -> Array[int]:
var result: Array[int] = []
for peer_id: int in _records:
result.append(peer_id)
result.sort()
return result
func size() -> int:
return _records.size()
func clear() -> void:
_records.clear()

View file

@ -0,0 +1 @@
uid://de3jtu2e0ut65

View file

@ -0,0 +1,96 @@
class_name PlayerSpawnService
extends Node
const PlayerScene: PackedScene = preload("res://player/player.tscn")
signal avatar_spawned(peer_id: int, avatar: Player)
signal avatar_removed(peer_id: int)
var _players_root: Node3D
var _local_player: Player
var _spawn_transform: Transform3D
var _avatars: Dictionary[int, Player] = {}
var _local_peer_id: int = 1
func setup(
players_root: Node3D,
local_player: Player,
spawn_transform: Transform3D,
) -> void:
_players_root = players_root
_local_player = local_player
_spawn_transform = spawn_transform
func register_local_player(peer_id: int) -> void:
if _local_player == null:
return
for existing_id: int in _avatars.keys():
if _avatars[existing_id] == _local_player:
_avatars.erase(existing_id)
_local_peer_id = peer_id
_avatars[peer_id] = _local_player
_local_player.name = "Player_%d" % peer_id
_local_player.set_local_control(true)
_local_player.set_network_peer_id(peer_id)
func spawn_remote_player(
peer_id: int,
transform: Transform3D,
authoritative_simulation: bool = false,
) -> Player:
if peer_id <= 0 or peer_id == _local_peer_id:
return null
var existing: Player = _avatars.get(peer_id)
if existing != null and is_instance_valid(existing):
return existing
var avatar := PlayerScene.instantiate() as Player
avatar.name = "Player_%d" % peer_id
avatar.set_local_control(false)
avatar.set_network_peer_id(peer_id)
_players_root.add_child(avatar)
avatar.global_transform = transform
avatar.configure_network_remote(authoritative_simulation)
_avatars[peer_id] = avatar
avatar_spawned.emit(peer_id, avatar)
return avatar
func remove_peer(peer_id: int) -> void:
if peer_id == _local_peer_id:
return
var avatar: Player = _avatars.get(peer_id)
_avatars.erase(peer_id)
if avatar != null and is_instance_valid(avatar):
avatar.queue_free()
avatar_removed.emit(peer_id)
func clear_remote_players() -> void:
for peer_id: int in _avatars.keys():
if peer_id != _local_peer_id:
remove_peer(peer_id)
func get_avatar(peer_id: int) -> Player:
return _avatars.get(peer_id)
func get_peer_ids() -> Array[int]:
var result: Array[int] = []
for peer_id: int in _avatars:
result.append(peer_id)
result.sort()
return result
func get_spawn_transform_for_index(index: int) -> Transform3D:
if index <= 0:
return _spawn_transform
var angle: float = float(index) * 2.3999632297
var ring: float = 1.4 + floor(float(index - 1) / 8.0) * 1.2
var result: Transform3D = _spawn_transform
result.origin += Vector3(cos(angle) * ring, 0.0, sin(angle) * ring)
return result

View file

@ -0,0 +1 @@
uid://8w70d0nrf4a7

View file

@ -0,0 +1,187 @@
class_name SavedServerStore
extends Node
const FORMAT_VERSION: int = 1
const STORE_PATH: String = "user://saved_servers.json"
const TEMP_PATH: String = "user://saved_servers.json.tmp"
const BACKUP_PATH: String = "user://saved_servers.json.backup"
const MAX_ENTRIES: int = 100
var _entries: Array[Dictionary] = []
var _loaded: bool = false
func list_entries() -> Array[Dictionary]:
_ensure_loaded()
return _entries.duplicate(true)
func find_by_normalized_endpoint(value: String) -> Dictionary:
_ensure_loaded()
for entry: Dictionary in _entries:
if entry.get("normalized_endpoint", "") == value:
return entry.duplicate(true)
return {}
func save_entry(
display_name: String,
endpoint: ConnectionEndpoint,
) -> bool:
_ensure_loaded()
if (
endpoint == null
or not endpoint.is_valid()
or display_name.strip_edges().is_empty()
or display_name.length() > 80
):
return false
var now: int = int(Time.get_unix_time_from_system())
for entry: Dictionary in _entries:
if entry.get("normalized_endpoint", "") == endpoint.normalized_display:
entry["display_name"] = display_name.strip_edges()
entry["host"] = endpoint.host
entry["port"] = endpoint.port
entry["updated_at_unix"] = now
return _save_atomic()
if _entries.size() >= MAX_ENTRIES:
return false
_entries.append({
"entry_id": Crypto.new().generate_random_bytes(16).hex_encode(),
"display_name": display_name.strip_edges(),
"route_kind": "DIRECT",
"host": endpoint.host,
"port": endpoint.port,
"normalized_endpoint": endpoint.normalized_display,
"favorite": false,
"created_at_unix": now,
"updated_at_unix": now,
"last_success_at_unix": 0,
"last_observed_max_players": 0,
})
return _save_atomic()
func remove_entry(entry_id: String) -> bool:
_ensure_loaded()
for index: int in range(_entries.size()):
if _entries[index].get("entry_id", "") == entry_id:
_entries.remove_at(index)
return _save_atomic()
return false
func record_successful_connection(
endpoint: ConnectionEndpoint,
observed_max_players: int,
) -> bool:
_ensure_loaded()
if endpoint == null or not endpoint.is_valid():
return false
for entry: Dictionary in _entries:
if entry.get("normalized_endpoint", "") == endpoint.normalized_display:
entry["last_success_at_unix"] = int(Time.get_unix_time_from_system())
entry["last_observed_max_players"] = maxi(observed_max_players, 0)
entry["updated_at_unix"] = int(Time.get_unix_time_from_system())
return _save_atomic()
# Successful manual connections are deliberately not auto-saved.
return true
func _ensure_loaded() -> void:
if _loaded:
return
_loaded = true
_recover_interrupted_write()
if not FileAccess.file_exists(STORE_PATH):
return
var file := FileAccess.open(STORE_PATH, FileAccess.READ)
if file == null:
return
var json := JSON.new()
var error: Error = json.parse(file.get_as_text())
file.close()
if error != OK or typeof(json.data) != TYPE_DICTIONARY:
return
var data: Dictionary = json.data
if data.get("format_version") != FORMAT_VERSION:
return
var raw_entries: Variant = data.get("entries")
if typeof(raw_entries) != TYPE_ARRAY:
return
var seen: Dictionary[String, bool] = {}
for value: Variant in raw_entries:
if typeof(value) != TYPE_DICTIONARY:
continue
var entry: Dictionary = value
var stored_host: String = str(entry.get("host", ""))
var stored_port: int = int(entry.get("port", 0))
var endpoint_text: String = (
"[%s]:%d" % [stored_host, stored_port]
if stored_host.contains(":")
else "%s:%d" % [stored_host, stored_port]
)
var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text)
if (
not endpoint.is_valid()
or seen.has(endpoint.normalized_display)
or typeof(entry.get("entry_id")) != TYPE_STRING
or typeof(entry.get("display_name")) != TYPE_STRING
):
continue
entry["normalized_endpoint"] = endpoint.normalized_display
seen[endpoint.normalized_display] = true
_entries.append(entry.duplicate(true))
if _entries.size() >= MAX_ENTRIES:
break
func _save_atomic() -> bool:
var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify({
"format_version": FORMAT_VERSION,
"entries": _entries,
}, "\t"))
file.flush()
var error: Error = file.get_error()
file.close()
if error != OK:
_remove_if_present(TEMP_PATH)
return false
_remove_if_present(BACKUP_PATH)
var had_primary: bool = FileAccess.file_exists(STORE_PATH)
if had_primary and not _rename(STORE_PATH, BACKUP_PATH):
_remove_if_present(TEMP_PATH)
return false
if not _rename(TEMP_PATH, STORE_PATH):
if had_primary:
_rename(BACKUP_PATH, STORE_PATH)
return false
_remove_if_present(BACKUP_PATH)
return true
func _recover_interrupted_write() -> void:
if FileAccess.file_exists(STORE_PATH):
_remove_if_present(TEMP_PATH)
_remove_if_present(BACKUP_PATH)
return
if FileAccess.file_exists(BACKUP_PATH):
_rename(BACKUP_PATH, STORE_PATH)
_remove_if_present(TEMP_PATH)
func _rename(from_path: String, to_path: String) -> bool:
return DirAccess.rename_absolute(
ProjectSettings.globalize_path(from_path),
ProjectSettings.globalize_path(to_path)
) == OK
func _remove_if_present(path: String) -> bool:
return (
not FileAccess.file_exists(path)
or DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) == OK
)

View file

@ -0,0 +1 @@
uid://b7j0er0u1nwy7