# 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 *(this session)* **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.