Files
transcience/CLAUDE.md
T
Adyrem 651c4ad94a
ci / verify (push) Successful in 1m57s
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
2026-09-03 16:03:57 +02:00

112 lines
5.4 KiB
Markdown

# Transcience
Top-down twin-stick bullet-hell with a dedicated, server-authoritative backend.
Godot 4.7, GDScript only. One executable is both server and client.
## Commands
```bash
tools/check.sh # parse-check every script (~5s) -- run after every edit
tools/test.sh # GUT suite, headless (~2s)
tools/smoke.sh # real server + 2 bot clients over ENet (~35s)
tools/bench.gd # godot --headless --path . --script tools/bench.gd
tools/server.sh # dedicated server
tools/client.sh --join --name ada
```
Everything after `--` goes to `GameOpts.parse()`:
| Flag | Effect |
| --- | --- |
| `--server` | Dedicated server, no view. |
| `--join` | Skip the menu and connect. |
| `--listen` | Skip the menu and host a listen server with a local player. |
| `--bot` | Scripted input instead of the keyboard. Implies `--join`. |
| `--host`, `--port`, `--name` | Connection details. |
| `--autoquit N` | Quit after N physics ticks. |
| `--boss-rush` | Server-side: new dungeons open straight onto the boss. |
| `--verbose` / `--quiet` | Log level. |
A change is done when `check.sh`, `test.sh` and — if it touched networking,
instances or the simulation — `smoke.sh` all pass. Say so explicitly; do not
report a networking change as working on the strength of unit tests alone.
## Hooks
`tools/install-hooks.sh` once per clone (points `core.hooksPath` at
`.githooks/`, committed in the repo — plain bash, no `pre-commit` framework, so
cloning costs nothing extra to run tools/*.sh). Own `.gd` files (not vendored
`addons/`) trigger `check.sh` + `test.sh` on commit (~7s); `smoke.sh` runs on
push (~35s, skip deliberately with `SKIP_SMOKE_HOOK=1 git push`).
## The one rule
**The server decides everything; the client only sends intent.**
A client can send exactly two things: an [InputFrame] (move vector, aim angle,
three button bits) and a handshake. There is no message for "I moved here", "I
hit that", "I took damage" or "my escape finished". Adding one would collapse
the whole security model, so don't — validate-after-the-fact is strictly weaker
than having no code path at all.
`SimWorld.authoritative` is `true` on the server and `false` on the client. In
replica mode the world runs no AI, fires no emitters and resolves no hits; it
only integrates bullets it was told about. `tests/unit/test_server_authority.gd`
and `tests/integration/test_replica_parity.gd` pin this down.
## Layout
| Path | What lives there |
| --- | --- |
| `src/sim/` | The whole game as plain RefCounted objects. No nodes, no physics server, no rendering. |
| `src/sim/patterns/` | Bullet emitters — the authoring surface for every enemy and boss. |
| `src/content/content.gd` | All enemies and bosses, defined in code. Source of truth. |
| `src/net/` | Codec, `ServerRuntime`, `ClientRuntime`. |
| `src/instances/` | Lobby hub and dungeon runs. |
| `src/view/`, `src/ui/` | Read-only rendering. Never decides anything. |
| `src/autoload/net.gd` | The only autoload. RPC surface. |
| `tools/` | Headless tooling. |
The simulation must not import anything from `src/net/`, `src/view/` or
`src/ui/`, and must not touch `Net`. That is what lets tests drive a thousand
ticks in milliseconds with no SceneTree.
## Godot gotchas that will waste your time
1. **New `class_name` needs a cache refresh.** `.godot/global_script_class_cache.cfg`
is only rebuilt by the editor or `godot --headless --path . --import`.
Until then every use of the new class reports `Identifier not declared`,
which looks like a real error. `check.sh` does the refresh for you.
2. **Autoload names do not resolve under `--script`.** A `--script` run has no
main loop, so `Net` is `Identifier not found`. Tools that need autoloads must
run as a scene (see `tools/check.tscn`); tools that don't can use `--script`.
This is why `GameLog` and `GameOpts` are static classes rather than autoloads.
3. **`ResourceLoader.load()` returns non-null for a broken script.** Never test
the return value to detect a parse error; read the engine's stderr instead.
And never call `Script.reload()` on the script you are running — it hangs.
4. **Input events default to `device = 16`, which matches nothing.** Bindings
must use `device = -1`. `tools/setup_input_map.gd` generates the input map
correctly; edit that file, not the `[input]` block in `project.godot`.
5. **`ProjectSettings.save()` drops settings equal to the engine default** and
strips comments. Anything load-bearing (the 60 Hz tick) is asserted in code
in `src/main.gd` instead of trusted to `project.godot`.
## Adding content
A new enemy or boss is data, never code. Add a builder to
`src/content/content.gd` returning an `EnemyDef` / `BossDef` made of the
emitters in `src/sim/patterns/`, register its id in `enemy()` / `boss()`, and
add a test. `tests/unit/test_boss.gd::test_a_brand_new_boss_needs_no_engine_changes`
builds a boss from scratch and asserts the simulation needs no changes to run
it — if you find yourself adding a per-boss branch to `SimWorld`, stop and add
an emitter type instead.
`tools/export_content.gd` writes `.tres` copies into `resources/` for tuning in
the editor inspector. Those are an export, not the source; port changes back.
## Style
Typed GDScript everywhere (`untyped_declaration` is a warning). Tabs, snake_case
files, `PascalCase` class names. Comments explain *why* a thing is the way it is —
the netcode and anti-cheat decisions especially. Keep the existing density.