Initial commit: Transcience MVP

Top-down twin-stick bullet-hell, Godot 4.7, server-authoritative dedicated
server with client-side prediction. Clients send input only; the server
resolves every hit for both players and enemies (no PvP).

- SimWorld: whole simulation as plain RefCounted objects (no nodes, no
  physics server), ~0.24ms/tick at peak load -- runs headless for free and
  drives 78 tests in under a second
- BulletPool: struct-of-arrays bullet storage, replicated as spawn/despawn
  events rather than per-tick state
- Emitter framework (Ring/AimedSpread/WallGap/ArcSweep) shared by trash
  enemies and bosses -- a new boss is data in src/content/content.gd, no
  simulation changes
- The Warden of the Fold: stationary 4-phase boss built entirely on that
  format
- Lobby hub with a portal into on-demand dungeon instances; one process
  hosts the hub plus every concurrent dungeon
- Emergency escape: 3s server-owned channel, cancelled by damage
- tools/check.sh, test.sh (GUT), smoke.sh (real server + bot clients over
  ENet), bench.gd; git hooks wired to the same scripts
- docs/ARCHITECTURE.md, NETCODE.md, WORKFLOW.md, ROADMAP.md
This commit is contained in:
2026-09-03 16:03:57 +02:00
commit c4beeae38f
385 changed files with 28725 additions and 0 deletions
+249
View File
@@ -0,0 +1,249 @@
extends Node
## Transport and RPC surface. This is the only autoload in the project.
##
## It has to be an autoload because Godot routes an RPC by node path: the sender
## and receiver must agree on where the node lives, and `/root/Net` is the one
## path that exists identically on a dedicated server and on every client.
##
## The naming rule here is load-bearing: `c_*` is client -> server, `s_*` is
## server -> client. The `s_*` methods are annotated "authority", so the engine
## itself drops any attempt by a client to fake one.
signal state_changed(new_state: State)
enum State { OFFLINE, CONNECTING, ONLINE, FAILED }
## Peer id the engine gives the host. On a listen server this is also a real
## player, which is why every send goes through the helpers below.
const LOCAL_PEER := 1
var state: State = State.OFFLINE
var server: ServerRuntime = null
var client: ClientRuntime = null
var last_error: String = ""
func _set_state(s: State) -> void:
state = s
state_changed.emit(s)
# --- Lifecycle --------------------------------------------------------------
func host(port: int) -> Error:
shutdown()
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(port, Protocol.MAX_CLIENTS, Protocol.CHANNEL_COUNT)
if err != OK:
last_error = "could not bind port %d (error %d)" % [port, err]
GameLog.error("net", last_error)
_set_state(State.FAILED)
return err
multiplayer.multiplayer_peer = peer
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
server = ServerRuntime.new()
server.name = "Server"
add_child(server)
_set_state(State.ONLINE)
GameLog.info("net", "listening on %d" % port)
return OK
func join(address: String, port: int) -> Error:
shutdown()
var peer := ENetMultiplayerPeer.new()
var err := peer.create_client(address, port, Protocol.CHANNEL_COUNT)
if err != OK:
last_error = "could not reach %s:%d (error %d)" % [address, port, err]
GameLog.error("net", last_error)
_set_state(State.FAILED)
return err
multiplayer.multiplayer_peer = peer
multiplayer.connected_to_server.connect(_on_connected)
multiplayer.connection_failed.connect(_on_connect_failed)
multiplayer.server_disconnected.connect(_on_server_disconnected)
client = ClientRuntime.new()
client.name = "Client"
add_child(client)
# Nothing is simulated until the server says hello back; on_welcome turns
# the physics process on.
client.set_physics_process(false)
_set_state(State.CONNECTING)
GameLog.info("net", "connecting to %s:%d" % [address, port])
return OK
func shutdown() -> void:
if multiplayer.multiplayer_peer != null \
and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = null
for sig in [multiplayer.peer_connected, multiplayer.peer_disconnected,
multiplayer.connected_to_server, multiplayer.connection_failed,
multiplayer.server_disconnected]:
for c in sig.get_connections():
sig.disconnect(c["callable"])
if server != null:
server.queue_free()
server = null
if client != null:
client.queue_free()
client = null
_set_state(State.OFFLINE)
func kick(peer_id: int) -> void:
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
(multiplayer.multiplayer_peer as ENetMultiplayerPeer).disconnect_peer(peer_id)
func is_server() -> bool:
return server != null
# --- Transport signals ------------------------------------------------------
func _on_peer_connected(peer_id: int) -> void:
if server != null:
server.on_peer_connected(peer_id)
func _on_peer_disconnected(peer_id: int) -> void:
if server != null:
server.on_peer_disconnected(peer_id)
func _on_connected() -> void:
_set_state(State.ONLINE)
c_hello.rpc_id(1, Protocol.VERSION, GameOpts.player_name)
func _on_connect_failed() -> void:
last_error = "connection refused"
GameLog.error("net", last_error)
_set_state(State.FAILED)
func _on_server_disconnected() -> void:
last_error = "server closed the connection"
GameLog.warn("net", last_error)
shutdown()
## Start a client inside the hosting process. The result is a listen server:
## the host plays through exactly the same code path as a remote player, sending
## input and learning outcomes from snapshots, with the transport short-circuited
## to a function call. No simulation code knows the difference.
func start_local_client() -> void:
if server == null:
GameLog.error("net", "start_local_client called with no server")
return
client = ClientRuntime.new()
client.name = "Client"
add_child(client)
server.on_hello(LOCAL_PEER, Protocol.VERSION, GameOpts.player_name)
func _is_local(peer_id: int) -> bool:
return peer_id == LOCAL_PEER and client != null
# --- Send helpers -----------------------------------------------------------
# Every server -> client message goes through one of these rather than calling
# rpc_id directly, so the listen server's own player is reachable too.
func send_welcome(peer_id: int) -> void:
if _is_local(peer_id):
client.on_welcome(peer_id)
else:
s_welcome.rpc_id(peer_id, peer_id, Protocol.VERSION)
func send_enter_instance(peer_id: int, id: int, kind: int, server_tick: int,
boss_id: String, spawn: Vector2) -> void:
if _is_local(peer_id):
client.on_enter_instance(id, kind, server_tick, boss_id, spawn)
else:
s_enter_instance.rpc_id(peer_id, id, kind, server_tick, boss_id, spawn)
func send_snapshot(peer_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id):
client.on_snapshot(data)
else:
s_snapshot.rpc_id(peer_id, data)
func send_events(peer_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id):
client.on_events(data)
else:
s_events.rpc_id(peer_id, data)
func send_reject(peer_id: int, reason: String) -> void:
if _is_local(peer_id):
GameLog.error("net", "local client rejected: %s" % reason)
else:
s_reject.rpc_id(peer_id, reason)
func send_input(data: PackedByteArray) -> void:
if server != null:
server.on_input(LOCAL_PEER, data) # listen server: no transport at all
elif state == State.ONLINE and client != null:
c_input.rpc_id(1, data)
# --- Client -> server -------------------------------------------------------
@rpc("any_peer", "call_remote", "reliable", 1)
func c_hello(version: int, display_name: String) -> void:
if server == null:
return
server.on_hello(multiplayer.get_remote_sender_id(), version, display_name)
@rpc("any_peer", "call_remote", "unreliable_ordered", 4)
func c_input(data: PackedByteArray) -> void:
if server == null:
return
server.on_input(multiplayer.get_remote_sender_id(), data)
# --- Server -> client -------------------------------------------------------
@rpc("authority", "call_remote", "reliable", 1)
func s_welcome(peer_id: int, _version: int) -> void:
if client == null:
return
client.on_welcome(peer_id)
client.set_physics_process(true)
@rpc("authority", "call_remote", "reliable", 1)
func s_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
spawn: Vector2) -> void:
if client == null:
return
client.on_enter_instance(id, kind, server_tick, boss_id, spawn)
@rpc("authority", "call_remote", "reliable", 1)
func s_reject(reason: String) -> void:
last_error = reason
GameLog.error("net", "rejected by server: %s" % reason)
_set_state(State.FAILED)
@rpc("authority", "call_remote", "unreliable", 2)
func s_snapshot(data: PackedByteArray) -> void:
if client != null:
client.on_snapshot(data)
@rpc("authority", "call_remote", "reliable", 3)
func s_events(data: PackedByteArray) -> void:
if client != null:
client.on_events(data)
+1
View File
@@ -0,0 +1 @@
uid://8itnd1lucfeu