feat(multiplayer): add Steam transport foundation

This commit is contained in:
Josh Creek
2026-08-21 18:52:07 +01:00
parent f2b72394de
commit 6bafdc7794
16 changed files with 402 additions and 31 deletions
+21
View File
@@ -0,0 +1,21 @@
class_name EnetTransport
extends NetTransport
func transport_id() -> String:
return "enet"
func is_available() -> bool:
return true
func create_server(port: int, max_clients: int) -> Dictionary:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(port, max_clients)
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
func create_client(address: String, port: int) -> Dictionary:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_client(address, port)
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
+26
View File
@@ -0,0 +1,26 @@
class_name NetTransport
extends RefCounted
# Narrow construction boundary for Godot's MultiplayerPeer implementations.
# NetworkManager owns polling, RPC policy, and lifecycle; a transport only
# creates a peer. Keeping that split means ENet remains a first-class path
# while Steam can use SDR without duplicating the rest of the networking code.
func transport_id() -> String:
return ""
func is_available() -> bool:
return false
func unavailable_reason() -> String:
return "transport is unavailable"
func create_server(_port: int, _max_clients: int) -> Dictionary:
return {"error": ERR_UNAVAILABLE, "peer": null, "reason": unavailable_reason()}
func create_client(_address: String, _port: int) -> Dictionary:
return {"error": ERR_UNAVAILABLE, "peer": null, "reason": unavailable_reason()}
+48 -15
View File
@@ -1,7 +1,7 @@
extends Node
# Autoload (project.godot [autoload] NetworkManager). Owns the ENet
# transport: hosting, joining, shutdown, and connection-state signals. Lives
# Autoload (project.godot [autoload] NetworkManager). Owns transport-neutral
# hosting, joining, shutdown, and connection-state signals. Lives
# at a fixed autoload path so RPC NodePaths never depend on which scene is
# loaded (§1.3 of multiplayer-todo.md's derived decisions).
#
@@ -52,6 +52,11 @@ signal shutting_down()
const DEFAULT_PORT := 7777
const MAX_CLIENTS := 32
const TRANSPORT_ENET := "enet"
const TRANSPORT_STEAM := "steam"
const EnetTransportScript = preload("res://scripts/enet_transport.gd")
const SteamTransportScript = preload("res://scripts/steam_transport.gd")
# Clock (task 1.8, §4.7): client pings the server once a second on the
# reliable control channel; clock_offset_ms is the min-RTT sample in a
@@ -64,7 +69,8 @@ const CLOCK_WINDOW_SEC := 5.0
var is_server := false
var is_client := false
var _peer: ENetMultiplayerPeer # keep a strong ref alongside multiplayer.multiplayer_peer
var _peer: MultiplayerPeer # keep a strong ref alongside multiplayer.multiplayer_peer
var active_transport := ""
var rtt_ms := -1.0 # min-RTT sample currently in the window; -1 = no sample yet
var clock_offset_ms := 0.0 # add to a local Time.get_ticks_msec() reading to estimate the server's clock
@@ -121,31 +127,46 @@ func poll() -> void:
multiplayer.poll()
func host(port: int = DEFAULT_PORT, max_clients: int = MAX_CLIENTS) -> Error:
func available_transports() -> PackedStringArray:
var transports := PackedStringArray([TRANSPORT_ENET])
if SteamTransportScript.new().is_available():
transports.append(TRANSPORT_STEAM)
return transports
func host(port: int = DEFAULT_PORT, max_clients: int = MAX_CLIENTS, transport: String = TRANSPORT_ENET) -> Error:
shutdown()
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(port, max_clients)
var implementation := _make_transport(transport)
if implementation == null:
return ERR_INVALID_PARAMETER
var result: Dictionary = implementation.create_server(port, max_clients)
var err := int(result.error)
if err != OK:
push_error("NetworkManager.host: create_server failed (%s)" % error_string(err))
push_error("NetworkManager.host(%s): create_server failed (%s): %s" % [transport, error_string(err), String(result.get("reason", ""))])
return err
_peer = peer
multiplayer.multiplayer_peer = peer
_peer = result.peer as MultiplayerPeer
multiplayer.multiplayer_peer = _peer
multiplayer.server_relay = false
active_transport = transport
is_server = true
is_client = false
return OK
func join(address: String, port: int = DEFAULT_PORT) -> Error:
func join(address: String, port: int = DEFAULT_PORT, transport: String = TRANSPORT_ENET) -> Error:
shutdown()
var peer := ENetMultiplayerPeer.new()
var err := peer.create_client(address, port)
var implementation := _make_transport(transport)
if implementation == null:
return ERR_INVALID_PARAMETER
var result: Dictionary = implementation.create_client(address, port)
var err := int(result.error)
if err != OK:
push_error("NetworkManager.join: create_client failed (%s)" % error_string(err))
push_error("NetworkManager.join(%s): create_client failed (%s): %s" % [transport, error_string(err), String(result.get("reason", ""))])
return err
_peer = peer
multiplayer.multiplayer_peer = peer
_peer = result.peer as MultiplayerPeer
multiplayer.multiplayer_peer = _peer
multiplayer.server_relay = false
active_transport = transport
is_server = false
is_client = true
return OK
@@ -164,6 +185,7 @@ func shutdown() -> void:
peer.close()
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
_peer = null
active_transport = ""
is_server = false
is_client = false
rtt_ms = -1.0
@@ -174,6 +196,17 @@ func shutdown() -> void:
_last_raw_rtt_ms = -1.0
func _make_transport(transport: String) -> NetTransport:
match transport:
TRANSPORT_ENET:
return EnetTransportScript.new()
TRANSPORT_STEAM:
return SteamTransportScript.new()
_:
push_error("NetworkManager: unknown transport '%s'" % transport)
return null
@rpc("any_peer", "call_remote", "reliable")
func _ping(client_send_ms: int) -> void:
if not multiplayer.is_server():
+42
View File
@@ -0,0 +1,42 @@
class_name SteamBootstrap
extends RefCounted
# Spacewar is Valve's development App ID. It is intentionally a development
# default, never a public-server identity or discovery configuration.
const SPACEWAR_APP_ID := 480
const APP_ID_ENV := "COSMIC_CLASH_STEAM_APP_ID"
static func app_id() -> int:
var configured := OS.get_environment(APP_ID_ENV).strip_edges()
if configured.is_valid_int() and int(configured) > 0:
return int(configured)
return SPACEWAR_APP_ID
static func is_runtime_available() -> bool:
return OS.has_feature("steam") and ClassDB.class_exists("SteamMultiplayerPeer") and Engine.has_singleton("Steam")
static func unavailable_reason() -> String:
if not OS.has_feature("steam"):
return "this export was not built with the steam feature"
if not ClassDB.class_exists("SteamMultiplayerPeer"):
return "SteamMultiplayerPeer is missing from this custom Godot build"
if not Engine.has_singleton("Steam"):
return "the GodotSteam Steam singleton is missing from this custom Godot build"
return "Steam is unavailable"
static func initialize() -> Dictionary:
if not is_runtime_available():
return {"error": ERR_UNAVAILABLE, "reason": unavailable_reason()}
var steam := Engine.get_singleton("Steam")
# `steamInit` is deliberately called dynamically: stock Godot must be able
# to parse and run this project without GodotSteam symbols installed.
var result = steam.call("steamInit")
if result is bool and result:
return {"error": OK, "app_id": app_id()}
if result is Dictionary and bool(result.get("status", false)):
return {"error": OK, "app_id": app_id()}
return {"error": ERR_CANT_CONNECT, "reason": "Steam initialization failed for App ID %d" % app_id()}
+44
View File
@@ -0,0 +1,44 @@
class_name SteamTransport
extends NetTransport
const SteamBootstrapScript = preload("res://scripts/steam_bootstrap.gd")
# The SteamMultiplayerPeer extension is looked up dynamically so a stock ENet
# build never references an unavailable native class while parsing scripts.
const VIRTUAL_PORT := 0
func transport_id() -> String:
return "steam"
func is_available() -> bool:
return SteamBootstrapScript.is_runtime_available()
func unavailable_reason() -> String:
return SteamBootstrapScript.unavailable_reason()
func create_server(_port: int, _max_clients: int) -> Dictionary:
var boot: Dictionary = SteamBootstrapScript.initialize()
if int(boot.error) != OK:
return boot
var peer := ClassDB.instantiate("SteamMultiplayerPeer") as MultiplayerPeer
if peer == null:
return {"error": ERR_UNAVAILABLE, "reason": "SteamMultiplayerPeer could not be instantiated"}
var err := int(peer.call("create_host", VIRTUAL_PORT))
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
func create_client(address: String, _port: int) -> Dictionary:
var steam_id_text := address.strip_edges()
if not steam_id_text.is_valid_int() or int(steam_id_text) <= 0:
return {"error": ERR_INVALID_PARAMETER, "reason": "Steam transport requires the server's numeric Steam ID"}
var boot: Dictionary = SteamBootstrapScript.initialize()
if int(boot.error) != OK:
return boot
var peer := ClassDB.instantiate("SteamMultiplayerPeer") as MultiplayerPeer
if peer == null:
return {"error": ERR_UNAVAILABLE, "reason": "SteamMultiplayerPeer could not be instantiated"}
var err := int(peer.call("create_client", int(steam_id_text), VIRTUAL_PORT))
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}