Files
transcience/docs/DECISIONS.md
T
claude a943aa19f6
ci / verify (push) Successful in 47s
Add a second dungeon: the Proving Grounds, a test harness you walk into
Two labelled portals now stand side by side in the hub. The Proving Grounds
runs the same generator, the same rooms, the same enemies and the same
four-phase Warden -- enemies at a fifth health, the boss at 288 instead of
3600, and trash dropping potions 80% of the time instead of 8%. A manual pass
over loot, the inventory, dropping and every boss phase takes a couple of
minutes rather than a quarter of an hour.

It is multipliers over the shared content rather than a parallel copy: a
duplicated Content would drift the first time anything was tuned, and
"identical but easier" would quietly stop being true. And it is a portal
rather than a launch flag, so the two can be compared back to back without
restarting the server -- which is most of the point.

Which dungeon you enter is resolved from the player's server-side position,
and PORTAL_USED carries the answer. There is deliberately no client message
that names a dungeon: one would let any client ask for the generous loot table
and bring the results back to the hub. Instance matching compares dungeon ids
too, so walking into one entrance can never drop you into the other's run on
timing alone.

SimWorld.portals replaces portal_pos/portal_enabled, enter_instance carries
the portal list and the dungeon id (the client needs the latter to scale the
boss bar's ceiling the way the server scaled the boss), and Protocol.VERSION
goes to 7.

Also pins what happens when two players reach for one item on the same tick:
exactly one gets it -- the loop is sequential and the pickup erases the entity
before the next player looks. The tie-break is join order rather than distance,
which is arbitrary rather than designed, so it is recorded as such.

Stale doc fixed while here: MapGen.build() still claimed the client rebuilds
the map from the seed, which has not been true since map streaming landed and
is the opposite of the rule.

check.sh clean, 288 tests, SMOKE PASS (18 assertions, both dungeon kinds
opened over a real socket), all three diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:34:41 +02:00

283 lines
13 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.