extends Node ## Autoload "Net": the multiplayer layer (docs/MULTIPLAYER_PLAN.md). ## ## M2.1 covers the skeleton: a dedicated server, joining it, the identity ## handshake, name rules and the player list. Gameplay over the network ## follows in M2.2-M2.5; single-player still runs as before. ## ## Server: `godot --headless --path . -- --server [--port 24242] ## [--config user://server/server.json] [--server-name "..."] ## [--max-players 8] [--password ...]` ## Client: Net.join("host:port", password) from the title screen. ## ## Handshake (all reliable ENet over one UDP port): ## client -> server _c2s_hello {protocol, name, appearance, public_key, password} ## server -> client _s2c_challenge nonce (32 random bytes) ## client -> server _c2s_proof signature (nonce signed with the private key) ## server -> client _s2c_welcome {...} or _s2c_rejected reason ## Then the server tells everyone about joins and leaves. signal state_changed(state: String) ## offline | connecting | connected | server signal joined ## the server welcomed us signal join_failed(reason: String) signal left(reason: String) ## disconnected after joining signal players_changed signal player_joined(name: String) ## someone else came in (clients) signal player_left(name: String) ## Everyone's positions at server time `t`: [[peer, pos, yaw, pitch, anim, held], ...]. signal snapshot(t: float, states: Array) signal corrected(pos: Vector3) ## the server refused a move: go back here signal clock(day: int, time: float) ## the server's time of day const Identity = preload("res://scripts/net/identity.gd") const ServerConfig = preload("res://scripts/net/server_config.gd") const PROTOCOL := 2 const DEFAULT_PORT := 24242 const HANDSHAKE_TIMEOUT := 10.0 const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join const SEND_INTERVAL := 0.05 ## position updates, both ways (20 Hz) const CLOCK_INTERVAL := 10.0 ## Movement checks: sprinting is 8 m/s, so 9.5 plus a little slack for ## packets that arrive bunched together. const MAX_SPEED := 9.5 const MAX_RISE := 9.0 const MOVE_SLACK := 1.0 const SPAWN_TELEPORT_RADIUS := 25.0 ## a flagged teleport (respawning) must land near the spawn var state := "offline" var dedicated := false ## started with --server var identity: RefCounted ## identity.gd (clients) var config: RefCounted ## server_config.gd (server) var server_info: Dictionary = {} ## the server's public settings (clients) ## Everyone on the server: peer id -> {"id", "name", "appearance", "admin"}. var players: Dictionary = {} var local_peer := 0 ## What the server told us about its world: seed, worldlet, spawn, clock. var world_info: Dictionary = {} var last_reason := "" ## why the last join failed or ended (the title shows it) ## Test hooks: simulated network delay on this client, both directions. var debug_latency_ms := 0.0 var debug_jitter_ms := 0.0 # Server state. var _terrain: Node3D ## the island, regenerated from the seed, for movement checks var _spawn := Vector3.ZERO var _states: Dictionary = {} ## peer -> {"pos", "yaw", "pitch", "anim", "held", "t", "corr_t"} var server_time := 0.0 ## seconds since the server started var _clock_day := 1 var _clock_time := 8.0 / 24.0 var _send_left := 0.0 var _clock_left := 0.0 # Client delay queues (debug latency): [release_ms, callable]. var _out_q: Array = [] var _in_q: Array = [] ## Test hooks (tests/net_bot.gd): sign with the wrong key, or claim another ## protocol version. var debug_forge := false var debug_protocol := 0 var debug_skip_name_check := false ## send a bad name anyway: the server must refuse it var _peer: ENetMultiplayerPeer var _pending: Dictionary = {} ## server: peer -> {"t": secs, "hello": {}, "nonce": bytes} var _reject_reason := "" var _join_left := 0.0 var _args: Dictionary = {} func _ready() -> void: _args = parse_args(OS.get_cmdline_user_args()) multiplayer.peer_connected.connect(_on_peer_connected) multiplayer.peer_disconnected.connect(_on_peer_disconnected) multiplayer.connected_to_server.connect(_on_connected) multiplayer.connection_failed.connect(_on_connection_failed) multiplayer.server_disconnected.connect(_on_server_disconnected) if _args.has("server"): dedicated = true _start_dedicated.call_deferred() # `--exit-with `: quit when that process is gone (test servers and # bots, so a crashed test never leaves them running). if _args.has("exit-with"): var parent := int(_args["exit-with"]) var t := Timer.new() t.wait_time = 1.0 t.autostart = true t.timeout.connect(func(): # (OS.is_process_running only works for our own children.) if OS.get_name() != "Windows" and OS.execute("kill", ["-0", str(parent)]) != 0: print("[net] parent process %d is gone, quitting" % parent) stop() get_tree().quit()) add_child(t) ## "--a=b", "--a b" and bare "--flag" all work. static func parse_args(args: PackedStringArray) -> Dictionary: var out := {} var i := 0 while i < args.size(): var a: String = args[i] if a.begins_with("--"): var k := a.trim_prefix("--") if k.contains("="): out[k.get_slice("=", 0)] = k.substr(k.find("=") + 1) elif i + 1 < args.size() and not args[i + 1].begins_with("--"): out[k] = args[i + 1] i += 1 else: out[k] = true i += 1 return out ## The local identity, loaded on first use (`--identity ` overrides). func get_identity() -> RefCounted: if identity == null: identity = Identity.open(String(_args.get("identity", Identity.DEFAULT_DIR))) return identity func _set_state(s: String) -> void: state = s state_changed.emit(s) func is_server() -> bool: return state == "server" # --- server ------------------------------------------------------------------- ## Why this Godot can't run the server ("" = it can): too old, or the game's ## scripts failed to load (usually a fresh copy that's never been imported). func startup_problem() -> String: var v := Engine.get_version_info() if v.major < 4 or (v.major == 4 and v.minor < 7): return "This game needs Godot 4.7 or newer, but this is %s. Download 4.7.2 from https://godotengine.org/download" % v.string var root: Window = (Engine.get_main_loop() as SceneTree).root for n in ["ItemDB", "Actions", "Saves"]: var node := root.get_node_or_null(n) if node == null or node.get_script() == null: return "The game's scripts didn't load (see the errors above). If this is a fresh copy of the project, " + \ "import it once first: godot --headless --path . --import (server/run_server.sh does this for you)" return "" func _start_dedicated() -> void: var bad := startup_problem() if not bad.is_empty(): printerr("[server] Not starting: ", bad) get_tree().quit(1) return var err := start_server(String(_args.get("config", ServerConfig.DEFAULT_PATH)), _args) if not err.is_empty(): printerr("[server] ", err) get_tree().quit(1) ## Opens the server. Returns "" on success, otherwise what went wrong. func start_server(config_path: String, overrides: Dictionary = {}) -> String: if state != "offline": return "Already %s" % state config = ServerConfig.open(config_path) var map := {"port": "port", "server-name": "server_name", "max-players": "max_players", "password": "password", "world": "world", "seed": "seed"} for flag in map: if overrides.has(flag): var v: Variant = overrides[flag] config.overrides[map[flag]] = int(v) if map[flag] in ["port", "max_players", "seed"] else String(v) # The island, from the seed: the same terrain every client generates. _terrain = load("res://scripts/world/terrain.gd").new() _terrain.name = "ServerTerrain" _terrain.noise_seed = int(config.v("seed")) add_child(_terrain) _spawn = _terrain.find_spawn(Vector2.ZERO) server_time = 0.0 _clock_day = 1 _clock_time = 8.0 / 24.0 _peer = ENetMultiplayerPeer.new() var port := int(config.v("port")) # A few spare slots so a full server can still say "server is full". var err := _peer.create_server(port, int(config.v("max_players")) + 4) if err != OK: return "Couldn't open UDP port %d (error %d). Is another server already running on it?" % [port, err] multiplayer.multiplayer_peer = _peer multiplayer.server_relay = false # clients only ever talk to the server players.clear() _pending.clear() _set_state("server") print("[server] '%s' listening on UDP %d (max %d players%s). Config: %s" % [config.v("server_name"), port, int(config.v("max_players")), ", password set" if not String(config.v("password")).is_empty() else "", ProjectSettings.globalize_path(config.path)]) return "" func stop() -> void: if _peer != null: _peer.close() _peer = null multiplayer.multiplayer_peer = null players.clear() _pending.clear() _states.clear() server_info.clear() world_info.clear() _out_q.clear() _in_q.clear() local_peer = 0 if _terrain != null: _terrain.queue_free() _terrain = null if state != "offline": _set_state("offline") func _on_peer_connected(id: int) -> void: if is_server(): _pending[id] = {"t": 0.0} func _on_peer_disconnected(id: int) -> void: if not is_server(): return _pending.erase(id) _states.erase(id) if players.has(id): var p: Dictionary = players[id] players.erase(id) print("[server] %s left (%d online)" % [p.name, players.size()]) _s2c_player_left.rpc(id) players_changed.emit() func _process(delta: float) -> void: if is_server(): for id in _pending.keys(): _pending[id].t += delta if _pending[id].t > HANDSHAKE_TIMEOUT: _reject(id, "Took too long to say hello") server_time += delta _tick_clock(delta) _send_left -= delta if _send_left <= 0.0: _send_left = SEND_INTERVAL _broadcast_snapshot() return if state == "connecting": _join_left -= delta if _join_left <= 0.0: _fail("No answer from the server") _run_queue(_out_q) _run_queue(_in_q) ## The world clock runs while anyone's on (or always, if the server's set ## not to pause when empty). func _tick_clock(delta: float) -> void: if players.is_empty() and bool(config.v("pause_when_empty")): return _clock_time += delta / (float(config.v("day_length_minutes")) * 60.0) while _clock_time >= 1.0: _clock_time -= 1.0 _clock_day += 1 _clock_left -= delta if _clock_left <= 0.0: _clock_left = CLOCK_INTERVAL for peer in players: _s2c_clock.rpc_id(peer, _clock_day, _clock_time) func _broadcast_snapshot() -> void: if _states.is_empty(): return var arr: Array = [] for peer in _states: var s: Dictionary = _states[peer] arr.append([peer, s.pos, s.yaw, s.pitch, s.anim, s.held, s.stamp]) for peer in players: _s2c_snapshot.rpc_id(peer, server_time, arr) ## Where player number `i` starts: around the island's spawn point. func _spawn_for(i: int) -> Vector3: if i == 0: return _spawn var a := i * 2.4 return _terrain.find_spawn(Vector2(_spawn.x + cos(a) * 3.0, _spawn.z + sin(a) * 3.0)) ## A position update from a player. Accepted if it's possible from their last ## accepted one; otherwise they're sent back there. @rpc("any_peer", "call_remote", "unreliable_ordered") func _c2s_state(pos: Vector3, yaw: float, pitch: float, anim: int, held: String, teleported: bool, client_t: float) -> void: var peer := multiplayer.get_remote_sender_id() if not is_server() or not _states.has(peer): return var s: Dictionary = _states[peer] # When the move happened, in server time: the client's own clock plus a # smoothed offset. Stamping with arrival time instead would bunch moves # into some snapshots and leave others empty, and avatars would surge. var off := server_time - client_t if s.off == INF or absf(off - s.off) > 1.0: s.off = off else: s.off = lerpf(s.off, off, 0.05) var stamp: float = client_t + s.off if stamp <= s.stamp: return # older than what we have var why := move_problem(s.pos, pos, server_time - s.t, teleported) if not why.is_empty(): if server_time - s.corr_t > 0.5: s.corr_t = server_time print("[server] moved %s back (%s)" % [players[peer].name, why]) _s2c_correct.rpc_id(peer, s.pos) return s.pos = pos s.yaw = wrapf(yaw, -PI, PI) s.pitch = clampf(pitch, -1.5, 1.5) s.anim = clampi(anim, 0, 4) s.held = held if held.length() < 40 and (held.is_empty() or not ItemDB.get_item(held).is_empty()) else "" s.t = server_time s.stamp = stamp ## Why a move from `from` to `to` over `dt` seconds isn't allowed ("" = fine). func move_problem(from: Vector3, to: Vector3, dt: float, teleported: bool) -> String: if not to.is_finite(): return "bad position" dt = clampf(dt, SEND_INTERVAL, 1.0) var flat := Vector2(to.x - from.x, to.z - from.z).length() if teleported: if Vector2(to.x - _spawn.x, to.z - _spawn.z).length() > SPAWN_TELEPORT_RADIUS: return "teleport away from the spawn" elif flat > MAX_SPEED * dt + MOVE_SLACK: return "too fast (%.0f m in %.2f s)" % [flat, dt] elif to.y - from.y > MAX_RISE * dt + MOVE_SLACK: return "rising too fast" if not _terrain.inside_boundary(to.x, to.z, -3.0): return "outside the island" if to.y < _terrain.sample_height(to.x, to.z) - 1.5: return "underground" return "" func _reject(peer: int, reason: String) -> void: _pending.erase(peer) print("[server] rejected peer %d: %s" % [peer, reason]) _s2c_rejected.rpc_id(peer, reason) # Let the message go out before hanging up. get_tree().create_timer(0.4).timeout.connect(func(): if _peer != null and is_server(): _peer.disconnect_peer(peer)) @rpc("any_peer", "call_remote", "reliable") func _c2s_hello(d: Dictionary) -> void: var peer := multiplayer.get_remote_sender_id() if not is_server() or not _pending.has(peer) or _pending[peer].has("hello"): return if int(d.get("protocol", 0)) != PROTOCOL: _reject(peer, "Different game version (server %d, you %d): update the game" % [PROTOCOL, int(d.get("protocol", 0))]) return var pw := String(config.v("password")) if not pw.is_empty() and String(d.get("password", "")) != pw: _reject(peer, "Wrong password") return var nonce := Crypto.new().generate_random_bytes(32) _pending[peer].hello = d _pending[peer].nonce = nonce _s2c_challenge.rpc_id(peer, nonce) @rpc("any_peer", "call_remote", "reliable") func _c2s_proof(sig: PackedByteArray) -> void: var peer := multiplayer.get_remote_sender_id() if not is_server() or not _pending.has(peer) or not _pending[peer].has("nonce"): return var hello: Dictionary = _pending[peer].hello var pem := String(hello.get("public_key", "")) if not Identity.verify(pem, _pending[peer].nonce, sig): _reject(peer, "Identity check failed") return var id := Identity.id_for(pem) var name := String(hello.get("name", "")) var why := _admission_problem(id, name) if not why.is_empty(): _reject(peer, why) return _pending.erase(peer) config.reserve_name(name, id) if config.data.admins.is_empty(): config.add_admin(id) # first player on a new server (MULTIPLAYER_PLAN.md ยง12) print("[server] %s is the first player, so they're now an admin" % name) var p := {"id": id, "name": name, "appearance": Identity.sanitize_appearance(hello.get("appearance", {})), "admin": config.is_admin(id)} var start := _spawn_for(players.size()) players[peer] = p _states[peer] = {"pos": start, "yaw": 0.0, "pitch": 0.0, "anim": 0, "held": "", "t": server_time, "corr_t": -10.0, "stamp": server_time, "off": INF} print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "", players.size(), int(config.v("max_players"))]) var world := {"seed": int(config.v("seed")), "worldlet": String(config.v("worldlet")), "spawn": start, "island_spawn": _spawn, "day": _clock_day, "time": _clock_time, "server_time": server_time} _s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world}) for other in players: if other != peer: _s2c_player_joined.rpc_id(other, peer, p) players_changed.emit() ## Why player `id` calling themselves `name` can't come in ("" = they can). func _admission_problem(id: String, name: String) -> String: if id in config.data.banned: return "You're banned from this server" if not config.data.allowlist.is_empty() and not id in config.data.allowlist: return "You're not on this server's allowlist" for other in players.values(): if other.id == id: return "You're already connected from another game" var bad := Identity.name_problem(name) if not bad.is_empty(): return bad var owner: String = config.name_owner(name) if not owner.is_empty() and owner != id: return "The name '%s' is taken on this server: pick another" % name if players.size() >= int(config.v("max_players")): return "The server is full (%d/%d)" % [players.size(), int(config.v("max_players"))] return "" # --- client ------------------------------------------------------------------- ## Splits "host", "host:port" or "[v6]:port". Returns [host, port]. static func parse_address(a: String) -> Array: a = a.strip_edges() if a.begins_with("["): var close := a.find("]") var host := a.substr(1, close - 1) var rest := a.substr(close + 1) return [host, int(rest.trim_prefix(":")) if rest.begins_with(":") else DEFAULT_PORT] if a.count(":") == 1: var port := a.get_slice(":", 1) return [a.get_slice(":", 0), int(port) if port.is_valid_int() else DEFAULT_PORT] return [a, DEFAULT_PORT] ## Connects to a server. The outcome arrives as `joined` or `join_failed`. func join(address: String, password: String = "") -> void: if state != "offline": stop() var me := get_identity() var bad := Identity.name_problem(me.name) if not bad.is_empty() and not debug_skip_name_check: join_failed.emit("Set your name first (%s)" % bad.to_lower()) return var hp := parse_address(address) if String(hp[0]).is_empty(): join_failed.emit("Enter a server address") return _peer = ENetMultiplayerPeer.new() var err := _peer.create_client(hp[0], hp[1]) if err != OK: _peer = null join_failed.emit("Couldn't start connecting (error %d)" % err) return multiplayer.multiplayer_peer = _peer _reject_reason = "" _pending_password = password _join_left = JOIN_TIMEOUT me.last_server = address.strip_edges() me.save_profile() _set_state("connecting") var _pending_password := "" func leave() -> void: var was := state stop() if was == "connected": last_reason = "You left the server" left.emit(last_reason) func _on_connected() -> void: var me := get_identity() _c2s_hello.rpc_id(1, {"protocol": debug_protocol if debug_protocol != 0 else PROTOCOL, "name": me.name, "appearance": me.appearance, "public_key": me.public_pem, "password": _pending_password}) @rpc("authority", "call_remote", "reliable") func _s2c_challenge(nonce: PackedByteArray) -> void: var sig: PackedByteArray = get_identity().sign(nonce) if debug_forge: var other := CryptoKey.new() other = Crypto.new().generate_rsa(2048) sig = Crypto.new().sign(HashingContext.HASH_SHA256, Identity._sha256(nonce), other) _c2s_proof.rpc_id(1, sig) @rpc("authority", "call_remote", "reliable") func _s2c_welcome(d: Dictionary) -> void: local_peer = int(d.you) server_info = d.server world_info = d.get("world", {}) players.clear() for k in d.players: players[int(k)] = d.players[k] last_reason = "" _set_state("connected") joined.emit() players_changed.emit() @rpc("authority", "call_remote", "reliable") func _s2c_rejected(reason: String) -> void: _reject_reason = reason @rpc("authority", "call_remote", "reliable") func _s2c_player_joined(peer: int, p: Dictionary) -> void: players[peer] = p players_changed.emit() player_joined.emit(String(p.get("name", "?"))) @rpc("authority", "call_remote", "reliable") func _s2c_player_left(peer: int) -> void: if not players.has(peer): return var n := String(players[peer].get("name", "?")) players.erase(peer) players_changed.emit() player_left.emit(n) @rpc("authority", "call_remote", "unreliable_ordered") func _s2c_snapshot(t: float, states: Array) -> void: _delayed(_in_q, func(): snapshot.emit(t, states)) @rpc("authority", "call_remote", "reliable") func _s2c_correct(pos: Vector3) -> void: _delayed(_in_q, func(): corrected.emit(pos)) @rpc("authority", "call_remote", "reliable") func _s2c_clock(day: int, time: float) -> void: world_info.day = day world_info.time = time clock.emit(day, time) ## Sends our position to the server (net_sync.gd calls this ~20 times a second). func send_state(pos: Vector3, yaw: float, pitch: float, anim: int, held: String, teleported: bool = false) -> void: if state != "connected": return var at := Time.get_ticks_usec() / 1000000.0 # when this happened, by our clock _delayed(_out_q, func(): if state == "connected": _c2s_state.rpc_id(1, pos, yaw, pitch, anim, held, teleported, at)) ## Runs `f` now, or after the simulated delay when testing with latency. func _delayed(q: Array, f: Callable) -> void: if debug_latency_ms <= 0.0: f.call() return var half := debug_latency_ms * 0.5 + randf_range(-debug_jitter_ms, debug_jitter_ms) * 0.5 var at := Time.get_ticks_msec() + maxf(half, 0.0) # Keep the queue in send order (networks rarely reorder within a stream). if not q.is_empty() and at < q[-1][0]: at = q[-1][0] q.append([at, f]) func _run_queue(q: Array) -> void: var now := Time.get_ticks_msec() while not q.is_empty() and q[0][0] <= now: var f: Callable = q.pop_front()[1] f.call() func _fail(reason: String) -> void: stop() last_reason = reason join_failed.emit(reason) func _on_connection_failed() -> void: _fail("Couldn't reach the server") func _on_server_disconnected() -> void: var was := state var reason := _reject_reason stop() if was == "connecting": last_reason = reason if not reason.is_empty() else "The server hung up" join_failed.emit(last_reason) else: last_reason = reason if not reason.is_empty() else "The server closed the connection" left.emit(last_reason) func _notification(what: int) -> void: if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE: if _peer != null: _peer.close()