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
+103
View File
@@ -0,0 +1,103 @@
# Architecture
## The shape
```
┌──────────────────────────────────────────┐
│ src/sim/ -- plain RefCounted objects │
authoritative │ SimWorld, SimPlayer, SimEnemy, SimBoss, │ replica
(server) │ BulletPool, emitters │ (client)
│ no nodes · no physics · no rendering │
└───────────────┬──────────────────────────┘
┌────────────────────────┼────────────────────────┐
│ │ │
┌───────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
│ src/instances/ │ │ src/net/ │ │ src/view/ ui/ │
│ lobby, dungeon │ │ codec, server & │ │ read-only draw │
│ progression │ │ client runtimes │ │ │
└────────────────┘ └────────┬────────┘ └─────────────────┘
┌────────▼────────┐
│ src/autoload/ │
│ net.gd (RPC) │
└─────────────────┘
```
Dependencies point inwards only. `src/sim/` imports nothing from `net`, `view`,
`ui` or `instances`, and never touches the `Net` autoload. That is what makes
the simulation runnable in a unit test, in a headless server and inside a client
without a single conditional.
## Why the simulation is not made of nodes
Godot's instinct is `CharacterBody2D` + `Area2D` + `MultiplayerSynchronizer`.
This project deliberately does not, for four reasons:
1. **Bullet count.** Hundreds of live bullets. A node and an `Area2D` each would
cost more than the entire simulation does. `BulletPool` is parallel packed
arrays; a tick over ~350 bullets costs 0.24 ms including AI and hit checks.
2. **Headless cost.** The dedicated server allocates no nodes and touches no
physics server.
3. **Testability.** A `SimWorld` is `SimWorld.new()`. The suite drives thousands
of ticks in ~2 s with no SceneTree, no awaits and no frame timing.
4. **Replication control.** `MultiplayerSynchronizer` is excellent for
prototyping and wrong for this shape: it would replicate per-bullet state and
saturate the link. See [NETCODE.md](NETCODE.md).
The trade is that there is no physics engine — no arbitrary collision shapes, no
tilemap collision. The arena is a rectangle and every hit is circle-vs-circle.
For a bullet hell that is not a limitation; hitboxes in the genre are circles by
convention anyway. A dungeon with real geometry would need a static collision
representation added to `SimWorld`, not a switch back to nodes.
## Tick
Everything runs on Godot's physics tick, pinned to 60 Hz in `src/main.gd`
(asserted in code, because `project.godot` drops settings equal to the engine
default). `SimConfig` holds every constant both sides must agree on; per-machine
options live in `GameOpts` and must never affect the simulation.
## Content pipeline
```
BulletEmitter (Resource, abstract)
├── RingEmitter count, arc, spin per shot -> rings and spirals
├── AimedSpreadEmitter fan at nearest player -> punishes standing still
├── WallGapEmitter curtain with a sliding gap -> forces a committed dodge
└── ArcSweepEmitter rotating arms -> forces tracking
EnemyDef = stats + movement enum + emitters
BossPhase = hp threshold + telegraph + looping timeline of emitters
BossDef = stats + ordered phases
```
`SimWorld._run_emitters()` is shared by enemies and bosses, so any pattern can
be dropped on either. A boss is four phases layering one idea at a time; a new
boss is a new function in `src/content/content.gd` and zero simulation changes.
## Entry point
One executable, one `src/main.tscn`. `GameOpts.parse()` reads the command line
after `--`:
- `--server``ServerRuntime`, no view, `max_fps` pinned to the tick rate.
- `--join` / `--bot` → connect immediately, skipping the menu.
- otherwise → the connect menu, which can also start a listen server.
## File map
| File | Role |
| --- | --- |
| `src/core/sim_config.gd` | Every constant server and client must agree on. |
| `src/core/movement.gd` | Pure movement + overlap helpers. Shared by prediction. |
| `src/core/game_log.gd`, `game_opts.gd` | Static; usable from tools and tests. |
| `src/sim/sim_world.gd` | The simulation. Authority flag decides what runs. |
| `src/sim/bullet_pool.gd` | Struct-of-arrays bullet storage and integration. |
| `src/sim/input_frame.gd` | The only thing a client may assert about itself. |
| `src/net/net_codec.gd` | Snapshot / event / input binary codecs. |
| `src/net/server_runtime.gd` | Instances, ticking, transfers, broadcast. |
| `src/net/client_runtime.gd` | Prediction, reconciliation, interpolation, bot input. |
| `src/instances/instance.gd` | Lobby hub and dungeon progression. |
| `src/autoload/net.gd` | ENet lifecycle, RPCs, local loopback for listen servers. |
| `src/view/bullet_renderer.gd` | The whole bullet field in one MultiMesh draw call. |
+156
View File
@@ -0,0 +1,156 @@
# Netcode
## Model
Authoritative dedicated server, clients send input only, with client-side
prediction and reconciliation for the local player.
```
client server (60 Hz)
| |
|-- InputFrame (60 Hz, x3) ---->| queue_input(): validate, drop, sort
| move, aim, 3 button bits | SimWorld.step(): move, AI, emitters,
| | integrate bullets, resolve hits
|<-- events (reliable, ch 3) ---| bullet spawns/despawns, hits, deaths
|<-- snapshot (unrel., ch 2) ---| players, enemies, boss @ 20 Hz
| |
predict locally, reconcile authoritative for everything
```
The client has no message that expresses a position, a hit, damage taken, or a
completed escape. This is deliberate and stronger than validating such messages
after the fact: there is no code path to exploit, only intent to interpret.
## Why bullets are not replicated as state
A bullet hell has hundreds of live bullets — this project peaks at ~350 with one
boss and four players. Sending a position per bullet per snapshot would be
roughly 350 × 8 bytes × 20 Hz ≈ 56 KB/s per client and would grow with the
pattern density, which is the one thing you want freedom to increase.
Instead the server sends **one spawn event per bullet, once**: uid, position,
velocity, radius, lifetime, kind, team, acceleration, turn rate (~36 bytes).
Both sides then run the identical integration in `BulletPool.step()`. A bullet
that expires or leaves the arena dies on both sides with no traffic at all.
The only bullets that need a despawn message are the ones killed early by a hit,
because that is the one outcome a client cannot derive.
Cost at the same peak: a few KB/s, dominated by whatever the patterns are
actually firing rather than by how much is in the air.
`tests/integration/test_replica_parity.gd` is the test that keeps this honest.
It runs the server and a replica side by side for 900 ticks of a boss fight —
once in-process, once with every event encoded and decoded through the real wire
format — and asserts that every live bullet matches within 0.01 px (1 px through
float32 on the wire). If that test fails, the bullets on screen are no longer
the bullets that can kill you.
Note what this does **not** require: lockstep determinism. Clients simulate
bullets for display only. The server never reads a client's bullet state, so
float drift between machines is a cosmetic concern, not a correctness one.
## Hit validation
All in `SimWorld._resolve_bullet_hits()` and `_resolve_contact_damage()`, server
only, circle-vs-circle:
- **Enemy bullet → player:** skipped entirely while the player has i-frames, so
a dense pattern costs one hit, not thirty. The bullet is consumed and a
`BULLET_DESPAWN` goes out.
- **Player bullet → boss, then enemies:** first overlap consumes the bullet.
Boss damage is scaled by the current phase's `damage_taken_mult`.
- **Enemy body → player:** contact damage, same i-frame gate.
There is no lag compensation and no rewind. For a bullet hell that is the right
call: what the player is dodging is their own position against a bullet field,
and rewinding the world to a shooter's view would mean a player who dodged on
their screen still gets hit. The cost is that a high-ping player's shots at a
moving enemy land where the enemy *was* on the server. Enemies are large and
slow relative to bullet speed, so this reads as latency rather than unfairness.
If it becomes a complaint, the fix is to lag-compensate **player bullets against
enemies only**, never enemy bullets against players.
## Input validation
`SimWorld.queue_input()` is the single audit point. It drops:
| Case | Guard |
| --- | --- |
| Replay of a consumed tick | `f.tick <= p.last_input_tick` |
| Stale input | `f.tick < tick - INPUT_MAX_AGE` |
| Input claiming the future | `f.tick > tick + INPUT_MAX_LEAD` |
| Flood | queue capped at `INPUT_MAX_AGE`, oldest dropped |
Beyond that, the wire format itself constrains the cheat surface: the move
vector is two signed bytes at 1/100 precision, and `Movement.sanitize_move()`
clamps to the unit disc, so no packet can express extra speed. Fire rate is a
server-side cooldown; holding the button every tick fires at exactly the same
rate as tapping it correctly.
A client that stops sending coasts on its last input for `INPUT_MAX_AGE` ticks
and then stops, so a dropped connection does not leave a player sliding.
## Prediction and reconciliation
`ClientRuntime` keeps every unacknowledged `InputFrame`. Each snapshot echoes
`last_input_tick` per player; on receipt the client discards acknowledged
frames, takes the server's authoritative position, and replays the rest through
the same `Movement.step_player()` the server used. Divergence over 24 px snaps;
smaller errors lerp at 0.3 so ordinary jitter does not read as rubber-banding.
Remote players, enemies and the boss are interpolated between the last two
snapshots. Bullets are not interpolated — they are simulated, and a spawn event
carries the server tick it was generated at so the client fast-forwards the
bullet by the packet's flight time (`BulletPool.advance_slot()`) instead of
popping it in at the muzzle a round-trip late.
## Channels
ENet channels keep a burst of reliable bullet events from head-of-line blocking
the snapshot stream:
| Channel | Traffic | Mode |
| --- | --- | --- |
| 1 | handshake, instance transitions | reliable |
| 2 | snapshots | unreliable, newest wins |
| 3 | bullet spawns/despawns, hits | reliable ordered |
| 4 | client input | unreliable ordered |
`Protocol.VERSION` is checked at handshake and mismatched clients are rejected
with a reason, rather than desyncing later.
## Instances
One server process hosts the lobby hub plus every concurrent dungeon. An
`Instance` is a `SimWorld` and a peer list; messages are addressed to the peers
of one instance, so a player never receives traffic for a world they are not in.
At ~0.24 ms/tick per instance, the limit is bandwidth and player count long
before it is CPU.
Transfers (portal in, escape out, dungeon cleared) are driven by server-only
events that `NetCodec` refuses to serialise, so the decision cannot leak to a
client as something it might try to send back.
## The listen server
"Host and play" runs a `ServerRuntime` and a `ClientRuntime` in one process. The
client is peer 1 and goes through the identical code path — it still sends input
and still learns outcomes from snapshots. Only the transport is short-circuited,
in `Net.send_*` / `Net.send_input`, which check for the local peer and call the
handler directly instead of going through ENet. No simulation code knows the
difference, so a bug cannot hide in a "host-only" branch.
## Known gaps
- No lag compensation (see above — deliberate, with a stated escape hatch).
- No snapshot delta compression. Full state at 20 Hz is fine at this actor
count; delta-encoding against the last acknowledged snapshot is the next step
if enemy counts grow.
- No encryption or authentication. `ENetMultiplayerPeer` supports DTLS; wire it
up before exposing a server to the internet.
- Bullet spawn events are per-bullet. Sending *pattern* events instead (emitter
id + tick + seed, clients regenerate) would cut bullet traffic by roughly the
count of each emitter's shot. Worth doing only if bandwidth becomes the
binding constraint — it couples the client to emitter behaviour, which the
current design deliberately avoids.
+56
View File
@@ -0,0 +1,56 @@
# Roadmap
## Built
| MVP requirement | State |
| --- | --- |
| Top-down 2D twin-stick bullet hell | Movement, aim, fire, i-frames, death and respawn; MultiMesh bullet rendering. |
| Multiplayer with server-side hit validation | Authoritative dedicated server; clients send input only; all hits resolved server-side for both players and enemies. No PvP. |
| Simple, predictable enemies | Drifter, Turret, Stalker, plus a lobby dummy. Five movement behaviours, all closed-form. |
| Stationary boss, adaptable format | The Warden of the Fold: four phases, data-defined. A new boss is one function and zero simulation changes. |
| Lobby hub with dungeon entry | Persistent lobby instance, portal, party forming window, dungeons opened on demand. |
| Emergency escape | Three-second channel, cancelled by damage or release, server-owned. |
78 tests plus an end-to-end smoke test over a real socket.
## Next, in rough order of value
**1. Make the dungeon a place.** Right now it is one arena and three stages.
Rooms, doors, and a `SimWorld` with static geometry — which means adding a
collision representation for walls (segment-vs-circle for players, segment
crossing for bullets) since there is no physics engine to lean on.
**2. Art and feel.** Everything is drawn with `draw_circle` and a generated dot
texture. Sprites, hit flashes, screen shake, muzzle flashes, death effects, and
sound. None of it touches the simulation — this is entirely `src/view/`.
**3. A second boss.** The format claims to be reusable; the way to find out is
to use it. A mobile boss will exercise `BossDef.stationary`, which the runtime
already reads but no content uses yet.
**4. Progression.** Loot, character stats, persistence. Needs a decision on
storage: for a dedicated server, player state belongs server-side in a database,
not in a client save file.
**5. Netcode hardening.** In the order they will actually matter:
- DTLS on `ENetMultiplayerPeer` before any public server.
- Authentication; `peer_names` is currently client-supplied and trusted for
display only, which is fine now and will not be once there is progression.
- Snapshot delta compression, once enemy counts grow.
- Interest management inside an instance, once arenas are larger than a
screen.
**6. Scale.** The simulation costs ~1.4% of a core per instance, so the ceiling
is bandwidth and process supervision, not CPU. A shard manager that runs several
server processes behind a lobby-of-lobbies is the shape, but it is premature
until there is a game to fill it.
## Deliberately not done
- **Lag compensation.** Discussed in [NETCODE.md](NETCODE.md): rewinding for a
shooter's view would mean a player who dodged still gets hit, which is the
wrong trade for this genre.
- **Pattern-level bullet replication.** A large bandwidth win that couples the
client to emitter behaviour. Not worth it until bandwidth binds.
- **`MultiplayerSynchronizer` / `MultiplayerSpawner`.** Right tools, wrong shape
for a bullet hell. See [ARCHITECTURE.md](ARCHITECTURE.md).
+177
View File
@@ -0,0 +1,177 @@
# Agent-assisted Godot development
Research notes and the resulting setup for this repo. The short version: Godot
is an editor-centric engine and a coding agent is a text-centric tool, so the
whole workflow is built around making the game fully runnable, inspectable and
verifiable from the command line — with the editor as an optional viewer rather
than a required step.
## The core problem
An agent editing a Godot project cannot see the editor. It cannot click play,
cannot look at the scene dock, and cannot tell whether a `.tscn` it wrote is
valid. Left alone it will produce code that looks right and does not run.
Everything below exists to close that gap. Three properties matter:
1. **A fast, honest failure signal.** Parse errors must surface in seconds, from
the CLI, with the engine's own words.
2. **A way to actually play the game without a human.** Not "the tests pass" —
the real binary, the real socket, the real loop.
3. **Source of truth in text.** Binary scenes and inspector-tuned resources are
invisible to review and produce meaningless diffs.
## Godot's CLI, as an agent uses it
| Command | Use |
| --- | --- |
| `godot --headless --path . --import` | Rebuild the import + **global class cache**. Required after adding any `class_name`. ~2s. |
| `godot --headless --path . res://scene.tscn` | Run a scene headless with autoloads alive. |
| `godot --headless --path . --script tools/x.gd` | Run a `SceneTree` script. **No autoloads.** |
| `godot --headless --check-only -s file.gd` | Parse one file. |
| `godot --headless --path . -s addons/gut/gut_cmdln.gd -gdir=res://tests -gexit` | Run the test suite. |
| `godot --headless --path . --export-release <preset> <out>` | Build. |
Four things cost real time to discover, so they are worth stating plainly:
- **The global class cache is not built by running the game.** It lives in
`.godot/global_script_class_cache.cfg` and is written by the editor or by
`--import`. Add a `class_name`, run your code, and every reference to it fails
with `Identifier "Foo" not declared in the current scope` — a message that
looks exactly like a typo and is not one. `tools/check.sh` runs `--import`
first for this reason.
- **Autoload singletons do not exist under `--script`.** `--script` replaces the
main loop, so autoload names are unresolved identifiers at compile time. Any
tool that needs an autoload has to run as a scene instead. The practical
consequence for design: keep autoloads to the minimum that genuinely needs to
be a node. This repo has exactly one (`Net`, because RPC routing is by node
path); logging and CLI options are static classes, which makes them usable
from tests and tools alike.
- **`ResourceLoader.load()` on a script with a parse error can return a non-null
object.** Detecting failures by null-checking the return value silently passes.
Read the engine's stderr instead — that is what `tools/check.sh` does.
- **Never call `Script.reload()` on the script currently executing.** It hangs
the engine with no output.
## What this repo does about it
### `tools/check.sh` — the inner loop
Refreshes the class cache, loads every `.gd` in the project, greps the engine's
stderr for `Parse Error` / `SCRIPT ERROR` / `Failed to load script`, and exits
non-zero on any hit. About five seconds. This is the command to run after every
edit; it catches the entire class of "looks right, does not compile" mistakes
that an agent produces most often.
### `tools/test.sh` — GUT, headless
[GUT 9.7.1](https://github.com/bitwes/Gut) is vendored into `addons/gut/`.
Chosen over [gdUnit4](https://github.com/godot-gdunit-labs/gdUnit4) because this
project is GDScript-only, where GUT is the lighter and simpler of the two;
gdUnit4 is the better pick when C# is in play or when you want its scene-runner
utilities. Either runs headless in CI.
The suite runs in about two seconds because of an architectural choice, not a
testing trick: **the entire simulation is plain `RefCounted` objects** — no
nodes, no physics server, no rendering. A test constructs a `SimWorld`, drives
a thousand ticks, and asserts. Nothing to await, nothing to instantiate, no
frame timing. If the simulation had been built out of `CharacterBody2D` and
`Area2D`, every one of these tests would need a live SceneTree and would be
slower and flakier by an order of magnitude.
That is the single highest-leverage decision in this repo for agent-assisted
work, and it is worth stating as a general rule: **keep game logic out of nodes.**
Nodes are for presentation and input. Logic in plain objects is testable,
diffable, reviewable, and runs on a headless server for free.
### `tools/smoke.sh` — the thing tests cannot do
Boots the real dedicated server, connects two scripted bot clients over a real
ENet socket, and asserts on the server and client logs that the whole loop
happened: handshake, lobby, portal into a dungeon, emergency escape back out.
Unit tests structurally cannot cover RPC wiring, codec round-trips over the
wire, instance transfers or client reconciliation. This does, in ~35 seconds,
with no display. The bot input lives in `ClientRuntime._bot_input()` behind
`--bot`, so the "player" driving it is the same code path a human uses.
This is the pattern to reach for whenever an agent needs to verify something
interactive: give the program a scripted-input mode and a `--autoquit`, run it
headless, and assert on structured log lines. Log markers like `SERVER_READY`
exist specifically to be grepped.
### `tools/bench.gd` — performance as a number
Runs the simulation with no transport and no rendering and prints milliseconds
per tick. Current numbers, 4 players, 60s of play:
```
boss fight 0.237 ms/tick peak 352 bullets headroom x70
trash wave 0.179 ms/tick peak 145 bullets headroom x93
```
A 60 Hz tick has a 16.6 ms budget, so one instance uses ~1.4% of one core. That
is the measurement that says a single server process can host dozens of
concurrent dungeons, and it took ten seconds to get because the simulation has
no engine dependencies.
## Content as code, not as `.tres`
Enemies and bosses are built by GDScript functions in `src/content/content.gd`,
not authored as `.tres` files. For agent-assisted work this is the right default:
- A boss is a readable diff. A `.tres` full of `SubResource` ids is not.
- No resource UID churn in version control.
- A test can build content inline without touching the filesystem.
- The agent can write a boss without an editor.
`tools/export_content.gd` writes `.tres` copies into `resources/` for anyone who
wants to tune numbers in the inspector, with the direction of truth documented:
code wins, port inspector changes back.
The same reasoning applies to scenes. This project has three `.tscn` files, each
a handful of nodes. Anything dynamic — the HUD, the menu — is built in code,
because a scene file for it would only be one more thing to keep in sync.
## Godot MCP servers
An MCP server lets an agent talk to a live Godot editor: inspect the scene tree,
read node properties, create nodes, run the project and read its output. The
most established option is
[Coding-Solo/godot-mcp](https://github.com/Coding-Solo/godot-mcp) (~5.5k stars,
Node ≥18):
```bash
claude mcp add godot -- npx @coding-solo/godot-mcp
```
It exposes `launch_editor`, `run_project`, `stop_project`, `get_debug_output`,
`get_godot_version`, `get_project_info`, `create_scene`, `add_node`,
`load_sprite`, `save_scene`, `export_mesh_library`, and UID helpers for 4.4+.
Set `GODOT_PATH` if `godot` is not on `PATH`.
**Where it earns its place:** projects with large hand-authored scene trees,
where the agent otherwise has to guess node names and paths. Being able to read
the real tree instead of inferring it is the biggest single quality jump in that
situation.
**Where it does not:** this repo. `run_project` and `get_debug_output` are what
`tools/server.sh` and `tools/smoke.sh` already do, more precisely and with
assertions attached; and the scene-authoring tools matter little when there are
three small scenes. It also adds a live-editor dependency, and editor
auto-reload racing against agent file writes is a known source of stale state.
The honest recommendation: **start with the headless CLI loop, add the MCP when
scene inspection becomes the bottleneck.** It is one command to add later.
## CI
`.github/workflows/ci.yml` runs check → test → smoke on a container image with
Godot preinstalled. All three are headless, so nothing extra is needed.
## Sources
- [Coding-Solo/godot-mcp](https://github.com/Coding-Solo/godot-mcp)
- [GUT](https://github.com/bitwes/Gut) · [gdUnit4](https://github.com/godot-gdunit-labs/gdUnit4)
- [Godot: high-level multiplayer](https://docs.godotengine.org/en/stable/tutorials/networking/high_level_multiplayer.html)
- [Godot: command line tutorial](https://docs.godotengine.org/en/stable/tutorials/editor/command_line_tutorial.html)