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
+27
View File
@@ -0,0 +1,27 @@
class_name AimedSpreadEmitter
extends BulletEmitter
## A fan aimed at the nearest player. This is the emitter that punishes standing
## still, so most patterns pair one of these with a geometric emitter.
@export var count: int = 5
@export var spread_deg: float = 24.0
## Random angular jitter. Uses the world RNG, which is seeded per instance so a
## replay of the same seed produces the same fight.
@export var jitter_deg: float = 0.0
@export var muzzle_offset: float = 24.0
## Extra speed given to the outermost bullets, which curves the fan forward.
@export var edge_speed_bonus: float = 0.0
func fire(ctx: EmitContext) -> void:
if count <= 0:
return
var aim := ctx.aim_angle()
var spread := deg_to_rad(spread_deg)
for i in count:
var t := 0.0 if count == 1 else (float(i) / float(count - 1)) - 0.5
var a := aim + spread * t
if jitter_deg > 0.0 and ctx.rng != null:
a += deg_to_rad(ctx.rng.randf_range(-jitter_deg, jitter_deg))
var scale := 1.0 + edge_speed_bonus * absf(t) * 2.0
emit_shot(ctx, a, ctx.origin + Vector2.RIGHT.rotated(aim) * muzzle_offset, scale)
@@ -0,0 +1 @@
uid://b4njg402onjdw
+31
View File
@@ -0,0 +1,31 @@
class_name ArcSweepEmitter
extends BulletEmitter
## A laser-like arm that sweeps back and forth. Dense enough to force movement,
## slow enough to read. Pairs well with a ring that fills the space behind it.
@export var arms: int = 2
@export var bullets_per_arm: int = 3
## Distance between bullets along one arm.
@export var arm_spacing: float = 22.0
## Degrees the arm travels per second at the sweep extremes.
@export var sweep_deg: float = 70.0
## Seconds for one full back-and-forth.
@export var sweep_period: float = 4.0
@export var base_angle_deg: float = 0.0
@export var muzzle_offset: float = 26.0
func fire(ctx: EmitContext) -> void:
if arms <= 0 or bullets_per_arm <= 0:
return
var t := float(ctx.local_tick) * SimConfig.TICK_DELTA
var phase := sin(TAU * t / maxf(sweep_period, 0.001))
var base := deg_to_rad(base_angle_deg + sweep_deg * phase)
for arm in arms:
var a := base + TAU * float(arm) / float(arms)
var heading := Vector2.RIGHT.rotated(a)
for j in bullets_per_arm:
var from := ctx.origin + heading * (muzzle_offset + arm_spacing * float(j))
# Outer bullets start further along, so give them matching speed to
# keep the arm straight instead of letting it bow.
emit_shot(ctx, a, from, 1.0 + 0.08 * float(j))
@@ -0,0 +1 @@
uid://bxgv7yc1b7rqx
+60
View File
@@ -0,0 +1,60 @@
@abstract
class_name BulletEmitter
extends Resource
## One firing behaviour on a timeline. A boss phase or an enemy is just a list
## of these, which is the whole "format that adapts to other bosses": authoring
## a new boss means writing a new list, never new code.
@export_group("Timeline")
## First tick of the owner's phase at which this emitter is armed.
@export var start_tick: int = 0
## Last armed tick, or -1 to stay armed for the whole phase.
@export var end_tick: int = -1
## Ticks between shots.
@export var interval: int = 30
@export_group("Bullet")
@export var speed: float = 150.0
## Units/sec^2 along the heading. Negative values make bullets stall and drift.
@export var accel: float = 0.0
## Degrees of heading change per tick. Non-zero produces curving bullets.
@export var turn_deg: float = 0.0
@export var radius: float = 7.0
@export var lifetime: int = 300
@export var damage: int = 12
@export var kind: int = SimConfig.KIND_ORB
func is_armed(local_tick: int) -> bool:
if local_tick < start_tick:
return false
if end_tick >= 0 and local_tick > end_tick:
return false
return true
func should_fire(local_tick: int) -> bool:
if not is_armed(local_tick):
return false
if interval <= 0:
return false
return (local_tick - start_tick) % interval == 0
## How many shots this emitter has fired by [param local_tick], used to drive
## per-shot spin without the emitter holding mutable state (emitters are shared
## resources -- two bosses of the same kind must not stomp each other).
func shot_index_at(local_tick: int) -> int:
if interval <= 0:
return 0
return int(floor(float(local_tick - start_tick) / float(interval)))
@abstract func fire(ctx: EmitContext) -> void
## Shared spawn path so every emitter produces bullets with identical physics.
func emit_shot(ctx: EmitContext, angle: float, from: Vector2, speed_scale: float = 1.0) -> void:
var v := Vector2.RIGHT.rotated(angle) * speed * speed_scale
ctx.pool.spawn(from, v, radius, lifetime, damage, SimConfig.TEAM_ENEMY, kind,
accel, deg_to_rad(turn_deg))
+1
View File
@@ -0,0 +1 @@
uid://c27tahmytk5vj
+23
View File
@@ -0,0 +1,23 @@
class_name EmitContext
extends RefCounted
## Everything an emitter is allowed to see. Keeping this narrow is what lets a
## unit test fire a boss pattern into a bare [BulletPool] with no world at all.
var pool: BulletPool
## Where the shots come from (the emitting actor's position).
var origin := Vector2.ZERO
## Nearest live player, used by aimed emitters. Falls back to [member origin]
## plus a down vector when nobody is alive.
var target := Vector2.ZERO
var has_target := false
## Ticks since the emitting actor entered its current phase.
var local_tick := 0
## How many times this emitter has fired in this phase. Drives spin/step.
var shot_index := 0
var rng: RandomNumberGenerator
func aim_angle() -> float:
if not has_target:
return PI * 0.5
return (target - origin).angle()
+1
View File
@@ -0,0 +1 @@
uid://cav5jlkxsy6qm
+29
View File
@@ -0,0 +1,29 @@
class_name RingEmitter
extends BulletEmitter
## Evenly spaced bullets around a full or partial circle. Give it a non-zero
## [member spin_per_shot_deg] and consecutive rings become a spiral.
@export var count: int = 12
## Total angle the bullets are spread over. 360 is a closed ring.
@export var arc_deg: float = 360.0
@export var base_angle_deg: float = 0.0
## Added to the base angle on every shot.
@export var spin_per_shot_deg: float = 7.0
## Spawn distance from the emitter, so big bosses do not fire from their centre.
@export var muzzle_offset: float = 24.0
## Aim the ring's base angle at the nearest player instead of using world space.
@export var aim_at_target: bool = false
func fire(ctx: EmitContext) -> void:
if count <= 0:
return
var base := deg_to_rad(base_angle_deg + spin_per_shot_deg * float(ctx.shot_index))
if aim_at_target:
base += ctx.aim_angle()
var arc := deg_to_rad(arc_deg)
# A closed ring must not place two bullets on top of each other at the seam.
var divisor := count if is_equal_approx(arc_deg, 360.0) else maxi(count - 1, 1)
for i in count:
var a := base + arc * (float(i) / float(divisor))
emit_shot(ctx, a, ctx.origin + Vector2.RIGHT.rotated(a) * muzzle_offset)
+1
View File
@@ -0,0 +1 @@
uid://c5664w2onh7wx
+47
View File
@@ -0,0 +1,47 @@
class_name WallGapEmitter
extends BulletEmitter
## A curtain of bullets sweeping across the arena with a survivable gap. The gap
## moves by [member gap_step] every shot so players cannot camp one lane.
## 0 = travels down, 1 = up, 2 = right, 3 = left.
@export_enum("Down", "Up", "Right", "Left") var direction: int = 0
@export var count: int = 18
## Slots left empty. Width 2-3 is tight but fair at default bullet radius.
@export var gap_width: int = 3
@export var gap_index: int = 6
## How far the gap slides each shot. Coprime-ish values feel least predictable.
@export var gap_step: int = 5
## Randomise the gap instead of stepping it.
@export var randomize_gap: bool = false
func _axis() -> Dictionary:
match direction:
1: return {"dir": Vector2.UP, "along": Vector2.RIGHT, "extent": SimConfig.ARENA_HALF.x, "edge": SimConfig.ARENA_HALF.y}
2: return {"dir": Vector2.RIGHT, "along": Vector2.DOWN, "extent": SimConfig.ARENA_HALF.y, "edge": SimConfig.ARENA_HALF.x}
3: return {"dir": Vector2.LEFT, "along": Vector2.DOWN, "extent": SimConfig.ARENA_HALF.y, "edge": SimConfig.ARENA_HALF.x}
_: return {"dir": Vector2.DOWN, "along": Vector2.RIGHT, "extent": SimConfig.ARENA_HALF.x, "edge": SimConfig.ARENA_HALF.y}
func fire(ctx: EmitContext) -> void:
if count <= 0:
return
var ax := _axis()
var dir: Vector2 = ax["dir"]
var along: Vector2 = ax["along"]
var extent: float = ax["extent"]
var edge: float = ax["edge"]
var gap := gap_index
if randomize_gap and ctx.rng != null:
gap = ctx.rng.randi_range(0, maxi(count - gap_width, 0))
else:
gap = posmod(gap_index + gap_step * ctx.shot_index, maxi(count - gap_width + 1, 1))
var start := -dir * (edge + 8.0)
var angle := dir.angle()
for i in count:
if i >= gap and i < gap + gap_width:
continue
var t := (float(i) / float(maxi(count - 1, 1))) * 2.0 - 1.0
emit_shot(ctx, angle, start + along * (t * extent))
+1
View File
@@ -0,0 +1 @@
uid://igr031g85v5q