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>
432 lines
21 KiB
Markdown
432 lines
21 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
|
||
|
||
**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.
|
||
|
||
---
|
||
|
||
## Inventory and loot
|
||
|
||
**Four slots, permanently on screen.** An inventory you have to open is a menu,
|
||
and a menu is a death in a game where the floor is bullets. `INVENTORY_SLOTS` is
|
||
one constant that the wire format, the save record and the HUD all read, so
|
||
growing it is a one-line change — but not into a paged or scrolling UI.
|
||
|
||
**Item actions ride the input frame rather than becoming new messages.**
|
||
`InputFrame` gained `BTN_USE`, `BTN_DROP` and a slot byte. Using an item happens
|
||
*during* a fight, so it has to be ordered against movement on the same tick and
|
||
be as cheap to reject as a movement vector. Riding the existing stream gets the
|
||
redundancy that covers a dropped packet, the replay guard on `last_input_tick`,
|
||
and a rate limit of one action per tick for free. A separate reliable RPC would
|
||
have needed every one of those bolted back on.
|
||
|
||
**Item actions are edge-triggered; movement and fire are not.** The client
|
||
resends its last few frames every tick and a starved server coasts on the last
|
||
one it holds, so a level-triggered read would empty the whole inventory in four
|
||
ticks. The *slot* is part of the edge as well — tapping 2 while 1 is still held
|
||
is a second, distinct action rather than a swallowed one.
|
||
|
||
**Loot has two visibilities, and the instanced one is enforced on the wire.**
|
||
World-shared loot is one entity the first player to reach it takes.
|
||
Player-instanced loot is one entity per eligible player, and a peer is never
|
||
told the other copies exist — the filter lives in `NetCodec.encode_snapshot`
|
||
beside the actor interest radius, not in the client. It is an
|
||
interest-management rule, not a UI convention.
|
||
|
||
**The Warden's Ration is useless on purpose.** It is dropped by every boss, one
|
||
per player who was alive for the kill, and does nothing when used. Its job is to
|
||
make sure the player-instanced path runs on every single boss kill instead of
|
||
being a code path nothing exercises. If it ever gains an effect, that job needs
|
||
a new holder.
|
||
|
||
**Anything dropped becomes world-shared, whatever it was before.** An instanced
|
||
trophy you do not want should be able to reach someone who does — otherwise
|
||
"droppable" means nothing for half the items in the game.
|
||
|
||
**A potion used at full health is refused, not spent.** Nobody drinks one on
|
||
purpose at full health, so a mistimed keypress must not do it for them. The
|
||
useless ration, by contrast, *is* consumed: "does nothing" has to mean a
|
||
completed transaction or it proves nothing about the path it exists to test.
|
||
|
||
**A full bag leaves the item on the floor.** Nothing is destroyed by a failed
|
||
pickup, and the failure does not block the portal, which shares the interact
|
||
key.
|
||
|
||
**Inventories live on the character and are written on every transaction.**
|
||
Not on a timer: a crash between "picked it up" and "wrote it down" must not be a
|
||
way to lose an item, or — far worse — to duplicate one. They are stored as item
|
||
*ids* rather than wire indices, so a save survives `Items.ORDER` being appended
|
||
to, and an id this build does not know decays to an empty slot rather than to
|
||
the wrong item.
|
||
|
||
**Items do not stack.** One id per slot, no count, no charges. Everything the
|
||
game currently needs fits that, and the wire format, the save record and the UI
|
||
are all simpler for it. Add a count when something actually needs one.
|
||
|
||
**Ground loot never expires; each world caps at `MAX_LOOT_PER_INSTANCE`,
|
||
oldest evicted.** Dungeons close and take their litter with them, so only the
|
||
hub — which never closes and where players can drop things — can realistically
|
||
reach the cap.
|
||
|
||
---
|
||
|
||
## More than one dungeon
|
||
|
||
**A second dungeon is a set of multipliers over the shared content, not a
|
||
parallel copy of it.** `DungeonDef` scales enemy health, boss health and loot
|
||
chance; the generator, the rooms, the enemy mix and the boss are the same
|
||
objects the real run uses. A duplicated `Content` would drift from the original
|
||
the first time anything was tuned, and the whole value of the Proving Grounds is
|
||
that it is *identical apart from the numbers*.
|
||
|
||
**It is reachable from the hub rather than hidden behind a launch flag.** A flag
|
||
would need a server restart to switch, which makes comparing the two a chore
|
||
and makes "does this behave the same in the real run?" a question nobody
|
||
bothers to ask. Two portals a few metres apart makes it a five-second check.
|
||
|
||
**Which dungeon you enter is decided by where you are standing.** The portal is
|
||
resolved server-side from the player's own position, and the `PORTAL_USED` event
|
||
carries the answer. There is deliberately no client message that names a
|
||
dungeon: one would let any client ask for the Proving Grounds' loot rate and
|
||
walk out with it.
|
||
|
||
**The instance matcher compares dungeon ids.** A forming run only accepts party
|
||
members who asked for that kind. Without it, walking into one entrance could
|
||
drop you into the other's run purely on timing.
|
||
|
||
**Scaling clamps at both ends.** Health never scales below 1 — a creature with
|
||
zero health is a crash waiting for a divide — and a boosted drop chance never
|
||
exceeds certain, or the roll becomes dead code and "chance" stops meaning
|
||
anything.
|
||
|
||
---
|
||
|
||
## Upgrades
|
||
|
||
**Damage is `base × (1 + Σ additive) × Π multiplicative`.** The flat +5% every
|
||
upgrade carries, Spread's −10%, Doubleshot's −50% and Glass Cannon's +100% pool
|
||
into the additive term; Sniper's ×2 multiplies the result. The brief called
|
||
Sniper out as multiplicative specifically, and this is what that buys: it stays
|
||
worth taking however many percentages have already piled up, and two Snipers is
|
||
4× rather than +200%.
|
||
|
||
**The +5% belongs to *taking an upgrade*, not to any particular upgrade.** It
|
||
lives in `SimConfig.UPGRADE_DAMAGE_BONUS` and is applied once per upgrade held,
|
||
rather than being baked into seven definitions where it would be seven places
|
||
to get wrong.
|
||
|
||
**A character record becomes a live player in exactly one place.**
|
||
`ServerRuntime._adopt_character`, called from `_place`, which every transfer
|
||
goes through. `SimWorld` knows nothing about characters and hands back a blank
|
||
player on every instance change, so something has to give it back its level,
|
||
upgrades and bag — and for a long time only character *select* did. Every
|
||
portal and every escape reset the player to level 1 with base stats and an
|
||
empty inventory, while the record on disk stayed correct, which made it look
|
||
like a display bug.
|
||
|
||
**Stats are derived from the upgrade list, never stored.** `PlayerStats.build()`
|
||
recomputes them from the ids the character holds, the same way `Progression`
|
||
derives level from experience. One source of truth means a saved stat can never
|
||
disagree with the upgrades that produced it.
|
||
|
||
**Glass Cannon halves the LEVELLED maximum health, and multiplies if taken
|
||
twice.** Half of base HP would be a flat −50 that fades from brutal at level 1
|
||
to nearly free at level 15, which is the wrong direction for a drawback.
|
||
|
||
**Poison doses stack independently rather than refreshing.** Chosen over the
|
||
alternatives knowing it is by far the strongest reading: at ~4.3 shots/sec that
|
||
is dozens of concurrent doses. It costs nothing to run because every dose lasts
|
||
the same number of ticks — so they expire in the order they were added, the
|
||
pending expiries are a plain FIFO, and only its front is ever examined.
|
||
|
||
**Split Shot's children leave at ±45° from the original heading**, 90° apart,
|
||
and are born just past the target rather than on it. A child spawned inside
|
||
what was just hit would be resolved against it again on the same tick — a free
|
||
second hit, and with several charges a free chain of them.
|
||
|
||
**Level-ups queue.** Reaching two levels in one run owes two choices. Losing one
|
||
for doing well is a punishment nobody would guess at.
|
||
|
||
**The offer is rolled once and held on the character.** If it regenerated when
|
||
the screen opened, closing and reopening would be a free reroll and everyone
|
||
would press it until a legendary turned up. It is written to disk with
|
||
everything else, so a crash is not a reroll either.
|
||
|
||
**Upgrades are spent standing at the hub NPC, enforced on the server.** Same
|
||
rule as the dungeon portal: where a player is standing is the one thing a
|
||
modified client cannot fake. The screen closes when you walk away so it never
|
||
offers a button that would be refused.
|
||
|
||
**Bullet speed has a hard ceiling.** Wall collision samples a position once per
|
||
tick, so anything faster than one tile per tick tunnels through geometry. Two
|
||
Snipers would ask for 2480 u/s against a 1920 threshold, and a tunnelling
|
||
bullet looks exactly like a bullet — so `MAX_BULLET_SPEED` clamps it and a test
|
||
pins the clamp.
|
||
|
||
**Poison ticks are not announced as hits.** They land many times a second on a
|
||
reliable channel, and the client learns enemy health from the snapshot anyway.
|
||
Death is still announced, because the experience award is keyed on that event
|
||
and a kill by poison has to score.
|
||
|
||
---
|
||
|
||
## The scale of the numbers
|
||
|
||
**Health and damage are ten times what they started as, and every ratio is
|
||
unchanged.** A shot is 60, a fresh character has 1000, the Warden has 36000,
|
||
and enemy health and emitter damage were scaled with them — time to kill is
|
||
exactly what it was.
|
||
|
||
The reason is rounding, not balance. Damage is an integer, and at a base of 6
|
||
the +5% that every upgrade carries computed to 6.3 and rounded back to 6: a
|
||
player took their first upgrade, was told it made them stronger, and it did
|
||
nothing. Ten times the base means every 5% step is worth 3 damage. The same
|
||
applies to the boss's per-phase armour multiplier, which at 6 damage rounded
|
||
1.15 into 1.17.
|
||
|
||
**The practice dummy is indestructible by flag, not by a large number.** The
|
||
old 100000 health was already past the u16 the snapshot sends enemy health in,
|
||
and once a shot did 60 a patient player could have worn it down and left the
|
||
hub without a practice target until the next restart. A flag says what was
|
||
actually meant.
|
||
|
||
---
|
||
|
||
## Bosses that move, and attacks that warn
|
||
|
||
**Movement belongs to the phase, not to the boss.** A fight that stands still
|
||
and then starts hunting you is one boss with two phases. `BossDef.stationary`
|
||
was removed rather than kept alongside the phases: a flag claiming the boss
|
||
stood still while one of its phases walked around would be a second source of
|
||
truth, and the wrong one. `BossDef.moves()` is derived.
|
||
|
||
**CHASE holds a distance instead of closing.** A boss standing on top of a
|
||
player is a boss whose bullets cannot be read, and this genre cannot afford
|
||
that. It backs off when you come inside its preferred range.
|
||
|
||
**Waypoints are fractions of the arena.** The Warden's hall and the Choir Vault
|
||
are different sizes, and a phase written against absolute coordinates would only
|
||
work in one of them.
|
||
|
||
**The boss room clamp moved after movement.** It was a no-op while every boss
|
||
was stationary. Boss rooms deliberately do not lock, so walking out is always an
|
||
escape — which is only true if the boss cannot follow.
|
||
|
||
**Telegraphed strikes are announced by a stateless emitter.** Emitters are
|
||
shared resources, so nothing may be remembered between the warning and the
|
||
strike; the positions are derived from the volley number instead, and a test
|
||
asserts the burst lands where the marker promised.
|
||
|
||
**Telegraph markers are drawn through fog and through walls**, unlike every
|
||
other thing in the view. A warning you cannot see is an unavoidable hit with
|
||
extra steps, which is the opposite of what a telegraph is for.
|
||
|
||
**Each boss has its own entrance in the hub.** A dungeon names the arena it ends
|
||
in, and the arena decides the boss. Which fight you are walking into is the one
|
||
thing a player chooses before a run, so it is not rolled for them — not from the
|
||
depth (a dev flag nothing in play raises, which left the second boss
|
||
unreachable) and not from the seed either. Only the Proving Grounds leaves it
|
||
open, because a harness you re-enter every couple of minutes wants whichever
|
||
fight comes up first.
|
||
|
||
---
|
||
|
||
## Bullets do not animate
|
||
|
||
**One frame, and a slow turn.** The bullet sheet's eight frames are a colour
|
||
cycle rather than a shape change, and running it meant every bullet on screen
|
||
strobed through a palette in unison — genuinely hard to look at with a few
|
||
hundred in the air, which is the normal case for this game.
|
||
|
||
Each bullet now holds one frame and rotates at `Art.BULLET_SPIN_RATE`, offset by
|
||
its own id so a ring of twenty does not turn 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 as spin — a test pins that against the
|
||
longest-lived bullet in the game.
|
||
|
||
The other seven frames stay in the atlas because that is what the source art is.
|
||
Nothing draws them, and the renderer now slices four textures at startup instead
|
||
of thirty-two.
|