# 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. **Starting a new session?** [docs/ROADMAP.md](docs/ROADMAP.md) says what is built, what is next, and which file implements each feature. [docs/DECISIONS.md](docs/DECISIONS.md) records what the user has already decided and why — read it before asking a design question, several answers there are not the obvious default. ## 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: dungeons spawn the boss and no trash. | | `--depth N` | Server-side: depth of new dungeons, which drives map size. | | `--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/sim/map_grid.gd` | Tile grid: collision, line of sight, chunk streaming. | | `src/sim/map_gen.gd` | Dungeon generation; `build()` is the only entry point. | | `src/content/rooms.gd` | Hand-authored room stamps (hub, boss arenas) as text. | | `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. - **`INPUT_MAX_LEAD` must stay well above `INPUT_LEAD_MAX`.** The server's input acceptance window has to be wider than the band in which the client re-syncs its own numbering. Violate it and drifting clocks land in a silent dead zone where the server rejects everything and the client never notices — the ship and the authoritative position separate permanently. Pinned by `tests/unit/test_input_lead.gd`. - **Never send the map, or its seed.** Geometry is streamed per peer in chunks around that peer's player (`ServerRuntime._stream_map`). The seed would let any client regenerate the whole dungeon. `MAP_STREAM_RADIUS` must stay wider than `FOG_VIEW_RADIUS`, or prediction runs on terrain the client lacks. - **Bullet speed must stay under one tile per tick.** Wall collision samples position once per tick, so anything faster tunnels. Pinned by `test_bullet_speeds_stay_below_the_tunnelling_threshold`. - **No contact damage.** Every enemy threatens through bullets only; touching one is harmless. `tests/unit/test_content.gd` enforces that every hostile has an emitter. - **No i-frames.** Every bullet that touches a player lands; `spawn_grace` is the only invulnerable state. Do not reintroduce post-hit immunity — it makes dense patterns safer than sparse ones. - **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.