Files
transcience/CLAUDE.md
T
Adyrem 1dc1952a3c
ci / verify (push) Successful in 45s
Fix bullet/ship desync; rework death, escape, arrival and hub awareness
(1) Bullets appeared to trail the ship. Two independent causes, measured with
the new tools/diag_prediction.gd rather than guessed at:
  - ServerRuntime ticked before ClientRuntime, so input sampled on frame N was
    not consumed until frame N+1, leaving the drawn ship a constant one tick
    (4.00px at 240 u/s) ahead of the authoritative one that bullets spawn from.
    ClientRuntime now sets process_physics_priority = -10. Gap on a listen
    server: 4.00px -> 0.10px mean, 0.30px worst.
  - PLAYER_MUZZLE_OFFSET was PLAYER_RADIUS + 6 = 12px against a 13px drawn
    ship, so bullets were born inside the sprite. Regression from the previous
    commit's hitbox shrink; it now derives from PLAYER_VISUAL_RADIUS.

(2) No more timed respawn. A downed player stays down until they ask for the
hub (E), which is an ordinary input -- the server has no "revive me" message.

(3) Escape channel 3s -> 1s, and damage no longer cancels it. An interruptible
channel makes killing the process strictly better than using the button, so a
dropped connection now runs the same channel: the player stays in the world as
linkdead, still killable, and is only released once it completes. Instances
refuse to close while a linkdead body is resolving, or a solo drop would delete
it on the next tick and hand the exploit straight back.

(4) Escape opens an in-game menu: return to hub (routed through the same held-
escape channel, not a new message), disconnect, quit.

(5) Server pushes a roster so the hub shows who is online and which dungeon
they are in. Entering a dungeon grants 2s arrival protection -- invulnerable
AND weapons-cold, since invulnerability alone would make the spawn a free
firing position -- flagged in the snapshot and drawn on every protected ship.

(6) Cleared dungeons hold the party 30s (was 5s) with a visible countdown.

(7) The hub's grey circle was a 100k-HP target dummy that read as scenery. Now
drawn as a bullseye so its purpose is legible.

Protocol version 1 -> 2. 91 tests (was 78); smoke.sh gains a bot that is
SIGKILLed mid-dungeon to prove the disconnect path end to end. check.sh,
test.sh and smoke.sh all pass.
2026-09-03 18:43:19 +02:00

136 lines
6.9 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. **`set_anchors_preset(preset)` does not zero the offsets.** `keep_offsets`
defaults to `false`, which despite the name means "recompute offsets to keep
the control's *current* rect on screen" — for a freshly created Control that
rect is `(0,0)`-sized, so it comes out pinned to the top-left corner
regardless of the anchors. Use `set_anchors_and_offsets_preset()` for any
Control built in code. Separately: `.position` assigns an *absolute*
coordinate even on an anchored control; `offset_left`/`offset_top` are the
anchor-relative ones. This combination silently broke the main menu and
three pieces of the HUD.
6. **`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`.
## Non-obvious invariants
- **`ClientRuntime.process_physics_priority = -10`.** The client must sample and
send input before `ServerRuntime` ticks, or a listen server's drawn ship sits
a permanent tick ahead of the authoritative one and bullets trail it. Measure
with `godot --headless --path . res://tools/diag_prediction.tscn` (~0.1px is
healthy, 4px means the ordering broke).
- **`PLAYER_RADIUS` (hitbox) < `PLAYER_VISUAL_RADIUS` (sprite), and
`PLAYER_MUZZLE_OFFSET` derives from the visual one.** Prefer a visible
near-miss over an invisible hit; keep the muzzle clear of the sprite.
- **A disconnect is not an exit.** Dropping in a dungeon keeps the player in the
world as `linkdead`, channelling out over the same second the escape costs.
Damage must never cancel the escape channel, or quitting beats the button.
See [docs/NETCODE.md](docs/NETCODE.md).
## 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.