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:
@@ -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
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user