Files
modbison/scripts/server.gd
T
claude-checkpoint-tooling 7be50e3313 Fix real scene collision + a real regression from removing the Server autoload
Two genuine bugs, found by reproducing rather than trusting the branch's
own state:

1. scenes/player.tscn collided with PC2's client-side player scene at the
   exact same path (server-authoritative PlayerNode vs. the local
   single-player character) -- whichever merged last would silently
   overwrite the other. The branch's own last commit had literally
   committed unresolved <<<<<<< conflict markers into the file instead of
   resolving them. Renamed the server's version to
   scenes/server_player.tscn, restored scenes/player.tscn to match main's
   real client scene exactly, repointed scripts/server.gd's preload.

2. scenes/server.tscn's root Node had no script attached at all -- it only
   ever worked before because project.godot had `Server` registered as a
   project-wide autoload (the exact wholesale-overwrite this task was
   already asked to fix). Once that autoload was correctly removed,
   nothing attached server.gd to the scene tree anymore and the dedicated
   server silently did nothing. Attached scripts/server.gd directly to
   scenes/server.tscn's root node instead.

Verified: godot --headless --import/--quit both clean, full 10-suite
client smoke test all passing, and the dedicated server actually listens
on UDP port 7777 when launched explicitly (`godot --path . scenes/server.tscn`)
-- confirmed with `ss -ulnp`, not just trusting the startup print (first
checked with `ss -tlnp`, which only shows TCP and falsely suggested it
wasn't listening at all; ENet is UDP).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
2026-08-21 00:59:24 +00:00

175 lines
4.9 KiB
GDScript

## ServerMain - Dedicated server root node.
## Accepts ENet connections, spawns Player nodes for each peer,
## and runs the simulation tick loop at 20 Hz.
extends Node
## Maximum number of concurrent players.
const MAX_PLAYERS := 16
## Server tick rate (Hz).
const TICK_RATE := 20
## Tick duration in seconds.
const TICK_DURATION := 1.0 / TICK_RATE
## ENet server port.
const SERVER_PORT := 7777
## Network peer used for ENet communication.
var network_peer: ENetMultiplayerPeer
## Map of peer_id -> Player node.
var players: Dictionary = {}
## Current tick counter (server-authoritative).
var tick: int = 0
## Whether the server is running.
var is_running: bool = false
## Accumulated real time for fixed timestep simulation.
var accumulator: float = 0.0
## Player scene resource (loaded once).
var _player_scene: PackedScene
func _ready() -> void:
# Preload the player scene once.
_player_scene = preload("res://scenes/server_player.tscn")
# Create and assign the ENet server peer.
network_peer = _create_server_peer()
if network_peer:
multiplayer.multiplayer_peer = network_peer
is_running = true
print("[Server] Started on port %d" % SERVER_PORT)
else:
printerr("[Server] Failed to create ENet peer.")
get_tree().quit(1)
# ---------------------------------------------------------------------------
# Server creation
# ---------------------------------------------------------------------------
func _create_server_peer() -> ENetMultiplayerPeer:
var peer := ENetMultiplayerPeer.new()
var error := peer.create_server(SERVER_PORT, MAX_PLAYERS)
if error != OK:
printerr("[Server] create_server error: %d" % error)
return null
return peer
# ---------------------------------------------------------------------------
# Fixed-timestep simulation loop
# ---------------------------------------------------------------------------
func _process(delta: float) -> void:
if not is_running:
return
# Fixed timestep accumulator.
accumulator += delta
while accumulator >= TICK_DURATION:
accumulator -= TICK_DURATION
_tick()
func _tick() -> void:
"""Main server simulation tick (called at TICK_RATE)."""
tick += 1
# Process player inputs and update state.
for peer_id in players:
var player: Node3D = players[peer_id]
if player and player.has_method("process_server_input"):
player.process_server_input(tick)
# Broadcast snapshot to all clients.
_broadcast_snapshot()
func _broadcast_snapshot() -> void:
"""Broadcast the current authoritative state to all connected clients."""
var snapshot_data := {
"tick": tick,
"players": {},
}
for peer_id in players:
var player: Node3D = players[peer_id]
if player and player.has_method("get_server_state"):
snapshot_data.players[peer_id] = player.get_server_state()
# Send snapshot to all peers except the server itself (peer_id == 1).
var peer_ids: Array = multiplayer.get_peers()
for pid in peer_ids:
if pid == 1: # 1 is the server itself
continue
rpc_id(pid, "_receive_snapshot", snapshot_data)
# Remote: clients receive snapshots.
@rpc("any_peer", "call_remote", "reliable")
func _receive_snapshot(snapshot_data: Dictionary) -> void:
pass # Handled by client scene.
# ---------------------------------------------------------------------------
# Connection lifecycle
# ---------------------------------------------------------------------------
func _on_peer_connected(peer_id: int) -> void:
print("[Server] Peer %d connected." % peer_id)
if players.size() >= MAX_PLAYERS:
print("[Server] Max players reached. Disconnecting peer %d." % peer_id)
rpc_id(peer_id, "_kick", "Server is full.")
return
# Spawn a Player node for this peer.
var player: Node3D = _player_scene.instantiate()
player.name = "Player_%d" % peer_id
player.peer_id = peer_id
add_child(player)
players[peer_id] = player
print("[Server] Spawned player node for peer %d." % peer_id)
func _on_peer_disconnected(peer_id: int) -> void:
print("[Server] Peer %d disconnected." % peer_id)
if players.has(peer_id):
var player: Node3D = players[peer_id]
if player.is_inside_tree():
player.queue_free()
players.erase(peer_id)
# ---------------------------------------------------------------------------
# RPCs
# ---------------------------------------------------------------------------
@rpc("any_peer", "call_remote", "reliable")
func _kick(reason: String) -> void:
print("[Server] Kicking caller: %s" % reason)
rpc_id(multiplayer.get_remote_sender_id(), "_disconnect", reason)
@rpc("call_remote", "reliable")
func _disconnect(reason: String) -> void:
print("[Server] Disconnecting: %s" % reason)
get_tree().quit(0)
# ---------------------------------------------------------------------------
# Signal connections
# ---------------------------------------------------------------------------
func _enter_tree() -> void:
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)