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.
164 lines
7.1 KiB
Markdown
164 lines
7.1 KiB
Markdown
# Settled decisions
|
|
|
|
Design choices the user has already made, with the reasoning. **Check here
|
|
before asking** — re-litigating a settled decision wastes a round trip, and
|
|
several of these look like defaults you would otherwise pick differently.
|
|
|
|
Ordered newest last.
|
|
|
|
---
|
|
|
|
## Simulation shape
|
|
|
|
**Game logic lives in plain `RefCounted` objects, not nodes.** No
|
|
`CharacterBody2D`, no `Area2D`, no physics server. Bullet counts make per-bullet
|
|
nodes unaffordable, the dedicated server allocates nothing, and the whole suite
|
|
runs with no `SceneTree`. Full reasoning in [ARCHITECTURE.md](ARCHITECTURE.md).
|
|
|
|
**Content is GDScript, not `.tres`.** A boss is a readable diff, there are no
|
|
resource UIDs churning in version control, and a test can build content inline.
|
|
`tools/export_content.gd` writes `.tres` copies for inspector tuning, but code
|
|
is the source of truth — port inspector edits back.
|
|
|
|
---
|
|
|
|
## Netcode
|
|
|
|
**The client sends input and nothing else.** No message exists for position,
|
|
hits, damage, or a finished escape. Having no code path is strictly stronger
|
|
than validating one.
|
|
|
|
**Bullets replicate as spawn events, not state.** ~36 bytes once per bullet;
|
|
both sides run the identical integration. Only early deaths (a hit, or a wall)
|
|
need announcing. Pinned by `tests/integration/test_replica_parity.gd`.
|
|
|
|
**No lag compensation.** Rewinding the world to a shooter's view would mean a
|
|
player who dodged on their own screen still takes the hit. If it becomes a
|
|
complaint, lag-compensate *player bullets against enemies only* — never enemy
|
|
bullets against players.
|
|
|
|
**`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. When it was not, drifting clocks landed in a dead zone where the
|
|
server silently rejected every input and the client never noticed. Pinned by
|
|
`tests/unit/test_input_lead.gd`.
|
|
|
|
---
|
|
|
|
## Combat feel
|
|
|
|
**Hitbox is smaller than the sprite** (`PLAYER_RADIUS` 6 vs
|
|
`PLAYER_VISUAL_RADIUS` 13), and `PLAYER_MUZZLE_OFFSET` derives from the visual
|
|
radius. A visible near-miss reads as fair; an invisible hit does not.
|
|
|
|
**No invulnerability frames.** Every bullet that touches you lands. I-frames
|
|
make dense patterns *safer* than sparse ones, which inverts the genre. Measured
|
|
cost: ~13.6s stationary in the Warden's opening phase. `spawn_grace` on entering
|
|
a dungeon is the sole exception, and it is a transition, not a combat mechanic.
|
|
|
|
**No contact damage.** Every threat is a bullet you can see and dodge. The
|
|
Stalker carries a point-blank shotgun rather than damaging you by touch.
|
|
`tests/unit/test_content.gd` asserts every hostile enemy has an emitter.
|
|
|
|
---
|
|
|
|
## Leaving a run
|
|
|
|
**Escape is a 1-second channel that damage does not interrupt.** An
|
|
interruptible channel makes killing the process better than pressing the button.
|
|
|
|
**A disconnect runs the same channel.** The player stays in the world as
|
|
`linkdead`, still killable. All four exits (key, menu button, clean disconnect,
|
|
SIGKILL) converge on one server-side path keyed on the socket closing — there is
|
|
deliberately no "clean leave" message. `tools/smoke.sh` asserts both the hard
|
|
kill and the polite disconnect.
|
|
|
|
**Boss rooms do not lock.** *(User, this session.)* You can always walk out of a
|
|
boss fight, and the boss cannot follow. The consequence: fights cannot rely on
|
|
trapping the player, and disengaging is always available.
|
|
|
|
---
|
|
|
|
## World
|
|
|
|
**Tile grid, generated layout, hand-authored boss arenas.** *(User, this
|
|
session.)* A grid because collision, line of sight and interest management all
|
|
become array lookups; authored arenas because a generated boss room is a bad one
|
|
about as often as a good one.
|
|
|
|
**Hard fog.** *(User, this session.)* No remembered terrain — anything outside
|
|
current line of sight is not drawn, including ground already walked over.
|
|
|
|
**Dungeon size scales with depth.** *(User, this session.)* `--depth N` is a dev
|
|
flag; what raises depth in actual play is still open.
|
|
|
|
**Never send the map, or its seed.** *(User, this session — corrected an earlier
|
|
choice of mine.)* Sending `(seed, depth)` and regenerating client-side is far
|
|
cheaper on the wire and hands any modified client the entire floor plan. Tiles
|
|
stream per peer instead. The accepted trade, in the user's words: a cheater
|
|
seeing further than they should is tolerable; seeing the whole map is not.
|
|
|
|
Consequence to preserve: the client holds *real* geometry it cannot see, because
|
|
it predicts movement against walls and simulates bullets that die on them. So
|
|
hard fog is a rendering rule, not secrecy. The secrecy is in what the server
|
|
declines to send.
|
|
|
|
---
|
|
|
|
## Identity and persistence
|
|
|
|
**Steam-shaped auth abstraction.** *(User, this session.)* The goal is
|
|
eventually Steam, so build the shape Steamworks uses and keep it swappable:
|
|
client presents an opaque ticket, server validates it and gets a stable 64-bit
|
|
account id (SteamID64's stand-in). A local dev provider persists a generated id
|
|
in `user://`, so **no Steam account is needed now**.
|
|
|
|
Not integrating GodotSteam yet: it needs a running Steam client and an app ID,
|
|
which would break the "no Steam account" requirement. Swapping it in later
|
|
should be one provider class and no schema change.
|
|
|
|
---
|
|
|
|
## Progression
|
|
|
|
**XP from kills, bosses worth far more.** *(User, this session.)* A first full
|
|
dungeon should give a bit more than is needed for the first level-up.
|
|
|
|
**Permadeath.** Death marks a character inactive — never deleted, for archival
|
|
and troubleshooting — and the player picks another character or creates one.
|
|
|
|
---
|
|
|
|
## Characters and progression *(this session)*
|
|
|
|
**Level 1 is base health; each level adds 10.** So level 15 is
|
|
`PLAYER_MAX_HP + 14 * 10` = 240. Level is *derived* from lifetime experience
|
|
rather than stored alongside it, so the two can never disagree — a hand-edited
|
|
save cannot produce a level 12 character with a level 3's experience.
|
|
|
|
**The five-character cap counts LIVING characters only.** Retired ones stay in
|
|
the store forever but free their slot. Counting the dead would lock a player out
|
|
of their own account permanently after five deaths, which is not a punishment
|
|
anyone signed up for.
|
|
|
|
**Death unbinds the character entirely.** There is deliberately no "return to
|
|
the hub as the character who just died" — the run is over, so the peer is
|
|
removed from the instance and left at the roster screen. The one exception is a
|
|
linkdead player, which has nobody to show a roster to, so its body is left for
|
|
the escape channel to resolve as before.
|
|
|
|
**Experience is shared across the party, undivided.** Everyone alive in the
|
|
instance receives the full amount for a kill. Splitting it would make bringing a
|
|
friend cost you progress, which is the opposite of what the hub roster exists to
|
|
encourage.
|
|
|
|
**A level-up heals by the amount it added.** Gaining a level mid-fight should
|
|
feel like relief, not like the bar you were watching got further from full.
|
|
|
|
**The character store refuses to start rather than starting empty.** A corrupt
|
|
or unreadable save aborts the server. Loading empty would look like it worked
|
|
and then overwrite every character on the first level-up.
|
|
|
|
**Account ids are written as decimal strings in JSON.** They are 64-bit and JSON
|
|
numbers are doubles, which would silently round them.
|