Files
transcience/docs/DECISIONS.md
T
claude d8197885ca
ci / verify (push) Successful in 46s
Hide dead characters, suggest names, make the XP bar live
The roster sent to clients now contains living characters only. Retirement stays
server-side bookkeeping for archival; from the player's side a dead character is
simply gone, and listing it offers a choice that cannot be taken.

The XP bar only moved on a level-up or a character swap because it read the
character roster, which is re-sent only when the SET of characters changes.
Experience now rides the snapshot -- four bytes on a message already going out
at 20Hz -- and the server mirrors each grant into the world immediately rather
than only when a level is crossed.

The create field starts with a suggested name instead of blank, and offers
another after each creation.

Two bugs found while testing, both mine:

The first was a bad patch of my own: a change meant for the snapshot decoder
also matched inside decode_characters, which then read a four-byte field its
encoder never wrote and ran off the end of every packet. This is precisely the
"encodes but decodes wrong" failure the codec tests exist to catch, and it was
caught within a minute of the test being written.

Chasing that exposed a real robustness gap: StreamPeerBuffer.get_utf8_string()
pushes an engine error and returns garbage when the buffer is short, so a
truncated or hostile character/roster packet produced error spam instead of
degrading. Both decoders now bounds-check every field, with tests that slice
each packet at many lengths and assert it degrades rather than inventing
entries -- the same guarantee the input decoder already had.

206 tests. check.sh, test.sh, smoke.sh and both diagnostics pass.
2026-09-04 01:12:53 +02:00

8.5 KiB

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.

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.

Character swapping is hub-only, enforced on the server. Swapping inside a dungeon would be an instant, uninterruptible exit from danger — strictly better than the one-second escape channel, and it would make that channel pointless. The menu greys the button out so the rule is visible, but the server refuses regardless of what any client's UI allows.

Health regenerates at 0.5% of MAXIMUM per second, with no out-of-combat gate. A percentage rather than a flat rate, so it does not become irrelevant at level 15 — a capped character regains 1.2 hp/s against a level 1's 0.5, and both take about 200 seconds to heal from nothing. No combat gate because at this rate it cannot out-heal anything actually shooting at you, and a trickle that never stops is easier to reason about than a timer players have to learn.

A dead character is gone, as far as the player is concerned. Retirement is the server's own bookkeeping for archival and troubleshooting; the roster the client receives contains living characters only. Listing the dead would offer a choice that cannot be taken.

Experience rides the snapshot, not the character roster. The roster is only re-sent when the set of characters changes, so a bar fed from it moved only on level-up or a swap. The live total is four bytes on a message that already goes out at 20 Hz.