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.
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.
(1) Bullets appeared to trail the ship. Two independent causes, measured with
the new tools/diag_prediction.gd rather than guessed at:
- ServerRuntime ticked before ClientRuntime, so input sampled on frame N was
not consumed until frame N+1, leaving the drawn ship a constant one tick
(4.00px at 240 u/s) ahead of the authoritative one that bullets spawn from.
ClientRuntime now sets process_physics_priority = -10. Gap on a listen
server: 4.00px -> 0.10px mean, 0.30px worst.
- PLAYER_MUZZLE_OFFSET was PLAYER_RADIUS + 6 = 12px against a 13px drawn
ship, so bullets were born inside the sprite. Regression from the previous
commit's hitbox shrink; it now derives from PLAYER_VISUAL_RADIUS.
(2) No more timed respawn. A downed player stays down until they ask for the
hub (E), which is an ordinary input -- the server has no "revive me" message.
(3) Escape channel 3s -> 1s, and damage no longer cancels it. An interruptible
channel makes killing the process strictly better than using the button, so a
dropped connection now runs the same channel: the player stays in the world as
linkdead, still killable, and is only released once it completes. Instances
refuse to close while a linkdead body is resolving, or a solo drop would delete
it on the next tick and hand the exploit straight back.
(4) Escape opens an in-game menu: return to hub (routed through the same held-
escape channel, not a new message), disconnect, quit.
(5) Server pushes a roster so the hub shows who is online and which dungeon
they are in. Entering a dungeon grants 2s arrival protection -- invulnerable
AND weapons-cold, since invulnerability alone would make the spawn a free
firing position -- flagged in the snapshot and drawn on every protected ship.
(6) Cleared dungeons hold the party 30s (was 5s) with a visible countdown.
(7) The hub's grey circle was a 100k-HP target dummy that read as scenery. Now
drawn as a bullseye so its purpose is legible.
Protocol version 1 -> 2. 91 tests (was 78); smoke.sh gains a bot that is
SIGKILLed mid-dungeon to prove the disconnect path end to end. check.sh,
test.sh and smoke.sh all pass.
Top-down twin-stick bullet-hell, Godot 4.7, server-authoritative dedicated
server with client-side prediction. Clients send input only; the server
resolves every hit for both players and enemies (no PvP).
- SimWorld: whole simulation as plain RefCounted objects (no nodes, no
physics server), ~0.24ms/tick at peak load -- runs headless for free and
drives 78 tests in under a second
- BulletPool: struct-of-arrays bullet storage, replicated as spawn/despawn
events rather than per-tick state
- Emitter framework (Ring/AimedSpread/WallGap/ArcSweep) shared by trash
enemies and bosses -- a new boss is data in src/content/content.gd, no
simulation changes
- The Warden of the Fold: stationary 4-phase boss built entirely on that
format
- Lobby hub with a portal into on-demand dungeon instances; one process
hosts the hub plus every concurrent dungeon
- Emergency escape: 3s server-owned channel, cancelled by damage
- tools/check.sh, test.sh (GUT), smoke.sh (real server + bot clients over
ENet), bench.gd; git hooks wired to the same scripts
- docs/ARCHITECTURE.md, NETCODE.md, WORKFLOW.md, ROADMAP.md