M2.2: player bodies, name tags, shared island and checked movement

- Joining loads the server's island (seed, spawn, clock); the server
  generates the same terrain from its seed and keeps a world clock that
  pauses when empty
- Other players are procedural low-poly characters in their chosen colours
  with name tags (hidden by walls, fading with distance), walk/run/swim
  animation and held items
- Movement: local and instant, sent at 20 Hz stamped with the sender's
  clock; the server refuses impossible moves (speed, rising, leaving the
  island, underground) and sends the player back; flagged respawns allowed
- Avatars drawn 100 ms behind and interpolated, with brief extrapolation
- Online: no pausing, player list in the Esc menu, Disconnect back to the
  title (with the reason); animals off and world changes local-only until
  M2.3-M2.5, shown on the HUD
- test_net_world: real server + walking and cheating bots at 100 ms +/-
  30 ms simulated latency (27 checks)
This commit is contained in:
Paul Bailey
2026-11-04 15:08:14 +00:00
parent f62f71658f
commit d0f473e57b
14 changed files with 1093 additions and 29 deletions

View File

@@ -22,14 +22,28 @@ 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 := 1
const PROTOCOL := 2
const DEFAULT_PORT := 24242
const HANDSHAKE_TIMEOUT := 10.0
const JOIN_TIMEOUT := 12.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
@@ -39,6 +53,26 @@ 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.
@@ -122,11 +156,20 @@ func start_server(config_path: String, overrides: Dictionary = {}) -> String:
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"}
"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"] else String(v)
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".
@@ -150,8 +193,15 @@ func stop() -> void:
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")
@@ -163,6 +213,7 @@ 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)
@@ -176,10 +227,104 @@ func _process(delta: float) -> void:
_pending[id].t += delta
if _pending[id].t > HANDSHAKE_TIMEOUT:
_reject(id, "Took too long to say hello")
elif state == "connecting":
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)
@@ -230,10 +375,15 @@ func _c2s_proof(sig: PackedByteArray) -> void:
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"))])
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": 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)
@@ -306,7 +456,8 @@ func leave() -> void:
var was := state
stop()
if was == "connected":
left.emit("You left the server")
last_reason = "You left the server"
left.emit(last_reason)
func _on_connected() -> void:
var me := get_identity()
@@ -326,9 +477,11 @@ func _s2c_challenge(nonce: PackedByteArray) -> void:
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()
@@ -341,14 +494,61 @@ func _s2c_rejected(reason: String) -> void:
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:
@@ -359,9 +559,11 @@ func _on_server_disconnected() -> void:
var reason := _reject_reason
stop()
if was == "connecting":
join_failed.emit(reason if not reason.is_empty() else "The server hung up")
last_reason = reason if not reason.is_empty() else "The server hung up"
join_failed.emit(last_reason)
else:
left.emit(reason if not reason.is_empty() else "The server closed the connection")
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: