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
+83
View File
@@ -0,0 +1,83 @@
extends SceneTree
## Headless performance probe for the simulation alone -- no transport, no
## rendering. Answers the only question that decides whether the design holds:
## how many ticks per second can one dedicated server sustain per instance, and
## how big does the bullet field get?
##
## godot --headless --path . --script tools/bench.gd
## godot --headless --path . --script tools/bench.gd -- --players 4 --ticks 5400
var players: int = 4
var ticks: int = 3600
func _init() -> void:
_parse()
print("bench: %d players, %d ticks (%.1f s of play)"
% [players, ticks, float(ticks) / float(SimConfig.TICK_RATE)])
_run("boss fight", _make_boss_world())
_run("trash wave", _make_wave_world())
quit(0)
func _parse() -> void:
var argv := OS.get_cmdline_user_args()
var i := 0
while i < argv.size():
match argv[i]:
"--players":
i += 1
players = int(argv[i])
"--ticks":
i += 1
ticks = int(argv[i])
i += 1
func _add_players(world: SimWorld) -> void:
for n in players:
var p := world.add_player(n + 2, "bench%d" % n)
p.pos = Vector2(-200.0 + 100.0 * float(n), 180.0)
func _make_boss_world() -> SimWorld:
var world := SimWorld.new(1234)
_add_players(world)
world.spawn_boss(Content.warden())
return world
func _make_wave_world() -> SimWorld:
var world := SimWorld.new(4321)
_add_players(world)
for i in 6:
world.spawn_enemy(Content.turret(), Vector2(-400.0 + 160.0 * float(i), -220.0), i * 20)
for i in 4:
world.spawn_enemy(Content.drifter(), Vector2(-240.0 + 160.0 * float(i), -80.0), i * 25)
return world
func _run(label: String, world: SimWorld) -> void:
var peak := 0
var total := 0
var start := Time.get_ticks_usec()
for t in ticks:
for peer in world.players:
var frames: Array[InputFrame] = [InputFrame.make(world.tick + 1,
Vector2(cos(float(t) * 0.02), sin(float(t) * 0.031)),
float(t) * 0.05, InputFrame.BTN_FIRE)]
world.queue_input(peer, frames)
world.step()
world.drain_events()
if world.boss != null:
# Sweep hp downwards instead of letting the boss die, so the run
# covers every phase including the heaviest one.
world.boss.hp = int(float(world.boss.def.max_hp)
* (0.95 - 0.9 * float(t) / float(ticks)))
peak = maxi(peak, world.pool.live_count)
total += world.pool.live_count
var elapsed := float(Time.get_ticks_usec() - start) / 1_000_000.0
var per_tick_ms := elapsed / float(ticks) * 1000.0
print(" %-12s %7.3f ms/tick peak %4d bullets avg %4d headroom x%.1f"
% [label, per_tick_ms, peak, total / ticks,
(1000.0 / float(SimConfig.TICK_RATE)) / maxf(per_tick_ms, 0.0001)])
+1
View File
@@ -0,0 +1 @@
uid://bcga32vht26cr
Executable
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Parse-check every script in the project. ~5s, so run it after every edit --
# it is the fastest signal that a change is syntactically and type-wise sound.
#
# Two stages, both necessary:
# 1. --import refreshes .godot/global_script_class_cache.cfg. Without it, a
# newly added `class_name` is invisible and every use of it reports
# "Identifier not declared", which looks like a real error but is not.
# 2. check.tscn loads every script so the engine prints real parse errors.
# Set SKIP_IMPORT=1 to skip stage 1 when you have not added a class_name.
set -uo pipefail
cd "$(dirname "$0")/.."
GODOT="${GODOT:-godot}"
if [[ "${SKIP_IMPORT:-0}" != "1" ]]; then
"$GODOT" --headless --path . --import >/dev/null 2>&1
fi
out=$("$GODOT" --headless --path . res://tools/check.tscn 2>&1)
if ! grep -q "CHECK_COMPLETE" <<<"$out"; then
echo "check.tscn did not finish -- engine output follows:" >&2
echo "$out" >&2
exit 2
fi
problems=$(grep -E "Parse Error|Failed to load script|does not inherit|SCRIPT ERROR" <<<"$out" || true)
if [[ -n "$problems" ]]; then
grep -E -A2 "Parse Error|Failed to load script|does not inherit|SCRIPT ERROR" <<<"$out"
echo
echo "FAIL: $(wc -l <<<"$problems") problem line(s)" >&2
exit 1
fi
grep -o 'CHECK_COMPLETE scripts=[0-9]*' <<<"$out" | sed 's/CHECK_COMPLETE /clean, /'
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/check_scripts.gd" id="1"]
[node name="Check" type="Node"]
script = ExtResource("1")
+48
View File
@@ -0,0 +1,48 @@
extends Node
## Parse every GDScript in the project so the engine prints any parse or type
## error, then quit.
##
## Run as a scene (not with --script) because autoload identifiers such as `Net`
## are only registered when the engine boots a real main loop; a --script run
## reports false "Identifier not found" errors for every file that uses one.
##
## godot --headless --path . res://tools/check.tscn
##
## `tools/check.sh` wraps this and owns the exit code: a script that fails to
## parse can still come back from ResourceLoader as a non-null object, so the
## engine's own stderr is the reliable signal.
const SKIP_DIRS := ["res://addons", "res://.godot"]
func _ready() -> void:
var scripts := _collect("res://")
scripts.sort()
for path in scripts:
ResourceLoader.load(path, "Script")
print("CHECK_COMPLETE scripts=%d" % scripts.size())
get_tree().quit(0)
func _collect(dir_path: String) -> Array[String]:
var out: Array[String] = []
for skip in SKIP_DIRS:
if dir_path.begins_with(skip):
return out
var dir := DirAccess.open(dir_path)
if dir == null:
return out
dir.list_dir_begin()
var entry := dir.get_next()
while entry != "":
if entry.begins_with("."):
entry = dir.get_next()
continue
var full := dir_path.path_join(entry)
if dir.current_is_dir():
out.append_array(_collect(full))
elif entry.ends_with(".gd"):
out.append(full)
entry = dir.get_next()
dir.list_dir_end()
return out
+1
View File
@@ -0,0 +1 @@
uid://gdilmx7e78su
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Run a windowed client. With no arguments it opens the connect menu.
# tools/client.sh --join --name ada # skip the menu
# tools/client.sh --join --host 10.0.0.5
# tools/client.sh --listen # listen server: host and play
set -euo pipefail
cd "$(dirname "$0")/.."
GODOT="${GODOT:-godot}"
exec "$GODOT" --path . -- "$@"
+36
View File
@@ -0,0 +1,36 @@
extends SceneTree
## Writes .tres copies of everything in [Content] into res://resources/, so the
## numbers can be tuned in the editor inspector.
##
## godot --headless --path . --script tools/export_content.gd
##
## These files are an export, not the source of truth: [Content] is. Re-running
## this overwrites them. If you tune a value in the inspector and want to keep
## it, port it back into src/content/content.gd -- otherwise the next export
## silently reverts your change.
const OUT_ENEMIES := "res://resources/enemies"
const OUT_BOSSES := "res://resources/bosses"
func _init() -> void:
DirAccess.make_dir_recursive_absolute(OUT_ENEMIES)
DirAccess.make_dir_recursive_absolute(OUT_BOSSES)
var written := 0
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET, Content.ENEMY_STALKER,
Content.ENEMY_DUMMY]:
written += _save(Content.enemy(id), "%s/%s.tres" % [OUT_ENEMIES, id])
for id in [Content.BOSS_WARDEN]:
written += _save(Content.boss(id), "%s/%s.tres" % [OUT_BOSSES, id])
print("exported %d resources" % written)
quit(0)
func _save(res: Resource, path: String) -> int:
# Sub-resources (the emitters) are inlined so one file is one whole enemy.
var err := ResourceSaver.save(res, path)
if err != OK:
printerr("failed to write %s (error %d)" % [path, err])
return 0
print(" ", path)
return 1
+1
View File
@@ -0,0 +1 @@
uid://b7gfvfowtnq2r
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# One-time setup after cloning: point git at the hooks committed in .githooks/.
# git deliberately never runs hooks from a fresh clone on its own (that would
# be arbitrary code execution on `git clone`), so this is the one manual step.
set -euo pipefail
cd "$(dirname "$0")/.."
git config core.hooksPath .githooks
echo "hooks installed: pre-commit (check + test), pre-push (smoke)"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Run the dedicated server. Everything after -- is parsed by GameOpts.
# tools/server.sh # port 27015, headless
# tools/server.sh --port 27020 --verbose
set -euo pipefail
cd "$(dirname "$0")/.."
GODOT="${GODOT:-godot}"
exec "$GODOT" --headless --path . -- --server "$@"
+48
View File
@@ -0,0 +1,48 @@
extends SceneTree
## Writes the project's input map into project.godot.
##
## Hand-editing the [input] section is error-prone -- the events are serialised
## engine objects with a long property list. Generating them through the real
## API means the file is always in the format the engine expects.
##
## godot --headless --path . --script tools/setup_input_map.gd
##
## Re-run after changing the bindings below. Physical keycodes are used so the
## bindings follow key position rather than layout.
func _init() -> void:
_action("move_up", [_key(KEY_W), _key(KEY_UP)])
_action("move_down", [_key(KEY_S), _key(KEY_DOWN)])
_action("move_left", [_key(KEY_A), _key(KEY_LEFT)])
_action("move_right", [_key(KEY_D), _key(KEY_RIGHT)])
_action("fire", [_mouse(MOUSE_BUTTON_LEFT), _key(KEY_SPACE)])
_action("emergency_escape", [_key(KEY_F)])
_action("interact", [_key(KEY_E)])
var err := ProjectSettings.save()
print("input map written, err=%d" % err)
quit(0 if err == OK else 1)
func _action(name: String, events: Array) -> void:
ProjectSettings.set_setting("input/" + name, {
"deadzone": 0.2,
"events": events,
})
## device -1 (DEVICE_ID_EMULATION) is what the editor writes and is the only
## value that matches input from a real device. A freshly constructed event
## defaults to 16, which silently matches nothing.
func _key(code: Key) -> InputEventKey:
var e := InputEventKey.new()
e.device = -1
e.physical_keycode = code
return e
func _mouse(button: MouseButton) -> InputEventMouseButton:
var e := InputEventMouseButton.new()
e.device = -1
e.button_index = button
return e
+1
View File
@@ -0,0 +1 @@
uid://bd7ich1hfc40u
Executable
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# End-to-end integration test: boot a dedicated server, connect two scripted
# bot clients over a real ENet socket, and assert that the whole loop happened --
# handshake, lobby, portal into a dungeon, and the emergency escape back.
#
# This is the test that catches everything unit tests structurally cannot: RPC
# wiring, codec round-trips over the wire, instance transfers, and the client's
# reconciliation loop. Run it before calling any networking change done.
#
# tools/smoke.sh # ~35s
# KEEP=1 tools/smoke.sh # keep the logs and print where they are
set -uo pipefail
cd "$(dirname "$0")/.."
GODOT="${GODOT:-godot}"
PORT="${PORT:-27099}"
SERVER_TICKS="${SERVER_TICKS:-2000}"
CLIENT_TICKS="${CLIENT_TICKS:-1700}"
OUT="$(mktemp -d -t transcience-smoke-XXXXXX)"
cleanup() {
kill %1 %2 %3 2>/dev/null
wait 2>/dev/null
if [[ "${KEEP:-0}" == "1" ]]; then
echo "logs kept in $OUT"
else
rm -rf "$OUT"
fi
}
trap cleanup EXIT
echo "smoke: server on port $PORT, logs in $OUT"
"$GODOT" --headless --path . -- --server --port "$PORT" --autoquit "$SERVER_TICKS" \
> "$OUT/server.log" 2>&1 &
for _ in $(seq 1 60); do
grep -q "SERVER_READY" "$OUT/server.log" 2>/dev/null && break
sleep 0.25
done
if ! grep -q "SERVER_READY" "$OUT/server.log" 2>/dev/null; then
echo "FAIL: server never became ready"; cat "$OUT/server.log"; exit 1
fi
for n in 1 2; do
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \
--name "bot$n" --autoquit "$CLIENT_TICKS" > "$OUT/bot$n.log" 2>&1 &
sleep 0.4
done
wait %2 %3 2>/dev/null
wait %1 2>/dev/null
fails=0
check() { # check <label> <file> <pattern>
if grep -qE "$3" "$2"; then
echo " ok $1"
else
echo " FAIL $1 (no match for /$3/ in $(basename "$2"))"
fails=$((fails + 1))
fi
}
refute() { # refute <label> <file> <pattern>
local hits
hits=$(grep -cE "$3" "$2" || true)
if [[ "$hits" == "0" ]]; then
echo " ok $1"
else
echo " FAIL $1 ($hits line(s) matched /$3/ in $(basename "$2"))"
grep -E "$3" "$2" | head -5 | sed 's/^/ /'
fails=$((fails + 1))
fi
}
echo "assertions:"
check "server accepted bot1" "$OUT/server.log" "joined as 'bot1'"
check "server accepted bot2" "$OUT/server.log" "joined as 'bot2'"
check "a dungeon instance opened" "$OUT/server.log" "opened dungeon instance"
check "emergency escape completed" "$OUT/server.log" "escaped to lobby"
check "bot1 reached a dungeon" "$OUT/bot1.log" "entered instance .*DUNGEON"
check "bot1 returned to the lobby" "$OUT/bot1.log" "entered instance .*LOBBY"
refute "no server script errors" "$OUT/server.log" "SCRIPT ERROR|Parse Error|USER ERROR"
refute "no client script errors" "$OUT/bot1.log" "SCRIPT ERROR|Parse Error|USER ERROR"
echo
if [[ $fails -eq 0 ]]; then
echo "SMOKE PASS"
else
echo "SMOKE FAIL ($fails)"
echo "--- server.log (tail) ---"; tail -40 "$OUT/server.log"
echo "--- bot1.log (tail) ---"; tail -40 "$OUT/bot1.log"
fi
exit $fails
Executable
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Run the GUT suite headlessly.
# tools/test.sh # everything under tests/
# tools/test.sh -gtest=res://tests/unit/test_bullet_pool.gd
# tools/test.sh -gunit_test_name=test_ring_is_evenly_spaced
set -uo pipefail
cd "$(dirname "$0")/.."
GODOT="${GODOT:-godot}"
if [[ "${SKIP_IMPORT:-0}" != "1" ]]; then
"$GODOT" --headless --path . --import >/dev/null 2>&1
fi
args=("-gdir=res://tests" "-ginclude_subdirs" "-gexit")
if [[ $# -gt 0 ]]; then
args=("$@" "-gexit")
fi
"$GODOT" --headless --path . -s addons/gut/gut_cmdln.gd "${args[@]}"