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
+430
View File
@@ -0,0 +1,430 @@
class_name SimWorld
extends RefCounted
## The whole game simulation for one instance (a lobby or one dungeon run).
##
## It is a plain RefCounted with no nodes, no physics server and no rendering,
## which buys three things: the dedicated server runs it headless at negligible
## cost, a unit test can drive a thousand ticks in milliseconds, and the client
## can run the exact same class in replica mode for prediction.
##
## Authority: [member authoritative] is true on the server only. In replica mode
## the world never runs AI, never fires emitters and never resolves a hit -- it
## only integrates bullets it was told about and holds actor state for drawing.
## That is the whole anti-cheat story: a client physically has no code path that
## can decide it dealt or avoided damage.
var tick: int = 0
var authoritative: bool = true
var pool := BulletPool.new()
var rng := RandomNumberGenerator.new()
var players: Dictionary[int, SimPlayer] = {}
var enemies: Dictionary[int, SimEnemy] = {}
var boss: SimBoss = null
## Drained by the owner every tick. See [SimEvent].
var events: Array[Dictionary] = []
## Set on a lobby world so the interact button can open a dungeon.
var portal_enabled: bool = false
var spawn_point := Vector2(0.0, 240.0)
var _next_actor_id: int = 1
var _ctx := EmitContext.new()
func _init(seed_value: int = 0) -> void:
rng.seed = seed_value
_ctx.rng = rng
func next_actor_id() -> int:
var id := _next_actor_id
_next_actor_id += 1
return id
# --- Population -------------------------------------------------------------
func add_player(peer_id: int, display_name: String) -> SimPlayer:
var p := SimPlayer.new()
p.peer_id = peer_id
p.display_name = display_name
p.reset_for_instance(spawn_point)
players[peer_id] = p
return p
func remove_player(peer_id: int) -> void:
players.erase(peer_id)
func spawn_enemy(def: EnemyDef, at: Vector2, phase_offset: int = 0) -> SimEnemy:
var e := SimEnemy.new()
e.id = next_actor_id()
e.def = def
e.pos = at
e.home = at
e.hp = def.max_hp
e.phase_offset = phase_offset
e.heading = Vector2.RIGHT.rotated(rng.randf() * TAU)
enemies[e.id] = e
return e
func spawn_boss(def: BossDef) -> SimBoss:
var b := SimBoss.new()
b.id = next_actor_id()
b.def = def
b.pos = def.spawn_pos
b.hp = def.max_hp
boss = b
return b
func alive_player_count() -> int:
var n := 0
for p in players.values():
if p.alive:
n += 1
return n
## Nearest living player to [param from]. Aimed emitters and chasing enemies use
## this; it is the only targeting primitive in the game.
func nearest_player(from: Vector2) -> SimPlayer:
var best: SimPlayer = null
var best_d := INF
for p in players.values():
if not p.alive:
continue
var d := from.distance_squared_to(p.pos)
if d < best_d:
best_d = d
best = p
return best
# --- Input ------------------------------------------------------------------
## Accept a batch of client inputs. Everything questionable is dropped here
## rather than deeper in, so there is a single place to audit.
func queue_input(peer_id: int, frames: Array[InputFrame]) -> void:
var p: SimPlayer = players.get(peer_id)
if p == null:
return
for f in frames:
if f.tick <= p.last_input_tick:
continue # replay or out-of-order duplicate
if f.tick < tick - SimConfig.INPUT_MAX_AGE:
continue # too old to matter
if f.tick > tick + SimConfig.INPUT_MAX_LEAD:
continue # client claiming to be far in the future
if p.input_queue.size() >= SimConfig.INPUT_MAX_AGE:
p.input_queue.pop_front() # flood guard
p.input_queue.append(f)
p.input_queue.sort_custom(func(a: InputFrame, b: InputFrame) -> bool: return a.tick < b.tick)
# --- Tick -------------------------------------------------------------------
func step() -> void:
tick += 1
if authoritative:
pool.clear_spawn_log()
_step_players()
_step_enemies()
_step_boss()
pool.step()
_resolve_bullet_hits()
_resolve_contact_damage()
_emit_spawn_events()
else:
# Replica: bullets only. Actor state arrives in snapshots.
pool.step()
func _step_players() -> void:
for p in players.values():
if p.iframes > 0:
p.iframes -= 1
if p.fire_cooldown > 0:
p.fire_cooldown -= 1
if not p.alive:
p.respawn_timer -= 1
if p.respawn_timer <= 0:
p.alive = true
p.hp = SimConfig.PLAYER_MAX_HP
p.pos = spawn_point
p.iframes = SimConfig.PLAYER_IFRAMES
events.append({"t": SimEvent.Type.PLAYER_RESPAWNED, "peer": p.peer_id, "pos": p.pos})
continue
var frame := _take_input(p)
p.aim = frame.aim
p.pos = Movement.step_player(p.pos, frame.move, SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
if frame.pressed(InputFrame.BTN_FIRE) and p.fire_cooldown == 0:
_fire_player_shot(p)
_step_escape(p, frame)
if portal_enabled and frame.pressed(InputFrame.BTN_INTERACT):
if p.pos.distance_to(SimConfig.PORTAL_POS) <= SimConfig.PORTAL_RADIUS:
events.append({"t": SimEvent.Type.PORTAL_USED, "peer": p.peer_id})
## Pull the next input for this player, or coast on the last one. Coasting is
## capped so a client that stops sending cannot keep moving forever.
func _take_input(p: SimPlayer) -> InputFrame:
if not p.input_queue.is_empty():
var f: InputFrame = p.input_queue.pop_front()
p.last_input_tick = f.tick
p.held_input = f
p.starved_ticks = 0
return f
p.starved_ticks += 1
if p.starved_ticks > SimConfig.INPUT_MAX_AGE:
var idle := InputFrame.new()
idle.aim = p.held_input.aim
p.held_input = idle
return p.held_input
func _fire_player_shot(p: SimPlayer) -> void:
p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN
var dir := Vector2.RIGHT.rotated(p.aim)
pool.spawn(
p.pos + dir * (SimConfig.PLAYER_RADIUS + 6.0),
dir * SimConfig.PLAYER_BULLET_SPEED,
SimConfig.PLAYER_BULLET_RADIUS,
SimConfig.PLAYER_BULLET_LIFETIME,
SimConfig.PLAYER_BULLET_DAMAGE,
SimConfig.TEAM_PLAYER,
SimConfig.KIND_PLAYER_SHOT)
func _step_escape(p: SimPlayer, frame: InputFrame) -> void:
if frame.pressed(InputFrame.BTN_ESCAPE):
if p.escape_ticks == 0:
events.append({"t": SimEvent.Type.ESCAPE_STARTED, "peer": p.peer_id})
p.escape_ticks += 1
if p.escape_ticks >= SimConfig.ESCAPE_CHANNEL_TICKS:
p.escape_ticks = 0
events.append({"t": SimEvent.Type.ESCAPE_COMPLETED, "peer": p.peer_id})
elif p.escape_ticks > 0:
p.escape_ticks = 0
events.append({"t": SimEvent.Type.ESCAPE_CANCELLED, "peer": p.peer_id})
func _step_enemies() -> void:
for e in enemies.values():
if not e.alive:
continue
_move_enemy(e)
_run_emitters(e.def.emitters, e.pos,
posmod(e.local_tick + e.phase_offset, maxi(e.def.pattern_loop_ticks, 1)))
e.local_tick += 1
func _move_enemy(e: SimEnemy) -> void:
var dt := SimConfig.TICK_DELTA
var speed: float = e.def.speed
match e.def.move:
EnemyDef.Move.STATIC:
pass
EnemyDef.Move.DRIFT:
var next := e.pos + e.heading * speed * dt
# Bounce off the arena so a drifter never leaves the fight.
if absf(next.x) > SimConfig.ARENA_HALF.x - e.def.radius:
e.heading.x = -e.heading.x
next.x = clampf(next.x, -SimConfig.ARENA_HALF.x + e.def.radius, SimConfig.ARENA_HALF.x - e.def.radius)
if absf(next.y) > SimConfig.ARENA_HALF.y - e.def.radius:
e.heading.y = -e.heading.y
next.y = clampf(next.y, -SimConfig.ARENA_HALF.y + e.def.radius, SimConfig.ARENA_HALF.y - e.def.radius)
e.pos = next
EnemyDef.Move.ORBIT:
var a := float(e.local_tick + e.phase_offset) * dt * (speed / maxf(e.def.move_param, 1.0))
e.pos = e.home + Vector2.RIGHT.rotated(a) * e.def.move_param
EnemyDef.Move.APPROACH:
if e.local_tick % maxi(e.def.retarget_interval, 1) == 0:
var t := nearest_player(e.pos)
e.target_dir = Vector2.ZERO if t == null else (t.pos - e.pos).normalized()
e.pos += e.target_dir * speed * dt
EnemyDef.Move.STRAFE:
if e.local_tick % maxi(e.def.retarget_interval, 1) == 0:
var t := nearest_player(e.pos)
if t == null:
e.target_dir = Vector2.ZERO
else:
var to_player := t.pos - e.pos
var d := to_player.length()
var radial := 0.0
if d > e.def.move_param + 20.0:
radial = 1.0
elif d < e.def.move_param - 20.0:
radial = -1.0
var toward := Vector2.ZERO if d < 0.001 else to_player / d
e.target_dir = (toward * radial + toward.orthogonal() * 0.7).normalized()
e.pos += e.target_dir * speed * dt
e.pos.x = clampf(e.pos.x, -SimConfig.ARENA_HALF.x, SimConfig.ARENA_HALF.x)
e.pos.y = clampf(e.pos.y, -SimConfig.ARENA_HALF.y, SimConfig.ARENA_HALF.y)
func _step_boss() -> void:
if boss == null or not boss.alive or boss.def == null:
return
var wanted := boss.def.phase_index_for(boss.hp_fraction())
if wanted != boss.phase_index:
boss.phase_index = wanted
boss.phase_tick = 0
events.append({"t": SimEvent.Type.BOSS_PHASE, "phase": wanted})
var phase := boss.current_phase()
if phase == null:
return
if boss.phase_tick >= phase.telegraph_ticks:
var local := posmod(boss.phase_tick - phase.telegraph_ticks, maxi(phase.loop_ticks, 1))
_run_emitters(phase.emitters, boss.pos, local)
boss.phase_tick += 1
## Shared emitter driver for enemies and bosses -- the reason a boss pattern can
## be dropped onto a trash mob and vice versa.
func _run_emitters(emitters: Array[BulletEmitter], origin: Vector2, local_tick: int) -> void:
if emitters.is_empty():
return
var target := nearest_player(origin)
_ctx.pool = pool
_ctx.origin = origin
_ctx.local_tick = local_tick
_ctx.has_target = target != null
_ctx.target = origin + Vector2.DOWN * 200.0 if target == null else target.pos
for e in emitters:
if e == null or not e.should_fire(local_tick):
continue
_ctx.shot_index = e.shot_index_at(local_tick)
e.fire(_ctx)
# --- Hit resolution (server only) -------------------------------------------
func _resolve_bullet_hits() -> void:
for i in pool.high_water:
if pool.alive[i] == 0:
continue
var bp: Vector2 = pool.pos[i]
var br: float = pool.radius[i]
if pool.team[i] == SimConfig.TEAM_ENEMY:
for p in players.values():
if not p.alive or p.iframes > 0:
continue
if Movement.circles_overlap(bp, br, p.pos, SimConfig.PLAYER_RADIUS):
_damage_player(p, pool.damage[i])
_kill_bullet(i)
break
else:
var consumed := false
if boss != null and boss.alive \
and Movement.circles_overlap(bp, br, boss.pos, boss.def.radius):
_damage_boss(pool.damage[i])
consumed = true
if not consumed:
for e in enemies.values():
if not e.alive:
continue
if Movement.circles_overlap(bp, br, e.pos, e.def.radius):
_damage_enemy(e, pool.damage[i])
consumed = true
break
if consumed:
_kill_bullet(i)
func _resolve_contact_damage() -> void:
for e in enemies.values():
if not e.alive or e.def.contact_damage <= 0:
continue
for p in players.values():
if not p.alive or p.iframes > 0:
continue
if Movement.circles_overlap(e.pos, e.def.radius, p.pos, SimConfig.PLAYER_RADIUS):
_damage_player(p, e.def.contact_damage)
## Bullets removed early must be announced -- clients cannot derive a hit.
func _kill_bullet(slot: int) -> void:
events.append({"t": SimEvent.Type.BULLET_DESPAWN, "uid": pool.uid[slot]})
pool.despawn(slot)
func _damage_player(p: SimPlayer, amount: int) -> void:
p.hp = maxi(p.hp - amount, 0)
p.iframes = SimConfig.PLAYER_IFRAMES
if SimConfig.ESCAPE_BREAK_ON_DAMAGE and p.escape_ticks > 0:
p.escape_ticks = 0
events.append({"t": SimEvent.Type.ESCAPE_CANCELLED, "peer": p.peer_id})
events.append({"t": SimEvent.Type.PLAYER_HIT, "peer": p.peer_id, "dmg": amount, "hp": p.hp})
if p.hp <= 0:
p.alive = false
p.respawn_timer = SimConfig.PLAYER_RESPAWN_DELAY
events.append({"t": SimEvent.Type.PLAYER_DIED, "peer": p.peer_id})
func _damage_enemy(e: SimEnemy, amount: int) -> void:
e.hp = maxi(e.hp - amount, 0)
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp})
if e.hp <= 0:
e.alive = false
events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id})
func _damage_boss(amount: int) -> void:
var phase := boss.current_phase()
var mult := 1.0 if phase == null else phase.damage_taken_mult
var applied := maxi(1, roundi(float(amount) * mult))
boss.hp = maxi(boss.hp - applied, 0)
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": boss.id, "dmg": applied, "hp": boss.hp})
if boss.hp <= 0:
boss.alive = false
events.append({"t": SimEvent.Type.BOSS_DIED})
func _emit_spawn_events() -> void:
for slot in pool.spawn_log:
if pool.alive[slot] == 0:
continue # spawned and killed inside the same tick
events.append({
"t": SimEvent.Type.BULLET_SPAWN,
"uid": pool.uid[slot],
"pos": pool.pos[slot],
"vel": pool.vel[slot],
"r": pool.radius[slot],
"life": pool.life[slot],
"kind": pool.kind[slot],
"team": pool.team[slot],
"accel": pool.accel[slot],
"turn": pool.turn[slot],
})
func drain_events() -> Array[Dictionary]:
var out := events
events = []
return out
# --- Replica side -----------------------------------------------------------
## Apply a server event to a non-authoritative world.
func apply_event(ev: Dictionary) -> void:
match int(ev["t"]):
SimEvent.Type.BULLET_SPAWN:
pool.spawn(ev["pos"], ev["vel"], ev["r"], ev["life"], 0,
ev["team"], ev["kind"], ev["accel"], ev["turn"], ev["uid"])
SimEvent.Type.BULLET_DESPAWN:
var slot := pool.find_by_uid(int(ev["uid"]))
if slot >= 0:
pool.despawn(slot)
_:
pass