Arriving anywhere set health to maximum, so a run cost nothing: walk in, take what you could, walk out, walk back in whole. Current health now lives on the character record for the same reason the inventory does -- SimWorld builds a blank player on every instance change, so anything that must survive a door has to be written down -- and it is banked at every transfer, every disconnect, and on every item transaction, since a potion moves an item and hit points in one action. The hub is where that damage is paid off, at 10% of maximum per second: about ten seconds from nothing, fast enough not to be a chore and slow enough to be a decision before walking back in. The dungeon rate is unchanged, so waiting one out is still not a plan. Regeneration is a property of the world now rather than a constant. adopt() no longer touches current health. It is called mid-session by on_choose_upgrade, and reading health from the record there would snap a player back to whatever was last written to disk; recompute_max_hp still clamps downwards so Glass Cannon takes the hit points above the new ceiling with it. ServerRuntime._adopt_character stays the one place a record becomes a live player. Experience is still undivided, but now only among players within XP_AWARD_RADIUS of the kill -- ten percent past what a player can see, so anyone who watched it die is inside and someone parked in the entrance room is not. The death events carry a position for this, and deliberately do not put it on the wire: encode_events still writes the id alone. diag_progression covers the whole chain end to end -- wounded in a dungeon, escaped to the hub with the wound intact, healing there, and walking back in still hurt -- because a unit test cannot transfer a peer. Both halves were mutation-tested against it; the unit suite alone missed the arrival heal entirely. The Proving Grounds is recorded in the roadmap as a debugging tool that does not ship: it pays full experience for a boss with 8% health. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
511 lines
25 KiB
Markdown
511 lines
25 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 — among everyone who was
|
||
there.** Everyone alive 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.
|
||
|
||
Presence is a separate question from the split, and it has a radius:
|
||
`SimConfig.XP_AWARD_RADIUS`, ten percent past `FOG_VIEW_RADIUS`. Anyone who
|
||
watched something die is inside it; a player parked in the entrance room while
|
||
the party clears the dungeon is not, and used to collect the whole run. Ten
|
||
percent past sight rather than exactly on it, so the edge of the fog is
|
||
comfortably inside and nobody loses a kill they actually saw. One consequence
|
||
worth knowing: an upgraded shot outranges this several times over, so a kill
|
||
made somewhere you cannot see pays nothing.
|
||
|
||
**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 in a dungeon, 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. 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.
|
||
|
||
**The hub regenerates at 10% per second, and health is carried through a door.**
|
||
Arriving anywhere used to set health to maximum, which meant a run cost nothing:
|
||
walk in, take what you can, walk out, walk back in whole. Current health now
|
||
lives on the character record — the same reason the inventory does, since
|
||
`SimWorld` builds a blank player on every instance change — and the hub is where
|
||
it is paid off, over about ten seconds. Fast enough that recovering is not a
|
||
chore, slow enough that it is a decision to make before walking back in. The
|
||
dungeon rate is unchanged, so waiting inside one is still not a plan.
|
||
|
||
More friction is expected here later; this is the floor, not the design.
|
||
|
||
**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.
|
||
|
||
---
|
||
|
||
## Settings
|
||
|
||
**Preferences are local to the client and nothing else knows about them.**
|
||
Which key fires produces the same [InputFrame] either way, so the server has no
|
||
opinion and never hears about it. `Settings` is static for the same reason
|
||
`GameOpts` is: a `--script` tool has no main loop and cannot resolve autoloads.
|
||
|
||
**Bindings are stored as physical keycodes and labelled through the active
|
||
layout.** Physical is what `tools/setup_input_map.gd` already uses, so bindings
|
||
follow key *position*; translating the label back means an AZERTY player reads
|
||
the letter printed on the key their fingers are on. The translation is skipped
|
||
on a display server with no keyboard — headless does not merely lack the call,
|
||
it logs an engine error and hands the argument back, and there is no feature
|
||
flag to test for.
|
||
|
||
**A rebind replaces every event on the action, not just the first.** Keeping the
|
||
alternates would mean the action still answered to the key you just moved away
|
||
from, which reads as the rebind not having worked. The cost is that rebinding
|
||
movement loses the arrow keys; untouched actions keep all their defaults.
|
||
|
||
**One key doing two things is refused, and the clash is named.** Silently
|
||
accepting it produces a control scheme that is broken in a way the player has to
|
||
diagnose themselves.
|
||
|
||
**"Reset" restores what the PROJECT shipped**, captured once before anything
|
||
overrides it. Captured later it would restore the last session's choice — that
|
||
is, the thing the player was trying to undo.
|
||
|
||
**Panels are the pack's shape in this game's colours.** The set is cream
|
||
throughout, which is right for buttons — they are meant to be the bright thing
|
||
you press — and wrong for a dialog laid over a dark dungeon. The panel stylebox
|
||
is modulated dark, which keeps the pixel border and the corner shape and lets
|
||
every label the game already draws in light colours stay readable. The
|
||
alternative was recolouring every label in five screens to suit the art.
|
||
|
||
**A card's rarity is its frame, not a word on it.** Three upgrade choices are
|
||
compared at a glance, and a colour reads faster than a label.
|
||
|
||
**Effects play on an SFX bus created at runtime.** A bus layout resource would
|
||
be one more file to keep in step with the code that reads it, and the sliders
|
||
being real mixer settings beats multiplying a number into every `play()` call.
|
||
|
||
---
|
||
|
||
## Drawing a boss that moves
|
||
|
||
**The boss is interpolated between snapshots like every other actor.** It was
|
||
not — `boss_state()` handed back the newest snapshot raw, so the boss stepped at
|
||
the 20 Hz snapshot rate rather than the frame rate. Invisible for as long as
|
||
every boss stood still, and the first one that moved looked broken. Not
|
||
interpolated across an instance change, though: the previous snapshot describes
|
||
a different fight in a different room.
|
||
|
||
**A CHASE boss has a dead band around its preferred distance.** The correction
|
||
is signed, so without one the sign flips every tick at the standoff and the boss
|
||
vibrates on the spot at the tick rate — a couple of pixels, and unmistakable.
|