4a98cf4b0e
ci / verify (push) Successful in 48s
A dungeon now names the boss arena it ends in, and the arena decides the boss. Three entrances in the hub: Warden's Descent, The Choir Vault, Proving Grounds. That replaces the seed coin-flip from the last commit, which was the wrong call. Which boss you are about to fight is the single thing a player decides before walking into a dungeon, and rolling it for them makes that decision unavailable. Only the Proving Grounds still leaves its arena open, because a harness you re-enter every couple of minutes wants whichever fight comes up first -- and it is the one place where "either will do" is true. test_every_boss_has_a_dungeon_that_reaches_it is the check that matters here: adding a boss and forgetting to give it a way in is now a failing test rather than a boss nobody meets. The smoke test asserts all three portals open over a real socket -- bots pick theirs by account id, so a run exercises each. Bullets no longer animate. The sheet's eight frames are a COLOUR cycle rather than a shape change, so running it had every bullet on screen strobing through a palette in unison, which with a few hundred in the air is exactly as hard to look at as it sounds. Each bullet holds one frame and turns slowly instead, offset by its own id so a ring of twenty does not rotate as one rigid wheel. The rate is slow enough that nothing completes a full turn inside its own lifetime, so it reads as drift rather than spin, and a test pins that against the longest-lived bullet in the game. The renderer slices four textures at startup now instead of thirty-two. check.sh clean, 414 tests, SMOKE PASS (22 assertions), all four diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
242 lines
14 KiB
Markdown
242 lines
14 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 (~3s)
|
|
tools/smoke.sh # real server + 4 bot clients over ENet (~40s)
|
|
tools/server.sh # dedicated server
|
|
tools/client.sh --listen # host and play, no menu
|
|
```
|
|
|
|
Diagnostics. Each runs as a *scene* (they need the `Net` autoload) and exits
|
|
non-zero on failure, so they gate like tests:
|
|
|
|
```bash
|
|
godot --headless --path . res://tools/diag_prediction.tscn # prediction gap; injects clock drift
|
|
godot --headless --path . res://tools/diag_progression.tscn # kill -> xp -> level -> death -> roster
|
|
godot --headless --path . res://tools/diag_loot.tscn # drop -> pick up -> persist -> use -> drop
|
|
godot --headless --path . res://tools/diag_upgrades.tscn # level -> choice -> taken at the NPC -> new stats
|
|
godot --headless --path . --script tools/bench.gd # sim cost per tick
|
|
python3 tools/build_local_assets.py # rebuild the local-only bullet atlas
|
|
```
|
|
|
|
`diag_progression`, `diag_loot` and `diag_upgrades` exist because the bot smoke
|
|
test cannot cover any of them: bots are poor shots, so they rarely kill
|
|
anything, which means they neither earn levels, produce drops, nor ever reach
|
|
the quartermaster.
|
|
|
|
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,
|
|
five button bits, an inventory slot) and a handshake — plus the two low-rate
|
|
character-roster requests, which are also pure intent. There is no message for
|
|
"I moved here", "I hit that", "I took damage", "my escape finished" or "I now
|
|
own this item". 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.
|
|
|
|
When a new player action needs a message, look at whether it fits in the input
|
|
frame first. Item use and drop did, and got the redundancy, the replay guard and
|
|
the per-tick rate limit for free. The cost was one rule: anything in the input
|
|
frame that must not repeat has to be **edge-triggered** (see `prev_buttons`),
|
|
because frames are resent and a starved server coasts on the last one it holds.
|
|
|
|
`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/actors/` | Data-only `Resource` definitions: `EnemyDef`, `BossDef`, `ItemDef`, `LootDrop`, `DungeonDef`, `UpgradeDef`. Shapes, not instances. |
|
|
| `src/sim/patterns/` | Bullet emitters — the authoring surface for every enemy and boss. Emitters are stateless: they are shared resources, and two bosses of the same kind must not stomp each other. |
|
|
| `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/content/items.gd` | All items, same idea. `Items.ORDER` is the wire format — append only. |
|
|
| `src/content/dungeons.gd` | The kinds of run. `Dungeons.ORDER` is both a wire format and the hub's portal order. |
|
|
| `src/content/upgrades.gd` | The seven upgrades and their draw weights. `Upgrades.ORDER` is a wire format. |
|
|
| `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. Upgrades multiply bullet
|
|
speed, so `SimConfig.MAX_BULLET_SPEED` clamps the result — without it two
|
|
Snipers put shots through walls. Pinned by
|
|
`test_bullet_speeds_stay_below_the_tunnelling_threshold`.
|
|
- **A character becomes a live player only in `ServerRuntime._adopt_character`,
|
|
from `_place`.** `SimWorld` knows nothing about characters and builds a blank
|
|
player on every instance change, so any new path that puts someone in a world
|
|
must go through `_place` or it hands them a level-1 body with an empty bag.
|
|
- **A player's combat numbers are derived, never stored.** `PlayerStats.build()`
|
|
recomputes them from the character's upgrade ids every time, so a saved stat
|
|
cannot disagree with the upgrades that produced it. Upgrade *riders* (split
|
|
charges, poison, erase chance) travel on the bullet instead, because a shot
|
|
in flight must keep what it was fired with.
|
|
- **Only `ServerRuntime` writes progression and persistence.** The simulation
|
|
reads a player's level and max health, and moves items between the ground and
|
|
a bag; it never grants experience, retires a character, or touches the store.
|
|
It announces what happened and `ServerRuntime` banks it. One writer means a
|
|
level can never disagree with the experience that earned it, and an inventory
|
|
on disk can never disagree with the one in the world.
|
|
- **`Items.ORDER` is a wire format.** An item's index in it is the byte that
|
|
rides the snapshot and every item event. Append, never reorder — reordering
|
|
makes every existing client decode a potion as a ration, so it needs a
|
|
`Protocol.VERSION` bump. `Dungeons.ORDER` is the same, and additionally
|
|
decides which hub portal opens which dungeon (Nth `P` marker in the lobby
|
|
stamp → Nth entry). A dungeon names the boss arena it ends in, so adding a
|
|
boss means giving it a dungeon or nobody will ever meet it.
|
|
- **Which dungeon you enter comes from where you are standing, never from the
|
|
client.** `SimWorld.portal_at()` resolves the player's server-side position
|
|
to a portal, and the `PORTAL_USED` event carries the answer. There is no
|
|
message that names a dungeon, and adding one would let any client pick the
|
|
easy variant's loot rate.
|
|
- **Loot has two visibilities, and the instanced one is enforced in the codec.**
|
|
`NetCodec.encode_snapshot` filters items owned by another peer, exactly like
|
|
the actor interest radius. Never move that check into the client: hiding an
|
|
entity the client was handed defends nothing.
|
|
- **`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.
|
|
- **A boss never leaves its arena.** `SimWorld._step_boss` clamps to
|
|
`SimBoss.room` *after* movement. Boss rooms deliberately do not lock, so
|
|
walking out is always an escape — which only holds if the boss cannot follow.
|
|
- **A telegraph must be visible through fog.** `WorldView._draw_telegraphs`
|
|
ignores line of sight on purpose; everything else in the view respects it. A
|
|
warning you cannot see is an unavoidable hit with extra steps.
|
|
- **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
|
|
in `ALL_ENEMIES` / `ALL_BOSSES`** (the export tool, the renderer and the tests
|
|
all iterate those), and add a test.
|
|
|
|
A boss phase can move — `BossPhase.Move` is `STATIC`, `ORBIT`, `CHASE` or
|
|
`WAYPOINTS`, handled generically in `SimWorld._move_boss`. Movement is a
|
|
property of the phase, not of the boss. `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.
|