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
+60
View File
@@ -0,0 +1,60 @@
extends MultiMeshInstance2D
## Draws the whole bullet field in one draw call.
##
## A bullet hell routinely has a thousand bullets on screen. One Node2D each
## would cost more than the simulation does, so the renderer reads the client's
## [BulletPool] arrays directly and pushes them into a MultiMesh. The sprite is
## generated at runtime, so the project needs no art to run.
const TEXTURE_SIZE := 32
var _colors := {
SimConfig.KIND_PLAYER_SHOT: Color(0.45, 0.95, 1.0),
SimConfig.KIND_ORB: Color(1.0, 0.35, 0.75),
SimConfig.KIND_NEEDLE: Color(1.0, 0.85, 0.3),
SimConfig.KIND_HEAVY: Color(1.0, 0.45, 0.2),
}
func _ready() -> void:
texture = _make_dot_texture()
var mm := MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_2D
mm.use_colors = true
var quad := QuadMesh.new()
quad.size = Vector2.ONE
mm.mesh = quad
mm.instance_count = SimConfig.MAX_BULLETS
mm.visible_instance_count = 0
multimesh = mm
## A soft-edged disc with a bright core, so overlapping bullets stay readable.
func _make_dot_texture() -> ImageTexture:
var img := Image.create(TEXTURE_SIZE, TEXTURE_SIZE, false, Image.FORMAT_RGBA8)
var centre := Vector2(TEXTURE_SIZE, TEXTURE_SIZE) * 0.5
for y in TEXTURE_SIZE:
for x in TEXTURE_SIZE:
var d := Vector2(x + 0.5, y + 0.5).distance_to(centre) / (TEXTURE_SIZE * 0.5)
var alpha := clampf(1.0 - d, 0.0, 1.0)
alpha = pow(alpha, 0.6)
var core := clampf(1.0 - d * 1.9, 0.0, 1.0)
img.set_pixel(x, y, Color(1.0, 1.0, 1.0, alpha).lerp(Color.WHITE, core * 0.8))
return ImageTexture.create_from_image(img)
func render_pool(pool: BulletPool) -> void:
var mm := multimesh
var n := 0
for i in pool.high_water:
if pool.alive[i] == 0:
continue
if n >= SimConfig.MAX_BULLETS:
break
# The texture's visible disc is about 80% of the quad, so scale up a
# little to make the drawn radius match the radius used for hits.
var d: float = pool.radius[i] * 2.5
mm.set_instance_transform_2d(n, Transform2D(0.0, Vector2(d, d), 0.0, pool.pos[i]))
mm.set_instance_color(n, _colors.get(pool.kind[i], Color.WHITE))
n += 1
mm.visible_instance_count = n
+1
View File
@@ -0,0 +1 @@
uid://dmjyy62xxru42
+29
View File
@@ -0,0 +1,29 @@
extends Node2D
## Root of the playable client scene. Wires the view and HUD to whatever client
## runtime [Net] currently has, and survives that runtime being null (during
## connection) or replaced (on reconnect).
@onready var world_view: Node2D = $WorldView
@onready var hud: CanvasLayer = $HUD
var _bound: ClientRuntime = null
func _ready() -> void:
world_view.position = get_viewport_rect().size * 0.5
get_viewport().size_changed.connect(_recentre)
func _recentre() -> void:
world_view.position = get_viewport_rect().size * 0.5
func _process(_delta: float) -> void:
if Net.client != _bound:
_bound = Net.client
if _bound != null and not _bound.local_hit.is_connected(_on_local_hit):
_bound.local_hit.connect(_on_local_hit)
func _on_local_hit(_damage: int) -> void:
hud.flash_hit()
+1
View File
@@ -0,0 +1 @@
uid://b0xkfsnpm1xe1
+119
View File
@@ -0,0 +1,119 @@
extends Node2D
## Draws everything that is not a bullet. There are only a handful of actors, so
## immediate-mode [method CanvasItem.draw_circle] is cheaper than keeping nodes
## in sync with a replicated world.
##
## The view is strictly read-only: it never advances the simulation and never
## decides anything. Every number it draws came from the server.
const COL_ARENA := Color(0.22, 0.24, 0.34)
const COL_LOCAL := Color(0.5, 1.0, 0.8)
const COL_REMOTE := Color(0.55, 0.75, 1.0)
const COL_DEAD := Color(0.4, 0.4, 0.45, 0.5)
const COL_PORTAL := Color(0.5, 0.9, 1.0)
const ENEMY_COLORS := [
Color(0.95, 0.55, 0.55), # drifter
Color(0.85, 0.7, 0.35), # turret
Color(0.9, 0.4, 0.9), # stalker
Color(0.5, 0.55, 0.6), # dummy
]
@onready var bullets: MultiMeshInstance2D = $Bullets
var client: ClientRuntime = null
func _process(_delta: float) -> void:
client = Net.client
if client != null:
bullets.render_pool(client.world.pool)
queue_redraw()
func _draw() -> void:
_draw_arena()
if client == null:
return
if client.instance_kind == Protocol.InstanceKind.LOBBY:
_draw_portal()
for e in client.enemies():
_draw_enemy(e)
_draw_boss()
for p in client.remote_players():
_draw_remote_player(p)
_draw_local_player()
func _draw_arena() -> void:
var h := SimConfig.ARENA_HALF
draw_rect(Rect2(-h, h * 2.0), COL_ARENA, false, 2.0)
# Faint grid, purely so movement reads against a background.
var step := 80.0
var faint := Color(COL_ARENA, 0.25)
var x := -h.x + step
while x < h.x:
draw_line(Vector2(x, -h.y), Vector2(x, h.y), faint, 1.0)
x += step
var y := -h.y + step
while y < h.y:
draw_line(Vector2(-h.x, y), Vector2(h.x, y), faint, 1.0)
y += step
func _draw_portal() -> void:
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.003)
draw_arc(SimConfig.PORTAL_POS, SimConfig.PORTAL_RADIUS, 0.0, TAU, 48,
Color(COL_PORTAL, 0.4 + 0.4 * pulse), 3.0)
draw_circle(SimConfig.PORTAL_POS, SimConfig.PORTAL_RADIUS * 0.25,
Color(COL_PORTAL, 0.25 + 0.25 * pulse))
func _draw_enemy(e: Dictionary) -> void:
var col: Color = ENEMY_COLORS[clampi(int(e["visual"]), 0, ENEMY_COLORS.size() - 1)]
var r: float = e["radius"]
draw_circle(e["pos"], r, Color(col, 0.35))
draw_arc(e["pos"], r, 0.0, TAU, 24, col, 2.0)
func _draw_boss() -> void:
var b := client.boss_state()
if b.is_empty():
return
var r: float = client.boss_def.radius if client.boss_def != null else 42.0
var pos: Vector2 = b["pos"]
draw_circle(pos, r, Color(0.9, 0.3, 0.4, 0.25))
draw_arc(pos, r, 0.0, TAU, 48, Color(1.0, 0.4, 0.5), 3.0)
draw_arc(pos, r + 10.0, 0.0, TAU, 48, Color(1.0, 0.4, 0.5, 0.25), 1.0)
func _draw_remote_player(p: Dictionary) -> void:
var alive := (int(p["flags"]) & Protocol.F_ALIVE) != 0
var col := COL_REMOTE if alive else COL_DEAD
_draw_ship(p["pos"], p["aim"], col, alive)
if (int(p["flags"]) & Protocol.F_ESCAPING) != 0:
_draw_escape_ring(p["pos"], float(p["escape"]), col)
func _draw_local_player() -> void:
if not client.my_alive:
_draw_ship(client.predicted_pos, client.aim, COL_DEAD, false)
return
_draw_ship(client.predicted_pos, client.aim, COL_LOCAL, true)
if client.my_escaping:
_draw_escape_ring(client.predicted_pos, client.my_escape, COL_LOCAL)
func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool) -> void:
var r := SimConfig.PLAYER_RADIUS
draw_circle(pos, r, Color(col, 0.4 if alive else 0.2))
draw_arc(pos, r, 0.0, TAU, 20, col, 2.0)
if alive:
var dir := Vector2.RIGHT.rotated(aim)
draw_line(pos + dir * r, pos + dir * (r + 12.0), col, 2.0)
## The escape channel is drawn on the player, not just in the HUD, so other
## players can see someone is about to leave and react.
func _draw_escape_ring(pos: Vector2, progress: float, col: Color) -> void:
draw_arc(pos, SimConfig.PLAYER_RADIUS + 8.0, -PI * 0.5,
-PI * 0.5 + TAU * progress, 32, Color(col, 0.9), 3.0)
+1
View File
@@ -0,0 +1 @@
uid://dhkx1oxbrhh32