Audited rather than appended. What had drifted, now corrected: - ROADMAP's opening still described the project before this session: no word of the anti-cheat fixes, 468 tests, 23 smoke assertions, and a screenshot list half its current size. It now summarises the session, lists seven open items raised with the user and not yet decided (the boss-kill experience edge, the hub frame rate, aggro through corner cracks, the Warden-hall alcoves, one-tile arena doors, the Proving Grounds before release, hit feedback), and quotes current numbers. Its radius table still gave BULLET_INTEREST_RADIUS as the bullet cull at 2200; it is a 2900 ceiling over a per-bullet check. - NETCODE described the flat bullet radius as the design, claimed the respawn lockout gates connected players (they are unbound on death), and called actor scoping "next stage". All corrected, and a new section covers what the handshake and server now refuse: duplicate sessions, reasons that now arrive, paced control messages, coalesced store writes, bounds-checked hot decoders. - DECISIONS gains the audit's rules, the Proving Grounds' status, the tunnel and entrance rules, and the interface calls -- each flagged as the user's or as a judgement call. - ARCHITECTURE's file map lacked the actor classes, the dev auth provider, the menu backdrop and any tools section. - README told people to start a client with --name, which now only names bot characters, and its feature list predated fog, tunnels and carried health. CLAUDE.md gains three invariants (health on the record, one session per account, tools that host use scratch data); WORKFLOW gains the four traps this session fell into or nearly did. Every doc was checked for unintended deletions against the session's start, and every relative link resolves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
32 KiB
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.
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.
The fog is geometry, not a verdict per tile — and a little less strict.
(User asked: "no longer grid based ... able to light up cells partially", and
"a bit less strict". How strict was left to me.) Visibility casts rays from
the eye — a fixed sweep plus two aimed either side of every wall corner, which
is what makes each shadow edge a straight line — and the view draws terrain and
actors first, then darkness over everything the rays did not reach. A shadow
edge runs straight through a tile; an enemy rounding a corner appears a slice
at a time instead of popping in whole.
"Less strict" is three specific things. The face of a wall you can see is lit 26 units deep, measured perpendicular to the face — along the ray it was a sliver for any wall seen at a grazing angle, and the wall beside you vanished, which is the complaint the old fog had. Shadows end in an 8-unit fade instead of a hard line. And the edge of sight fades over its last 64 units instead of stopping at a circle. Nothing is remembered: still hard fog.
Two lines this deliberately does not cross: light stops the moment a ray steps back out of the wall it was in, so it never rounds a corner or reaches the far side of a one-tile wall; and two blocking tiles that touch only at a corner are closed, not a crack. Bullets are drawn over the darkness — a shot out of the black is still one you can dodge.
Bullets fade out past the edge of sight, but never sooner than is safe.
(User asked to stop drawing bullets beyond sight, with the condition that
nobody is ever hit by a bullet they could not see.) Every enemy bullet is
drawn at full strength out to the edge of sight or to however far it could close
in BulletRenderer.WARNING_TIME (0.75s) with you running straight at it,
whichever is further, then fades over 80 units. Today the edge of sight is always
further: the fastest enemy bullet is 300 u/s, so even head-on it is on screen
0.83s before it lands. test_bullet_draw.gd checks that for every emitter in the
content, and for bullets far faster. Player shots fade at the edge of sight
regardless, since they cannot hurt you. Bullets in shadow within sight range
are still drawn: you can step round a corner into one, and hiding it would be
exactly the invisible hit the condition rules out.
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.
The anti-cheat audit, and the rules it left behind
(User asked for an audit for design flaws and cheats, then "fix these".) Each of these was found by reading the server's code paths and then proving the problem with a probe before fixing it.
One account, one session — and the newcomer is refused, not the incumbent. Two peers on one account each got a player built from the same character record, so both held a copy of one bag and whichever wrote last won: drop the one potion from both copies and two potions hit the floor. Refusing the second connection is the only fix that closes it. The new peer is the one turned away because, with an auth provider anyone can spoof, "kick whoever holds this account" would let anyone disconnect anyone. A dropped player's linkdead body still holds the account until its escape channel resolves, which is deliberate: reconnecting during that second would put the character in the world twice.
A rejection must reach the client. Net.reject_and_kick defers the kick,
because an RPC only leaves on the frame's flush and kicking in the same call threw
the reason away — a protocol mismatch used to arrive as "server closed the
connection".
Bullets are never born on the far side of geometry. A muzzle offset is a teleport the pool never sees. The player's shot falls back to the shooter's centre when blocked (six Doubleshots used to put shots through a one-tile wall); an enemy's shot is dropped (boss curtains were spawning through the arenas' own pillars and barricades). A telegraphed burst is checked from its marker, not from the boss.
Bullet spawns are culled per bullet, not by a flat radius. Each spawn event
carries the shooter's exact position, and the flat 2900-unit radius exceeded the
whole depth-1 map, so every firing enemy was located for every player.
ServerRuntime._bullet_concerns asks whether this bullet can reach ground the
player can see.
No packet buys unbounded work. Selecting a character you already hold is a
no-op; select and create are paced per peer (CONTROL_COOLDOWN_TICKS), and the
pacing logs once per burst, not once per packet. Item moves and experience grants
mark the store dirty and it is written once per tick at most.
The hub has no escape channel. Completing it there meant transferring into the instance you were already in — teleport, full heal, map re-streamed.
A player's drop is refused when the floor is full. The cap evicts the oldest item, which is right for a dungeon's own drops and was wrong for the hub, where it let a player destroy whatever someone else had left there longest.
Health, experience and a debug dungeon
The Proving Grounds is a debugging tool and does not ship. (User.) It pays
full experience for a boss with 8% of its health. Remove it from Dungeons.ORDER
and its P marker before a release — see the roadmap.
Tunnels and what is in them
Tunnels are 2 to 4 tiles wide, rolled per leg. (User: "a varied width with a minimum of 2".) A one-tile corridor in a bullet hell is a shooting gallery. The width is per leg of each L, not varying along a leg; that was the simpler reading and the user has not asked for the other. The arena is excluded from every carve — an L that bent the wrong way used to slit 45% of arenas' west walls open.
Now and then a tunnel has a group in it. (User: "rarely".) A 10% chance per leg of two or three of one kind; about one depth-1 run in three gets one. Never within aggro range of the entrance, checked per member.
Nothing can be watching the entrance when you arrive. Room 0 was always left empty for this, but only room 0 — and wider tunnels tripled how often a neighbouring room could see the entrance (25 to 75 runs in 200). Enemies that would be awake on arrival are skipped: about one in sixteen. Clearing everything within aggro range would have removed nearly a third, which is a balance change the user has not asked for.
Interface
Every key a prompt names is the one actually bound. All actions are
rebindable; prompts go through Settings.short_label().
The boss bar waits until the boss has been seen. The server sends the boss through walls from 800 units, and the bar used to announce it to a player still in the entrance room. It appears once the fog has lit the boss and stays for the fight. A judgement call, flagged to the user as reversible.
The controls hint fades six seconds into a dungeon and stays in the hub. Also
a judgement call; one constant (Hud.HINT_HOLD).
The connect screen asks for no name. The field it had only named the characters bots create; people name characters on the roster screen.
Getting hit reddens the screen's edge, not the whole screen — a full-screen tint dimmed the bullets at the moment you were among them.