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
+193
View File
@@ -0,0 +1,193 @@
class_name ServerRuntime
extends Node
## The dedicated server. Owns every instance, ticks them all at the physics
## rate, and is the only place in the codebase allowed to decide what happened.
##
## Clients send intent and nothing else (see [method SimWorld.queue_input]), so
## there is no client message that can move a player, deal damage, cancel a hit
## or shorten an escape channel. Refusing to accept those messages at all is a
## stronger guarantee than validating them after the fact.
var instances: Dictionary[int, Instance] = {}
var peer_instance: Dictionary[int, int] = {}
var peer_names: Dictionary[int, String] = {}
var lobby: Instance
var _next_instance_id: int = SimConfig.LOBBY_INSTANCE_ID
var _snapshot_phase: int = 0
func _ready() -> void:
lobby = Instance.make_lobby(_take_instance_id())
instances[lobby.id] = lobby
GameLog.info("server", "lobby instance %d up" % lobby.id)
func _take_instance_id() -> int:
var id := _next_instance_id
_next_instance_id += 1
return id
func _physics_process(_delta: float) -> void:
_snapshot_phase += 1
var send_snapshot := _snapshot_phase % SimConfig.SNAPSHOT_INTERVAL == 0
var closing: Array[int] = []
for inst in instances.values():
inst.step()
_dispatch_events(inst)
if send_snapshot and not inst.peers.is_empty():
var snap := NetCodec.encode_snapshot(inst.world)
for peer in inst.peers:
Net.send_snapshot(peer, snap)
if inst.kind == Protocol.InstanceKind.DUNGEON \
and inst.state == Instance.State.CLEARED and inst.stage_delay <= 0:
closing.append(inst.id)
elif inst.kind == Protocol.InstanceKind.DUNGEON and inst.is_empty() and inst.age > 60:
closing.append(inst.id)
for id in closing:
_close_dungeon(id)
## Split the tick's events into the ones clients need and the ones only the
## server acts on (escape completion, portal use).
func _dispatch_events(inst: Instance) -> void:
var events := inst.world.drain_events()
if events.is_empty():
return
var transfers: Array[Dictionary] = []
for ev in events:
match int(ev["t"]):
SimEvent.Type.ESCAPE_COMPLETED:
transfers.append({"peer": int(ev["peer"]), "to_lobby": true})
SimEvent.Type.PORTAL_USED:
transfers.append({"peer": int(ev["peer"]), "to_lobby": false})
_:
pass
var payload := NetCodec.encode_events(inst.world.tick, events)
for peer in inst.peers:
Net.send_events(peer, payload)
for t in transfers:
if t["to_lobby"]:
_send_to_lobby(int(t["peer"]))
else:
_send_to_dungeon(int(t["peer"]))
# --- Peer lifecycle ---------------------------------------------------------
func on_peer_connected(peer_id: int) -> void:
GameLog.info("server", "peer %d connected, awaiting hello" % peer_id)
func on_peer_disconnected(peer_id: int) -> void:
var inst := instance_of(peer_id)
if inst != null:
inst.remove_peer(peer_id)
peer_instance.erase(peer_id)
peer_names.erase(peer_id)
GameLog.info("server", "peer %d disconnected" % peer_id)
func on_hello(peer_id: int, version: int, display_name: String) -> void:
if peer_names.has(peer_id):
return # a second hello from the same peer is either a bug or an attack
if version != Protocol.VERSION:
GameLog.warn("server", "peer %d protocol %d != %d, rejecting" % [peer_id, version, Protocol.VERSION])
Net.send_reject(peer_id, "protocol mismatch: server %d, client %d" % [Protocol.VERSION, version])
Net.kick(peer_id)
return
# Never trust a client-supplied string for anything but display.
var clean := display_name.strip_edges().substr(0, 24)
if clean.is_empty():
clean = "player%d" % peer_id
peer_names[peer_id] = clean
Net.send_welcome(peer_id)
_place(peer_id, lobby)
GameLog.info("server", "peer %d joined as '%s'" % [peer_id, clean])
func on_input(peer_id: int, data: PackedByteArray) -> void:
var inst := instance_of(peer_id)
if inst == null:
return
inst.world.queue_input(peer_id, NetCodec.decode_inputs(data))
func instance_of(peer_id: int) -> Instance:
var id: int = peer_instance.get(peer_id, 0)
return instances.get(id)
# --- Transfers --------------------------------------------------------------
func _place(peer_id: int, inst: Instance) -> void:
inst.add_peer(peer_id, peer_names.get(peer_id, "player"))
peer_instance[peer_id] = inst.id
Net.send_enter_instance(peer_id, inst.id, int(inst.kind), inst.world.tick,
String(inst.boss_id), inst.world.spawn_point)
# A player arriving mid-fight has no idea what is already in the air, so
# replay the live bullets as spawn events before the next snapshot lands.
var backlog := _live_bullet_events(inst.world)
if not backlog.is_empty():
Net.send_events(peer_id, NetCodec.encode_events(inst.world.tick, backlog))
Net.send_snapshot(peer_id, NetCodec.encode_snapshot(inst.world))
func _transfer(peer_id: int, to: Instance) -> void:
var from := instance_of(peer_id)
if from != null:
from.remove_peer(peer_id)
_place(peer_id, to)
func _send_to_lobby(peer_id: int) -> void:
GameLog.info("server", "peer %d escaped to lobby" % peer_id)
_transfer(peer_id, lobby)
func _send_to_dungeon(peer_id: int) -> void:
var target: Instance = null
for inst in instances.values():
if inst.accepts_new_party_member():
target = inst
break
if target == null:
target = Instance.make_dungeon(_take_instance_id(), randi())
instances[target.id] = target
GameLog.info("server", "opened dungeon instance %d" % target.id)
_transfer(peer_id, target)
func _close_dungeon(id: int) -> void:
var inst: Instance = instances.get(id)
if inst == null:
return
for peer in inst.peers.duplicate():
_transfer(peer, lobby)
instances.erase(id)
GameLog.info("server", "closed dungeon instance %d" % id)
func _live_bullet_events(world: SimWorld) -> Array[Dictionary]:
var out: Array[Dictionary] = []
for i in world.pool.high_water:
if world.pool.alive[i] == 0:
continue
out.append({
"t": SimEvent.Type.BULLET_SPAWN,
"uid": world.pool.uid[i],
"pos": world.pool.pos[i],
"vel": world.pool.vel[i],
"r": world.pool.radius[i],
"life": world.pool.life[i],
"kind": world.pool.kind[i],
"team": world.pool.team[i],
"accel": world.pool.accel[i],
"turn": world.pool.turn[i],
})
return out