Commit Graph

7 Commits

Author SHA1 Message Date
claude de48afcbd9 Fix aiming under a scrolling camera; add status and decisions docs
ci / verify (push) Successful in 46s
The aim bug was collateral from the camera work. "Mouse relative to the centre
of the screen" WAS the cursor's world position while the world was drawn fixed
at the origin, so subtracting the player position gave the right vector. Once
the camera scrolled, that expression became the aim vector itself, and
subtracting the player position again made the ship aim at a fixed world
location -- walking around swung the crosshair with the mouse held still.

Fixed by inverting the transform the view actually draws with (world = screen -
world_view.position, published by the game scene each frame) rather than
assuming the player is centred, so it still holds if the camera later clamps at
map edges or gets shake or look-ahead. tests/unit/test_aim.gd pins it, including
the regression directly: moving the player must not move the crosshair.

Documentation, for other sessions picking this up cold:
- docs/ROADMAP.md rewritten as the status map -- every feature in the brief
  against its state and the file implementing it, the known gaps called out
  (actor interest management is the notable one), and the ten design questions
  that are genuinely unspecified and should not be guessed at.
- docs/DECISIONS.md, new: settled decisions with their reasoning, so a session
  does not re-litigate or re-ask. Several are not the obvious default -- no
  i-frames, no contact damage, non-interruptible escape, and never sending the
  map seed.
- CLAUDE.md and README point at both.

137 tests; check.sh, test.sh and smoke.sh pass.
2026-09-03 20:58:03 +02:00
claude 7af439341d Stage 1: tile maps, walls, fog of war, aggro, scrolling camera
ci / verify (push) Successful in 46s
Replaces the fixed centred arena with per-world tile geometry, which is the
foundation the remaining features sit on.

- MapGrid: tile grid with three independent flags -- blocks movement, bullets,
  sight -- so a pit stops feet but not bullets or eyes, and a barricade stops
  feet and bullets but not eyes. Circle collision with per-axis sliding, and
  Bresenham line of sight shared by fog and aggro.
- MapGen: rooms and corridors generated from (seed, depth), with hand-authored
  boss arenas from Rooms stamped in first so a corridor can never carve through
  a designed fight. Reachability from spawn to boss is asserted over 40 seeds --
  "usually connected" is the failure mode that ruins one run in twenty.
- Dungeons are populated at creation, per room, instead of gating on waves. You
  explore and choose your fights; the run ends when the boss dies, not when the
  map is swept. Boss rooms have no lock, so walking out is always available.
- Aggro: enemies need range AND line of sight, so a dungeon stays quiet until
  engaged and cover actually protects.
- Hard fog, scrolling camera, and terrain rendering.

Maps are streamed per peer in chunks around that peer's player, and the seed is
deliberately NOT sent -- a client holding it could regenerate the whole dungeon,
which is a map hack for free. Stream radius (900u) is wider than view radius
(460u) because the client predicts movement against walls and simulates bullets
that die on them; the accepted cost is a cheater seeing a little further than
the fog, never the floor plan.

Partial map knowledge means wall deaths must be announced rather than derived.
A test caught the subtle half of that: out-of-bounds tiles read as WALL by
design, so checking geometry before bounds reported every bullet leaving the map
as a wall kill.

128 tests (was 103); check.sh, test.sh and smoke.sh pass. 0.26 ms/tick with 4
players and a boss, ~65x headroom.
2026-09-03 20:49:27 +02:00
claude e1f9fa8096 Unlock menu after failed join; remove contact damage; cover clean disconnects
ci / verify (push) Successful in 45s
A failed connection left the connect buttons disabled forever. _show_menu()
returned early when a menu already existed, so the set_busy(false) that
re-enables them never ran on the way back from _abort_connect -- the player was
locked out of both joining and hosting with no way out but a restart. Also
guards against a second attempt stacking on an in-flight one.

Contact damage is gone as a concept: nothing in this game hurts you by touching
it, because every threat should be a bullet you can see and dodge. The Stalker
walked at you and dealt contact damage and nothing else, so it now carries a
point-blank shotgun instead -- five pellets, 62 degrees, 18-tick lifetime for
about 78px of reach, so it still has to close and leaves nothing lingering.
tests/unit/test_content.gd asserts every hostile enemy has an emitter, so a new
enemy cannot quietly reintroduce the mechanic.

