4765bbce28
ci / verify (push) Successful in 47s
Identity is shaped like Steamworks so swapping to it is one subclass and no schema change: the client presents an opaque ticket, the server validates it into a stable 64-bit account id, and nothing downstream sees anything else. LocalAuthProvider takes any ticket at face value -- insecure on purpose, and labelled as such everywhere, because the point is the shape rather than the security. Do not ship it. Characters persist as JSON keyed by account. Account ids are written as decimal strings because they are 64-bit and JSON numbers are doubles, which would silently round them. A corrupt store aborts the server rather than starting empty: starting empty looks like it worked and then saves over every character on the first level-up. Levels 1-15, +10 max health each, level DERIVED from lifetime experience rather than stored beside it, so a hand-edited save cannot produce a level 12 character with a level 3's experience. Experience is shared undivided across everyone alive in the instance -- splitting it would make bringing a friend cost you progress. A level-up heals by what it added, so gaining one mid-fight is relief rather than a bar that moved further from full. Death is permanent and unbinds the character entirely: no "return to the hub as the character who just died", because the run is over. The record is retired, never deleted. The five-character cap counts LIVING characters only -- counting the dead would lock a player out of their own account after five deaths. Verified by tools/diag_progression.tscn, which drives the real server through kill -> xp -> level -> health and death -> retire -> roster. The bot smoke test cannot cover that: bots are poor shots and rarely kill anything. Writing it caught two real ordering bugs -- the death event was dispatched before the payload that tells the player they died, and the dead character stayed bound to the peer. Also added --account and --store so several clients and test runs can coexist on one machine. The smoke test now uses a scratch store; without it a rerun resumed the previous run's characters and "a character was created" quietly stopped being true. 193 tests. check.sh, test.sh, smoke.sh, diag_progression and diag_prediction all pass.
174 lines
9.5 KiB
Markdown
174 lines
9.5 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.
|
|
|
|
**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. |
|
|
| `--account N` | Client-side: override the local account, so several clients can coexist on one machine. |
|
|
| `--store PATH` | Server-side: character store location. Use a scratch path in tests. |
|
|
| `--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/meta/` | Accounts, characters, persistence, XP curve. Server-owned. |
|
|
| `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`.
|
|
- **Only `ServerRuntime` writes progression.** The simulation reads a player's
|
|
level and max health; it never grants experience or retires a character. One
|
|
writer means a level can never disagree with the experience that earned it.
|
|
- **`LocalAuthProvider` is insecure on purpose.** Any client can claim any
|
|
account. It exists to have the same shape as Steamworks (opaque ticket in,
|
|
64-bit account id out) so swapping is one class. Do not ship it.
|
|
- **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.
|