straywild/network/network_session.gd
Voyager 3b84bfe3a0 feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:49:54 -04:00

2876 lines
85 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class_name NetworkSession
extends Node
const DEFAULT_PORT: int = 7777
const DEFAULT_PRIVATE_HOST_PORT_ATTEMPTS: int = 16
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 = 60.0
const ENET_TIMEOUT_LIMIT: int = 32
const ENET_TIMEOUT_MINIMUM_MS: int = 10000
const ENET_TIMEOUT_MAXIMUM_MS: int = 120000
const INPUT_INTERVAL: float = 1.0 / 30.0
const IDLE_INPUT_INTERVAL: float = 1.0 / 5.0
const SNAPSHOT_INTERVAL: float = 1.0 / 30.0
const NEAR_REMOTE_SNAPSHOT_DIVISOR: int = 2
const FAR_REMOTE_SNAPSHOT_DIVISOR: int = 6
const DISTANT_REMOTE_SNAPSHOT_DIVISOR: int = 8
const NEAR_REMOTE_DISTANCE: float = 48.0
const FAR_REMOTE_DISTANCE: float = 96.0
const MOVEMENT_SNAPSHOT_BATCH_SIZE: int = 8
const MOVEMENT_INPUT_FIELD_COUNT: int = 8
const MOVEMENT_SNAPSHOT_FIELD_COUNT: int = 6
const MOVEMENT_ANIMATION_FIELD_COUNT: int = 7
const MOVEMENT_ANIMATION_ACTION_FIELD_COUNT: int = 5
const MAX_PENDING_MOVEMENT_INPUTS: int = 96
const ANIMATION_REFRESH_INTERVAL: float = 1.0
const MAX_MOVEMENT_INPUT_SEQUENCE: int = 2147483647
const MAX_MOVEMENT_ONE_WAY_TRANSIT_SECONDS: float = 0.25
const MOVEMENT_FLAG_JUMP: int = 1 << 0
const MOVEMENT_FLAG_SPRINT: int = 1 << 1
const MOVEMENT_FLAG_SNEAK: int = 1 << 2
const MOVEMENT_FLAG_SLOW_WALK: int = 1 << 3
const MOVEMENT_FLAG_SITTING: int = 1 << 4
const MOVEMENT_FLAG_CASTING: int = 1 << 5
const MOVEMENT_ALLOWED_FLAGS: int = (
MOVEMENT_FLAG_JUMP
| MOVEMENT_FLAG_SPRINT
| MOVEMENT_FLAG_SNEAK
| MOVEMENT_FLAG_SLOW_WALK
| MOVEMENT_FLAG_SITTING
| MOVEMENT_FLAG_CASTING
)
const SNAPSHOT_FLAG_GROUNDED: int = 1 << 0
const SNAPSHOT_FLAG_SITTING: int = 1 << 1
const SNAPSHOT_FLAG_CASTING: int = 1 << 2
const SNAPSHOT_ALLOWED_FLAGS: int = (
SNAPSHOT_FLAG_GROUNDED
| SNAPSHOT_FLAG_SITTING
| SNAPSHOT_FLAG_CASTING
)
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 session_display_name_changed(display_name: String)
signal peer_count_changed(player_count: int, max_players: int)
signal peer_display_name_changed(peer_id: int, display_name: String)
signal peer_profile_changed(
peer_id: int,
display_name: String,
appearance: Dictionary,
)
signal join_authenticated
signal server_trust_required(
endpoint: String,
expected_fingerprint: String,
received_fingerprint: String,
is_changed: bool,
)
signal peer_identity_observed(peer_id: int, status: String)
signal operator_status_changed(peer_id: int, is_operator: bool)
signal server_lost(message: String)
signal remote_recovery_requested(peer_id: int, entry_position: Vector3)
signal remote_recovery_presentation_changed(
peer_id: int,
active: bool,
attempt_id: String,
)
enum State {
INACTIVE,
STARTING_PRIVATE_HOST,
PRIVATE_HOST,
OPEN_HOST,
CONNECTING,
AUTHENTICATING,
VERIFYING_SERVER_IDENTITY,
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 _idle_input_accumulator: float = 0.0
var _last_input_state_hash: int = 0
var _pending_movement_inputs: Array[Dictionary] = []
var _snapshot_accumulator: float = 0.0
var _movement_snapshot_tick: int = 0
var _animation_refresh_accumulator: float = 0.0
var _last_animation_state_by_peer: Dictionary[int, Dictionary] = {}
var _pending_animation_state_by_peer: Dictionary[int, Dictionary] = {}
var _last_local_animation_action_signature: Array = []
var _movement_inputs_sent: int = 0
var _movement_inputs_received: int = 0
var _movement_snapshot_packets_sent: int = 0
var _movement_snapshot_states_sent: int = 0
var _movement_animation_packets_sent: int = 0
var _movement_animation_states_sent: int = 0
var _last_server_max_players: int = DEFAULT_SESSION_MAX_PLAYERS
var _last_server_player_count: int = 0
var _last_server_display_name: String = ""
var _last_server_protocol_version: int = 0
var _last_server_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED
var _last_server_world_layout: StringName = WorldLayout.GENERATED
var _server_capabilities: PackedStringArray = PackedStringArray()
var _profile_ready: bool = false
var _player_identity: PlayerIdentityStore
var _host_identity: HostIdentityStore
var _known_players: KnownPlayerStore
var _server_trust: ServerTrustStore
var _host_bans: HostBanStore
var _pending_identity_challenges: Dictionary[int, Dictionary] = {}
var _authenticated_identity_cache: Dictionary[int, Dictionary] = {}
var _client_identity_attempt: Dictionary = {}
var _pending_server_proof: Dictionary = {}
var _recovery_attempts: Dictionary[int, String] = {}
var _server_identity_fingerprint: String = ""
var _server_identity_public_key: String = ""
var _session_identity_keys: Dictionary[String, String] = {}
var _local_appearance_snapshot: Dictionary = (
CharacterCustomizationCatalog.default_snapshot()
)
var _moderation_disconnect_message := ""
var _host_port: int = 0
var _session_display_name: String = "NETfishing Room"
var _dedicated_host: bool = false
var _configured_operator_fingerprints: Dictionary[String, bool] = {}
var _session_operator_fingerprints: Dictionary[String, bool] = {}
var _operator_peer_ids: Dictionary[int, bool] = {}
var _local_operator: bool = false
var _host_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED
var _host_world_layout: StringName = WorldLayout.GENERATED
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,
player_identity: PlayerIdentityStore,
host_identity: HostIdentityStore,
known_players: KnownPlayerStore,
server_trust: ServerTrustStore,
host_bans: HostBanStore,
dedicated: bool = false,
) -> void:
_profile = profile
_saved_servers = saved_servers
_spawn_service = spawn_service
_player_identity = player_identity
_host_identity = host_identity
_known_players = known_players
_server_trust = server_trust
_host_bans = host_bans
if not dedicated and _profile != null:
_profile_ready = _profile.load_or_create()
if not dedicated and _player_identity != null:
_profile_ready = _profile_ready and _player_identity.load_or_create()
func configure_dedicated_operators(
fingerprints: PackedStringArray,
) -> bool:
if state != State.INACTIVE:
return false
var configured: Dictionary[String, bool] = {}
for fingerprint: String in fingerprints:
if not NetworkIdentityCrypto.valid_fingerprint(fingerprint):
return false
configured[fingerprint] = true
_configured_operator_fingerprints = configured
return true
func start_private_host(
port: int = DEFAULT_PORT,
port_attempts: int = 1,
) -> bool:
if (
state != State.INACTIVE
or not _profile_ready
or _profile == null
or _spawn_service == null
or _host_identity == null
):
return false
return _start_host(port, port_attempts, false)
func start_dedicated_host(
port: int = DEFAULT_PORT,
max_players: int = DEFAULT_SESSION_MAX_PLAYERS,
bind_address: String = "*",
) -> bool:
if (
state != State.INACTIVE
or _spawn_service == null
or _host_identity == null
or max_players < 1
or max_players > 128
or bind_address.is_empty()
):
return false
session_max_players = max_players
transport_max_clients = maxi(transport_max_clients, max_players)
return _start_host(port, 1, true, bind_address)
func _start_host(
port: int,
port_attempts: int,
dedicated: bool,
bind_address: String = "*",
) -> bool:
if not _host_identity.load_or_create():
_fail(_host_identity.error_message)
return false
if port < 1 or port > 65535 or port_attempts < 1:
_fail("The hosting port must be from 1 to 65535.")
return false
_operation_generation += 1
_set_state(
State.STARTING_PRIVATE_HOST,
"Starting dedicated server..." if dedicated else "Starting private game...",
)
var selected_port: int = 0
var final_port: int = mini(port + port_attempts - 1, 65535)
for candidate_port: int in range(port, final_port + 1):
if not _can_bind_udp_port(candidate_port, bind_address):
continue
_replace_transport()
var error: Error = _transport.start_host(
candidate_port,
transport_max_clients,
bind_address,
)
if error == OK:
selected_port = candidate_port
break
if selected_port == 0:
_fail(
"Could not start a private game on UDP ports %d%d."
% [port, final_port]
)
return false
_host_port = selected_port
_dedicated_host = dedicated
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()
_session_operator_fingerprints.clear()
_operator_peer_ids.clear()
_local_operator = false
_spawn_service.clear_remote_players()
if not dedicated:
_register_player_host()
_set_state(
State.PRIVATE_HOST,
(
"Dedicated server • UDP %d"
if dedicated else "Private game • UDP %d"
) % selected_port,
)
host_openness_changed.emit(false)
_emit_peer_count()
return true
func _register_player_host() -> void:
_registry.add_peer(
1,
_profile.profile_id,
_profile.display_name,
NetworkProtocol.PROTOCOL_VERSION,
_player_identity.fingerprint,
_player_identity.public_pem,
PackedStringArray([
NetworkProtocol.FISH_QUALITY_CAPABILITY,
NetworkProtocol.SURFACE_DRAWING_CAPABILITY,
NetworkProtocol.WORLD_TIME_CAPABILITY,
NetworkProtocol.WORLD_WEATHER_CAPABILITY,
NetworkProtocol.JOBS_CAPABILITY,
NetworkProtocol.WORLD_SPAWN_CAPABILITY,
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
NetworkProtocol.WORLD_GENERATION_CAPABILITY,
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
NetworkProtocol.FRIENDS_CAPABILITY,
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
]),
)
_registry.update_appearance(1, _local_appearance_snapshot)
var host_profile_hello := NetworkProtocol.make_client_hello(
_profile.profile_id,
_profile.display_name,
NetworkIdentityCrypto.secure_id(32),
_local_appearance_snapshot,
_player_identity.fingerprint,
)
host_profile_hello["identity_signature"] = _player_identity.sign(
"handshake_client_profile",
NetworkProtocol.client_profile_fields(host_profile_hello),
)
var host_record := _registry.get_peer(1)
if host_record != null:
host_record.profile_authorization = {
"domain": "handshake_client_profile",
"signature": host_profile_hello["identity_signature"],
"client_nonce": host_profile_hello["client_nonce"],
}
_archive_authenticated_identity(host_record)
_spawn_service.register_local_player(1)
var host_avatar := _spawn_service.get_avatar(1)
if host_avatar != null:
host_avatar.apply_appearance_snapshot(_local_appearance_snapshot)
func get_host_port() -> int:
return _host_port if is_host() else 0
func send_traversal_packet(
destination_address: String,
destination_port: int,
packet: PackedByteArray,
) -> bool:
return (
_transport != null
and state in [
State.PRIVATE_HOST,
State.OPEN_HOST,
State.CONNECTING,
State.AUTHENTICATING,
]
and _transport.send_raw_packet(
destination_address,
destination_port,
packet,
)
)
func is_dedicated_host() -> bool:
return is_host() and _dedicated_host
func is_local_operator() -> bool:
return state == State.JOINED_CLIENT and _local_operator
func can_local_moderate() -> bool:
return is_host() or is_local_operator()
func can_manage_operators() -> bool:
return is_host() and not _dedicated_host
func is_peer_operator(peer_id: int) -> bool:
return bool(_operator_peer_ids.get(peer_id, false))
func set_peer_operator(
peer_id: int,
fingerprint: String,
enabled: bool,
) -> bool:
if not can_manage_operators() or peer_id <= 1:
return false
var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id)
if (
record == null
or not record.identity_authenticated
or record.identity_fingerprint != fingerprint
):
return false
if enabled:
_session_operator_fingerprints[fingerprint] = true
else:
_session_operator_fingerprints.erase(fingerprint)
_set_operator_status(peer_id, enabled)
return true
func set_session_display_name(value: String) -> void:
var cleaned: String = value.strip_edges().left(48)
if cleaned.is_empty():
cleaned = "NETfishing Room"
if cleaned == _session_display_name:
return
_session_display_name = cleaned
session_display_name_changed.emit(_session_display_name)
func get_session_display_name() -> String:
return _session_display_name
func get_local_display_name() -> String:
return _profile.display_name if _profile != null else ""
static func _can_bind_udp_port(port: int, bind_address: String = "*") -> bool:
var probe := PacketPeerUDP.new()
var error: Error = probe.bind(port, bind_address)
probe.close()
return error == OK
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,
State.VERIFYING_SERVER_IDENTITY,
]:
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,
(
"Dedicated server • UDP %d%d / %d players"
if _dedicated_host
else "Open game • UDP %d%d / %d players"
if is_open
else "Private game • UDP %d%d / %d players"
) % [_host_port, _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 is_session_active() -> bool:
return is_host() or is_joined_client()
func get_player_count() -> int:
return _registry.size()
func get_session_max_players() -> int:
return _last_server_max_players if is_joined_client() else 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 get_current_endpoint() -> ConnectionEndpoint:
return (
_current_route.direct_endpoint
if _current_route != null
and _current_route.kind == ConnectionRoute.Kind.DIRECT
else null
)
func get_last_server_metadata() -> Dictionary:
return {
"server_display_name": _last_server_display_name,
"protocol_version": _last_server_protocol_version,
"player_count": _last_server_player_count,
"max_players": _last_server_max_players,
"world_seed": _last_server_world_seed,
"world_layout": String(_last_server_world_layout),
}
func set_host_world_seed(seed: int) -> bool:
return set_host_world(WorldLayout.GENERATED, seed)
func set_host_world(world_layout: StringName, seed: int) -> bool:
if (
state != State.INACTIVE
or not WorldLayout.is_valid(world_layout)
or seed <= 0
or seed > NetworkProtocol.MAX_WORLD_SEED
):
return false
_host_world_layout = world_layout
_host_world_seed = seed
return true
func get_authoritative_world_layout() -> StringName:
return (
_last_server_world_layout
if is_joined_client()
else _host_world_layout
)
func get_authoritative_world_seed() -> int:
return _last_server_world_seed if is_joined_client() else _host_world_seed
func can_use_host_gameplay() -> bool:
return is_host()
func is_gameplay_session_active() -> bool:
return state in [State.PRIVATE_HOST, State.OPEN_HOST, State.JOINED_CLIENT]
func is_authenticated_peer(peer_id: int) -> bool:
return _registry.has_peer(peer_id)
func get_session_id() -> String:
return _session_id
func get_operation_generation() -> int:
return _operation_generation
func supports_server_capability(capability: StringName) -> bool:
if is_host():
return str(capability) in PackedStringArray([
"movement_v1", "fishing_v1", "sale_v1", "shop_v1",
NetworkProtocol.ART_SHOP_CAPABILITY,
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
"item_use_v1", "equipment_v1", "fish_showcase_v1",
NetworkProtocol.FISH_QUALITY_CAPABILITY,
NetworkProtocol.SURFACE_DRAWING_CAPABILITY,
NetworkProtocol.WORLD_TIME_CAPABILITY,
NetworkProtocol.WORLD_WEATHER_CAPABILITY,
NetworkProtocol.WORLD_SPAWN_CAPABILITY,
NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY,
NetworkProtocol.WORLD_GENERATION_CAPABILITY,
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
NetworkProtocol.FRIENDS_CAPABILITY,
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
"chat_v1",
"mail_v1",
"profile_v1",
"identity_v1",
])
return str(capability) in _server_capabilities
func get_peer_record(peer_id: int) -> PeerRegistry.PeerRecord:
return _registry.get_peer(peer_id)
func peer_supports_capability(
peer_id: int,
capability: StringName,
) -> bool:
var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id)
return record != null and str(capability) in record.capability_flags
func get_authenticated_peer_ids() -> Array[int]:
return _registry.get_peer_ids()
func get_peer_rtt_ms(peer_id: int) -> int:
if peer_id == 1:
return 0
var enet := multiplayer.multiplayer_peer as ENetMultiplayerPeer
if (
enet == null
or enet.get_connection_status()
!= MultiplayerPeer.CONNECTION_CONNECTED
):
return -1
var measurable := is_host() or (
is_joined_client() and peer_id == get_local_peer_id()
)
if not measurable:
return -1
if is_joined_client() and not _registry.has_peer(1):
return -1
var packet_peer: ENetPacketPeer = enet.get_peer(peer_id if is_host() else 1)
if packet_peer == null:
return -1
return int(packet_peer.get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIME))
func get_movement_metrics() -> Dictionary:
return {
"inputs_sent": _movement_inputs_sent,
"inputs_received": _movement_inputs_received,
"snapshot_packets_sent": _movement_snapshot_packets_sent,
"snapshot_states_sent": _movement_snapshot_states_sent,
"animation_packets_sent": _movement_animation_packets_sent,
"animation_states_sent": _movement_animation_states_sent,
"pending_local_inputs": _pending_movement_inputs.size(),
}
func kick_authenticated_peer(
peer_id: int,
fingerprint: String,
banned: bool = false,
) -> bool:
if not is_host() or peer_id <= 1:
return false
var record := _registry.get_peer(peer_id)
if record == null or record.identity_fingerprint != fingerprint:
return false
receive_moderation_disconnect.rpc_id(
peer_id,
(
"You are not permitted to join this server."
if banned else "You were removed by the host."
),
)
call_deferred("_disconnect_rejected_peer", peer_id)
return true
func get_local_identity_fingerprint() -> String:
return _player_identity.fingerprint if _player_identity != null else ""
func sign_local_action(domain: String, fields: Array) -> PackedByteArray:
return (
_player_identity.sign(domain, fields)
if _player_identity != null else PackedByteArray()
)
func verify_peer_action(
peer_id: int,
domain: String,
fields: Array,
signature: PackedByteArray,
) -> bool:
var record := _registry.get_peer(peer_id)
if record == null or not record.identity_authenticated:
return false
return NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(record.identity_public_key),
domain,
fields,
signature,
)
func matches_authenticated_session_identity(
fingerprint: String, public_pem: String
) -> bool:
var normalized := NetworkIdentityCrypto.normalize_public_pem(public_pem)
return (
NetworkIdentityCrypto.valid_fingerprint(fingerprint)
and NetworkIdentityCrypto.fingerprint_public_pem(normalized)
== fingerprint
and _session_identity_keys.get(fingerprint, "") == normalized
)
func sign_host_action(domain: String, fields: Array) -> PackedByteArray:
return (
_host_identity.sign(domain, fields)
if is_host() and _host_identity != null else PackedByteArray()
)
func get_host_identity_fingerprint() -> String:
return (
_host_identity.fingerprint
if is_host() and _host_identity != null
else _server_identity_fingerprint
)
func verify_host_action(
domain: String, fields: Array, signature: PackedByteArray
) -> bool:
var public_pem: String = (
_host_identity.public_pem
if is_host() and _host_identity != null
else _server_identity_public_key
)
return NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
domain,
fields,
signature,
)
func update_local_display_name(value: String) -> bool:
if _profile == null or not _profile.set_display_name(value):
return false
var peer_id := get_local_peer_id()
if is_host():
_apply_display_name(peer_id, _profile.display_name)
receive_display_name.rpc(peer_id, _profile.display_name)
elif is_joined_client() and supports_server_capability(&"chat_v1"):
submit_display_name.rpc_id(1, _profile.display_name)
return true
func set_local_appearance_snapshot(snapshot: Dictionary) -> void:
if CharacterCustomizationCatalog.validate_snapshot(snapshot):
_local_appearance_snapshot = snapshot.duplicate(true)
var local_id := get_local_peer_id()
if local_id > 0:
_registry.update_appearance(local_id, _local_appearance_snapshot)
func apply_canonical_profile(
peer_id: int,
display_name: String,
appearance: Dictionary,
) -> bool:
if (
not NetworkProfilePreferences.is_valid_display_name(display_name)
or not CharacterCustomizationCatalog.validate_snapshot(appearance)
):
return false
var name_changed := _registry.update_display_name(peer_id, display_name)
var appearance_changed := _registry.update_appearance(peer_id, appearance)
if name_changed:
peer_display_name_changed.emit(peer_id, display_name)
if appearance_changed:
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar != null:
avatar.apply_appearance_snapshot(appearance)
if name_changed or appearance_changed:
peer_profile_changed.emit(peer_id, display_name, appearance.duplicate(true))
return name_changed or appearance_changed
@rpc("any_peer", "call_remote", "reliable", 0)
func submit_display_name(value: String) -> void:
var sender_id := multiplayer.get_remote_sender_id()
if (
is_host()
and is_authenticated_peer(sender_id)
and NetworkProfilePreferences.is_valid_display_name(value)
):
_apply_display_name(sender_id, value.strip_edges())
receive_display_name.rpc(sender_id, value.strip_edges())
@rpc("authority", "call_remote", "reliable", 0)
func receive_display_name(peer_id: int, value: String) -> void:
if NetworkProfilePreferences.is_valid_display_name(value):
_apply_display_name(peer_id, value.strip_edges())
func _apply_display_name(peer_id: int, value: String) -> void:
if _registry.update_display_name(peer_id, value):
peer_display_name_changed.emit(peer_id, value)
func get_local_peer_id() -> int:
if is_dedicated_host():
return 0
return multiplayer.get_unique_id() if is_gameplay_session_active() else 0
func get_latest_movement_input_sequence() -> int:
return _input_sequence if state == State.JOINED_CLIENT else 0
func _process(delta: float) -> void:
var now: float = Time.get_ticks_msec() / 1000.0
if (
state in [
State.CONNECTING,
State.AUTHENTICATING,
State.VERIFYING_SERVER_IDENTITY,
]
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
_animation_refresh_accumulator += delta
if state == State.JOINED_CLIENT and _input_accumulator >= INPUT_INTERVAL:
var elapsed_input_time: float = _input_accumulator
_input_accumulator = fmod(_input_accumulator, INPUT_INTERVAL)
_idle_input_accumulator += elapsed_input_time
_maybe_send_local_animation_action()
_maybe_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
_configure_enet_peer_timeout(peer_id)
_pending_authentication[peer_id] = (
Time.get_ticks_msec() / 1000.0 + AUTHENTICATION_TIMEOUT_SECONDS
)
func _on_connected_to_server() -> void:
if state != State.CONNECTING:
return
_configure_enet_peer_timeout(1)
_set_state(State.AUTHENTICATING, "Authenticating...")
_client_nonce = NetworkIdentityCrypto.secure_id(32)
_client_identity_attempt = NetworkProtocol.make_identity_hello(
_player_identity.public_pem,
_player_identity.fingerprint,
_client_nonce,
NetworkIdentityCrypto.secure_id(16),
)
submit_identity_hello.rpc_id(1, _client_identity_attempt)
func _configure_enet_peer_timeout(peer_id: int) -> void:
var enet := multiplayer.multiplayer_peer as ENetMultiplayerPeer
if enet == null:
return
var packet_peer: ENetPacketPeer = enet.get_peer(peer_id)
if packet_peer == null:
return
# A deterministic generated world can temporarily occupy the client's main
# thread during the authenticated join transition. Keep that bounded load
# from looking like a dead connection while retaining normal ENet liveness
# checks once the client resumes polling.
packet_peer.set_timeout(
ENET_TIMEOUT_LIMIT,
ENET_TIMEOUT_MINIMUM_MS,
ENET_TIMEOUT_MAXIMUM_MS,
)
func _on_connection_failed() -> void:
if state not in [
State.CONNECTING,
State.AUTHENTICATING,
State.VERIFYING_SERVER_IDENTITY,
]:
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()
if state in [
State.CONNECTING,
State.AUTHENTICATING,
State.VERIFYING_SERVER_IDENTITY,
]:
_fail("The server rejected or ended authentication.")
return
var message := (
_moderation_disconnect_message
if not _moderation_disconnect_message.is_empty()
else "The server connection was lost."
)
_moderation_disconnect_message = ""
_set_state(State.SERVER_LOST, message)
server_lost.emit(message)
connection_error.emit(message)
func _on_peer_disconnected(peer_id: int) -> void:
_pending_authentication.erase(peer_id)
_pending_identity_challenges.erase(peer_id)
_authenticated_identity_cache.erase(peer_id)
_operator_peer_ids.erase(peer_id)
_last_animation_state_by_peer.erase(peer_id)
_pending_animation_state_by_peer.erase(peer_id)
var recovery_attempt: String = _recovery_attempts.get(peer_id, "")
if not recovery_attempt.is_empty():
_recovery_attempts.erase(peer_id)
remote_recovery_presentation_changed.emit(
peer_id, false, recovery_attempt
)
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_identity_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 client_game_version: String = str(data.get("game_version", ""))
var version_rejection: NetworkProtocol.RejectionCode = (
NetworkProtocol.game_version_rejection(
client_game_version,
NetworkProtocol.game_version(),
)
)
if version_rejection != NetworkProtocol.RejectionCode.NONE:
_reject_peer(sender_id, version_rejection)
return
if (
not NetworkProtocol.validate_identity_hello(data)
or int(data["protocol_version"]) != NetworkProtocol.PROTOCOL_VERSION
):
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.PROTOCOL_MISMATCH
)
return
var public_pem := NetworkIdentityCrypto.normalize_public_pem(data["public_key"])
var fingerprint := NetworkIdentityCrypto.fingerprint_public_pem(public_pem)
if (
fingerprint != str(data["fingerprint"])
or NetworkIdentityCrypto.load_public_key(public_pem) == null
):
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF
)
return
if _registry.has_fingerprint(fingerprint):
_reject_peer(sender_id, NetworkProtocol.RejectionCode.DUPLICATE_IDENTITY)
return
var server_nonce := NetworkIdentityCrypto.secure_id(32)
var challenge := {
"peer_id": sender_id,
"attempt_id": str(data["attempt_id"]),
"client_nonce": str(data["client_nonce"]),
"client_fingerprint": fingerprint,
"client_public_key": public_pem,
"client_game_version": client_game_version,
"server_nonce": server_nonce,
"server_fingerprint": _host_identity.fingerprint,
"server_game_version": NetworkProtocol.game_version(),
"session_id": _session_id,
"generation": _operation_generation,
"expires_at_msec": Time.get_ticks_msec() + 60000,
}
var fields := _identity_proof_fields(challenge)
var proof := challenge.duplicate(true)
proof["server_public_key"] = _host_identity.public_pem
proof["server_signature"] = _host_identity.sign(
"handshake_server_proof", fields
)
_pending_identity_challenges[sender_id] = challenge
receive_server_identity_proof.rpc_id(sender_id, proof)
@rpc("authority", "call_remote", "reliable", 0)
func receive_server_identity_proof(data: Dictionary) -> void:
if state != State.AUTHENTICATING:
return
var server_game_version: String = str(data.get("server_game_version", ""))
if server_game_version.is_empty():
_fail_identity(NetworkProtocol.rejection_text(
NetworkProtocol.RejectionCode.SERVER_OUTDATED
))
return
var version_rejection: NetworkProtocol.RejectionCode = (
NetworkProtocol.game_version_rejection(
NetworkProtocol.game_version(),
server_game_version,
)
)
if version_rejection != NetworkProtocol.RejectionCode.NONE:
_fail_identity(NetworkProtocol.rejection_text(version_rejection))
return
if not _valid_server_proof_shape(data):
_fail_identity("The server sent an invalid identity proof.")
return
if (
str(data["attempt_id"]) != str(_client_identity_attempt.get("attempt_id", ""))
or str(data["client_nonce"]) != _client_nonce
):
_fail_identity("The server identity proof did not match this connection.")
return
var public_pem := NetworkIdentityCrypto.normalize_public_pem(data["server_public_key"])
var fingerprint := NetworkIdentityCrypto.fingerprint_public_pem(public_pem)
var public_key := NetworkIdentityCrypto.load_public_key(public_pem)
if (
fingerprint != str(data["server_fingerprint"])
or not NetworkIdentityCrypto.verify_fields(
public_key,
"handshake_server_proof",
_identity_proof_fields(data),
data["server_signature"],
)
):
_fail_identity("The server identity proof was invalid.")
return
_pending_server_proof = data.duplicate(true)
_server_identity_fingerprint = fingerprint
_server_identity_public_key = public_pem
var verification := _server_trust.verify(
get_current_endpoint(), fingerprint
)
if verification == ServerTrustStore.Verification.MATCH:
_server_trust.touch(get_current_endpoint())
_send_client_identity_proof()
return
_set_state(
State.VERIFYING_SERVER_IDENTITY,
"Confirm this server identity before continuing.",
)
_connection_deadline = 0.0
var expected := str(
_server_trust.get_record(get_current_endpoint()).get("fingerprint", "")
)
server_trust_required.emit(
get_current_route_display(),
expected,
fingerprint,
verification == ServerTrustStore.Verification.CHANGED,
)
func resolve_server_trust(accepted: bool) -> void:
if state != State.VERIFYING_SERVER_IDENTITY:
return
if not accepted:
cancel_connection()
return
if not _server_trust.trust(
get_current_endpoint(),
str(_pending_server_proof["server_fingerprint"]),
"NETfishing",
):
_fail_identity("The server identity could not be pinned.")
return
_set_state(State.AUTHENTICATING, "Authenticating identity...")
_connection_deadline = (
Time.get_ticks_msec() / 1000.0 + CONNECTION_TIMEOUT_SECONDS
)
_send_client_identity_proof()
func _send_client_identity_proof() -> void:
var proof := {
"attempt_id": _pending_server_proof["attempt_id"],
"session_id": _pending_server_proof["session_id"],
"client_fingerprint": _player_identity.fingerprint,
"client_signature": _player_identity.sign(
"handshake_client_proof",
_identity_proof_fields(_pending_server_proof),
),
}
submit_client_identity_proof.rpc_id(1, proof)
@rpc("any_peer", "call_remote", "reliable", 0)
func submit_client_identity_proof(data: Dictionary) -> void:
var sender_id := multiplayer.get_remote_sender_id()
var challenge: Dictionary = _pending_identity_challenges.get(sender_id, {})
if (
not is_host()
or challenge.is_empty()
or Time.get_ticks_msec() > int(challenge["expires_at_msec"])
or data.get("attempt_id") != challenge["attempt_id"]
or data.get("session_id") != _session_id
or data.get("client_fingerprint") != challenge["client_fingerprint"]
or typeof(data.get("client_signature")) != TYPE_PACKED_BYTE_ARRAY
):
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
return
var verified := NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(challenge["client_public_key"]),
"handshake_client_proof",
_identity_proof_fields(challenge),
data["client_signature"],
)
_pending_identity_challenges.erase(sender_id)
if not verified:
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
return
_authenticated_identity_cache[sender_id] = {
"fingerprint": challenge["client_fingerprint"],
"public_key": challenge["client_public_key"],
"game_version": challenge["client_game_version"],
}
if (
_host_bans != null
and _host_bans.is_banned(
_host_identity.fingerprint,
str(challenge["client_fingerprint"]),
)
):
_reject_peer(sender_id, NetworkProtocol.RejectionCode.BANNED)
return
request_client_profile.rpc_id(sender_id)
@rpc("authority", "call_remote", "reliable", 0)
func receive_moderation_disconnect(message: String) -> void:
_moderation_disconnect_message = message.left(120)
@rpc("authority", "call_remote", "reliable", 0)
func request_client_profile() -> void:
if state != State.AUTHENTICATING:
return
var hello := NetworkProtocol.make_client_hello(
_profile.profile_id,
_profile.display_name,
_client_nonce,
_local_appearance_snapshot,
_player_identity.fingerprint,
)
hello["identity_signature"] = _player_identity.sign(
"handshake_client_profile",
NetworkProtocol.client_profile_fields(hello),
)
submit_client_hello.rpc_id(1, hello)
@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
var client_game_version: String = str(data.get("game_version", ""))
var version_rejection: NetworkProtocol.RejectionCode = (
NetworkProtocol.game_version_rejection(
client_game_version,
NetworkProtocol.game_version(),
)
)
if version_rejection != NetworkProtocol.RejectionCode.NONE:
_reject_peer(sender_id, version_rejection)
return
var client_capabilities: PackedStringArray = _sanitized_capabilities(
data.get("capability_flags", [])
)
if NetworkProtocol.FISH_QUALITY_CAPABILITY not in client_capabilities:
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT,
)
return
if NetworkProtocol.WORLD_GENERATION_CAPABILITY not in client_capabilities:
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT,
)
return
for required_capability: String in [
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
]:
if required_capability not in client_capabilities:
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT,
)
return
if (
_host_world_layout == WorldLayout.STARTER_ISLAND
and NetworkProtocol.WORLD_LAYOUT_CAPABILITY not in client_capabilities
):
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT,
)
return
var identity: Dictionary = _authenticated_identity_cache.get(sender_id, {})
if identity.is_empty():
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
return
if (
data["identity_fingerprint"] != identity["fingerprint"]
or client_game_version != str(identity.get("game_version", ""))
or not NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(identity["public_key"]),
"handshake_client_profile",
NetworkProtocol.client_profile_fields(data),
data["identity_signature"],
)
):
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
return
if _registry.size() >= session_max_players:
_reject_peer(sender_id, NetworkProtocol.RejectionCode.SERVER_FULL)
return
var profile_id: String = data["local_profile_id"]
var display_name: String = data["display_name"]
if not NetworkProfilePreferences.is_valid_display_name(display_name):
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE
)
return
if not _registry.add_peer(
sender_id,
profile_id,
display_name,
NetworkProtocol.PROTOCOL_VERSION,
identity["fingerprint"],
identity["public_key"],
client_capabilities,
):
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE
)
return
var operator_enabled: bool = _operator_for_fingerprint(
str(identity["fingerprint"])
)
if operator_enabled:
_operator_peer_ids[sender_id] = true
var submitted_appearance := CharacterCustomizationCatalog.sanitized_snapshot(
data["cosmetic_snapshot"]
)
_registry.update_appearance(sender_id, submitted_appearance)
var peer_record := _registry.get_peer(sender_id)
if peer_record != null:
peer_record.profile_authorization = {
"domain": "handshake_client_profile",
"signature": data["identity_signature"],
"client_nonce": data["client_nonce"],
}
_archive_authenticated_identity(peer_record)
var identity_status := _known_players.observe(
str(identity["fingerprint"]), display_name
)
peer_identity_observed.emit(sender_id, identity_status)
_pending_authentication.erase(sender_id)
_authenticated_identity_cache.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)
var spawned_avatar: Player = _spawn_service.get_avatar(sender_id)
if spawned_avatar != null:
spawned_avatar.apply_appearance_snapshot(submitted_appearance)
peer_profile_changed.emit(
sender_id,
display_name,
submitted_appearance.duplicate(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,
_session_display_name,
_host_world_seed,
_host_world_layout,
)
)
receive_spawn_list.rpc_id(sender_id, _build_spawn_list())
receive_operator_snapshot.rpc_id(sender_id, _operator_peer_id_snapshot())
receive_peer_spawn.rpc(
_make_spawn_entry(sender_id, display_name, spawn_transform)
)
if operator_enabled:
receive_operator_status.rpc(sender_id, true)
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:
await get_tree().create_timer(0.15).timeout
if (
is_host()
and multiplayer.multiplayer_peer != null
and multiplayer.multiplayer_peer.get_connection_status()
== MultiplayerPeer.CONNECTION_CONNECTED
and peer_id in multiplayer.get_peers()
):
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("game_version")) != TYPE_STRING
or typeof(data.get("rejection_code")) != TYPE_INT
or typeof(data.get("world_seed")) != TYPE_INT
or typeof(
data.get("world_layout", String(WorldLayout.GENERATED))
) not in [TYPE_STRING, TYPE_STRING_NAME]
):
_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
var version_rejection: NetworkProtocol.RejectionCode = (
NetworkProtocol.game_version_rejection(
NetworkProtocol.game_version(),
str(data["game_version"]),
)
)
if version_rejection != NetworkProtocol.RejectionCode.NONE:
_teardown_peer()
_fail(NetworkProtocol.rejection_text(version_rejection))
return
if int(data["protocol_version"]) != NetworkProtocol.PROTOCOL_VERSION:
_teardown_peer()
_fail("The server uses a different network protocol.")
return
var received_world_seed := int(data["world_seed"])
if received_world_seed <= 0 or received_world_seed > NetworkProtocol.MAX_WORLD_SEED:
_teardown_peer()
_fail("The server sent an invalid world seed.")
return
var received_world_layout := WorldLayout.normalized(
data.get("world_layout", String(WorldLayout.GENERATED)),
&"",
)
if received_world_layout.is_empty():
_teardown_peer()
_fail("The server sent an invalid world layout.")
return
_session_id = str(data.get("session_id", ""))
_last_server_max_players = int(data.get(
"max_players",
DEFAULT_SESSION_MAX_PLAYERS
))
_last_server_player_count = int(data.get("player_count", 1))
_last_server_display_name = str(data.get("server_display_name", ""))
_last_server_protocol_version = int(data.get(
"protocol_version", NetworkProtocol.PROTOCOL_VERSION
))
_last_server_world_seed = received_world_seed
_last_server_world_layout = received_world_layout
_server_capabilities = PackedStringArray()
var advertised_capabilities: Variant = data.get(
"capability_flags", PackedStringArray()
)
if typeof(advertised_capabilities) in [
TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY
]:
for value: Variant in advertised_capabilities:
if typeof(value) in [TYPE_STRING, TYPE_STRING_NAME]:
_server_capabilities.append(str(value))
if NetworkProtocol.FISH_QUALITY_CAPABILITY not in _server_capabilities:
_teardown_peer()
_fail("This server does not support fish quality data.")
return
if NetworkProtocol.WORLD_GENERATION_CAPABILITY not in _server_capabilities:
_teardown_peer()
_fail("This server does not support generated worlds.")
return
for required_capability: String in [
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
]:
if required_capability not in _server_capabilities:
_teardown_peer()
_fail("This server is missing required network capabilities.")
return
if (
received_world_layout == WorldLayout.STARTER_ISLAND
and NetworkProtocol.WORLD_LAYOUT_CAPABILITY not in _server_capabilities
):
_teardown_peer()
_fail("This server does not support selectable world layouts.")
return
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,
_player_identity.fingerprint,
_player_identity.public_pem,
PackedStringArray([
NetworkProtocol.FISH_QUALITY_CAPABILITY,
NetworkProtocol.SURFACE_DRAWING_CAPABILITY,
NetworkProtocol.WORLD_TIME_CAPABILITY,
NetworkProtocol.WORLD_WEATHER_CAPABILITY,
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
NetworkProtocol.WORLD_GENERATION_CAPABILITY,
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
NetworkProtocol.FRIENDS_CAPABILITY,
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
]),
)
_registry.update_appearance(local_peer_id, _local_appearance_snapshot)
var local_record := _registry.get_peer(local_peer_id)
if local_record != null:
_archive_authenticated_identity(local_record)
_spawn_service.clear_remote_players()
_spawn_service.register_local_player(local_peer_id)
var local_avatar := _spawn_service.get_avatar(local_peer_id)
if local_avatar != null:
local_avatar.apply_appearance_snapshot(_local_appearance_snapshot)
_connection_deadline = 0.0
_set_state(
State.JOINED_CLIENT,
"Connected • %d / %d players" % [
int(data.get("player_count", 1)),
_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
_operator_peer_ids.erase(peer_id)
_last_animation_state_by_peer.erase(peer_id)
_pending_animation_state_by_peer.erase(peer_id)
_registry.remove_peer(peer_id)
_spawn_service.remove_peer(peer_id)
peer_removed.emit(peer_id)
_emit_peer_count()
@rpc("authority", "call_remote", "reliable", 0)
func receive_operator_snapshot(peer_ids: PackedInt32Array) -> void:
if (
state != State.JOINED_CLIENT
or peer_ids.size() > get_session_max_players()
):
return
_operator_peer_ids.clear()
for peer_id: int in peer_ids:
if peer_id > 1:
_operator_peer_ids[peer_id] = true
_update_local_operator()
for peer_id: int in _operator_peer_ids:
operator_status_changed.emit(peer_id, true)
@rpc("authority", "call_remote", "reliable", 0)
func receive_operator_status(peer_id: int, enabled: bool) -> void:
if state != State.JOINED_CLIENT or peer_id <= 1:
return
if enabled:
_operator_peer_ids[peer_id] = true
else:
_operator_peer_ids.erase(peer_id)
_update_local_operator()
operator_status_changed.emit(peer_id, enabled)
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]
or typeof(entry.get("sitting")) != TYPE_BOOL
or not NetworkPlayerAnimationProtocol.validate_state(
entry.get("animation_state")
)
):
return
if not _verify_spawn_identity(entry):
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_snapshot["sitting"] = bool(entry["sitting"])
own_avatar.apply_network_teleport(own_snapshot)
return
var peer_was_added: bool = false
if not _registry.has_peer(peer_id):
if not _registry.add_peer(
peer_id,
entry["profile_id"],
entry["display_name"],
NetworkProtocol.PROTOCOL_VERSION,
str(entry.get("identity_fingerprint", "")),
str(entry.get("identity_public_key", "")),
_sanitized_capabilities(entry.get("capability_flags", [])),
):
return
peer_was_added = true
var added_record := _registry.get_peer(peer_id)
if added_record != null:
added_record.profile_authorization = Dictionary(
entry.get("profile_authorization", {})
).duplicate(true)
if added_record != null and added_record.identity_authenticated:
_archive_authenticated_identity(added_record)
var status := _known_players.observe(
added_record.identity_fingerprint,
added_record.display_name,
)
peer_identity_observed.emit(peer_id, status)
if typeof(entry.get("appearance")) == TYPE_DICTIONARY:
var appearance := CharacterCustomizationCatalog.sanitized_snapshot(
entry["appearance"]
)
_registry.update_appearance(peer_id, appearance)
var transform: Transform3D = _spawn_service.get_spawn_transform_for_index(0)
var spawn_position: Array = entry["position"]
if spawn_position.size() != 3:
return
transform.origin = Vector3(
float(spawn_position[0]),
float(spawn_position[1]),
float(spawn_position[2])
)
transform.basis = Basis(Vector3.UP, float(entry["yaw"]))
var avatar := _spawn_service.spawn_remote_player(peer_id, transform, false)
var record := _registry.get_peer(peer_id)
if avatar != null and record != null:
avatar.apply_appearance_snapshot(record.appearance_snapshot)
# Lifecycle and animation updates use separate reliable ENet channels.
# Include the current authoritative state in the spawn record so a peer
# joining during an action presents it immediately, regardless of which
# channel arrives first.
avatar.apply_network_animation_state(entry["animation_state"])
avatar.apply_network_sitting_state(bool(entry["sitting"]))
var pending_animation: Dictionary = (
_pending_animation_state_by_peer.get(peer_id, {})
)
if not pending_animation.is_empty():
avatar.apply_network_animation_state(pending_animation)
_pending_animation_state_by_peer.erase(peer_id)
if peer_was_added and record != null:
peer_authenticated.emit(peer_id, record.display_name)
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)
var avatar: Player = _spawn_service.get_avatar(peer_id)
var animation_state: Dictionary = (
avatar.make_network_animation_state()
if avatar != null
else NetworkPlayerAnimationProtocol.make_state(
NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE,
true,
)
)
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,
"sitting": (
avatar.get_network_sitting_state()
if avatar != null
else false
),
"animation_state": animation_state,
"appearance": (
record.appearance_snapshot.duplicate(true)
if record != null
else CharacterCustomizationCatalog.default_snapshot()
),
"identity_fingerprint": (
record.identity_fingerprint if record != null else ""
),
"identity_public_key": (
record.identity_public_key if record != null else ""
),
"profile_authorization": (
record.profile_authorization.duplicate(true)
if record != null else {}
),
"capability_flags": (
record.capability_flags.duplicate()
if record != null else PackedStringArray()
),
}
func _sanitized_capabilities(value: Variant) -> PackedStringArray:
var result := PackedStringArray()
if typeof(value) not in [TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY]:
return result
for capability: Variant in value:
if (
typeof(capability) in [TYPE_STRING, TYPE_STRING_NAME]
and not str(capability).is_empty()
and str(capability).length() <= 64
and str(capability) not in result
):
result.append(str(capability))
return result
func _operator_for_fingerprint(fingerprint: String) -> bool:
return bool((
_configured_operator_fingerprints
if _dedicated_host
else _session_operator_fingerprints
).get(fingerprint, false))
func _operator_peer_id_snapshot() -> PackedInt32Array:
var result: PackedInt32Array = PackedInt32Array()
for peer_id: int in _operator_peer_ids:
result.append(peer_id)
result.sort()
return result
func _set_operator_status(peer_id: int, enabled: bool) -> void:
if enabled:
_operator_peer_ids[peer_id] = true
else:
_operator_peer_ids.erase(peer_id)
operator_status_changed.emit(peer_id, enabled)
receive_operator_status.rpc(peer_id, enabled)
func _update_local_operator() -> void:
var local_peer_id: int = multiplayer.get_unique_id()
_local_operator = bool(_operator_peer_ids.get(local_peer_id, false))
func verify_profile_authorization(
peer_id: int,
display_name: String,
appearance: Dictionary,
authorization: Dictionary,
) -> bool:
var record := _registry.get_peer(peer_id)
if record == null:
return false
return _verify_profile_authorization_fields(
peer_id,
record.profile_id,
display_name,
appearance,
record.identity_fingerprint,
record.identity_public_key,
authorization,
)
func _verify_spawn_identity(entry: Dictionary) -> bool:
if typeof(entry.get("profile_authorization")) != TYPE_DICTIONARY:
return false
return _verify_profile_authorization_fields(
int(entry.get("peer_id", 0)),
str(entry.get("profile_id", "")),
str(entry.get("display_name", "")),
Dictionary(entry.get("appearance", {})),
str(entry.get("identity_fingerprint", "")),
str(entry.get("identity_public_key", "")),
Dictionary(entry.get("profile_authorization", {})),
)
func _verify_profile_authorization_fields(
peer_id: int,
profile_id: String,
display_name: String,
appearance: Dictionary,
fingerprint: String,
identity_public_key: String,
authorization: Dictionary,
) -> bool:
var public_pem := NetworkIdentityCrypto.normalize_public_pem(
identity_public_key
)
if NetworkIdentityCrypto.fingerprint_public_pem(public_pem) != fingerprint:
return false
if authorization.is_empty():
return peer_id == 1
if authorization.get("domain") == "handshake_client_profile":
var hello := NetworkProtocol.make_client_hello(
profile_id,
display_name,
str(authorization.get("client_nonce", "")),
appearance,
fingerprint,
authorization.get("signature", PackedByteArray()),
)
return NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
"handshake_client_profile",
NetworkProtocol.client_profile_fields(hello),
hello["identity_signature"],
)
if authorization.get("domain") == "appearance_preview":
if str(authorization.get("sender_fingerprint", "")) != fingerprint:
return false
var preview := authorization.duplicate(true)
preview["appearance"] = appearance
return NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
"appearance_preview",
NetworkProfileProtocol.appearance_preview_signature_fields(preview),
preview.get("sender_signature", PackedByteArray()),
)
if authorization.has("sender_signature"):
if str(authorization.get("sender_fingerprint", "")) != fingerprint:
return false
var signed := authorization.duplicate(true)
signed["display_name"] = display_name
signed["appearance"] = appearance
return NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
"profile_update",
NetworkProfileProtocol.signature_fields(signed),
signed["sender_signature"],
)
return false
func _identity_proof_fields(data: Dictionary) -> Array:
return [
NetworkProtocol.PROTOCOL_VERSION,
str(data.get("client_game_version", "")),
str(data.get("server_game_version", "")),
str(data.get("attempt_id", "")),
str(data.get("client_fingerprint", "")),
str(data.get("client_nonce", "")),
str(data.get("server_fingerprint", "")),
str(data.get("server_nonce", "")),
str(data.get("session_id", "")),
int(data.get("peer_id", 0)),
int(data.get("generation", 0)),
int(data.get("expires_at_msec", 0)),
]
func _archive_authenticated_identity(record: PeerRegistry.PeerRecord) -> void:
if (
record != null
and record.identity_authenticated
and NetworkIdentityCrypto.fingerprint_public_pem(
record.identity_public_key
) == record.identity_fingerprint
):
_session_identity_keys[record.identity_fingerprint] = (
NetworkIdentityCrypto.normalize_public_pem(
record.identity_public_key
)
)
func _valid_server_proof_shape(data: Variant) -> bool:
if typeof(data) != TYPE_DICTIONARY:
return false
var value: Dictionary = data
return (
typeof(value.get("attempt_id")) == TYPE_STRING
and str(value["attempt_id"]).length() == 32
and typeof(value.get("client_nonce")) == TYPE_STRING
and str(value["client_nonce"]).length() == 64
and typeof(value.get("server_nonce")) == TYPE_STRING
and str(value["server_nonce"]).length() == 64
and NetworkIdentityCrypto.valid_fingerprint(value.get("client_fingerprint"))
and NetworkIdentityCrypto.valid_fingerprint(value.get("server_fingerprint"))
and typeof(value.get("client_game_version")) == TYPE_STRING
and str(value["client_game_version"]) == NetworkProtocol.game_version()
and typeof(value.get("server_game_version")) == TYPE_STRING
and str(value["server_game_version"]) == NetworkProtocol.game_version()
and typeof(value.get("server_public_key")) == TYPE_STRING
and str(value["server_public_key"]).to_utf8_buffer().size()
<= NetworkProtocol.MAX_PUBLIC_KEY_LENGTH
and typeof(value.get("server_signature")) == TYPE_PACKED_BYTE_ARRAY
and PackedByteArray(value["server_signature"]).size()
<= NetworkIdentityCrypto.MAX_SIGNATURE_BYTES
and typeof(value.get("session_id")) == TYPE_STRING
and typeof(value.get("peer_id")) == TYPE_INT
and typeof(value.get("generation")) == TYPE_INT
and typeof(value.get("expires_at_msec")) == TYPE_INT
)
func _fail_identity(message: String) -> void:
_teardown_peer()
_fail(message)
func _maybe_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
var state_hash: int = avatar.get_network_input_state_hash()
var state_changed: bool = state_hash != _last_input_state_hash
if (
avatar.has_active_network_input()
or state_changed
or _idle_input_accumulator >= IDLE_INPUT_INTERVAL
):
_send_local_input()
_last_input_state_hash = state_hash
_idle_input_accumulator = 0.0
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
if _input_sequence >= MAX_MOVEMENT_INPUT_SEQUENCE
else _input_sequence + 1
)
var input: Dictionary = avatar.capture_network_input(_input_sequence)
var encoded: Array = _encode_movement_input(input)
if encoded.is_empty():
return
_pending_movement_inputs.append(input.duplicate(true))
while _pending_movement_inputs.size() > MAX_PENDING_MOVEMENT_INPUTS:
_pending_movement_inputs.pop_front()
_movement_inputs_sent += 1
submit_movement_input.rpc_id(1, encoded)
func _maybe_send_local_animation_action() -> void:
var peer_id: int = multiplayer.get_unique_id()
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar == null:
return
var state: Dictionary = avatar.make_network_animation_state()
var action: Dictionary = state.get("action", {})
if not NetworkPlayerAnimationProtocol.validate_action_state(action):
return
var signature: Array = [
str(action["id"]),
int(action["sequence"]),
bool(action.get("paused", false)),
avatar.get_network_sitting_state(),
]
if signature == _last_local_animation_action_signature:
return
var encoded: Array = _encode_movement_animation_action(
action,
avatar.get_network_sitting_state(),
)
if encoded.is_empty():
return
_last_local_animation_action_signature = signature
submit_movement_animation_action.rpc_id(1, encoded)
func submit_neutral_local_movement() -> void:
if state != State.JOINED_CLIENT:
return
_send_local_input()
@rpc("any_peer", "call_remote", "unreliable_ordered", 1)
func submit_movement_input(encoded: Array) -> 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)
var data: Dictionary = _decode_movement_input(encoded)
if avatar == null or data.is_empty():
return
_movement_inputs_received += 1
avatar.apply_authoritative_network_input(
data,
Player.resolve_network_input_stale_timeout_seconds(
get_peer_rtt_ms(sender_id)
),
)
@rpc(
"any_peer",
"call_remote",
"reliable",
NetworkProtocol.MOVEMENT_ANIMATION_CHANNEL,
)
func submit_movement_animation_action(encoded: Array) -> 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)
var state: Dictionary = _decode_movement_animation_action(encoded)
if avatar == null or state.is_empty():
return
avatar.apply_authoritative_network_animation_action(state["action"])
avatar.apply_authoritative_network_sitting_state(bool(state["sitting"]))
static func _encode_movement_input(data: Dictionary) -> Array:
if not _is_valid_movement_input(data):
return []
var axis: Array = data["axis"]
var flags: int = 0
if bool(data["jump"]):
flags |= MOVEMENT_FLAG_JUMP
if bool(data["sprint"]):
flags |= MOVEMENT_FLAG_SPRINT
if bool(data["sneak"]):
flags |= MOVEMENT_FLAG_SNEAK
if bool(data["slow_walk"]):
flags |= MOVEMENT_FLAG_SLOW_WALK
if bool(data["sitting"]):
flags |= MOVEMENT_FLAG_SITTING
if bool(data["casting"]):
flags |= MOVEMENT_FLAG_CASTING
var action: Dictionary = data["animation_action"]
return [
int(data["sequence"]),
Vector2(float(axis[0]), float(axis[1])),
float(data["camera_yaw"]),
flags,
str(action["id"]),
int(action["sequence"]),
float(action["elapsed"]),
bool(action.get("paused", false)),
]
static func _decode_movement_input(value: Variant) -> Dictionary:
if typeof(value) != TYPE_ARRAY:
return {}
var fields: Array = value
if (
fields.size() != MOVEMENT_INPUT_FIELD_COUNT
or typeof(fields[0]) != TYPE_INT
or typeof(fields[1]) != TYPE_VECTOR2
or typeof(fields[2]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(fields[3]) != TYPE_INT
or typeof(fields[4]) not in [TYPE_STRING, TYPE_STRING_NAME]
or typeof(fields[5]) != TYPE_INT
or typeof(fields[6]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(fields[7]) != TYPE_BOOL
):
return {}
var axis: Vector2 = fields[1]
var flags: int = int(fields[3])
if flags < 0 or flags & ~MOVEMENT_ALLOWED_FLAGS != 0:
return {}
var decoded: Dictionary = {
"sequence": int(fields[0]),
"axis": [axis.x, axis.y],
"camera_yaw": float(fields[2]),
"jump": bool(flags & MOVEMENT_FLAG_JUMP),
"sprint": bool(flags & MOVEMENT_FLAG_SPRINT),
"sneak": bool(flags & MOVEMENT_FLAG_SNEAK),
"slow_walk": bool(flags & MOVEMENT_FLAG_SLOW_WALK),
"sitting": bool(flags & MOVEMENT_FLAG_SITTING),
"casting": bool(flags & MOVEMENT_FLAG_CASTING),
"animation_action": NetworkPlayerAnimationProtocol.make_action_state(
StringName(str(fields[4])),
int(fields[5]),
float(fields[6]),
bool(fields[7]),
),
}
return decoded if _is_valid_movement_input(decoded) else {}
static 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("sitting")) != TYPE_BOOL
or typeof(data.get("casting")) != TYPE_BOOL
or not NetworkPlayerAnimationProtocol.validate_action_state(
data.get("animation_action")
)
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
if (
typeof(axis[0]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(axis[1]) not in [TYPE_FLOAT, TYPE_INT]
):
return false
var x: float = float(axis[0])
var y: float = float(axis[1])
var camera_yaw: float = float(data["camera_yaw"])
return (
int(data["sequence"]) > 0
and int(data["sequence"]) <= MAX_MOVEMENT_INPUT_SEQUENCE
and 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:
_movement_snapshot_tick += 1
var peer_ids: Array[int] = _registry.get_peer_ids()
for recipient_id: int in peer_ids:
# Peer 1 is the local listen-server player. Dedicated servers do not
# register a peer 1, so every remaining record is a remote recipient.
if recipient_id == 1:
continue
var recipient_avatar: Player = _spawn_service.get_avatar(recipient_id)
if recipient_avatar == null:
continue
var snapshots: Array = []
for subject_id: int in peer_ids:
var subject_avatar: Player = _spawn_service.get_avatar(subject_id)
if subject_avatar == null:
continue
if (
subject_id != recipient_id
and not _should_send_remote_snapshot(
recipient_avatar.global_position,
subject_avatar.global_position,
)
):
continue
var encoded: Array = _encode_movement_snapshot(
subject_avatar.make_network_snapshot(subject_id)
)
if not encoded.is_empty():
snapshots.append(encoded)
_send_movement_snapshot_batches(recipient_id, snapshots)
_broadcast_movement_animation_updates(peer_ids)
func _should_send_remote_snapshot(
recipient_position: Vector3,
subject_position: Vector3,
) -> bool:
var distance_squared: float = recipient_position.distance_squared_to(
subject_position
)
var divisor: int = DISTANT_REMOTE_SNAPSHOT_DIVISOR
if distance_squared <= NEAR_REMOTE_DISTANCE * NEAR_REMOTE_DISTANCE:
divisor = NEAR_REMOTE_SNAPSHOT_DIVISOR
elif distance_squared <= FAR_REMOTE_DISTANCE * FAR_REMOTE_DISTANCE:
divisor = FAR_REMOTE_SNAPSHOT_DIVISOR
return _movement_snapshot_tick % divisor == 0
func _send_movement_snapshot_batches(
recipient_id: int,
snapshots: Array,
) -> void:
for start_index: int in range(
0,
snapshots.size(),
MOVEMENT_SNAPSHOT_BATCH_SIZE,
):
var batch: Array = snapshots.slice(
start_index,
mini(
start_index + MOVEMENT_SNAPSHOT_BATCH_SIZE,
snapshots.size(),
),
)
receive_movement_snapshots.rpc_id(recipient_id, batch)
_movement_snapshot_packets_sent += 1
_movement_snapshot_states_sent += batch.size()
func _broadcast_movement_animation_updates(peer_ids: Array[int]) -> void:
var refresh_all: bool = (
_animation_refresh_accumulator >= ANIMATION_REFRESH_INTERVAL
)
if refresh_all:
_animation_refresh_accumulator = fmod(
_animation_refresh_accumulator,
ANIMATION_REFRESH_INTERVAL,
)
var updates: Array = []
for subject_id: int in peer_ids:
var avatar: Player = _spawn_service.get_avatar(subject_id)
if avatar == null:
continue
var state: Dictionary = avatar.make_network_animation_state()
var previous: Dictionary = _last_animation_state_by_peer.get(
subject_id, {}
)
if (
not refresh_all
and _movement_animation_signature(state)
== _movement_animation_signature(previous)
):
continue
_last_animation_state_by_peer[subject_id] = state.duplicate(true)
var encoded: Array = _encode_movement_animation(subject_id, state)
if not encoded.is_empty():
updates.append(encoded)
if updates.is_empty():
return
for recipient_id: int in peer_ids:
if recipient_id == 1:
continue
receive_movement_animations.rpc_id(recipient_id, updates)
_movement_animation_packets_sent += 1
_movement_animation_states_sent += updates.size()
static func _movement_animation_signature(state: Dictionary) -> Array:
if not NetworkPlayerAnimationProtocol.validate_state(state):
return []
var action: Dictionary = state["action"]
return [
str(state["locomotion_id"]),
bool(state["grounded"]),
str(action["id"]),
int(action["sequence"]),
bool(action.get("paused", false)),
]
static func _encode_movement_snapshot(snapshot: Dictionary) -> Array:
var position: Variant = snapshot.get("position")
var snapshot_velocity: Variant = snapshot.get("velocity")
if (
typeof(position) != TYPE_ARRAY
or position.size() != 3
or typeof(snapshot_velocity) != TYPE_ARRAY
or snapshot_velocity.size() != 3
):
return []
var flags: int = 0
if bool(snapshot.get("grounded", false)):
flags |= SNAPSHOT_FLAG_GROUNDED
if bool(snapshot.get("sitting", false)):
flags |= SNAPSHOT_FLAG_SITTING
if bool(snapshot.get("casting", false)):
flags |= SNAPSHOT_FLAG_CASTING
return [
int(snapshot.get("peer_id", 0)),
int(snapshot.get("acknowledged_input", 0)),
Vector3(float(position[0]), float(position[1]), float(position[2])),
Vector3(
float(snapshot_velocity[0]),
float(snapshot_velocity[1]),
float(snapshot_velocity[2]),
),
float(snapshot.get("visual_yaw", 0.0)),
flags,
]
static func _decode_movement_snapshot(value: Variant) -> Dictionary:
if typeof(value) != TYPE_ARRAY:
return {}
var fields: Array = value
if (
fields.size() != MOVEMENT_SNAPSHOT_FIELD_COUNT
or typeof(fields[0]) != TYPE_INT
or typeof(fields[1]) != TYPE_INT
or typeof(fields[2]) != TYPE_VECTOR3
or typeof(fields[3]) != TYPE_VECTOR3
or typeof(fields[4]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(fields[5]) != TYPE_INT
):
return {}
var position: Vector3 = fields[2]
var snapshot_velocity: Vector3 = fields[3]
var flags: int = int(fields[5])
if (
int(fields[0]) <= 0
or int(fields[1]) < 0
or not position.is_finite()
or not snapshot_velocity.is_finite()
or not is_finite(float(fields[4]))
or flags < 0
or flags & ~SNAPSHOT_ALLOWED_FLAGS != 0
):
return {}
return {
"peer_id": int(fields[0]),
"acknowledged_input": int(fields[1]),
"position": [position.x, position.y, position.z],
"velocity": [
snapshot_velocity.x,
snapshot_velocity.y,
snapshot_velocity.z,
],
"visual_yaw": float(fields[4]),
"grounded": bool(flags & SNAPSHOT_FLAG_GROUNDED),
"sitting": bool(flags & SNAPSHOT_FLAG_SITTING),
"casting": bool(flags & SNAPSHOT_FLAG_CASTING),
}
static func _encode_movement_animation(
peer_id: int,
state: Dictionary,
) -> Array:
if not NetworkPlayerAnimationProtocol.validate_state(state):
return []
var action: Dictionary = state["action"]
return [
peer_id,
str(state["locomotion_id"]),
bool(state["grounded"]),
str(action["id"]),
int(action["sequence"]),
float(action["elapsed"]),
bool(action.get("paused", false)),
]
static func _decode_movement_animation(value: Variant) -> Dictionary:
if typeof(value) != TYPE_ARRAY:
return {}
var fields: Array = value
if (
fields.size() != MOVEMENT_ANIMATION_FIELD_COUNT
or typeof(fields[0]) != TYPE_INT
or typeof(fields[1]) not in [TYPE_STRING, TYPE_STRING_NAME]
or typeof(fields[2]) != TYPE_BOOL
or typeof(fields[3]) not in [TYPE_STRING, TYPE_STRING_NAME]
or typeof(fields[4]) != TYPE_INT
or typeof(fields[5]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(fields[6]) != TYPE_BOOL
):
return {}
var state: Dictionary = NetworkPlayerAnimationProtocol.make_state(
StringName(str(fields[1])),
bool(fields[2]),
StringName(str(fields[3])),
int(fields[4]),
float(fields[5]),
bool(fields[6]),
)
if (
int(fields[0]) <= 0
or not NetworkPlayerAnimationProtocol.validate_state(state)
):
return {}
return {"peer_id": int(fields[0]), "state": state}
static func _encode_movement_animation_action(
action: Dictionary,
sitting: bool,
) -> Array:
if not NetworkPlayerAnimationProtocol.validate_action_state(action):
return []
return [
str(action["id"]),
int(action["sequence"]),
float(action["elapsed"]),
bool(action.get("paused", false)),
sitting,
]
static func _decode_movement_animation_action(value: Variant) -> Dictionary:
if typeof(value) != TYPE_ARRAY:
return {}
var fields: Array = value
if (
fields.size() != MOVEMENT_ANIMATION_ACTION_FIELD_COUNT
or typeof(fields[0]) not in [TYPE_STRING, TYPE_STRING_NAME]
or typeof(fields[1]) != TYPE_INT
or typeof(fields[2]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(fields[3]) != TYPE_BOOL
or typeof(fields[4]) != TYPE_BOOL
):
return {}
var action: Dictionary = NetworkPlayerAnimationProtocol.make_action_state(
StringName(str(fields[0])),
int(fields[1]),
float(fields[2]),
bool(fields[3]),
)
if not NetworkPlayerAnimationProtocol.validate_action_state(action):
return {}
return {"action": action, "sitting": bool(fields[4])}
@rpc("authority", "call_remote", "unreliable_ordered", 2)
func receive_movement_snapshots(encoded_snapshots: Array) -> void:
if state != State.JOINED_CLIENT:
return
var local_peer_id: int = multiplayer.get_unique_id()
var estimated_transit_seconds: float = (
_estimated_movement_transit_seconds()
)
for value: Variant in encoded_snapshots:
var snapshot: Dictionary = _decode_movement_snapshot(value)
if snapshot.is_empty():
continue
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:
_discard_acknowledged_movement_inputs(
int(snapshot.get("acknowledged_input", 0))
)
avatar.apply_local_prediction_correction(
snapshot,
_pending_movement_inputs,
INPUT_INTERVAL,
)
else:
avatar.push_network_snapshot(
snapshot,
estimated_transit_seconds,
)
func _discard_acknowledged_movement_inputs(acknowledged_sequence: int) -> void:
while (
not _pending_movement_inputs.is_empty()
and int(_pending_movement_inputs[0].get("sequence", 0))
<= acknowledged_sequence
):
_pending_movement_inputs.pop_front()
@rpc(
"authority",
"call_remote",
"reliable",
NetworkProtocol.MOVEMENT_ANIMATION_CHANNEL,
)
func receive_movement_animations(encoded_states: Array) -> void:
if state != State.JOINED_CLIENT:
return
for value: Variant in encoded_states:
var decoded: Dictionary = _decode_movement_animation(value)
if decoded.is_empty():
continue
var avatar: Player = _spawn_service.get_avatar(
int(decoded["peer_id"])
)
if avatar == null:
_pending_animation_state_by_peer[int(decoded["peer_id"])] = (
Dictionary(decoded["state"]).duplicate(true)
)
continue
avatar.apply_network_animation_state(decoded["state"])
func _estimated_movement_transit_seconds() -> float:
if state != State.JOINED_CLIENT:
return -1.0
var round_trip_msec: int = get_peer_rtt_ms(get_local_peer_id())
if round_trip_msec < 0:
return -1.0
return minf(
float(round_trip_msec) / 2000.0,
MAX_MOVEMENT_ONE_WAY_TRANSIT_SECONDS,
)
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_recovery_presentation(
active: bool,
attempt_id: String,
) -> void:
if (
attempt_id.is_empty()
or attempt_id.length() > 96
or not is_gameplay_session_active()
):
return
if is_host():
_set_authoritative_recovery_presentation(
get_local_peer_id(), active, attempt_id
)
elif state == State.JOINED_CLIENT:
submit_recovery_presentation.rpc_id(
1, _session_id, active, attempt_id
)
@rpc("any_peer", "call_remote", "reliable", 0)
func submit_recovery_presentation(
session_id: String,
active: bool,
attempt_id: String,
) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if (
not is_host()
or not _registry.has_peer(sender_id)
or session_id != _session_id
or attempt_id.is_empty()
or attempt_id.length() > 96
):
return
_set_authoritative_recovery_presentation(sender_id, active, attempt_id)
func _set_authoritative_recovery_presentation(
peer_id: int,
active: bool,
attempt_id: String,
) -> void:
var current_attempt: String = _recovery_attempts.get(peer_id, "")
if active:
if not current_attempt.is_empty() and current_attempt != attempt_id:
return
elif current_attempt != attempt_id:
return
var data := {
"session_id": _session_id,
"generation": _operation_generation,
"peer_id": peer_id,
"active": active,
"attempt_id": attempt_id,
}
_apply_recovery_presentation(data)
receive_recovery_presentation.rpc(data)
@rpc("authority", "call_remote", "reliable", 0)
func receive_recovery_presentation(data: Dictionary) -> void:
_apply_recovery_presentation(data)
func _apply_recovery_presentation(data: Dictionary) -> void:
if (
typeof(data.get("session_id")) != TYPE_STRING
or str(data["session_id"]) != _session_id
or typeof(data.get("generation")) != TYPE_INT
or int(data["generation"]) < 1
or typeof(data.get("peer_id")) != TYPE_INT
or typeof(data.get("active")) != TYPE_BOOL
or typeof(data.get("attempt_id")) != TYPE_STRING
or str(data["attempt_id"]).is_empty()
or str(data["attempt_id"]).length() > 96
):
return
var peer_id: int = data["peer_id"]
var attempt_id: String = data["attempt_id"]
var active: bool = data["active"]
var current_attempt: String = _recovery_attempts.get(peer_id, "")
if active:
if not current_attempt.is_empty() and current_attempt != attempt_id:
return
_recovery_attempts[peer_id] = attempt_id
elif current_attempt != attempt_id:
return
else:
_recovery_attempts.erase(peer_id)
remote_recovery_presentation_changed.emit(peer_id, active, attempt_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.VERIFYING_SERVER_IDENTITY,
State.JOINED_CLIENT,
State.CONNECTION_FAILED,
State.DISCONNECTING,
State.INACTIVE,
],
State.VERIFYING_SERVER_IDENTITY: [
State.AUTHENTICATING,
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:
var local_avatar: Player
if _spawn_service != null:
local_avatar = _spawn_service.get_local_player()
if local_avatar != null:
local_avatar.reset_network_movement_state()
for peer_id: int in _recovery_attempts.keys():
remote_recovery_presentation_changed.emit(
peer_id, false, _recovery_attempts[peer_id]
)
_recovery_attempts.clear()
_pending_authentication.clear()
_pending_identity_challenges.clear()
_authenticated_identity_cache.clear()
_client_identity_attempt.clear()
_pending_server_proof.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_sequence = 0
_input_accumulator = 0.0
_idle_input_accumulator = 0.0
_last_input_state_hash = 0
_pending_movement_inputs.clear()
_snapshot_accumulator = 0.0
_movement_snapshot_tick = 0
_animation_refresh_accumulator = 0.0
_last_animation_state_by_peer.clear()
_pending_animation_state_by_peer.clear()
_last_local_animation_action_signature.clear()
_server_capabilities = PackedStringArray()
_server_identity_fingerprint = ""
_server_identity_public_key = ""
_session_identity_keys.clear()
_session_operator_fingerprints.clear()
_operator_peer_ids.clear()
_local_operator = false
_host_port = 0
_dedicated_host = false