The menu's "Disconnect" DOES get the anti-cheese protection -- verified, not
assumed. It calls Net.shutdown(), the socket closes, and the server takes the
same linkdead path as a SIGKILL, because the channel is keyed on the socket
closing rather than on how it closed. Made permanent: a --leave-after bot flag
and two smoke assertions covering the polite exit alongside the hard kill, so a
future "clean leave" message that skipped the channel would fail a test. The
menu now says so out loud in a dungeon -- the mechanic only reads as fair if
the cost is known before clicking.

103 tests, 12 smoke assertions; check.sh, test.sh and smoke.sh all pass.
2026-09-03 19:37:26 +02:00
claude f70de1b825 Fix permanent input-timing desync; no i-frames; UI respawn; guard dead joins
ci / verify (push) Successful in 45s
The real cause of the ship/bullet separation, which the previous commit only
half-addressed. The server dropped inputs past a lead of 12 while the client
only re-synced past 16, so a client whose lead drifted into 13-16 had every
input silently rejected while believing its timing was fine. The server coasted
on held_input and then stopped; the client kept predicting. The two separated
permanently and the reconciler fought it every snapshot -- "shoved around".
It needed two independent clocks to drift, hence "only after some time", and
nothing in the loop could notice, hence "then persists". The listen-server
diagnostic could never reproduce it: one process, one physics tick, lead
constant by construction.

Two defences: INPUT_MAX_LEAD (40) is now far wider than the client's correction
band (3..20), asserted by tests/unit/test_input_lead.gd so narrowing it fails a
test; and an ack-stall detector re-syncs when last_input_tick stops advancing,
which catches the whole class regardless of cause -- lead alone cannot, because
a wrong lead looks normal from the client. diag_prediction.gd now injects a +14
tick drift and exits non-zero unless the gap recovers.

Also:
- No invulnerability frames. Every bullet that touches a player lands; i-frames
  made dense patterns safer than sparse ones, which inverts the genre. Measured:
  a stationary player survives ~13.6s of the Warden's opening phase, ~17.5s
  drifting. spawn_grace remains the only invulnerable state.
- Death is exited with a HUD button, disabled for the first 3s. The lockout is
  enforced in SimWorld, not just by graying the button -- a client that ignores
  its own UI still waits. The interact key no longer respawns.
- Joining a server that is not there no longer drops the player into an empty
  lobby they cannot act in. Net.join() only creates an ENet object; the game
  scene now waits for the server to actually place us in an instance, with an
  8s timeout, and headless runs exit non-zero instead of idling.

Protocol 2 -> 3. 98 tests; check.sh, test.sh and smoke.sh all pass.
2026-09-03 19:19:33 +02:00
claude 005679f1b5 Fix bullet/ship desync; rework death, escape, arrival and hub awareness
(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.
2026-09-03 18:43:19 +02:00
claude d9a59fff03 Fix lobby/HUD UI pinned at (0,0); shrink player hitbox below visual size
The lobby connect menu and three pieces of the HUD (hit-flash overlay, boss
bar centering, death-message centering, hint label) were all silently broken
by the same Godot gotcha: set_anchors_preset(preset) with the default
keep_offsets=false does NOT zero the offsets to the preset's margins -- it
recomputes them to preserve the control's *current* rect, which for a
freshly constructed Control is (0,0). Anchors end up correct; the actual
rect stays pinned to the top-left corner regardless. Fixed by switching to
set_anchors_and_offsets_preset() everywhere a Control is built in code, and
by using offset_left/offset_top (anchor-relative) instead of .position
(absolute) for the HUD hint label. Documented as gotcha #5 in CLAUDE.md.

Also split PLAYER_RADIUS into two constants: PLAYER_RADIUS (6.0, the
authoritative hitbox used by SimWorld) and PLAYER_VISUAL_RADIUS (13.0,
view-only, used by world_view.gd). The client renders every ship a little
late relative to the server -- interpolation delay, reconciliation
smoothing -- so a hitbox that matched the sprite would let bullets connect
against a ship the player watched dodge clear of them. A smaller hitbox
means the occasional bullet visibly clips the sprite without a hit, which
reads as more forgiving of latency than the reverse.

Verified headlessly: a throwaway scene instantiating MainMenu/HUD under a
real 1280x720 viewport, asserting the panel is centered and _canvas.size /
hint position resolve correctly, before and after each fix. check.sh,
test.sh (78/78) and smoke.sh all pass.
2026-09-03 16:58:42 +02:00
claude c4beeae38f Initial commit: Transcience MVP
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
2026-09-03 16:03:57 +02:00