Compare commits

...
42 Commits
Author SHA1 Message Date
claudeandClaude Opus 5.5 08f58af1a8 Documentation pass before a compact
ci / verify (push) Successful in 53s
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>
2026-10-05 18:05:56 +02:00
claudeandClaude Opus 5.5 94ad6b3429 Stop drawing bullets past the edge of sight -- but never sooner than is safe
ci / verify (push) Successful in 54s
Bullets were drawn everywhere on screen, including out in the black past
the edge of sight: shots that could not reach you, streaking across the
dark. They now fade out beyond sight, under one condition that bounds the
whole change: no enemy bullet may hit you without having been on screen,
at full strength, for at least WARNING_TIME (0.75s) first.

So each enemy bullet is drawn out to the edge of sight or to however far
it could close in that window with you running straight at it -- its own
speed, plus acceleration over the window, plus yours -- whichever is
further, and fades over 80 units beyond. With today's content the edge of
sight always wins: the fastest enemy bullet is 300 u/s, so head-on it is
on screen 0.83s before it lands. The speed term is what keeps that true
when something faster is added. Player shots fade at the edge of sight
however fast they are, since they cannot hurt you; a Sniper's 1500 u/s
shot was the commonest thing crossing the dark.

Shadow does not hide a bullet. One behind a wall within sight range is
still drawn over the darkness, because you can step round the corner into
it, and hiding it would be exactly the invisible hit the condition rules
out.

The guarantee is tested the hard way: every emitter in the content, and
bullets far faster than any of them, simulated head-on against a player
running at them, measuring how long each was fully drawn before it
landed. The rule's weakenings -- no speed term, forgetting the player's
own approach, ignoring acceleration -- each fail it. The connect screen's
backdrop passes no eye and draws everything, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:57:50 +02:00
claudeandClaude Opus 5.5 170eb409dd Restore the 451 lines of the roadmap the flake note deleted
ci / verify (push) Successful in 53s
Commit 3d1b5da replaced the "Known flake" note by slicing the file from
that heading to "## Open questions for the user", on the assumption the
two were adjacent. They are not: between them sit the verification
surface and every stage section -- what each one built, its decisions and
its known gaps. All of it went, and it was pushed.

Restored from the commit before, with only the two intended changes
applied on top: the flake section replaced with its diagnosis, now ending
at the rule that actually closes it, and the HUD and connect-screen rows
the second UI pass meant to add. Every other document was checked for the
same kind of loss against the start of the session; none had any.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:14:58 +02:00
claudeandClaude Opus 5.5 6e872e2cf5 HUD and connect screen, second pass
ci / verify (push) Successful in 53s
At 331 of 1500 health the bar read as nearly full. The pack's bar sprite
is light all the way through, so the EMPTY part was the brightest thing
on it. Every bar now has a dark track inside its frame, scaled with the
bar's height -- a fixed inset left the thin experience bar four pixels of
track and six of white -- so empty reads as empty.

Health carries its number inside the bar, and a damage trail: the part
the last hit took stays in a darker red for a moment and then drains, so
a hit reads as an amount rather than as a bar that is suddenly shorter.
Two quick hits read as one, measured from where the first began. The
trail was pale at first and the number vanished against it; darker fixed
that. Experience flashes and shows "+N xp" on a kill, summed across a
burst so it does not flicker. Every block of HUD text sits on a dark
panel, since the floor art is busy enough to beat a text shadow.

Centre banners for the moments that change what you should do: a level
gained, a boss changing phase (not the phase it was found in), and the
escape channel -- which was a second bar in the corner and is now one
bar where you are looking, with the key to keep holding. Party members
you cannot see get a marker at the edge of sight pointing the way to
them. Empty inventory slots step back, so the blank parchment square is
no longer the most prominent thing in the row.

The connect screen opens on a real boss pattern behind its panel -- the
bosses' own emitters, fired into a pool and drawn by the game's own bullet
renderer, dimmed. It is primed with five seconds of ticks: every pattern
starts at the centre of the screen, which is where the panel is, so
unprimed it showed nothing for its first second or two. The title is
spaced and underlined, Credits is on the screen itself, Join says Enter
works and has focus, the port is validated before it can fail, and
"connecting" animates so a slow attempt does not look frozen.

The boss-bar logic is pulled out into track_boss() so it can be tested:
it waits for the fog to light the boss, stays for the fight, has to be
seen again in the next dungeon -- every dungeon's boss is actor 1, so the
id alone would have carried "seen" across -- and announces only real
phase changes. screenshot.tscn captures the escape banner by progress
rather than by frame count, because the channel is server ticks and a
completed escape teleports the player out mid-sequence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:13:11 +02:00
claudeandClaude Opus 5.5 3d1b5da72b Fix the smoke test's intermittent failure: a bot taking the wrong portal
The long-standing smoke flake -- failing now and then, always first in a
pre-push hook whose logs were discarded -- finally failed with its logs
kept. bot1, whose account picks the Proving Grounds, had gone into the
Choir Vault, so "the proving grounds opened" never matched.

The bot wandered a full-speed Lissajous for 90 of every 120 ticks in the
hub, then walked toward its entrance for 30 holding interact the entire
way. The three entrances sit in a row, so a wander that happened to end
near the middle one sent the bot bound for the right-hand portal across
the Choir Vault's ring with the key down, and in it went. Where the
wander stopped depended on network timing, which is why it was
intermittent and why it never reproduced on demand.

The bot now walks straight to its own entrance and presses interact only
while standing on that one. Six consecutive smoke runs pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:12:43 +02:00
claudeandClaude Opus 5.5 99f0d2a783 Nothing is awake and watching when the party arrives
Room 0 is left empty so that arriving is not an ambush, but that only
ever covered room 0. An enemy in a neighbouring room within aggro range
and in sight of the entrance woke the moment you arrived -- in one run
in eight before the tunnels were widened, and in three runs in eight
after, because wider tunnels mean more open ground and more lines of
sight. Measured over 200 runs: 25 before, 75 after. A screenshot of an
arrival with enemies visibly in range is what prompted counting.

Room enemies that would be awake on arrival -- within their aggro range
of the entrance with nothing in the way, the same two questions aggro
asks -- are now skipped. That is about one enemy in sixteen. Clearing
everything within aggro range regardless of sight would have removed
nearly a third, which is a different dungeon rather than a fixed one.
The skipped enemy's dice have already been rolled, so every other enemy
stands exactly where that seed always put it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:03:22 +02:00
claudeandClaude Opus 5.5 19d2295bec A UI pass: prompts that tell the truth, and a HUD that does not collide
ci / verify (push) Successful in 53s
Prompts named the default keys whatever was bound. Every action is
rebindable, but the HUD said "WASD move", "LMB fire", "hold F" and "1-4
use", the quartermaster said "E talk" and the upgrade screen "Close (E)".
All of them now go through Settings.short_label(), and a test rebinds
fire, escape, interact and a slot key and checks each prompt follows.

The HUD's top-left was a developer's status line -- name, instance id,
health as text, frame rate -- with the boss bar drawn over its right end
and the level-up notice sharing a row with the escape bar. It is now
name and level, where you are, health with its numbers, experience,
escape and level-ups, stacked by a running cursor so nothing has a fixed
row to collide on. Instance and fps moved behind F1, which is who they
were for. Arrival protection is a centred banner rather than a note
beside the health bar, because it is the one moment the rules change.

A hit used to tint the whole screen red, which in a bullet hell dims the
bullets at exactly the moment you are among them. It is an edge now, and
the same edge pulses below a third of your health. New: a message feed
above the inventory for what the server just confirmed -- items taken,
used and dropped, levels gained -- and party health top-right in a
dungeon, where the hub shows who is online. The pickup prompt now also
names the dungeon under your feet when you stand on its ring, in the
same order the server resolves that key: loot first, then the entrance.

The boss bar used to announce the boss to anyone within the actor
interest radius -- which passes through walls -- so a player in the
entrance room was told the Warden was near. It waits until the fog has
actually lit the boss once, then stays for the fight.

Permadeath was announced only by the roster screen appearing. The client
records its own death from the server's event and the roster says who
fell, at what level, and where. The connect screen lost a name field
that did nothing for a person -- it only named the characters bots
create -- gained a proper panel, remembers the last server, and shows
the protocol number a mismatch refusal will quote.

And screenshot.tscn ran as the player's real account against their real
character store, walking their character into dungeons for pictures --
with health now carried between runs, a stray hit would have stuck. It
has its own store and account now, like every diagnostic, and a seeded
character. The real store was checked: nothing it carries changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:41:04 +02:00
claudeandClaude Opus 5.5 069abdb3fa Now and then, a group of enemies in a tunnel
Tunnels are wide enough to fight in now, so occasionally one is occupied:
each tunnel leg rolls a 10% chance of a group of two or three of one kind,
standing within two tiles of each other. Most legs are too short or too
near the entrance to qualify, so in practice it is about one depth-1 run
in three (60 of 200 measured), rising with depth as tunnels get longer.

Rare on purpose -- tunnels are where you go between fights, and a tunnel
that is usually empty is what makes the one that is not a surprise.

Never within aggro range of the entrance, checked per member rather than
per group: members stand up to two tiles off the group's centre, so a
centre that clears the entrance does not mean everyone does. The first
version checked the centre, and the nearest member landed 15 units
outside aggro range by luck. Never a lone straggler: a leg that cannot
fit two gets nobody. Placed after every room, so each room's population
is exactly what its seed always produced, and every leg rolls the same
dice whether or not it is used, so one tunnel's luck never depends on
another's shape. Scaled by the dungeon like every other spawn, each
member from its own fresh definition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:26:25 +02:00
claudeandClaude Opus 5.5 855669a665 Fog lit by rays: shadows cut through tiles, and walls stay visible
ci / verify (push) Successful in 52s
The fog asked one question per tile -- can I see its centre? -- and drew or
skipped the whole tile on the answer. Every shadow was a staircase, tiles
snapped in and out as you walked, and a wall beside you vanished whenever
its centre happened to sit behind its neighbour.

Visibility casts rays from the eye instead: a fixed sweep, plus two aimed
either side of every wall corner, which is what makes each shadow edge a
straight line rather than a smear between rays. The view now draws terrain
and actors first and then darkness over whatever the rays did not reach,
so a shadow runs straight through a tile and an enemy rounding a corner
appears a slice at a time. Bullets are a child node and are still drawn
over the dark; telegraphs are drawn after it, as they must be.

"A bit less strict" is three specific things. A wall face you can see is
lit 26 units in, measured perpendicular to the face; shadows end in a
short fade rather than a line; and sight fades over its last 64 units
rather than stopping at a circle. Still hard fog -- nothing remembered.

The first version measured that depth along the ray, and a screenshot
caught what no test had: a wall you look along got a sliver, so the wall
beside you in a tunnel looked missing -- the old fog's own complaint.
Perpendicular depth fixes it, and light stops the instant a ray steps
back into the open, so it never rounds a corner, never reaches the far
side of a one-tile wall, and never leaves a stepped wall through its
other face. Two tiles touching only at a corner are closed.

About half a millisecond a frame. Measured against the old fog in the
same scene: hub 68 fps against 62, dungeon 240 against 240. The hub's
own low rate predates this.

MapGrid gains a revision counter so the corner list is rebuilt only when
the map changes or the eye crosses a tile. screenshot.tscn now captures a
dungeon, a tunnel and the tunnel with F1's overlay, which draws where
each ray stopped -- and makes SHOT_DIR rather than failing every capture
when it does not exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:07:33 +02:00
claudeandClaude Opus 5.5 83c3b920fb Varied-width tunnels, and stop carving slits in the boss arenas
Every corridor was one tile wide, which in a bullet hell is a shooting
gallery: there is no side to step to, so anything fired down it lands.
Each leg of each L now rolls its own width between 2 and 4, so a tunnel
can open up or pinch at its bend rather than every connection being the
same pipe. Carving stops one tile short of the map's outer ring, which
keeps the world sealed however close to the edge a wide tunnel runs.

Found while doing it: 45% of generated arenas had their own walls carved
open. The last corridor aimed at the door tile, which sits ON the
arena's west wall, and an L that bent the other way ran its second leg
straight down that column -- 181 of 398 arenas, measured. It now aims at
the tile outside the door and always arrives along the door's own row,
and the arena is excluded from every carve, so a wide tunnel stops at
the wall instead of eating into it.

Three tests: no corridor tile sits in a passage one tile wide, every
width in the range turns up and none falls below two, and every arena
keeps every wall tile it was drawn with. The overshoot at a bend is
cosmetic -- the legs always overlap on the inner side, so removing it
bevels the outer corner and narrows nothing -- and the comment now says
so rather than claiming otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:51:19 +02:00
claudeandClaude Opus 5 a6084c1a8d Tell the roster the truth about a wounded character
ci / verify (push) Successful in 52s
Health survives a run now, so a character sitting on the select screen
can be nearly dead -- and the screen was still printing only the ceiling.
"level 11, 2000 hp" for a character on 160 is a lie told at the exact
moment it matters most, because the next thing that happens is the player
walks it into a dungeon under permadeath.

The roster message carries current hit points alongside the maximum, and
the screen shows a fraction only when it means something: a healthy
character still reads "1300 hp" rather than "1300/1300". The field is
inserted mid-record, which shifts every field after it, so the protocol
version goes to 10 -- a mismatched client would read the active flag out
of the middle of a health value.

tools/screenshot.gd had no capture for this screen, which is how a UI
change to it could have shipped unlooked-at. It now shows a made-up
roster of three characters -- whole, badly hurt, nearly dead -- for the
same reason the upgrade screen is fed a made-up offer: the point is the
layout, not a dungeon run. Getting there needs the player walked off the
quartermaster first, or the game scene re-opens the upgrade screen over
the top of it every frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 21:54:40 +02:00
claudeandClaude Opus 5 4aeb6d229d Health survives a door, and experience needs you to be there
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>
2026-09-07 21:48:20 +02:00
claudeandClaude Opus 5 54086b5604 Stop the rate limiter from being its own amplifier
The control-message pacing added two commits ago logged one WARN line
per refused packet, which is a thousand formatted strings and a thousand
stdout writes per second for a client sending a thousand -- the exact
cost the limiter was added to bound, moved from the disk write to the
log. One line per burst now, re-armed when the peer behaves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:27:07 +02:00
claudeandClaude Opus 5 7b710471df Make the arenas' cover actually stop bullets
The player's muzzle was fixed last commit; every enemy and boss emitter
has one too, and theirs reach further. A ring sits 46px out, an arc
sweep's outer arms sit past 100px, and a wall-gap curtain spans the whole
arena -- most of a curtain is nowhere near the thing that fired it. The
pool samples geometry once per tick and never sees the jump, so all of
those were spawning bullets on the far side of whatever stood between.

That matters here more than it sounds, because both boss arenas are
authored out of cover. The hall has pillars; the vault has barricades,
and its own comment says they are there so that knowing where the Cantor
is does not hand you a firing lane to it. A barricade blocks bullets and
deliberately does NOT block sight -- so it is exactly the cover this bug
cancelled. Measured before fixing: the Warden's curtain put 13 shots
through a one-tile wall and its arc sweep 13 more; the Cantor's curtain,
16.

Two checks, not one. The path test skips its own starting tile so a
shooter in a doorway still resolves, and that skip let a telegraphed
burst whose centre and ring sit inside one wall tile through -- born in
the wall, announced to every client, dead on the next tick. The
destination is now asked separately, so a bullet never exists inside
geometry.

A telegraphed strike passes its own marker as the source rather than the
boss: that burst is born where the player was warned, and a pillar
between the boss and the spot has nothing to say about it. A strike
marked on cover is now simply a dud, which is cover working; pinned by a
test so it stays a known shape.

First attempt at this used a turret behind a barricade and proved
nothing -- a turret's 24px offset cannot clear its own 16px radius plus a
32px tile, so the test could not have failed. Replaced with the two
emitters that can reach, plus both real arenas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:24:34 +02:00
claudeandClaude Opus 5 20715e77cc Make the two hot decoders as careful as the six cold ones
Every colder message here checks get_available_bytes() before it reads,
and has a test named for it. Snapshots and events -- twenty and sixty
times a second, the two that actually matter -- did neither.

A snapshot's counts are a u8 and two u16s read straight off the wire and
then looped without a bounds check. A garbage enemy count is up to 65535
dictionaries built out of an empty buffer, and a garbage tick is fed
directly to the client's clock estimate and its input-lead resync. The
channel is unreliable and ENet's checksum is off, so a short snapshot is
a network event, not only an attack. It now answers valid:false and the
client drops it -- one dropped snapshot costs 50ms of interpolation.

Events were worse in kind. The decoder read a type byte and matched on
it, so a type it did not recognise consumed one byte and carried on,
reading every field after it at the wrong offset: one stray byte became a
stream of bullets spawned at invented positions, which the client then
applied. Lengths are now declared up front and an unknown type stops the
stream. The prefix is kept, because the channel is reliable and ordered
and throwing the packet away would strand every despawn in it.

Both size tables are pinned to their encoders by tests that encode one of
each record and compare, so they cannot drift -- and a new SimEvent.Type
with no declared length fails a test rather than shipping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:14:14 +02:00
claudeandClaude Opus 5 39d857c4d1 Bound what one packet can cost, and stop the hub eating its own loot
Selecting a character rewrote the entire store to disk, re-placed the
player, re-streamed its map chunks and broadcast a roster to everyone --
off one small reliable packet, at whatever rate a client cared to send.
Repeating a selection you already hold now answers yes and stops, and
select/create are paced per peer. Item moves and experience grants, the
two paths a client can trigger every tick, mark the store dirty and the
server flushes once per tick instead of once per event.

That flush also fixes experience quietly not being saved: grant_xp only
wrote when a level was crossed, and level is DERIVED from lifetime xp, so
the number nobody was keeping was the only number there is. A restart
rolled a character back to its last level-up.

The emergency escape ran in the hub, where completing it means
transferring into the instance you are already in: teleported to spawn,
healed to full, instanced loot dropped, every map chunk re-streamed, once
a second for as long as the key is held. The hub now opts out.

Ground loot evicts the oldest item when the floor fills. That is right
for a dungeon, which closes and takes its litter with it, and wrong for
the hub, which never closes -- a player dropping into a full floor
destroyed whatever someone else had left there longest, and drop is
edge-triggered per tick. A player's drop is refused instead; the world's
own drops still make room.

Also: a linkdead body is not in inst.peers, so closing a CLEARED dungeon
walked past it and left that peer's account and name in the dictionaries
forever -- with the new one-account guard, enough to lock someone out of
their own session. And several encoders wrote mini(size, 255) as a count
and then looped the whole array, which puts records after the count on
the wire; MAX_CLIENTS makes it unreachable, which is why it would have
survived to the day it was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:09:43 +02:00
claudeandClaude Opus 5 ef28970654 Close three holes an unmodified client could walk through
One account is now one session. Two peers signed in as the same account
each got their own player built from the SAME character record, so both
held a copy of one bag and whichever wrote last won: drop the single
potion from both copies and two potions hit the floor. Reconciling two
live mirrors of one record cannot be made to work, so the handshake
refuses the second connection -- the newcomer, not the incumbent, or
claiming an account would be a way to kick anyone off the server.

A muzzle offset is a teleport and the bullet pool never sees it: it
samples geometry once per tick. Forward that is 19px, but Doubleshot
adds up to 45px sideways, wider than a 32px wall plus the 6px hitbox
holding you off it. Six Doubleshots, stand flush against a wall, fire
along it, and live shots came out the far side. Shots now start from the
shooter when geometry stands in the way, which keeps the count honest.

Bullet-spawn events carry the firing actor's exact position, and they
were filtered by a flat 2900-unit radius against an 800-unit actor cull.
A depth-1 map's diagonal is about 2521, so that radius culled nothing:
every enemy that fired was pinpointed for every player, through fog, on
terrain the client was deliberately not streamed. The test is now
per bullet -- can THIS one reach ground you can see -- which keeps the
guarantee the wide radius existed for and drops the radar.

Found while fixing the above: a rejection never reached the client. The
RPC is queued and only leaves on the frame's flush, so kicking in the
same call threw the reason away, and _on_server_disconnected overwrote
last_error anyway. Both were true of protocol mismatch too, which is the
one message a player most needs. Smoke asserts the reason arrives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:04:05 +02:00
claudeandClaude Opus 5 b8332b697d Documentation pass for a cold start
ci / verify (push) Successful in 49s
Audited the whole set rather than appending to it, and the two files a fresh
session reads first were both wrong.

README claimed the emergency escape takes three seconds and is CANCELLED BY
DAMAGE. It takes one, and damage explicitly does not interrupt it -- that is a
settled decision with its own entry in DECISIONS.md, and the front page said
the opposite. It also described one stationary boss, one hub portal, and none
of characters, permadeath, levels, inventory, loot, upgrades, settings or
credits. Rewritten, with the "not ready for the internet" warning made explicit.

ROADMAP had no "what is next" at all: every stage reads *done*, which for a
cold start is a dead end. It opens with where things stand and a table of
candidates -- auth, a reason to play past level 15, an economy, hit feedback,
replacing the non-redistributable packs, DTLS -- each with what blocks it, and
says plainly that the user has chosen none of them. Open questions renumbered
from the orphaned 8-11 they were left at, with the solved one dropped and three
real ones added.

ARCHITECTURE's file map listed two files twice, missed five subsystems
(upgrades, stats, poison, settings, credits, the UI theme), and still said
MapGen.build() is "the entry point both sides use" -- it is server-only, and
the whole anti-map-hack story depends on that. Rebuilt by layer and audited
against the tree: every path listed exists, and every one of the 64 source
files is covered.

CLAUDE.md's security paragraph said a client can send "exactly two things"
plus two roster requests. There are five client -> server messages. That number
is the security model, so it is now a table naming each one and what it
carries. Also records that nothing automated can see the screen.

Smaller: a stale 156/156 test count in ASSETS.md, and test counts refreshed to
468 where they are quoted.

check.sh clean, 468 tests, SMOKE PASS, all four diagnostics green, no broken
internal links.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:20:10 +02:00
claudeandClaude Opus 5 61680d00d7 Style the rest of the interface: panels, sliders, scrollbars, cards
ci / verify (push) Successful in 48s
Buttons were the easy half. This is everything else.

Dialogs sit on panels now rather than being text over a dimmed world -- the
pause menu, character select, upgrades, settings and credits all root through
UiTheme.dialog_panel(). The panel stylebox is the pack's frame MODULATED DARK.
The set is cream throughout, which is right for buttons and wrong for a dialog
over a dark dungeon; tinting keeps the pixel border and the corner shape and
lets every label the game already draws in light colours stay readable, instead
of recolouring every label in five screens to suit the art.

Sliders and scrollbars are the pack's too, section headings sit on its banner
ribbon, and an upgrade card's rarity is now its frame rather than a word on it
-- three choices are compared at a glance and a colour reads faster than a
label.

Two bugs of the same shape, and neither was findable without looking at the
screen. A Slider and a ScrollBar take their THICKNESS from the stylebox's
minimum size, which for a StyleBoxTexture is its content margins. Mine were
zero, so both resolved the correct stylebox, reported the correct texture, and
drew a groove zero pixels tall. A probe confirmed the theme was resolving
perfectly while the track was invisible. Tests now assert every slider and
scrollbar stylebox has a non-zero minimum, and it is gotcha 7 in CLAUDE.md.

The first attempt at the slider groove also used the pack's HOLLOW bar sprite,
whose middle is transparent -- tinting it dark left an outline and nothing
else. It uses the solid one.

tools/screenshot.tscn gained the upgrade screen, which it has to stage: the
game scene owns that screen's visibility and re-asserts it every frame, so
setting `visible` lasted exactly one frame, and standing the player at the NPC
client-side lasted until reconciliation pulled them back. It now moves the
player on both sides and holds it until the shot. That tool has caught four
bugs the automated gates all passed.

check.sh clean, 468 tests, SMOKE PASS, all four diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:45:46 +02:00
claudeandClaude Opus 5 132646f6c3 Rework the UI on Crusenho's pack; credit it in the game, not just the repo
ci / verify (push) Successful in 48s
Crusenho's Complete UI Essential Pack is CC BY 4.0 -- redistributable and
commercial-friendly, confirmed from the License.txt the pack itself ships --
so unlike the two bdragon packs a subset is committed: twelve PNGs, 48 KB,
under assets/sprites/ui/. Only what is used, because each committed PNG costs
a Godot .import sidecar and a directory nothing references is one nobody
prunes.

UiTheme builds a Theme in code from it -- button states, panels, line edits --
and every screen roots itself through UiTheme.themed_root(). The HUD's bars are
the pack's frame with a tinted fill, drawn as three horizontal slices because
Godot's nine-patch lives on nodes and the HUD is drawn rather than built from
controls. Inventory slots use the pack's slot art at exactly twice the source
size; a non-integer scale on a 1px border reads as a wobble along every edge.

The credits screen is the other half of the request and it is a licence
obligation, not a nicety: two packs are now CC BY, which asks for attribution
"in any reasonable manner", and a markdown file in a source repo is not
reasonable for someone who downloaded a build. Settings -> Credits shows every
source with its terms and a link to the licence text. test_credits.gd asserts
CREDITS.md and docs/ASSETS.md name every entry, so the three cannot drift.

Two things found by actually looking at the screen, which is the point:

  - The FIRST version of this styled nothing. A Control inherits its theme from
    Control ANCESTORS only, and the chain breaks at the first plain Node or
    CanvasLayer -- which is every screen here. get_window().theme set the
    property, changed nothing, and read as correct. check.sh, 458 tests and a
    clean smoke run all passed with the entire interface unstyled. The theme
    test now instantiates every screen and asks what its buttons resolve.
  - The settings screen showed Fire bound to the right mouse button, because
    the test suite was writing the player's real user://settings.cfg --
    rebinding calls save() and nothing had redirected the path. Settings.path
    is now redirectable, the fixture points it at a scratch file, and a test
    asserts the default is still the player's own.

tools/screenshot.tscn is what found both. It boots the client windowed and
saves the menus, the HUD, settings and credits. Manual, needs a display, and
the only thing in the project that can tell you the interface rendered.

check.sh clean, 460 tests, SMOKE PASS, all four diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:12:13 +02:00
claudeandClaude Opus 5 872922e9e2 Fix boss jitter; add a settings screen for controls and volume
ci / verify (push) Successful in 48s
The jitter had two causes, and the larger one is embarrassing: boss_state()
handed back the newest snapshot raw while players and enemies both went
through the interpolator. The boss therefore stepped at the 20Hz snapshot rate
instead of the frame rate. Invisible for as long as every boss stood still,
and the first one that moved looked broken. It is interpolated now -- but not
across an instance change, where the previous snapshot describes a different
fight in a different room and lerping to it would fling the new boss across
the map for a frame.

The smaller cause was server-side: a CHASE boss corrects by the SIGN of its
distance error, so at the standoff the sign flipped every tick and the boss
vibrated a couple of pixels at 60Hz. It has a dead band now.

The settings screen covers rebindable controls and volume, reachable from both
the main menu and the in-game menu. Bindings are stored as physical keycodes
-- following key position, the choice setup_input_map.gd already made -- and
labelled back through the active layout so an AZERTY player reads the letter on
the key their fingers are on. A rebind replaces every event on the action
rather than the first, because an action that kept its alternates would still
answer to the key you just moved away from. A key already in use is refused and
the clash is named. Reset restores what the PROJECT shipped, captured once
before anything overrides it -- captured later it would restore the last
session's choice, which is the thing being undone.

Effects play on an SFX bus created at runtime, so both sliders are real mixer
settings rather than a number multiplied into every play() call.

Worth recording: the test suite AND the smoke test both passed while a client
logged twelve engine errors on every startup. ConfigFile.get_value(s, k, null)
does not mean "no default" -- it means the key is absent and no default was
given, and the engine logs an error per action. Nothing caught it because the
smoke refutations matched SCRIPT ERROR and friends, and a plain ERROR: is none
of those. It surfaced from running the client and reading the output. smoke.sh
now asserts no plain engine errors either, excluding by name the one line Godot
prints on every clean exit, and reintroducing the bug makes it fail.

One mutation caught nothing and should not have: the early return in
linear_to_db_clamped was dead code, since the clamp beneath it already prevents
negative infinity. Removed rather than left looking tested.

check.sh clean, 439 tests, SMOKE PASS (23 assertions), all four diagnostics
green, and a real client boots with zero engine errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:56:44 +02:00
claudeandClaude Opus 5 4a98cf4b0e The Cantor gets its own portal; bullets stop strobing
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>
2026-09-07 10:36:06 +02:00
claudeandClaude Opus 5 42568a40ad Fix: every zone change was handing the player a blank character
ci / verify (push) Successful in 48s
SimWorld knows nothing about characters, so Instance.add_peer builds a fresh
SimPlayer -- level 1, base stats, empty inventory. Something has to give that
player back its character, and only character SELECT ever did. Every portal
into a dungeon and every escape back to the hub therefore reset the player's
level, experience, upgrades and bag. The record on disk stayed correct
throughout, which is what made it read as a display glitch: the level shown was
1 because the level being played really was 1, and the first kill's experience
grant partially repaired it, so the numbers appeared to come and go.

_place now adopts, through a single _adopt_character that every transfer runs.
That let _enter_world_as drop its adopt/reset/adopt dance -- three lines that
existed only because reset_for_instance clobbered the health adopt had just
computed -- and let the level-up path derive maximum health through
recompute_max_hp instead of keeping a second copy of the formula.

diag_upgrades now walks hub -> dungeon -> hub after taking an upgrade and
asserts level, experience, upgrades, damage, maximum health and inventory all
survive each leg. With the fix reverted it reports exactly what was described:
level 1, no upgrades, base damage, empty bag. One of the new checks compared
health against a formula fed the player's own level, which agrees with itself
even when the level is wrong; it compares against the character record instead.

Also drops "(every upgrade)" from the choice cards. That a flat bonus rides
along with all of them is a design principle, not something a player needs
told -- the number is enough.

check.sh clean, 409 tests, SMOKE PASS, all four diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:13:14 +02:00
claudeandClaude Opus 5 e0c1e0d5c6 Stage 5: bosses that move, attacks that warn, and a second boss
ci / verify (push) Successful in 49s
Boss movement is a property of the PHASE, not of the boss -- a fight that
stands still and then starts hunting you is one boss with two phases. Four
modes (STATIC, ORBIT, CHASE, WAYPOINTS) handled generically in
SimWorld._move_boss, so a boss that moves is still data. BossDef.stationary
is gone rather than kept beside the phases: a flag claiming the boss stood
still while a phase walked around would be a second source of truth and the
wrong one, so moves() is derived.

CHASE holds a distance instead of closing, because a boss standing on top of
you is a boss whose bullets cannot be read. Waypoints are fractions of the
arena so one phase works in rooms of different sizes. Every mode is speed
clamped in one place -- ORBIT computes an absolute destination and would
otherwise snap onto its circle on the first tick -- and movement slides
against geometry so a boss cannot walk through the pillars its own arena was
designed around.

The room clamp moved to after movement, where it is finally load-bearing. It
was a no-op while every boss stood still, which is exactly when an invariant
is cheapest to establish: boss rooms deliberately do not lock, so walking out
is always an escape, and that only holds if the boss cannot follow.

TelegraphedStrikeEmitter marks spots and fills them a moment later. The moment
between is the feature: a burst at your feet is a coin flip, the same burst
with a second of notice is a question. It stays stateless like every other
emitter -- they are shared resources and two bosses of the same kind must not
stomp each other -- so strike positions are derived from the volley number and
a test asserts the burst lands where the marker promised. Markers are drawn
through fog and through walls, unlike everything else in the view, because a
warning you cannot see is an unavoidable hit with extra steps.

The Cantor of the Vault fights in the choir vault: static, then a four-corner
circuit, then a chase, then orbiting while marking. It exists to prove the
format stretched, and a test asserts it uses both new mechanisms.

Which boss a run has now comes from its SEED rather than its depth. Depth is a
dev flag nothing in play raises, so the arena was keyed to something no player
can change and the second boss was unreachable in an actual game.

Two things found while finishing:

  - tools/export_content.gd had a hand-maintained boss list and had already
    gone stale, silently not writing the Cantor. Content.ALL_ENEMIES and
    ALL_BOSSES now feed the export tool, the renderer and five tests that each
    kept their own copy.
  - diag_loot failed intermittently after another diagnostic. Taking over from
    the bot cleared its input queue but not its HELD input, so a starved server
    coasted on the bot's last movement vector for half a second and walked the
    player off the item it had been placed on. The press arrived correctly,
    which is why "the press reached the simulation" passed while everything it
    should have caused failed.

check.sh clean, 409 tests, SMOKE PASS (19 assertions), all four diagnostics
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:32:12 +02:00
claudeandClaude Opus 5 cb2c1e7840 Scale health and damage by ten so percentages have somewhere to land
A shot is 60, a fresh character has 1000, the Warden has 36000. Enemy health,
boss health and every emitter's damage were scaled with them, so every ratio
is unchanged and time to kill is exactly what it was -- tests/unit/test_content
now pins shots-to-kill per enemy and hits-to-kill a player so a careless edit
to either side shows up as a number a designer recognises.

The reason is rounding rather than balance. Damage is an integer, and at a base
of 6 the +5% every upgrade carries computed to 6.3 and rounded straight back to
6: a player took their first upgrade, was told it made them stronger, and
nothing happened. diag_upgrades used to print "damage matches the formula
(6 -> 6)"; it now prints (60 -> 63). The boss's per-phase armour multiplier had
the same problem, turning 1.15 into an effective 1.17.

The practice dummy stopped relying on a huge health pool at the same time. Its
old 100000 was already past the u16 the snapshot sends enemy health in, and
once a shot did 60 a patient player could have destroyed the hub's only
practice target for everyone until the next restart. EnemyDef.indestructible
says what was actually meant, and a test asserts nothing else uses it.

check.sh clean, 364 tests, SMOKE PASS, all four diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:16:38 +02:00
claudeandClaude Opus 5 b351bc2d55 Stage 4: upgrades, and a quartermaster to spend them at
ci / verify (push) Successful in 48s
Every level banks one choice. Choices queue, and are spent at an NPC in the
hub: walk to it, press E, take one of three weighted options. Seven upgrades,
all data — split shot, glass cannon, spread, sniper, doubleshot, poison,
eraser — and SimWorld gained no per-upgrade branch to run any of them.

The four ambiguities in the brief were settled with the user first, since
each changes what gets written:

  damage      base x (1 + sum additive) x product multiplicative. The flat
              +5% every upgrade carries, spread's -10%, doubleshot's -50%
              and glass cannon's +100% pool; sniper multiplies on top, so
              two snipers is 4x and not +200%.
  glass       half the LEVELLED maximum, multiplying if taken twice, so the
              price does not fade to a rounding error by level 15.
  poison      independent stacks, not a refresh.
  split       +/-45 degrees from the original heading.

Independent poison stacks sound expensive and are not: every dose lasts the
same number of ticks, so doses expire in the order they were added, the
pending expiries are a plain FIFO, and PoisonTrack only ever looks at its
front. O(1) per actor per tick however many are live.

Stats are derived from the upgrade list and never stored, the way level is
derived from experience -- a saved stat cannot disagree with the upgrades
that produced it. Upgrade riders (split charges, poison, erase chance) travel
on the bullet instead, because a shot in flight has to keep what it was fired
with rather than gaining Poison because the shooter just took it.

Two invariants this collided with, both now pinned:

  - bullet speed gained a ceiling. Wall collision samples once per tick, so
    anything over a tile per tick tunnels; two snipers asked for 2480 u/s
    against a 1920 threshold, and a tunnelling bullet looks like a bullet.
  - BULLET_INTEREST_RADIUS rose to 2900, because an upgraded player shot is
    now the longest-travelling bullet in the game. test_interest measured
    the worst case from static content, which upgrades quietly invalidated.

Choosing is intent checked three ways: a choice must be owed, the index must
name one of the three options the SERVER put on the table, and the player
must be standing at the NPC. The offer is rolled once and persisted, so
closing the screen is not a reroll and neither is a crash.

tools/diag_upgrades.tscn covers level -> banked choice -> refused in a
dungeon and refused across the room -> taken at the NPC -> new stats ->
on disk. Bots never walk to the quartermaster, so the smoke test cannot.

Known gap recorded in the roadmap: at PLAYER_BULLET_DAMAGE = 6, the +5% the
first upgrade carries rounds back to 6 and visibly does nothing. It comes out
right in aggregate, but the fix is a balance edit across content.gd and so is
the user's call.

check.sh clean, 357 tests, SMOKE PASS (18 assertions), all four diagnostics
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:34:58 +02:00
claudeandClaude Opus 5 a943aa19f6 Add a second dungeon: the Proving Grounds, a test harness you walk into
ci / verify (push) Successful in 47s
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
claudeandClaude Opus 5 050b8251a7 Stage 3: inventory, ground loot, and two loot visibilities
ci / verify (push) Successful in 48s
Four always-on-screen slots, items as data, and loot tables on enemies and
bosses. Health potions drop rarely from trash and always from the Warden;
the Warden also drops a Warden's Ration, one per living player, which does
nothing at all.

The ration is not filler. Player-instanced loot is a separate code path from
shared loot -- a distinct entity per owner, filtered per peer in the snapshot
encoder -- and the cheapest way to keep that path honest is to have something
in the game that exercises it on every boss kill.

Item actions ride the input frame rather than becoming new client messages.
InputFrame gained BTN_USE, BTN_DROP and a slot byte, which buys the packet-loss
redundancy, the replay guard on last_input_tick, ordering against movement on
the same tick, and a rate limit of one action per tick -- all of which a
separate RPC would have needed bolted back on. The cost is that anything in
the frame which must not repeat has to be edge-triggered, since frames are
resent and a starved server coasts on the last one it holds.

Instanced loot is enforced in NetCodec.encode_snapshot, beside the actor
interest radius: a peer is never told another player's copy exists. Hiding it
client-side would have been the same mistake as relying on fog to hide enemies.

Inventories live on the character and are written to the store on every
transaction, so a crash between "picked it up" and "wrote it down" cannot lose
or duplicate an item. Anything dropped becomes world-shared whatever it was
before, and a potion used at full health is refused rather than spent.

tools/diag_loot.tscn covers drop -> snapshot -> pick up -> persist -> use ->
drop plus both visibilities on the wire, for the same reason diag_progression
exists: bots are poor shots and almost never produce a drop. It asserts each
input frame was actually consumed, after an early version silently dropped its
first press and every later check passed for the wrong reason.

check.sh clean, 266 tests, SMOKE PASS, all three diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:16:15 +02:00
claude ded7bf96d5 Documentation pass before context compaction
ci / verify (push) Successful in 47s
The roadmap was actively misleading: an earlier stage renumbering left Stage 2's
completed work sitting under a "Stage 3 -- todo" heading, and the inventory
table orphaned with no heading at all. Since that file is the primary handoff
document, a fresh session would have started by re-implementing accounts and
characters. Rewritten.

Captured in full, from the original brief rather than from memory, the two
stages not yet built:

- Stage 3 (inventory and loot): 4 slots, potions rare from trash and guaranteed
  from bosses, world-shared loot, and the player-instanced food item -- with a
  note that two loot visibilities must exist from the start, because proving the
  instanced path works is the food item's entire purpose.
- Stage 4 (upgrades): every upgrade with its exact stated effect, plus the two
  constraints it will collide with -- sniper's 2x bullet speed against the
  tunnelling threshold, and per-player bullet travel against the interest radius
  that test_interest.gd currently derives from static content.

Ten open questions are listed as explicitly do-not-guess, seven of them blocking
Stage 4.

ARCHITECTURE.md still claimed "the arena is a rectangle" and "no tilemap
collision", both untrue since Stage 1. Rewritten around MapGrid, with what the
grid costs (axis-aligned, 32px-quantised, bullets under a tile per tick) rather
than only what it buys.

Added a "verification traps" section to WORKFLOW.md recording six mistakes made
during this work, each of which cost a round trip of reporting something fixed
that was not: verifying the artefact rather than the behaviour, dumping the
wrong channel, measuring a configuration where the bug cannot exist, a test
whose setup silently invalidated it, git checkout reverting real work alongside
a probe, and a pattern edit matching in two files. They are specific enough to
be actionable.

Also documented the diagnostics in CLAUDE.md -- they were undiscoverable -- and
recorded the current verification surface so "everything passes" has a stated
meaning.

206 tests, 15 smoke assertions, both diagnostics pass.
2026-09-04 20:34:45 +02:00
claude d8197885ca Hide dead characters, suggest names, make the XP bar live
ci / verify (push) Successful in 46s
The roster sent to clients now contains living characters only. Retirement stays
server-side bookkeeping for archival; from the player's side a dead character is
simply gone, and listing it offers a choice that cannot be taken.

The XP bar only moved on a level-up or a character swap because it read the
character roster, which is re-sent only when the SET of characters changes.
Experience now rides the snapshot -- four bytes on a message already going out
at 20Hz -- and the server mirrors each grant into the world immediately rather
than only when a level is crossed.

The create field starts with a suggested name instead of blank, and offers
another after each creation.

Two bugs found while testing, both mine:

The first was a bad patch of my own: a change meant for the snapshot decoder
also matched inside decode_characters, which then read a four-byte field its
encoder never wrote and ran off the end of every packet. This is precisely the
"encodes but decodes wrong" failure the codec tests exist to catch, and it was
caught within a minute of the test being written.

Chasing that exposed a real robustness gap: StreamPeerBuffer.get_utf8_string()
pushes an engine error and returns garbage when the buffer is short, so a
truncated or hostile character/roster packet produced error spam instead of
degrading. Both decoders now bounds-check every field, with tests that slice
each packet at many lengths and assert it degrades rather than inventing
entries -- the same guarantee the input decoder already had.

206 tests. check.sh, test.sh, smoke.sh and both diagnostics pass.
2026-09-04 01:12:53 +02:00
claude 7ef972e3b3 Hub character swapping, XP percentage, passive health regeneration
ci / verify (push) Successful in 47s
Character swapping is hub-only, and refused by the SERVER rather than merely
greyed out in the menu. Allowing it inside a dungeon would be an instant,
uninterruptible exit from danger -- strictly better than the one-second escape
channel, which would make that channel pointless. Creating a character in a
dungeon is refused for the same reason. Both are covered by diag_progression.

Health regenerates at 0.5% of MAXIMUM per second. 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 out-of-combat gate -- 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 fractional carry is needed
because a tick heals well under one hit point, so truncating each tick would
heal exactly nothing; there is a test for that specifically.

The XP bar now states the percentage and the level it leads to, since a bar
answers "how far" vaguely and a number answers it exactly.

Recorded the two Stage 3 answers: 4 inventory slots, and the boss's food item
is player-instanced now so the mechanism gets exercised rather than deferred.

Writing the swap guard's test caught my own mistake: the first version created
its spare character through the store, which has no opinion about where you
are, so it bypassed the guard it was meant to prove and left a stray character
behind that broke a later assertion.

200 tests. check.sh, test.sh, smoke.sh and both diagnostics pass.
2026-09-04 00:56:29 +02:00
claude 4765bbce28 Stage 2: accounts, characters, permadeath, levels and experience
ci / verify (push) Successful in 47s
Identity is shaped like Steamworks so swapping to it is one subclass and no
schema change: the client presents an opaque ticket, the server validates it
into a stable 64-bit account id, and nothing downstream sees anything else.
LocalAuthProvider takes any ticket at face value -- insecure on purpose, and
labelled as such everywhere, because the point is the shape rather than the
security. Do not ship it.

Characters persist as JSON keyed by account. Account ids are written as decimal
strings because they are 64-bit and JSON numbers are doubles, which would
silently round them. A corrupt store aborts the server rather than starting
empty: starting empty looks like it worked and then saves over every character
on the first level-up.

Levels 1-15, +10 max health each, level DERIVED from lifetime experience rather
than stored beside it, so a hand-edited save cannot produce a level 12 character
with a level 3's experience. Experience is shared undivided across everyone
alive in the instance -- splitting it would make bringing a friend cost you
progress. A level-up heals by what it added, so gaining one mid-fight is relief
rather than a bar that moved further from full.

Death is permanent and unbinds the character entirely: no "return to the hub as
the character who just died", because the run is over. The record is retired,
never deleted. The five-character cap counts LIVING characters only -- counting
the dead would lock a player out of their own account after five deaths.

Verified by tools/diag_progression.tscn, which drives the real server through
kill -> xp -> level -> health and death -> retire -> roster. The bot smoke test
cannot cover that: bots are poor shots and rarely kill anything. Writing it
caught two real ordering bugs -- the death event was dispatched before the
payload that tells the player they died, and the dead character stayed bound to
the peer.

Also added --account and --store so several clients and test runs can coexist
on one machine. The smoke test now uses a scratch store; without it a rerun
resumed the previous run's characters and "a character was created" quietly
stopped being true.

193 tests. check.sh, test.sh, smoke.sh, diag_progression and diag_prediction
all pass.
2026-09-04 00:44:34 +02:00
claude ff5e527ad4 Fix black wall tops, pillar art, player anchor, and whole-sheet bullets
ci / verify (push) Successful in 46s
Bullets rendered as the entire sprite sheet, which was the clue that named the
cause: a MultiMeshInstance2D cannot show a sub-region of a texture. Its mesh UVs
run 0..1 across whatever texture it is handed, and an AtlasTexture does not
help -- the draw resolves to the underlying atlas and the region is discarded.
Frames are now pre-sliced into 32 standalone 16x16 textures at startup, so each
frame is one texture assignment per kind and no region is involved. My previous
two attempts fixed the atlas contents while the renderer was structurally
incapable of showing one cell of it.

Black voids above walls: wall_top_mid has 12 of its 16 rows transparent. It is a
cap meant to be laid over a wall, not a tile, and drawn alone it was three
quarters nothing. Floor is now drawn beneath every tile so transparency shows
ground rather than clear colour, the wall face is drawn from the opaque
wall_mid, and the cap is overlaid only where open floor lies above.

Pillars used a wall segment and read as stray blocks; they now use the column
tile, which looks like something you walk around.

The player hitbox appeared at the head because it was: the knight occupies rows
8..27 of a 28-row cell, so anchoring on the cell centre put the draw origin
about 4px above the body's centre. Added a per-sprite anchor offset that cancels
it, with a test that recomputes the offset from the art so a re-cut sheet fails
rather than silently misplacing the character.

Tests pin the two tileset facts that caused this: the cap is mostly transparent
(so it may never be drawn alone) and the wall face is fully opaque (so it may).

166 tests. check.sh, test.sh and smoke.sh pass.
2026-09-04 00:22:01 +02:00
claude 801f328093 Fix idle/run animation flicker and bullet cell bleed; add F1 hitbox overlay
ci / verify (push) Successful in 46s
The ship looked like it played both animations at once because it effectively
did. is_moving() read the tail of `pending`, which _reconcile drains on every
snapshot -- so it returned false about 20 times a second and the sprite
alternated between the run and idle strips. Now reads the last sampled input,
which survives the queue being emptied.

Bullets were still wrong for a reason my earlier ASCII check could not see: I
had dumped alpha only, and this pack animates as a colour shimmer over a fixed
silhouette, so identical-looking frames told me nothing. The actual defect was
that several sprites overflow their 16px cell and bleed into the neighbour
below -- the first "heavy" I picked dragged in a solid slice of the sprite
above it. Cells are re-picked to ones with empty borders in every frame, and
build_local_assets.py now asserts that rather than trusting the choice, so a
future pick that bleeds fails at build time instead of looking like a
rendering bug.

Added a hitbox overlay on F1 (src/view/debug_draw.gd). It draws what the
simulation actually collides against over what is drawn: player hitbox against
sprite radius, the muzzle point, enemy and boss radii, aggro rings, every live
bullet's radius, and terrain outlined by which of the three flags each tile
sets -- so a pit that stops feet but not bullets looks different from a wall.
It deliberately ignores fog, since hiding half the evidence would defeat the
point, and it reads every number from SimConfig/MapGrid/Content rather than
keeping its own copies, or it would just confirm its own mistakes.

Nearly every "that looked wrong" report in this project has been art and
simulation disagreeing, and each took a round trip to diagnose. This makes that
class of bug visible directly.

163 tests. check.sh, test.sh and smoke.sh pass.
2026-09-04 00:12:37 +02:00
claude afe76c22ce Fix bullet atlas, animation speed, sprite flip, fire rate, silent shots
ci / verify (push) Successful in 46s
Bullet sprites were wrong because I read the pack's layout backwards. Each of
its 8 PNGs is one ANIMATION FRAME, and a column within a file is a COLOUR
variant -- so walking columns cycled the palette while the shape sat still, and
the rows I picked were in a region of larger multi-cell sprites, which is the
scattered debris that showed on screen. tools/build_local_assets.py now composes
a proper atlas: 8 frames across, one row per SimConfig.KIND_*.

Animations ran at whatever the machine's framerate was, because they counted
_process calls. At 240fps that is four times too fast on its own, before the
requested halving. Both actor and bullet animation are now driven by elapsed
seconds, so they look the same on any machine.

The knight shifted sideways instead of mirroring: a Rect2 with negative width
does not flip, the draw call normalises it, so the sprite kept its orientation
and jumped right by its own width. Mirrored through the canvas transform about
the sprite's centre instead.

Shots were silent when a bullet spawned inside an enemy. The sound rode on
BULLET_SPAWN, which is deliberately suppressed for a bullet resolved and removed
within the same tick -- so no event, no sound, and this got more likely the
closer you stood. Firing is now its own event (PLAYER_FIRED): the shot happened
whether or not a bullet survived to be replicated. Protocol 3 -> 4, because
inserting mid-enum shifts the wire value of every event after it.

Fire cooldown 7 -> 14 ticks (4.3 shots/sec).

Licence question 2, confirmed verbatim: the paid tier DOES grant commercial use
("You may use these assets in personal, commercial or non-commercial
projects"), but its next sentence still forbids redistribution "no matter how
much you modify it". Paying moves these from local-only-and-non-commercial to
local-only-and-commercial; it never makes them committable. Only replacing them
with permissively licensed art does that.

Question 3: CREDITS.md now credits every pack, including the two whose licences
do not require it and the unused ones in considering_dont_use_yet/.

158 tests. check.sh, test.sh and smoke.sh pass.
2026-09-04 00:03:04 +02:00
claude 3586555aea Remove non-redistributable art from the repo; record licence terms
Researched the licence at each of the four itch.io sources. Two of the four
forbid redistribution, and publishing a repository containing them is exactly
that, so they are out of the tree.

  0x72 DungeonTileset II    CC0 1.0    commercial ok, no credit required
  Helton Yan Pixel Combat   CC BY 4.0  commercial ok, CREDIT REQUIRED
  Fire Pixel Bullet 16x16   custom     non-commercial, NO REDISTRIBUTION
  750+ Effect and FX Pixel  custom     non-commercial, NO REDISTRIBUTION

Both bdragon1727 packs state verbatim: "You cannot do: Resell / redistribute
this asset." The FX pack's paid tier is no better for this purpose -- "You can
NOT re-distribute the file, no matter how much you modify it" -- so paying does
not unlock it and neither does editing the art.

bullets.png and impact.png therefore move to assets/local/, gitignored.
Art.bullets_texture() / impact_texture() load them at runtime and return null
when absent; the bullet renderer falls back to the generated dot it used before,
tinted per kind so bullet types stay distinguishable. Nothing there may be
preload()ed -- preload resolves at compile time and would fail the build on
every machine without the files, which is every clone.

Verified both ways rather than assumed: with the files present the game uses
them and no fallback triggers; with them moved aside, 156/156 tests pass and the
game runs clean on the generated dots.

Added CREDITS.md, because Helton Yan's CC BY 4.0 makes attribution a licence
obligation rather than a courtesy. A file in the source tree does not discharge
it once the game ships -- an in-game credits screen is now a tracked todo, and
flagged as a licence requirement rather than polish.

NOT addressed here, and it needs a decision: these files remain in git history
at 9fa2e26. Removing them from the tip does not remove them from a clone. If
this repo is ever made public, history has to be rewritten (git filter-repo)
and force-pushed. Also, both bdragon packs are non-commercial in their free
tier, which conflicts with the stated Steam goal -- either contribute for the
commercial tier (still no redistribution) or replace with CC0 art, which would
solve both problems and let the assets back into the repo.
2026-09-03 23:52:41 +02:00
claude 9fa2e260f2 Replace placeholder rendering with real sprites and audio
Terrain, actors, boss and bullets are sprites now, and four sounds play off
server events. 548KB total across seven files, out of 3.9GB of source packs.

- src/view/art.gd holds every atlas rect and sound path in one table, copied
  from the 0x72 pack's own tile_list. Scattering coordinates through draw calls
  would make re-cutting the atlas a hunt, and a wrong rect invisible.
- Bullets get one MultiMesh per kind rather than one overall: a
  MultiMeshInstance2D carries a single texture and each kind needs a different
  region of the sheet. Four draw calls, no custom shader passing UVs through
  per-instance data.
- src/view/sfx.gd is a 16-voice round-robin pool. Bullet-hell fire rates mean
  sounds overlap constantly, and identical sounds landing within two frames are
  collapsed so a ring hitting eight bullets is a bang rather than clipping.
  Every sound is triggered by a server event, never a local guess, so what you
  hear matches what happened.

Audio was converted, not copied: the pack ships 24-bit/96kHz masters averaging
2MB. Downsampled to 16-bit/44.1kHz mono, silence-trimmed, and the shoot sound
hard-capped to 0.30s -- it came out at 2.03s, against a fire cooldown of 0.12s,
which would have smeared held fire into noise.

tests/unit/test_art.gd checks what cannot be eyeballed here: every atlas rect
lands inside its texture, every animation frame of a strip fits (the last frame
is what runs off the sheet, not the first), there is a sprite per bullet kind
and per enemy visual, and no sound is long enough to stack badly. A wrong atlas
coordinate does not error -- it silently draws the wrong pixels.

Licence sources recorded in docs/ASSETS.md now that they are known. All four
are free versions and four rows still say "confirm at source": free itch packs
vary on credit and commercial use, and there is no credits screen yet.

155 tests (was 146). check.sh, test.sh and smoke.sh pass.
2026-09-03 23:43:13 +02:00
claude 7ee6c8e761 Close Stage 1: per-peer actor interest, boss room confinement
ci / verify (push) Successful in 46s
The gap Stage 1 left open: snapshots were encoded once and broadcast to every
peer in an instance, so clients were handed enemies the fog then hid. Fog is a
rendering rule -- a modified client draws whatever it holds -- so that was no
defence at all. Snapshots are now encoded per peer, and actors beyond
ACTOR_INTEREST_RADIUS (800u) are never sent. Measured cost of four filtered
encodes against one shared: 95.6us vs 24.9us, or 0.032 ms/tick amortised over
the snapshot interval.

Two asymmetries worth keeping:

- The observer's own player record is never filtered, however far out the
  arithmetic puts it. The client reconciles its prediction against that record,
  so dropping it breaks the player's own movement rather than hiding someone.
- Only bullet SPAWNS are filtered, never despawns. A client told about a bullet
  must always be told it died, or it keeps a phantom.

Bullet spawns use a much wider radius (2200u) than actors, deliberately. An
enemy appearing at the edge of sight is cosmetic; a bullet withheld at spawn
that later flies into view is invisible damage. The floor is longest bullet
travel + fog radius -- 1500 for the Warden's Collapse snipe -- and
test_interest.gd recomputes that from live content, so adding a faster or
longer-lived bullet fails a test instead of producing bullets that wink into
existence.

Also clamped bosses to their room. A no-op today since every boss is
stationary, which is exactly when the invariant is cheap to establish: boss
rooms have no door that locks, so the only thing keeping a fight in the boss
room is the boss, and Stage 5's movement work would otherwise break it quietly.

146 tests (was 137). check.sh, test.sh and smoke.sh pass. Stage 1 has no
partials left; docs/ROADMAP.md now records the three interest radii and the
floor each must respect.
2026-09-03 23:33:32 +02:00
claude 20dd6bc502 Keep asset packs out of git and out of Godot's importer
ci / verify (push) Successful in 46s
The packs total 3.9GB, almost all of it Helton Yan's SFX shipped as 24-bit /
96kHz studio masters -- ~2MB for a single gunshot. Two guards, because they
solve different problems:

- .gitignore excludes the contents. The directory and its guard files stay
  tracked, so a fresh clone has the .gdignore in place before anyone drops
  packs back in.
- assetpacks/.gdignore stops Godot descending into the tree at all. This is the
  urgent half: tools/check.sh runs --import, which walks all of res://, so the
  next check would have imported several thousand PNGs and 2,100 of those WAVs,
  and every run after it. Verified by controlled test rather than assumed -- an
  unguarded PNG under res:// imports and gets a .import file beside it; with
  .gdignore neither happens. check.sh stays at 3.3s and .godot/imported at
  1.2MB.

The trade the .gdignore buys is that nothing in assetpacks/ can be referenced
from a scene or script. That is the intended shape: raw source on one side,
game-ready files copied into res://assets/ on the other, so adding a huge pack
costs the repo and the import step nothing.

docs/ASSETS.md records attribution, because gitignoring the packs also
gitignores their licence files. Worth flagging: four of the six packs shipped
with no licence text at all, and two of the undecided ones are demo versions,
which are often more restricted than the paid packs. Only RF Catacombs has
explicit terms (public domain, no resale). Those rows need a source URL before
anything ships.

Also removed __MACOSX and .DS_Store extraction artefacts, and left
considering_dont_use_yet/ untouched as the user's own "not chosen" marker.
2026-09-03 23:24:54 +02:00
claude de48afcbd9 Fix aiming under a scrolling camera; add status and decisions docs
ci / verify (push) Successful in 46s
The aim bug was collateral from the camera work. "Mouse relative to the centre
of the screen" WAS the cursor's world position while the world was drawn fixed
at the origin, so subtracting the player position gave the right vector. Once
the camera scrolled, that expression became the aim vector itself, and
subtracting the player position again made the ship aim at a fixed world
location -- walking around swung the crosshair with the mouse held still.

Fixed by inverting the transform the view actually draws with (world = screen -
world_view.position, published by the game scene each frame) rather than
assuming the player is centred, so it still holds if the camera later clamps at
map edges or gets shake or look-ahead. tests/unit/test_aim.gd pins it, including
the regression directly: moving the player must not move the crosshair.

Documentation, for other sessions picking this up cold:
- docs/ROADMAP.md rewritten as the status map -- every feature in the brief
  against its state and the file implementing it, the known gaps called out
  (actor interest management is the notable one), and the ten design questions
  that are genuinely unspecified and should not be guessed at.
- docs/DECISIONS.md, new: settled decisions with their reasoning, so a session
  does not re-litigate or re-ask. Several are not the obvious default -- no
  i-frames, no contact damage, non-interruptible escape, and never sending the
  map seed.
- CLAUDE.md and README point at both.

137 tests; check.sh, test.sh and smoke.sh pass.
2026-09-03 20:58:03 +02:00
claude 7af439341d Stage 1: tile maps, walls, fog of war, aggro, scrolling camera
ci / verify (push) Successful in 46s
Replaces the fixed centred arena with per-world tile geometry, which is the
foundation the remaining features sit on.

- MapGrid: tile grid with three independent flags -- blocks movement, bullets,
  sight -- so a pit stops feet but not bullets or eyes, and a barricade stops
  feet and bullets but not eyes. Circle collision with per-axis sliding, and
  Bresenham line of sight shared by fog and aggro.
- MapGen: rooms and corridors generated from (seed, depth), with hand-authored
  boss arenas from Rooms stamped in first so a corridor can never carve through
  a designed fight. Reachability from spawn to boss is asserted over 40 seeds --
  "usually connected" is the failure mode that ruins one run in twenty.
- Dungeons are populated at creation, per room, instead of gating on waves. You
  explore and choose your fights; the run ends when the boss dies, not when the
  map is swept. Boss rooms have no lock, so walking out is always available.
- Aggro: enemies need range AND line of sight, so a dungeon stays quiet until
  engaged and cover actually protects.
- Hard fog, scrolling camera, and terrain rendering.

Maps are streamed per peer in chunks around that peer's player, and the seed is
deliberately NOT sent -- a client holding it could regenerate the whole dungeon,
which is a map hack for free. Stream radius (900u) is wider than view radius
(460u) because the client predicts movement against walls and simulates bullets
that die on them; the accepted cost is a cheater seeing a little further than
the fog, never the floor plan.

Partial map knowledge means wall deaths must be announced rather than derived.
A test caught the subtle half of that: out-of-bounds tiles read as WALL by
design, so checking geometry before bounds reported every bullet leaving the map
as a wall kill.

128 tests (was 103); check.sh, test.sh and smoke.sh pass. 0.26 ms/tick with 4
players and a boss, ~65x headroom.
2026-09-03 20:49:27 +02:00
claude e1f9fa8096 Unlock menu after failed join; remove contact damage; cover clean disconnects
ci / verify (push) Successful in 45s
A failed connection left the connect buttons disabled forever. _show_menu()
returned early when a menu already existed, so the set_busy(false) that
re-enables them never ran on the way back from _abort_connect -- the player was
locked out of both joining and hosting with no way out but a restart. Also
guards against a second attempt stacking on an in-flight one.

Contact damage is gone as a concept: nothing in this game hurts you by touching
it, because every threat should be a bullet you can see and dodge. The Stalker
walked at you and dealt contact damage and nothing else, so it now carries a
point-blank shotgun instead -- five pellets, 62 degrees, 18-tick lifetime for
about 78px of reach, so it still has to close and leaves nothing lingering.
tests/unit/test_content.gd asserts every hostile enemy has an emitter, so a new
enemy cannot quietly reintroduce the mechanic.

The menu's "Disconnect" DOES get the anti-cheese protection -- verified, not
assumed. It calls Net.shutdown(), the socket closes, and the server takes the
same linkdead path as a SIGKILL, because the channel is keyed on the socket
closing rather than on how it closed. Made permanent: a --leave-after bot flag
and two smoke assertions covering the polite exit alongside the hard kill, so a
future "clean leave" message that skipped the channel would fail a test. The
menu now says so out loud in a dungeon -- the mechanic only reads as fair if
the cost is known before clicking.

103 tests, 12 smoke assertions; check.sh, test.sh and smoke.sh all pass.
2026-09-03 19:37:26 +02:00
237 changed files with 20321 additions and 755 deletions

No files matched your search

+17
View File
@@ -1,5 +1,22 @@
# Godot's import cache and editor state. Regenerated by `godot --import`.
.godot/
# Assets whose licence forbids redistribution. bdragon1727's packs say
# plainly: "You cannot do: Resell / redistribute this asset." Publishing a repo
# containing them IS redistribution, so they stay local-only. The game loads
# them if present and falls back to generated placeholders if not, so a clone
# without them still runs. See docs/ASSETS.md.
/assets/local/*
!/assets/local/README.md
# Raw asset packs: gigabytes of source artwork and audio. Redistributable
# third-party material belongs where it was bought or downloaded, not in a code
# repo. The directory itself and its guards stay tracked, so a fresh clone has
# the .gdignore in place before anyone drops packs back in -- without it the
# next `tools/check.sh` tries to import the lot.
/assetpacks/*
!/assetpacks/.gdignore
!/assetpacks/README.md
# Export artefacts.
build/
*.pck
+175 -15
View File
@@ -3,17 +3,51 @@
Top-down twin-stick bullet-hell with a dedicated, server-authoritative backend.
Godot 4.7, GDScript only. One executable is both server and client.
**Starting a new session?** [docs/ROADMAP.md](docs/ROADMAP.md) says what is
built, what is next, and which file implements each feature.
[docs/DECISIONS.md](docs/DECISIONS.md) records what the user has already decided
and why — read it before asking a design question, several answers there are not
the obvious default.
## Commands
```bash
tools/check.sh # parse-check every script (~5s) -- run after every edit
tools/test.sh # GUT suite, headless (~2s)
tools/smoke.sh # real server + 2 bot clients over ENet (~35s)
tools/bench.gd # godot --headless --path . --script tools/bench.gd
tools/test.sh # 576 GUT tests, headless (~4s)
tools/smoke.sh # real server + 5 bot clients over ENet (~40s)
tools/server.sh # dedicated server
tools/client.sh --join --name ada
tools/client.sh --listen # host and play, no menu
```
Diagnostics. Each runs as a *scene* (they need the `Net` autoload) and exits
non-zero on failure, so they gate like tests:
```bash
godot --headless --path . res://tools/diag_prediction.tscn # prediction gap; injects clock drift
godot --headless --path . res://tools/diag_progression.tscn # kill -> xp -> level -> death -> roster
godot --headless --path . res://tools/diag_loot.tscn # drop -> pick up -> persist -> use -> drop
godot --headless --path . res://tools/diag_upgrades.tscn # level -> choice -> taken at the NPC -> new stats
godot --headless --path . --script tools/bench.gd # sim cost per tick
python3 tools/build_local_assets.py # rebuild the local-only bullet atlas
SHOT_DIR=/tmp/shots godot --path . res://tools/screenshot.tscn # every screen, the fog, a staged boss fight (needs a display; own store and account)
```
`diag_progression`, `diag_loot` and `diag_upgrades` exist because the bot smoke
test cannot cover any of them: bots are poor shots, so they rarely kill
anything, which means they neither earn levels, produce drops, nor ever reach
the quartermaster.
**Every key the game names to the player goes through `Settings.short_label()`.**
All of them are rebindable, and a prompt that names the default is wrong for
anyone who changed it — the HUD said "LMB fire" and the upgrade screen said
"Close (E)" whatever those were bound to.
**Nothing automated can see the screen.** `check.sh`, the suite and `smoke.sh`
have all passed with the entire interface rendering unstyled, an invisible
slider and an invisible scrollbar. After touching anything under `src/ui/` or
`src/view/`, run `screenshot.tscn` and open the files. It needs a display, so it
is not a gate.
Everything after `--` goes to `GameOpts.parse()`:
| Flag | Effect |
@@ -22,9 +56,13 @@ Everything after `--` goes to `GameOpts.parse()`:
| `--join` | Skip the menu and connect. |
| `--listen` | Skip the menu and host a listen server with a local player. |
| `--bot` | Scripted input instead of the keyboard. Implies `--join`. |
| `--host`, `--port`, `--name` | Connection details. |
| `--host`, `--port` | Connection details. |
| `--name` | Bots only: what a bot calls the character it creates. People name characters on the roster screen; the connect screen no longer asks. |
| `--autoquit N` | Quit after N physics ticks. |
| `--boss-rush` | Server-side: new dungeons open straight onto the boss. |
| `--boss-rush` | Server-side: dungeons spawn the boss and no trash. |
| `--depth N` | Server-side: depth of new dungeons, which drives map size. |
| `--account N` | Client-side: override the local account, so several clients can coexist on one machine. |
| `--store PATH` | Server-side: character store location. Use a scratch path in tests. |
| `--verbose` / `--quiet` | Log level. |
A change is done when `check.sh`, `test.sh` and — if it touched networking,
@@ -43,11 +81,28 @@ push (~35s, skip deliberately with `SKIP_SMOKE_HOOK=1 git push`).
**The server decides everything; the client only sends intent.**
A client can send exactly two things: an [InputFrame] (move vector, aim angle,
three button bits) and a handshake. There is no message for "I moved here", "I
hit that", "I took damage" or "my escape finished". Adding one would collapse
the whole security model, so don't — validate-after-the-fact is strictly weaker
than having no code path at all.
There are exactly **five** client -> server messages, and that number is worth
watching:
| Message | Carries |
| --- | --- |
| `c_hello` | protocol version + an opaque auth ticket |
| `c_input` | an [InputFrame]: move vector, aim angle, five button bits, an inventory slot |
| `c_select_character` | a character id, checked against *that account's* list |
| `c_create_character` | a name, sanitised at the boundary |
| `c_choose_upgrade` | an index into the three options the SERVER put on the table |
Every one is pure intent. There is no message for "I moved here", "I hit that",
"I took damage", "my escape finished", "I now own this item", "I am in this
dungeon" or "my damage is X". Adding one would collapse the whole security
model, so don't — validate-after-the-fact is strictly weaker than having no code
path at all.
When a new player action needs a message, look at whether it fits in the input
frame first. Item use and drop did, and got the redundancy, the replay guard and
the per-tick rate limit for free. The cost was one rule: anything in the input
frame that must not repeat has to be **edge-triggered** (see `prev_buttons`),
because frames are resent and a starved server coasts on the last one it holds.
`SimWorld.authoritative` is `true` on the server and `false` on the client. In
replica mode the world runs no AI, fires no emitters and resolves no hits; it
@@ -59,11 +114,22 @@ and `tests/integration/test_replica_parity.gd` pin this down.
| Path | What lives there |
| --- | --- |
| `src/sim/` | The whole game as plain RefCounted objects. No nodes, no physics server, no rendering. |
| `src/sim/patterns/` | Bullet emitters — the authoring surface for every enemy and boss. |
| `src/actors/` | Data-only `Resource` definitions: `EnemyDef`, `BossDef`, `ItemDef`, `LootDrop`, `DungeonDef`, `UpgradeDef`. Shapes, not instances. |
| `src/sim/patterns/` | Bullet emitters — the authoring surface for every enemy and boss. Emitters are stateless: they are shared resources, and two bosses of the same kind must not stomp each other. |
| `src/sim/map_grid.gd` | Tile grid: collision, line of sight, chunk streaming. |
| `src/sim/map_gen.gd` | Dungeon generation. **Server-side only** — handing a client the seed would be a map hack with no work required. |
| `src/content/rooms.gd` | Hand-authored room stamps (hub, boss arenas) as text. |
| `src/meta/` | Accounts, characters, persistence, XP curve. Server-owned. |
| `src/content/content.gd` | All enemies and bosses, defined in code. Source of truth. |
| `src/content/items.gd` | All items, same idea. `Items.ORDER` is the wire format — append only. |
| `src/content/dungeons.gd` | The kinds of run. `Dungeons.ORDER` is both a wire format and the hub's portal order. |
| `src/content/upgrades.gd` | The seven upgrades and their draw weights. `Upgrades.ORDER` is a wire format. |
| `src/net/` | Codec, `ServerRuntime`, `ClientRuntime`. |
| `src/instances/` | Lobby hub and dungeon runs. |
| `src/view/`, `src/ui/` | Read-only rendering. Never decides anything. |
| `src/core/settings.gd` | Client-local preferences: key bindings and volumes. Never reaches the server. |
| `src/core/credits.gd` | Every third-party asset and its licence. Two are CC BY, so this is a legal requirement, not a nicety. |
| `src/view/ui_theme.gd` | The control theme, built in code from the UI pack. |
| `src/autoload/net.gd` | The only autoload. RPC surface. |
| `tools/` | Headless tooling. |
@@ -96,7 +162,21 @@ ticks in milliseconds with no SceneTree.
coordinate even on an anchored control; `offset_left`/`offset_top` are the
anchor-relative ones. This combination silently broke the main menu and
three pieces of the HUD.
6. **`ProjectSettings.save()` drops settings equal to the engine default** and
6. **A Control's theme is inherited from Control ANCESTORS only.** The chain
breaks at the first parent that is a plain `Node` or a `CanvasLayer`, which
here is every screen: they hang off `main.gd` or off a CanvasLayer. Setting
`get_window().theme` therefore compiles, runs, changes the property — and
styles nothing. Apply the theme to each screen's own root Control
(`UiTheme.themed_root()`). `tests/unit/test_ui_theme.gd` instantiates every
screen and asks what its buttons actually resolve, because this failure is
invisible to every other kind of check.
7. **A themed `Slider` or `ScrollBar` takes its THICKNESS from the stylebox's
minimum size**, which for a `StyleBoxTexture` is its content margins. Style
one with margins of zero and it resolves correctly, reports the right
texture, and draws a groove zero pixels tall — indistinguishable from having
no theme at all. Both happened. `tests/unit/test_ui_theme.gd` asserts every
slider and scrollbar stylebox has a non-zero minimum.
8. **`ProjectSettings.save()` drops settings equal to the engine default** and
strips comments. Anything load-bearing (the 60 Hz tick) is asserted in code
in `src/main.gd` instead of trusted to `project.godot`.
@@ -116,6 +196,81 @@ ticks in milliseconds with no SceneTree.
dead zone where the server rejects everything and the client never notices —
the ship and the authoritative position separate permanently. Pinned by
`tests/unit/test_input_lead.gd`.
- **Never send the map, or its seed.** Geometry is streamed per peer in chunks
around that peer's player (`ServerRuntime._stream_map`). The seed would let
any client regenerate the whole dungeon. `MAP_STREAM_RADIUS` must stay wider
than `FOG_VIEW_RADIUS`, or prediction runs on terrain the client lacks.
- **Bullet speed must stay under one tile per tick.** Wall collision samples
position once per tick, so anything faster tunnels. Upgrades multiply bullet
speed, so `SimConfig.MAX_BULLET_SPEED` clamps the result — without it two
Snipers put shots through walls. Pinned by
`test_bullet_speeds_stay_below_the_tunnelling_threshold`.
- **A character becomes a live player only in `ServerRuntime._adopt_character`,
from `_place`.** `SimWorld` knows nothing about characters and builds a blank
player on every instance change, so any new path that puts someone in a world
must go through `_place` or it hands them a level-1 body with an empty bag.
Current health comes from the record too (`Character.hp`), and is banked by
`_bank_health` wherever a player leaves a world — miss one and that door
becomes a free heal.
- **One account, one session.** `on_hello` refuses a peer whose account is
already playing; two live players on one character duplicate items.
- **Anything that calls `Net.host()` from a tool sets `GameOpts.store_path` and
`GameOpts.account_override` first.** The default is the player's real store and
account; `screenshot.tscn` once played their character into dungeons.
- **A player's combat numbers are derived, never stored.** `PlayerStats.build()`
recomputes them from the character's upgrade ids every time, so a saved stat
cannot disagree with the upgrades that produced it. Upgrade *riders* (split
charges, poison, erase chance) travel on the bullet instead, because a shot
in flight must keep what it was fired with.
- **Only `ServerRuntime` writes progression and persistence.** The simulation
reads a player's level and max health, and moves items between the ground and
a bag; it never grants experience, retires a character, or touches the store.
It announces what happened and `ServerRuntime` banks it. One writer means a
level can never disagree with the experience that earned it, and an inventory
on disk can never disagree with the one in the world.
- **`Items.ORDER` is a wire format.** An item's index in it is the byte that
rides the snapshot and every item event. Append, never reorder — reordering
makes every existing client decode a potion as a ration, so it needs a
`Protocol.VERSION` bump. `Dungeons.ORDER` is the same, and additionally
decides which hub portal opens which dungeon (Nth `P` marker in the lobby
stamp → Nth entry). A dungeon names the boss arena it ends in, so adding a
boss means giving it a dungeon or nobody will ever meet it.
- **Which dungeon you enter comes from where you are standing, never from the
client.** `SimWorld.portal_at()` resolves the player's server-side position
to a portal, and the `PORTAL_USED` event carries the answer. There is no
message that names a dungeon, and adding one would let any client pick the
easy variant's loot rate.
- **Loot has two visibilities, and the instanced one is enforced in the codec.**
`NetCodec.encode_snapshot` filters items owned by another peer, exactly like
the actor interest radius. Never move that check into the client: hiding an
entity the client was handed defends nothing.
- **`LocalAuthProvider` is insecure on purpose.** Any client can claim any
account. It exists to have the same shape as Steamworks (opaque ticket in,
64-bit account id out) so swapping is one class. Do not ship it.
- **No contact damage.** Every enemy threatens through bullets only; touching
one is harmless. `tests/unit/test_content.gd` enforces that every hostile has
an emitter.
- **A boss never leaves its arena.** `SimWorld._step_boss` clamps to
`SimBoss.room` *after* movement. Boss rooms deliberately do not lock, so
walking out is always an escape — which only holds if the boss cannot follow.
- **Every actor the client draws is interpolated between snapshots.** Players,
enemies and the boss all go through the same lerp. The boss did not for a
long time, which is invisible while bosses stand still and looks broken the
moment one moves.
- **The fog works by draw order, not by deciding per object.** `WorldView._draw`
draws terrain and every actor within sight range, then the darkness from
`Visibility` over them, then whatever must stay visible. That is what lets a
tile or an enemy be half lit. Anything new that fog may hide goes *before*
`_draw_darkness`; anything it must not goes after.
- **No enemy bullet may land without having been drawn first.** Bullets fade
beyond the edge of sight, but `BulletRenderer.visibility()` stretches each
enemy bullet's draw distance with its speed so it is always fully on screen
`WARNING_TIME` before it could reach you. A faster bullet in new content is
covered automatically; `test_bullet_draw.gd` fails if it is not. Never hide
bullets by shadow alone — you can walk round a corner into one.
- **A telegraph must be visible through fog.** `WorldView._draw_telegraphs`
is drawn after the darkness on purpose; everything else in the view is under it. A
warning you cannot see is an unavoidable hit with extra steps.
- **No i-frames.** Every bullet that touches a player lands; `spawn_grace` is
the only invulnerable state. Do not reintroduce post-hit immunity — it makes
dense patterns safer than sparse ones.
@@ -128,8 +283,13 @@ ticks in milliseconds with no SceneTree.
A new enemy or boss is data, never code. Add a builder to
`src/content/content.gd` returning an `EnemyDef` / `BossDef` made of the
emitters in `src/sim/patterns/`, register its id in `enemy()` / `boss()`, and
add a test. `tests/unit/test_boss.gd::test_a_brand_new_boss_needs_no_engine_changes`
emitters in `src/sim/patterns/`, register its id in `enemy()` / `boss()` **and
in `ALL_ENEMIES` / `ALL_BOSSES`** (the export tool, the renderer and the tests
all iterate those), and add a test.
A boss phase can move — `BossPhase.Move` is `STATIC`, `ORBIT`, `CHASE` or
`WAYPOINTS`, handled generically in `SimWorld._move_boss`. Movement is a
property of the phase, not of the boss. `tests/unit/test_boss.gd::test_a_brand_new_boss_needs_no_engine_changes`
builds a boss from scratch and asserts the simulation needs no changes to run
it — if you find yourself adding a per-boss branch to `SimWorld`, stop and add
an emitter type instead.
+63
View File
@@ -0,0 +1,63 @@
# Credits
Every third-party asset pack used or bundled during development, credited
regardless of whether its licence requires it. Some of them do not require it —
they are here because the work deserves it.
**This file is not the only place credit appears.** Two of the packs are CC BY
4.0, which asks for attribution "in any reasonable manner", and a markdown file
in a source repository is not reasonable for someone who downloaded a build. The
game carries the same list on its own credits screen (Settings → Credits), built
from `src/core/credits.gd`; `tests/unit/test_credits.gd` asserts the two agree.
## In the game
**16x16 DungeonTileset II** — [0x72 (Robert Norenberg)](https://0x72.itch.io/dungeontileset-ii)
Terrain, player, enemies and boss sprites.
[CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) — public domain.
*Credit not required.*
**Complete UI Essential Pack** — [Crusenho Agus Hennihuno](https://crusenho.itch.io/complete-ui-essential-pack)
Buttons, panels, bars and inventory slots. Used unmodified; a subset of the
pack's individual sprites is committed under `assets/sprites/ui/`.
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
**Credit required by licence.**
**Pixel Combat SFX** — [Helton Yan](https://heltonyan.itch.io/pixelcombat)
All sound effects. Converted to 16-bit / 44.1 kHz mono and trimmed.
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
**Credit required by licence.**
**Fire Pixel Bullet 16x16** — [bdragon1727](https://bdragon1727.itch.io/fire-pixel-bullet-16x16)
Bullet sprites. Custom licence; *credit encouraged, not required.*
Not distributed with this repository — see below.
**750+ Effect and FX Pixel All** — [bdragon1727](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all)
Impact effect artwork. Custom licence; *credit encouraged, not required.*
Not distributed with this repository — see below.
## Present during development, not used in the game
Kept in the untracked `assetpacks/considering_dont_use_yet/` and referenced by
nothing. Credited for completeness.
**RF Catacombs v1.0** — Szadi art. Public domain per the pack's bundled
`public-license.txt`: free for personal or commercial use, credit appreciated
but not required, no reselling of the pack itself.
**Tiny RPG Character Asset Pack 02** and **neo_zero** (v1.0 and 3.0 demo) —
creators not recorded in the packs themselves. If either is ever used, record
the source and terms here first.
## Why two packs are not in this repository
Both bdragon1727 packs forbid redistribution, in **every** tier:
> Free tier: "You cannot do: Resell / redistribute this asset."
>
> Paid tier: "You can NOT re-distribute the file, no matter how much you modify
> it you can use it but not share or re-sell it."
A public repository distributes what it contains, so those files live in the
untracked `assets/local/`. The game falls back to generated placeholders
without them. See [docs/ASSETS.md](docs/ASSETS.md).
+40 -24
View File
@@ -7,41 +7,57 @@ Godot 4.7, GDScript, no external runtime dependencies.
```bash
tools/server.sh # dedicated server on :27015
tools/client.sh --join --name ada # connect a client
tools/client.sh --join # connect a client; you name a character on the roster screen
```
Or run one client and press **Host and play** for a listen server.
**Controls** — WASD move, mouse aim, LMB fire, **E** on the ring in the hub to
enter a dungeon, **hold F** for three seconds to escape back to the hub.
## Develop
```bash
tools/check.sh # parse-check every script ~5s
tools/test.sh # GUT suite, headless ~2s
tools/smoke.sh # server + 2 bot clients, ENet ~35s
godot --headless --path . --script tools/bench.gd
```
Read [CLAUDE.md](CLAUDE.md) first — it is short, and the Godot CLI gotchas in it
will otherwise cost an afternoon.
**Controls** — WASD move, mouse aim, LMB fire, **E** to pick up loot and to use
the hub's portals and quartermaster, **1–4** use an inventory slot, **shift+1–4**
drop one, **hold F** for one second to escape a dungeon, **Esc** menu, **F1**
hitbox overlay. Every one of those is rebindable in Settings.
## What is here
| | |
| --- | --- |
| **Twin-stick bullet hell** | Server-simulated bullet field, ~350 concurrent bullets at 0.24 ms/tick. |
| **Authoritative multiplayer** | Clients send input only. Hits, damage, death and instance transfers are server decisions. Prediction + reconciliation for the local player. |
| **Enemies** | Four readable behaviours (static, drift, orbit, approach, strafe) composed with bullet emitters. |
| **Boss** | The Warden of the Fold: stationary, four phases, each adding one idea. Defined entirely as data. |
| **Lobby hub** | Shared persistent instance with a portal into dungeon runs. |
| **Emergency escape** | Three-second channel back to the hub, cancelled by damage. |
| **Twin-stick bullet hell** | Server-simulated bullet field, ~300 concurrent bullets at 0.28 ms/tick — around 60x headroom against the 60 Hz budget. |
| **Authoritative multiplayer** | Clients send input and nothing else. Hits, damage, death, loot, upgrades and instance transfers are all server decisions. Prediction and reconciliation for the local player. |
| **Enemies** | Five readable movements (static, drift, orbit, approach, strafe) composed with bullet emitters. Nothing deals contact damage; every threat is a bullet you can see. |
| **Two bosses** | The Warden of the Fold stands still through four phases. The Cantor of the Vault walks a circuit, chases, orbits, and marks the floor before it strikes. Both are pure data. |
| **Three dungeons** | Two real runs, one per boss, plus a Proving Grounds: the same content at a fraction of the health and ten times the drop rate, for testing by hand — and not for release. Generated rooms joined by tunnels two to four tiles wide, which now and then hold a group of enemies. |
| **Fog of war** | Lit by rays, so a shadow cuts straight through a tile and an enemy rounding a corner appears a slice at a time. Bullets fade past the edge of sight, but never so soon that one could reach you unseen. |
| **Characters** | Up to five living per account, permadeath, levels 1–15. Health carries between runs and the hub heals it. Experience is shared, undivided, among the players near enough to have seen the kill. |
| **Inventory and loot** | Four slots, always on screen. Loot is either world-shared or instanced per player, and the instanced kind is filtered on the wire rather than hidden in the client. |
| **Upgrades** | Seven, drawn three at a time and spent at a hub NPC. Damage, fire rate, extra projectiles, splitting, poison, and a legendary that deletes enemy bullets. |
| **Emergency escape** | A one-second channel back to the hub that damage does **not** interrupt — because if it did, quitting the process would be the better escape. Dropping your connection runs the same channel. |
| **Settings** | Rebindable controls and volumes, and an in-game credits screen. |
## Develop
```bash
tools/check.sh # parse-check every script ~5s
tools/test.sh # 576 GUT tests, headless ~4s
tools/smoke.sh # server + 5 bot clients over ENet ~40s
```
Read [CLAUDE.md](CLAUDE.md) first — it is short, and the Godot CLI gotchas in it
will otherwise cost an afternoon.
## Docs
- [CLAUDE.md](CLAUDE.md) — commands, invariants, Godot CLI gotchas
- [docs/WORKFLOW.md](docs/WORKFLOW.md) — agent-assisted Godot development, and why the setup looks like this
- [docs/ROADMAP.md](docs/ROADMAP.md) — what is built, what is next, where each feature lives
- [docs/DECISIONS.md](docs/DECISIONS.md) — settled design decisions and their reasoning
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — code map and the node-free simulation
- [docs/NETCODE.md](docs/NETCODE.md) — replication model, hit validation, known gaps
- [docs/ROADMAP.md](docs/ROADMAP.md) — what is built and what is next
- [docs/WORKFLOW.md](docs/WORKFLOW.md) — agent-assisted Godot development, and the verification traps this project has actually hit
- [docs/ASSETS.md](docs/ASSETS.md) — asset packs and their licence findings
- [CREDITS.md](CREDITS.md) — third-party attribution
- [CLAUDE.md](CLAUDE.md) — commands, invariants, Godot CLI gotchas
## Not ready for the internet
`LocalAuthProvider` lets any client claim any account id. It exists to have the
same shape as Steamworks so swapping is one class, and it must be replaced
before this is reachable from anywhere untrusted. There is no transport
encryption either. See [docs/ROADMAP.md](docs/ROADMAP.md).
View File
Whitespace-only changes.
+32
View File
@@ -0,0 +1,32 @@
# Raw asset packs
Source material as downloaded: unedited, uncropped, and mostly unused. **Not in
git** — this directory is gigabytes, and the contents are redistributable
artwork and audio that belong wherever you bought or downloaded them, not in a
code repository. Only this README and the `.gdignore` beside it are tracked, so
a fresh clone has the guard in place before anyone drops packs back in.
## Why `.gdignore`
Godot's importer walks everything under `res://`. Without the `.gdignore` here,
`tools/check.sh` — which runs `--import` — would try to import several thousand
PNGs and 2,100 studio-master WAVs on the next run, and every run after it. The
`.gdignore` makes the engine skip this tree entirely: nothing here is imported,
nothing lands in `.godot/imported/`, and nothing reaches an export.
The cost of that is the point: **files here cannot be referenced from a scene or
script.** Godot does not know they exist.
## Getting an asset into the game
Copy the specific files you need into `res://assets/`, converted to something
game-ready first. That directory *is* imported and *is* committed, so it stays
small and every file in it is one the game actually uses.
Audio in particular needs conversion. The Helton Yan pack is 24-bit / 96 kHz
stereo — studio masters, averaging ~2 MB for a single gunshot. Ship 16-bit /
44.1 kHz mono for SFX unless something specifically needs better.
Attribution and licence status for every pack is recorded in
[docs/ASSETS.md](../docs/ASSETS.md). Keep it current — the licence files
themselves are not in git, because the packs are not.
+28
View File
@@ -0,0 +1,28 @@
# Game-ready assets
Assets the game actually references. Committed, imported by Godot, and expected
to stay small.
Everything here should already be in its final form: cropped, atlased where it
helps, audio downsampled to something sane for a game. Raw packs live in
[`assetpacks/`](../assetpacks/README.md), which Godot ignores and git does not
track.
The split exists so that adding a 3.9 GB pack of source material costs the
repository and the import step nothing, while the handful of files the game
loads stay reviewable.
What is here:
| Path | What | Source |
| --- | --- | --- |
| `sprites/dungeon_tileset.png` | terrain, characters, bosses, item icons | 0x72, CC0 |
| `sprites/ui/` | buttons, panels, bars, inventory slots | Crusenho, CC BY 4.0 |
| `audio/sfx/` | every sound the game plays | Helton Yan, CC BY 4.0 |
| `local/` | bullet and impact art — **gitignored**, licence forbids redistribution | bdragon1727 |
Sprite rects and sound paths are declared in
[src/view/art.gd](../src/view/art.gd); UI textures in
[src/view/ui_theme.gd](../src/view/ui_theme.gd). Attribution lives in
[CREDITS.md](../CREDITS.md) and on the game's own credits screen — see
[docs/ASSETS.md](../docs/ASSETS.md) for the licence findings.
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://ba050ytxjw6uf"
path="res://.godot/imported/boss_death.wav-0f78bd9c615fc1e1f69662e3e2b02b1e.sample"
[deps]
source_file="res://assets/audio/sfx/boss_death.wav"
dest_files=["res://.godot/imported/boss_death.wav-0f78bd9c615fc1e1f69662e3e2b02b1e.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://b4yijsry80ccc"
path="res://.godot/imported/enemy_death.wav-6bae1d1bbf98e070071e748aa5403ba3.sample"
[deps]
source_file="res://assets/audio/sfx/enemy_death.wav"
dest_files=["res://.godot/imported/enemy_death.wav-6bae1d1bbf98e070071e748aa5403ba3.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://c7abhuhkwrnbi"
path="res://.godot/imported/player_hit.wav-478748d8be6b3a13fa393c622448db8e.sample"
[deps]
source_file="res://assets/audio/sfx/player_hit.wav"
dest_files=["res://.godot/imported/player_hit.wav-478748d8be6b3a13fa393c622448db8e.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cgxntaxdtxyqp"
path="res://.godot/imported/shoot.wav-f3ce9b4ae60220ecd3cf78df7278a10f.sample"
[deps]
source_file="res://assets/audio/sfx/shoot.wav"
dest_files=["res://.godot/imported/shoot.wav-f3ce9b4ae60220ecd3cf78df7278a10f.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
+24
View File
@@ -0,0 +1,24 @@
# Local-only assets
Files here are **deliberately not in git**, because their licences forbid
redistribution — and publishing a repository containing them is redistribution.
Currently:
| File | Pack | Restriction |
| --- | --- | --- |
| `bullets.png` | [Fire Pixel Bullet 16x16](https://bdragon1727.itch.io/fire-pixel-bullet-16x16) | "You cannot do: Resell / redistribute this asset." Free tier is non-commercial only. |
| `impact.png` | [750 Effect and FX Pixel All](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all) | Same wording. Even the paid tier: "You can NOT re-distribute the file, no matter how much you modify it." |
The game degrades rather than breaks without them: `Art.bullets_texture()`
returns null and the renderer falls back to a generated dot. That fallback is
what a fresh clone gets, and it is why nothing here may be `preload()`ed —
`preload` is resolved at compile time and would fail the whole build on a
machine that does not have these files.
To restore them locally, copy from `assetpacks/` (also untracked):
```
cp "assetpacks/New_All_Fire_Bullet_Pixel_16x16/All_Fire_Bullet_Pixel_16x16_00.png" assets/local/bullets.png
cp "assetpacks/Effect and FX Pixel All Free/Part 1/03.png" assets/local/impact.png
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bircs55cjivvx"
path="res://.godot/imported/dungeon_tileset.png-32ddb268ab9db09e8f122db6a00ca77a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/dungeon_tileset.png"
dest_files=["res://.godot/imported/dungeon_tileset.png-32ddb268ab9db09e8f122db6a00ca77a.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://m2nhvybbia58"
path="res://.godot/imported/Banner01a.png-79d54606f6c5efa37ca262c86fb3c88b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Banner01a.png"
dest_files=["res://.godot/imported/Banner01a.png-79d54606f6c5efa37ca262c86fb3c88b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b4w7eso648lkb"
path="res://.godot/imported/Bar01a.png-4fa78ad3d34ea11ebad35ea23bc8ff70.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Bar01a.png"
dest_files=["res://.godot/imported/Bar01a.png-4fa78ad3d34ea11ebad35ea23bc8ff70.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://p6h7i0qxhoao"
path="res://.godot/imported/Bar05a.png-6dc27709fd6ce83b5c13f1aeece67a02.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Bar05a.png"
dest_files=["res://.godot/imported/Bar05a.png-6dc27709fd6ce83b5c13f1aeece67a02.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c32skror5ojq5"
path="res://.godot/imported/Bar07a.png-4832809288d6d60de539798b274f9dea.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Bar07a.png"
dest_files=["res://.godot/imported/Bar07a.png-4832809288d6d60de539798b274f9dea.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://0txc40f6u7pc"
path="res://.godot/imported/BarFill01f.png-8f813e9f1aea22470c4cd1a20dc14300.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/BarFill01f.png"
dest_files=["res://.godot/imported/BarFill01f.png-8f813e9f1aea22470c4cd1a20dc14300.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dk2bbq73k8h27"
path="res://.godot/imported/Button01a_1.png-2cddde3a12e70b9f65fa9b0cba03379d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Button01a_1.png"
dest_files=["res://.godot/imported/Button01a_1.png-2cddde3a12e70b9f65fa9b0cba03379d.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b8nsxodlmgomt"
path="res://.godot/imported/Button01a_2.png-35c4875fe9ac1c69211a75ffbcc0fd8c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Button01a_2.png"
dest_files=["res://.godot/imported/Button01a_2.png-35c4875fe9ac1c69211a75ffbcc0fd8c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cvj6idjcu0j1s"
path="res://.godot/imported/Button01a_3.png-3896dc57fbcbb9c973f680df22c8b858.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Button01a_3.png"
dest_files=["res://.godot/imported/Button01a_3.png-3896dc57fbcbb9c973f680df22c8b858.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 164 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dj0kffckj1iug"
path="res://.godot/imported/Button01a_4.png-9c12b7189c542159f43a7e003d114d95.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Button01a_4.png"
dest_files=["res://.godot/imported/Button01a_4.png-9c12b7189c542159f43a7e003d114d95.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 389 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bybgrl0ye24w0"
path="res://.godot/imported/Frame01a.png-626104f1220a56d01a83a690493b113d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Frame01a.png"
dest_files=["res://.godot/imported/Frame01a.png-626104f1220a56d01a83a690493b113d.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 368 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://y42352ti7y2t"
path="res://.godot/imported/Frame02a.png-ac57f8ba2960b573fe171292b8a7c378.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Frame02a.png"
dest_files=["res://.godot/imported/Frame02a.png-ac57f8ba2960b573fe171292b8a7c378.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cqehg1mvy1tcn"
path="res://.godot/imported/Frame03a.png-5c1b611e7b9fe641c92bf2e2fd3b816b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Frame03a.png"
dest_files=["res://.godot/imported/Frame03a.png-5c1b611e7b9fe641c92bf2e2fd3b816b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bqe250bmbxli0"
path="res://.godot/imported/FrameMarker01a.png-7ed0bebde8d986061a9a76a04179edf2.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/FrameMarker01a.png"
dest_files=["res://.godot/imported/FrameMarker01a.png-7ed0bebde8d986061a9a76a04179edf2.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://btuhdrpfr8td3"
path="res://.godot/imported/FrameSlot01a.png-069d1091d125c87d7c14afba6400418d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/FrameSlot01a.png"
dest_files=["res://.godot/imported/FrameSlot01a.png-069d1091d125c87d7c14afba6400418d.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 189 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://btf5al7f07nl"
path="res://.godot/imported/FrameSlot01b.png-bd6ad2d53ba974202493dbaf3f360ea5.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/FrameSlot01b.png"
dest_files=["res://.godot/imported/FrameSlot01b.png-bd6ad2d53ba974202493dbaf3f360ea5.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://e2u2b24f7dr4"
path="res://.godot/imported/Handle02a.png-f2f44a66c106ed5473f5b69eb2a37bd8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Handle02a.png"
dest_files=["res://.godot/imported/Handle02a.png-f2f44a66c106ed5473f5b69eb2a37bd8.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dxd0tojksos8m"
path="res://.godot/imported/Handle03a.png-22d133e9d9604a2db6d3a7746a093159.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Handle03a.png"
dest_files=["res://.godot/imported/Handle03a.png-22d133e9d9604a2db6d3a7746a093159.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 186 B

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cb41i0vnvjmri"
path="res://.godot/imported/InputField01a.png-d4cf01240254dbae255720ff161f1d6a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/InputField01a.png"
dest_files=["res://.godot/imported/InputField01a.png-d4cf01240254dbae255720ff161f1d6a.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+128 -11
View File
@@ -45,11 +45,43 @@ This project deliberately does not, for four reasons:
prototyping and wrong for this shape: it would replicate per-bullet state and
saturate the link. See [NETCODE.md](NETCODE.md).
The trade is that there is no physics engine — no arbitrary collision shapes, no
tilemap collision. The arena is a rectangle and every hit is circle-vs-circle.
For a bullet hell that is not a limitation; hitboxes in the genre are circles by
convention anyway. A dungeon with real geometry would need a static collision
representation added to `SimWorld`, not a switch back to nodes.
The trade is that there is no physics engine, so collision is hand-written.
Since Stage 1 that means a tile grid ([MapGrid](../src/sim/map_grid.gd)):
circle-vs-tile for actors, a point test for bullets, and Bresenham line of sight
for aggro. Actor-vs-actor hits stay circle-vs-circle, which is the genre
convention anyway. The client's fog no longer shares that line test: it casts
exact rays through the same grid ([Visibility](../src/view/visibility.gd)), so
shadows cut through tiles rather than snapping to them.
That was the right call rather than a compromise: the same grid answers
collision, sight, and per-peer interest management as array lookups. On arbitrary
polygons all three become intersection tests, and the fog query in particular
stops being cheap enough to run every frame.
What it costs: geometry is axis-aligned and 32px-quantised, and bullets must
travel less than one tile per tick or the point test steps over walls (pinned by
`test_bullet_speeds_stay_below_the_tunnelling_threshold`).
## Where the layers sit now
Four things are worth knowing before reading any file:
- **Geometry is per-world.** There is no global arena. `SimWorld.map` is a
[MapGrid](../src/sim/map_grid.gd); the hub and every dungeon have their own,
and the client holds a *partial* copy streamed to it in chunks.
- **`src/meta/` is server-only.** Accounts, characters, levels, experience,
inventories and upgrades live there. The simulation reads a player's level and
maximum health and moves items between the ground and a bag; it never writes
progression or touches the store. It announces what happened and
`ServerRuntime` banks it. One writer means a level cannot disagree with the
experience that earned it.
- **Derived, never stored.** Level comes from lifetime experience; combat
numbers come from the upgrade list ([PlayerStats](../src/sim/player_stats.gd));
maximum health comes from the level and the upgrades. Nothing that can be
recomputed is persisted, so nothing saved can disagree with what produced it.
- **`src/core/settings.gd` is the exception to "shared".** Everything else in
`src/core/` is agreed by both sides; settings are the player's own machine
and never reach the server.
## Tick
@@ -75,6 +107,13 @@ BossDef = stats + ordered phases
`SimWorld._run_emitters()` is shared by enemies and bosses, so any pattern can
be dropped on either. A boss is four phases layering one idea at a time; a new
boss is a new function in `src/content/content.gd` and zero simulation changes.
`SimWorld._move_boss()` is the same idea for movement: the mode is a field on
the phase, and adding a boss that walks needs no code.
Emitters are **stateless**. They are shared resources — two bosses of the same
kind would otherwise stomp each other's timers — so anything an emitter needs to
remember between two ticks has to be derived instead. `TelegraphedStrikeEmitter`
computes its strike positions from the volley number for exactly this reason.
## Entry point
@@ -87,17 +126,95 @@ after `--`:
## File map
Grouped by layer. The rule the whole thing hangs on: **`src/sim/` imports
nothing from `net`, `view` or `ui`, and never touches `Net`.**
### Shared constants and helpers
| File | Role |
| --- | --- |
| `src/core/sim_config.gd` | Every constant server and client must agree on. |
| `src/core/movement.gd` | Pure movement + overlap helpers. Shared by prediction. |
| `src/core/game_log.gd`, `game_opts.gd` | Static; usable from tools and tests. |
| `src/sim/sim_world.gd` | The simulation. Authority flag decides what runs. |
| `src/core/settings.gd` | Client-local preferences. Never reaches the server. |
| `src/core/credits.gd` | Third-party assets and their licences. Two are CC BY, so this is a legal requirement. |
### The simulation
| File | Role |
| --- | --- |
| `src/sim/sim_world.gd` | The simulation. The authority flag decides what runs. |
| `src/sim/sim_player.gd`, `sim_enemy.gd`, `sim_boss.gd` | Actor state. A player's current health is restored from its character on arrival, never reset. |
| `src/sim/sim_events.gd` | Every event type. Order is a wire format; `NetCodec.event_payload_size` must know each one's length. |
| `src/sim/bullet_pool.gd` | Struct-of-arrays bullet storage and integration. |
| `src/sim/input_frame.gd` | The only thing a client may assert about itself. |
| `src/net/net_codec.gd` | Snapshot / event / input binary codecs. |
| `src/net/server_runtime.gd` | Instances, ticking, transfers, broadcast. |
| `src/sim/player_stats.gd` | Combat numbers derived from a character's upgrades. |
| `src/sim/poison_track.gd` | Damage over time. O(1) per actor per tick however many doses. |
| `src/sim/sim_loot.gd` | An item on the ground: world-shared, or owned by one peer. |
| `src/sim/sim_portal.gd` | A dungeon entrance, and which dungeon it opens. |
| `src/sim/map_grid.gd` | Tile grid: collision, line of sight, chunked streaming. |
| `src/sim/map_gen.gd` | Dungeon generation. **Server-side only** — the client is never given the seed. |
| `src/sim/patterns/` | Bullet emitters. Stateless; the authoring surface for every fight. |
### Content (data, in code)
| File | Role |
| --- | --- |
| `src/content/content.gd` | Every enemy and boss. `ALL_ENEMIES` / `ALL_BOSSES` are what the tools and tests iterate. |
| `src/content/items.gd` | Every item. `Items.ORDER` doubles as the wire format. |
| `src/content/upgrades.gd` | The seven upgrades and their draw weights. |
| `src/content/dungeons.gd` | The kinds of run. `ORDER` is a wire format *and* the hub's portal order. |
| `src/content/rooms.gd` | Hand-authored stamps: the hub and each boss arena, as text. |
| `src/actors/` | The `Resource` definitions those tables build: enemy, boss, item, loot, upgrade, dungeon. |
### Server-owned state
| File | Role |
| --- | --- |
| `src/meta/progression.gd` | XP curve and what a level is worth. Pure functions. |
| `src/meta/character.gd`, `character_store.gd` | Characters and their JSON persistence. |
| `src/meta/auth_provider.gd` | Identity, shaped like Steamworks so it swaps out. |
| `src/meta/local_auth_provider.gd` | The development provider. **Insecure on purpose** — any client can claim any account. Do not ship it. |
| `src/instances/instance.gd` | The hub and one dungeon run: a world plus a peer list. |
### Network
| File | Role |
| --- | --- |
| `src/net/net_codec.gd` | Every binary codec: snapshot, events, input, roster, characters, map chunks, portals, upgrade state. |
| `src/net/server_runtime.gd` | Instances, ticking, transfers, interest, progression, persistence, map streaming. |
| `src/net/client_runtime.gd` | Prediction, reconciliation, interpolation, bot input. |
| `src/instances/instance.gd` | Lobby hub and dungeon progression. |
| `src/autoload/net.gd` | ENet lifecycle, RPCs, local loopback for listen servers. |
| `src/view/bullet_renderer.gd` | The whole bullet field in one MultiMesh draw call. |
| `src/net/protocol.gd` | Wire version and constants. Bump `VERSION` whenever a layout changes. |
| `src/autoload/net.gd` | ENet lifecycle, RPCs, local loopback for listen servers. The only autoload. |
### View and UI (read-only; decides nothing)
| File | Role |
| --- | --- |
| `src/view/world_view.gd` | Everything that is not a bullet. Fog, actors, loot, telegraphs. Draw order is the fog's mechanism: everything it may hide, then the darkness, then what it must not. |
| `src/view/visibility.gd` | The fog's geometry: rays from the eye, wall corners, the shadow mesh. Pure, so `test_visibility.gd` asks it which points are lit. |
| `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. Fades bullets past the edge of sight, never sooner than `WARNING_TIME` before one could land (`visibility()`). |
| `src/view/art.gd` | Every atlas rect and sound path, in one table. |
| `src/view/ui_theme.gd` | The control theme, built in code from the UI pack. |
| `src/view/debug_draw.gd` | F1 overlay: what the simulation collides against. |
| `src/view/game_scene.gd`, `sfx.gd` | Wiring the view to whatever client `Net` currently has. |
| `src/ui/hud.gd` | Player panel, party, boss bar, banners, message feed, inventory, prompts. Drawn, not built from controls; stacked by a running cursor so nothing collides. |
| `src/ui/main_menu.gd`, `game_menu.gd` | Connect screen and the in-game menu. |
| `src/ui/menu_backdrop.gd` | The real boss patterns playing behind the connect screen, primed so the first frame is already full. |
| `src/ui/character_select.gd` | Roster screen: pick or create. |
| `src/ui/upgrade_screen.gd` | The quartermaster's three choices, and what you hold. |
| `src/ui/settings_screen.gd`, `credits_screen.gd` | Controls, volumes, attribution. |
### Tools
| File | Role |
| --- | --- |
| `tools/check.sh`, `check_scripts.gd` | Parse and type-check every script; refreshes the class cache first. |
| `tools/test.sh` | The GUT suite, headless. |
| `tools/smoke.sh` | A real server and five bots over ENet, asserting the whole loop. |
| `tools/server.sh`, `client.sh` | Run a dedicated server, or a client. |
| `tools/install-hooks.sh` | Points git at `.githooks/`: check and test on commit, smoke on push. |
| `tools/diag_*.gd` | Scene-based end-to-end checks the bots cannot reach: prediction, progression, loot, upgrades. |
| `tools/bench.gd` | Simulation cost per tick. |
| `tools/screenshot.gd` | Every screen, staged. Needs a display. **Its own store and account.** |
| `tools/export_content.gd`, `setup_input_map.gd` | Content to `.tres` for the inspector; the input map, generated correctly. |
+134
View File
@@ -0,0 +1,134 @@
# Asset packs and attribution
The packs themselves are **not in git** (see
[`assetpacks/README.md`](../assetpacks/README.md)), which means their bundled
licence files are not either. This file is the record that survives. Keep it
current when a pack is added or removed.
## How assets get from a pack into the game
`assetpacks/` carries a `.gdignore`, so Godot skips it entirely — nothing there
can be referenced from a scene or script. Files are copied into `res://assets/`
in game-ready form, and only then imported. The tree is 3.9 GB and
`tools/check.sh` runs `--import`, which would otherwise walk all of it every run.
What is in the game today is three sprite sheets, four sounds and twelve small
UI sprites. Sprite rects and sound paths are all declared in one
place, [src/view/art.gd](../src/view/art.gd), and
`tests/unit/test_art.gd` asserts every rect lands inside its texture — a wrong
atlas coordinate does not error, it silently draws the wrong pixels.
## Licence findings
Checked against each pack's itch.io page, and against the licence file the pack
itself ships where there is one. **Two of the five forbid redistribution**,
which is what publishing a repository containing them would be — so they are not
in the repository.
| Pack | Licence | Commercial | Credit | In this repo? |
| --- | --- | --- | --- | --- |
| [0x72 DungeonTileset II](https://0x72.itch.io/dungeontileset-ii) | **CC0 1.0** (public domain) | Yes | Not required | **Yes** — `assets/sprites/dungeon_tileset.png` |
| [Helton Yan Pixel Combat](https://heltonyan.itch.io/pixelcombat) | **CC BY 4.0** | Yes | **Required** | **Yes** — `assets/audio/sfx/` |
| [Complete UI Essential Pack](https://crusenho.itch.io/complete-ui-essential-pack) | **CC BY 4.0** | Yes | **Required** | **Yes** — `assets/sprites/ui/` |
| [Fire Pixel Bullet 16x16](https://bdragon1727.itch.io/fire-pixel-bullet-16x16) | Custom | Free tier non-commercial; **paid tier allows commercial** | Encouraged | **No, at any tier** — local only |
| [750+ Effect and FX Pixel All](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all) | Custom | Free tier non-commercial; **paid tier allows commercial** | Encouraged | **No, at any tier** — local only |
### The blocking clause
Both bdragon1727 packs state, verbatim:
> **You cannot do:** Resell / redistribute this asset.
And the paid tier of the FX pack is no better for this purpose:
> You can NOT re-distribute the file, no matter how much you modify it you can
> use it but not share or re-sell it.
Putting those files in a public repository distributes them to everyone who
clones it, which is squarely what that forbids. Paying for the commercial tier
does **not** unlock it — the restriction survives payment and survives
modification.
So they live in `assets/local/`, which is gitignored. `Art.bullets_texture()`
and `Art.impact_texture()` load them at runtime if present and return null if
not; the bullet renderer falls back to a generated dot. Verified both ways: with the files
present the game uses them, and with them hidden the whole suite still passes
and the game runs — `tests/unit/test_art.gd` has an explicit case for their
absence, because a clean clone is the configuration most contributors will
actually have.
This is also why nothing there may be `preload()`ed — `preload` resolves at
compile time and would fail the build on every machine that lacks the files.
### The UI pack is the easy case
Crusenho's *Complete UI Essential Pack* ships its own `License.txt` naming
CC BY 4.0 outright, with the licensor and store link spelled out. That permits
redistribution and commercial use, so unlike the bullet and FX packs a subset of
it is committed — twelve PNGs, 48 KB, under `assets/sprites/ui/`, listed in
`UiTheme.TEXTURES`.
Only what the game uses was copied rather than all 98 sprites: each committed
PNG costs a Godot `.import` sidecar, and a directory of files nothing references
is a directory nobody prunes. The rest stays in the untracked pack.
The obligation it creates is the same one the audio creates — see below.
### Two obligations this creates
1. **Two packs are CC BY 4.0 and require attribution** — Helton Yan's audio and
Crusenho's UI art. A file in the source tree does not satisfy that once the
game ships, because a player who downloaded a build never sees it. **Done:**
Settings → Credits shows the same list, built from
[src/core/credits.gd](../src/core/credits.gd), including a link to the
licence text as CC BY asks. `tests/unit/test_credits.gd` asserts every source
there also appears in [CREDITS.md](../CREDITS.md) and in this file, so the
three cannot drift apart.
2. **Both bdragon packs are non-commercial in their free tier.** Buying the
full tier *does* fix that — confirmed verbatim from the FX pack's paid
licence: "You may use these assets in personal, commercial or
non-commercial projects."
It does **not** fix the repository problem. The paid licence's very next
sentence is: "You can NOT re-distribute the file, no matter how much you
modify it you can use it but not share or re-sell it." Redistribution is
forbidden at every tier, so paying moves these assets from
*local-only-and-non-commercial* to *local-only-and-commercial*. They cannot
be committed to a public repo at any price.
The only route that puts bullet and FX art back into the repository is
replacing it with something permissive (CC0, or CC BY with attribution),
which would also remove the local-asset fallback machinery.
### Not in use
`assetpacks/considering_dont_use_yet/` is the "not chosen" marker and nothing
there is referenced. It contains RF Catacombs v1.0 (public domain per its
bundled `public-license.txt` — free for personal or commercial use, credit
appreciated, no reselling; artwork by Szadi art), plus Tiny RPG Character Pack
02 and neo_zero demos, whose terms are unrecorded.
## Converting audio before use
Helton Yan's pack is 24-bit / 96 kHz stereo — studio masters, averaging ~2 MB
per file and 5 MB at the largest. That is roughly a hundred times what a game
needs for a gunshot.
Downsample to 16-bit / 44.1 kHz mono before copying anything into
`res://assets/`. Godot imports `.wav` losslessly and keeps it in memory
uncompressed for low-latency playback, so the source format is what you pay for
in RAM and in export size.
## Where converted assets go
`res://assets/`, which is committed and imported. See
[`assets/README.md`](../assets/README.md). Art is Stage 2 in
[ROADMAP.md](ROADMAP.md); everything currently on screen is drawn from
primitives plus one runtime-generated dot texture.
## Placeholder art worth revisiting
- **Warden's Ration** draws as a gold flask (`Art.ITEM_ICONS[1]`). The 0x72
tileset has no food sprite, and the item is deliberately useless flavour, so a
recoloured flask stands in until the art pass. It reads as a potion, which is
the wrong thing for it to read as.
+637
View File
@@ -0,0 +1,637 @@
# 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.
**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.
+238 -10
View File
@@ -9,17 +9,26 @@ prediction and reconciliation for the local player.
client server (60 Hz)
| |
|-- InputFrame (60 Hz, x3) ---->| queue_input(): validate, drop, sort
| move, aim, 3 button bits | SimWorld.step(): move, AI, emitters,
| | integrate bullets, resolve hits
|<-- events (reliable, ch 3) ---| bullet spawns/despawns, hits, deaths
|<-- snapshot (unrel., ch 2) ---| players, enemies, boss @ 20 Hz
| move, aim, 5 button bits, | SimWorld.step(): move, AI, emitters,
| inventory slot | integrate bullets, resolve hits, items
|<-- events (reliable, ch 3) ---| bullet spawns/despawns, hits, deaths,
| | item pickups/uses/drops
|<-- snapshot (unrel., ch 2) ---| players, enemies, boss, ground loot,
| | your own inventory @ 20 Hz
| |
predict locally, reconcile authoritative for everything
```
The client has no message that expresses a position, a hit, damage taken, or a
completed escape. This is deliberate and stronger than validating such messages
after the fact: there is no code path to exploit, only intent to interpret.
The client has no message that expresses a position, a hit, damage taken, a
completed escape, or an item it now owns. This is deliberate and stronger than
validating such messages after the fact: there is no code path to exploit, only
intent to interpret.
Item actions are worth noting as the newest thing to resist becoming a message
of its own. Using or dropping an item is a request that reaches the server as
two button bits and a slot number on the ordinary input frame, and the server
decides what — if anything — happened. See *Item actions ride the input frame*
below.
## Why bullets are not replicated as state
@@ -104,6 +113,195 @@ Two defences now:
`tools/diag_prediction.gd` injects a +14 tick drift mid-run and asserts the gap
returns to normal; it exits non-zero if it does not.
### Every exit runs the same channel
There is deliberately no "I am leaving cleanly" message. The escape channel is
keyed on the socket closing, so all four ways out converge on it:
| Route | Path |
| --- | --- |
| Hold F | `BTN_ESCAPE` -> channel |
| Menu -> "Return to hub" | synthesises a held `BTN_ESCAPE` -> channel |
| Menu -> "Disconnect to menu" | `Net.shutdown()` -> socket close -> `linkdead` -> channel |
| Killing the process / pulling the cable | socket close -> `linkdead` -> channel |
The last two are the same server-side code path, and `tools/smoke.sh` asserts
both: one bot is SIGKILLed mid-dungeon, another calls the same `Net.shutdown()`
the menu button calls. Adding a clean-leave message that skipped the channel
would break that test, which is the point of having it.
The menu says so out loud when you are in a dungeon -- the mechanic only reads
as fair if the player knows the cost before clicking.
## Maps are streamed, never sent
A dungeon's geometry reaches a client as **chunks around that client's own
player**, and nothing else. In particular the server does *not* send the
generation seed, even though maps are a pure function of (kind, seed, depth) and
sending two integers would be far cheaper: a client holding the seed can
regenerate the entire dungeon locally, which is a complete map hack requiring no
skill. Streaming bounds what a modified client can know to roughly where its
player has physically been.
`MAP_STREAM_RADIUS` (900u) is deliberately wider than `FOG_VIEW_RADIUS` (460u).
The client needs real geometry it cannot see, because it predicts its own
movement against walls and simulates bullets that die on them. The accepted
trade is that a cheater sees somewhat further than the fog shows -- what they
cannot get is the floor plan.
Two consequences fall out of partial knowledge:
- **Wall deaths are announced.** A bullet dying to its lifetime or by leaving
the map is derivable from the spawn event and the map's dimensions, so it
costs nothing. A bullet dying against a *wall* is not derivable by a client
that has not been streamed that wall, so the server sends an explicit
`BULLET_DESPAWN`. Getting the order of those two checks wrong is easy and was
caught by a test: out-of-bounds tiles read as `WALL` by design, so testing
geometry before bounds reports every bullet that merely left the map as a
wall kill.
- **Unknown tiles are treated as empty**, not solid, so an unstreamed region can
never wrongly stop a prediction. The stream radius keeps far enough ahead of
the player that this never decides anything visible.
Hard fog is therefore a *rendering* rule, not a secrecy mechanism. The secrecy
lives in what the server declines to send: terrain outside the stream radius,
and actors outside the interest radius (below).
## Actors are scoped per peer
Snapshots are encoded **once per peer**, not once and broadcast: an actor
further than `ACTOR_INTEREST_RADIUS` (800u) from a player is never sent to that
player. Before this, fog was hiding enemies the client had already been handed,
which is no defence at all against a modified client.
Measured cost of encoding four filtered snapshots instead of one shared:
95.6µs against 24.9µs, which amortised over the 3-tick snapshot interval is
0.032 ms/tick — 0.2% of the frame budget.
Two rules fall out of it:
- **The observer's own player record is never filtered**, however far outside
the radius the arithmetic puts it. The client reconciles its prediction
against that record; dropping it would break the player's own movement rather
than merely hide someone.
- **Bullet despawns are not filtered, only spawns.** A client that was told
about a bullet must always be told it died, or it keeps a phantom until the
lifetime expires.
Bullet spawns are filtered **per bullet**: a spawn is sent if *that bullet*,
at its speed and acceleration over its remaining life, could reach ground the
player can see (`ServerRuntime._bullet_concerns`). The failure modes are not
symmetric — an enemy appearing at the edge of sight is cosmetic, a bullet
withheld at spawn that later flies into view is invisible damage — so the rule
errs long.
It used to be a flat radius, `BULLET_INTEREST_RADIUS`, sized for the
longest-travelling shot in the game. That was a leak: every spawn event carries
the shooter's exact position, and at 2900 units the radius exceeded the entire
depth-1 map, so every firing enemy anywhere on the floor was located for every
player — past the actor cull, past the map stream, through the fog. The constant
survives only as a hard ceiling, and `test_interest.gd` checks both: that no real
enemy's bullet is announced across a dungeon, and that the ceiling still covers
the longest shot in the game.
The same rule decides what is sent to a player arriving mid-fight
(`_live_bullet_events`), which is the moment they have seen least of the map.
## Loot has two visibilities, and one of them is an interest rule
Ground loot rides the snapshot rather than an event stream: items do not move,
so re-sending them 20 times a second costs almost nothing and a lost packet
costs nothing at all — which a spawn-once event could not claim.
Each item is either **world-shared** (`SimLoot.owner_peer == 0`) or
**player-instanced** (owned by exactly one peer). The instanced kind is filtered
in `NetCodec.encode_snapshot`, next to the actor interest radius and for the
same reason: a peer is never *told* that another player's copy exists, so a
modified client has nothing to reveal. Hiding it in the UI would have been the
same class of mistake as relying on fog to hide enemies.
Two consequences:
- **An instanced item leaves with its owner.** `SimWorld.remove_player` deletes
loot owned by the departing peer. Nobody else can see or take it, so leaving
it behind would be an invisible entity the instance carries until it closes.
- **Anything dropped becomes world-shared**, whatever it was before. That is
what makes dropping worth having.
The Warden's Ration exists to keep this path honest: it is dropped instanced on
every boss kill, so the filter runs in every real fight rather than only in
tests.
## Which dungeon you enter is a position, not a request
The hub holds a list of portals, each bound to a dungeon id. Pressing interact
resolves the player's **server-side** position through `SimWorld.portal_at()`,
and the resulting `PORTAL_USED` event carries which dungeon that entrance opens.
There is no client message that names a dungeon, and there must not be: one
would let any client ask for the Proving Grounds' loot rate — ten times the drop
chance — and bring the results back to the hub. The same reasoning as everywhere
else here; the difference between "the server checks your request" and "there is
no request" is the whole model.
`enter_instance` carries the portal list (position plus a dungeon index, via
`NetCodec.encode_portals`) and the id of the dungeon you have arrived in. The
client needs the latter to scale the boss's health ceiling the same way the
server did, or the boss bar would sit near empty for an entire easy fight.
## Item actions ride the input frame
`InputFrame` carries `BTN_USE`, `BTN_DROP` and a slot byte (10 bytes total, up
from 9). The alternative — a reliable `c_use_item(slot)` RPC — would have needed
its own replay guard, its own rate limit, and its own ordering story against the
movement on the same tick. The input stream already has all three.
The one thing it does not give for free is edge detection. The client repeats
its last few frames every tick (that redundancy is what covers a dropped packet)
and a starved server coasts on the last frame it holds, so a level-triggered
read would spend four items in four ticks. `SimPlayer.prev_buttons` and
`prev_slot` hold the edge; the slot is part of it, so tapping 2 while 1 is still
held is a second action rather than a swallowed one. Movement and fire stay
level-triggered — holding them is exactly what you mean.
Pickup shares `BTN_INTERACT` with the dungeon portal. Loot wins when both are in
reach, but only on a tick where something was actually taken, so a full bag
cannot leave a player standing on the portal unable to use it.
## Upgrades change what a shot is, and none of it is client-side
A player's damage, fire rate, bullet speed, shot count and every rider
(splitting, poison, erasure) come from `PlayerStats`, rebuilt on the server from
the upgrade ids the character holds. The client builds its own copy for the
choice screen's summary, and that copy decides nothing — it exists so the panel
can show real numbers instead of promises.
Three things fall out of it worth writing down:
- **The riders live on the bullet, not on the shooter.** A shot already in
flight keeps what it was fired with, so taking Poison mid-flight cannot reach
back and poison it. They stay zero on a replica, which never resolves a hit
and has no use for them.
- **Choosing an upgrade is intent, checked three ways.** The client sends an
index; the server checks a choice is owed, that the index names one of the
three options *it* put on the table, and that the player is standing at the
hub NPC. The index selects from a list the client did not write.
- **The offer is server state.** Rolled once, stored on the character, and
persisted — so closing the screen is not a reroll, and neither is a crash.
## No contact damage
Nothing hurts you by touching it. Every threat is a bullet you can see and
dodge, which is the contract the genre runs on; an enemy that damages you for
occupying the same space is an unavoidable, unreadable hit.
The Stalker used to be exactly that -- it walked at you and dealt contact
damage. It now carries a point-blank shotgun instead: five pellets, 62 degrees,
and an 18-tick lifetime that gives it about 78px of reach, so it still has to
close the distance and still leaves nothing lingering in the arena.
`tests/unit/test_content.gd` asserts that every hostile enemy has at least one
emitter, so a new enemy cannot quietly reintroduce the mechanic.
## No invulnerability frames
A hit grants no immunity — every bullet that touches you deals its damage. In a
@@ -126,10 +324,16 @@ transition, not a combat mechanic.
| Input claiming the future | `f.tick > tick + INPUT_MAX_LEAD` |
| Flood | queue capped at `INPUT_MAX_AGE`, oldest dropped |
| Leaving the hub early after death | `RESPAWN_LOCKOUT_TICKS`, server-side |
| Item action repeated by a held key | edge-triggered against `prev_buttons` / `prev_slot` |
| Inventory slot index out of range | `SimPlayer.take_slot` answers "nothing" |
| Upgrade choice made anywhere but the hub NPC | `SimWorld.at_upgrade_npc`, server-side position |
| Upgrade index outside the server's own offer | `CharacterStore.take_upgrade` refuses |
The respawn lockout is worth calling out: the HUD disables its button for the
same three seconds, but that is presentation. A client that ignores its own UI
and holds the bit down still waits, because the check lives in `SimWorld`.
The respawn lockout lives in `SimWorld`, not only in the HUD's disabled button —
but note what reaches it. A connected player is unbound on the tick it dies and
sent to the roster screen (a settled decision; see DECISIONS.md), so it never
waits at all. The lockout governs the *linkdead* body, which has nobody to show a
roster to and asks for itself.
Beyond that, the wire format itself constrains the cheat surface: the move
vector is two signed bytes at 1/100 precision, and `Movement.sanitize_move()`
@@ -220,6 +424,30 @@ the snapshot stream:
`Protocol.VERSION` is checked at handshake and mismatched clients are rejected
with a reason, rather than desyncing later.
## What the handshake and the server refuse
- **One account, one session.** A second connection claiming an account that is
already playing is refused — the newcomer, not the incumbent, or a spoofed
claim would kick anyone off. Two peers on one account each held a copy of one
character's bag, which duplicated items. A linkdead body still holds its
account until its escape channel resolves.
- **The reason arrives.** `Net.reject_and_kick` sends the refusal, then kicks
after `REJECT_LINGER`: an RPC only leaves on the frame's flush, so kicking in
the same call discarded it, and the client's disconnect handler overwrote it
anyway. Both are fixed; smoke asserts a refused client reads its reason.
- **No small packet buys large work.** Character select and create rewrite the
whole store, re-place the player and broadcast a roster, so they are paced per
peer (`ServerRuntime.CONTROL_COOLDOWN_TICKS`), and re-selecting the character
you hold is a no-op. Item moves and experience grants — the two persistence
paths a client can trigger every tick — mark the store dirty, and it is written
once per tick at most (`CharacterStore.save_deferred` / `flush`).
- **The two hot decoders check their bounds.** A short or hostile snapshot
decodes as `valid: false` and is dropped; the snapshot channel is unreliable
with ENet's checksum off, so this is a network event, not only an attack. Event
lengths are declared per type (`NetCodec.event_payload_size`), and an unknown
type stops the stream rather than unaligning everything after it. Both size
tables are pinned to their encoders by tests.
## Instances
One server process hosts the lobby hub plus every concurrent dungeon. An
+543 -52
View File
@@ -1,67 +1,558 @@
# Roadmap
# Status and roadmap
## Built
**Read this first when picking up work.** It maps every feature in the design
brief to its current state and the files that implement it, so you can find the
relevant code without re-reading the whole project.
| MVP requirement | State |
Art and audio source packs, and their licence status, are recorded in
[ASSETS.md](ASSETS.md) — the packs themselves are not in git.
Design decisions that are already settled — and the reasoning behind them — live
in [DECISIONS.md](DECISIONS.md). Check there before asking the user something
that may already have an answer.
Legend: **done** · **partial** (works, with a stated gap) · **todo** (not started)
---
## Where things stand
**Every stage in the original brief is built.** The MVP asked for a twin-stick
bullet hell, server-side hit validation, predictable enemies, an adaptable boss,
a lobby hub with dungeon runs, and an emergency escape. All of that exists, plus
the four stages that followed: characters and permadeath, inventory and loot,
upgrades, and a second boss that moves.
Since then, in one long session:
- **An anti-cheat audit, and its fixes.** Item duplication through two sessions
on one account; shots through walls from muzzle offsets (the player's and every
boss's); every firing enemy located by the flat bullet-interest radius;
unbounded store writes from small packets; the hub's escape channel; hub loot
griefing; unbounded snapshot and event decoders. Each is in DECISIONS.md under
"The anti-cheat audit".
- **Health survives a door**, the hub regenerates it at 10%/s, and experience goes
only to players within `XP_AWARD_RADIUS` of the kill.
- **Fog lit by rays**, so shadows cut through tiles ([visibility.gd](../src/view/visibility.gd)),
and **bullets fade past the edge of sight** without ever arriving unseen.
- **Tunnels 2–4 wide**, an occasional enemy group in them, nothing watching the
entrance on arrival, and arenas that keep their walls.
- **Two interface passes** — see the Interface section of DECISIONS.md.
- **The smoke flake is fixed** (below).
So there is no obvious "next ticket". What follows is the honest list of
candidates, none of which the user has chosen — **ask before starting one.**
| Candidate | Why it might be next | What it needs first |
| --- | --- | --- |
| **Replace `LocalAuthProvider`** | It is the one thing standing between this and a server anyone else can reach. Any client can claim any account id. | Nothing technical — it is one `AuthProvider` subclass. Needs a decision about Steam vs something else. |
| **A reason to keep playing past level 15** | Levels cap, `--depth` never rises in play, and a cleared dungeon gives experience and a potion. The long loop is missing. | Open question 1 below. |
| **An economy** | Loot only drops from kills; nothing sells anything. The quartermaster is the obvious shop and has no currency to take. | Open questions 2 and 3. |
| **Impact and hit feedback** | `Art.IMPACT` is loaded and validated and nothing plays it. No hit flash, no screen shake, no death animation. | Nothing. This is the cheapest visible win. |
| **Replace the two non-redistributable packs** | Bullet and FX art is local-only and non-commercial, which blocks both a public repo copy and a commercial release. | CC0 or CC BY replacements. See [ASSETS.md](ASSETS.md). |
| **Transport encryption** | `ENetMultiplayerPeer` supports DTLS. Required before a public server, not before then. | Follows the auth decision. |
And smaller things raised with the user and not yet answered or chosen:
| Open item | What is known |
| --- | --- |
| Top-down 2D twin-stick bullet hell | Movement, aim, fire, i-frames, death and respawn; MultiMesh bullet rendering. |
| Multiplayer with server-side hit validation | Authoritative dedicated server; clients send input only; all hits resolved server-side for both players and enemies. No PvP. |
| Simple, predictable enemies | Drifter, Turret, Stalker, plus a lobby dummy. Five movement behaviours, all closed-form. |
| Stationary boss, adaptable format | The Warden of the Fold: four phases, data-defined. A new boss is one function and zero simulation changes. |
| Lobby hub with dungeon entry | Persistent lobby instance, portal, party forming window, dungeons opened on demand. |
| Emergency escape | One-second server-owned channel, cancelled by release but not by damage; a disconnect runs the same channel so quitting is never a cheaper exit. |
| Leaving and arriving | Downed players return to the hub on request (no timed respawn); entering a dungeon grants 2s of invulnerable, weapons-cold arrival protection. |
| Hub awareness | Server-pushed roster showing who is online and which dungeon they are in. |
| In-game menu | Escape opens return-to-hub / disconnect / quit. |
| **A boss kill can pay nothing** | Both arenas are wider corner to corner (803 and 630 units) than `XP_AWARD_RADIUS` (506), so a player in the far corner when the boss dies gets no experience for the run. Suggested fix: anyone inside the boss room counts as present for a boss kill. |
| **The hub renders at ~60 fps, a dungeon at ~240** | Measured with the old fog and the new, same scene: not the fog. Cause unknown. |
| **Aggro sees through cracks the fog closes** | `MapGrid.has_line_of_sight` steps diagonally between two tiles that touch only at a corner. The fog treats that as closed, so an enemy can occasionally see and shoot you through a gap you cannot see through. |
| **Two one-tile alcoves in the Warden's hall** | Rows 4 and 8 of `Rooms.warden_hall()` are one character longer than the rest, leaving notches in the east wall. Possibly a typo; asked, not answered. |
| **Arena doors are one tile wide** | Authored in the stamps. Tunnels into them are now at least two. |
| **The Proving Grounds must not ship** | Full experience for an 8%-health boss. Remove it from `Dungeons.ORDER` and its `P` marker before a release. |
| **Hit feedback on enemies** | `Art.IMPACT` is still loaded and never played (the candidate above). |
91 tests plus an end-to-end smoke test over a real socket, including a bot that
is SIGKILLed mid-dungeon to prove the disconnect path.
Each stage below also carries its own **Known gaps** section — those are smaller
and more specific than the table above.
## Next, in rough order of value
### The smoke flake (diagnosed and fixed)
**1. Make the dungeon a place.** Right now it is one arena and three stages.
Rooms, doors, and a `SimWorld` with static geometry — which means adding a
collision representation for walls (segment-vs-circle for players, segment
crossing for bullets) since there is no physics engine to lean on.
`tools/smoke.sh` failed intermittently, always first in a pre-push hook, whose
logs were thrown away. With `KEEP=1` the third occurrence was readable: bot1,
whose account picks the Proving Grounds, had gone into the Choir Vault. The
bot wandered for 90 of every 120 ticks in the hub and then walked to its
entrance holding interact the whole way; the three entrances sit in a row, so a
wander that ended near the middle one carried it across the wrong ring with the
key down. Which dungeons opened depended on where a timing-dependent wander
stopped. The bot now walks straight to its own entrance and presses interact
only on it (`ClientRuntime._bot_input`). If smoke fails again, push with
`KEEP=1 git push` so the logs survive.
**1b. Progression, and what it turns on.** Several things are currently sized
for "no persistence yet" and should be revisited together with it: death sends
you to the hub rather than costing anything, a linkdead body is simply deleted
once it channels out (there is no hub state to put it in), and `peer_names` is
client-supplied and trusted for display. All three are fine now and none of them
are once a character has anything worth losing.
---
**2. Art and feel.** Everything is drawn with `draw_circle` and a generated dot
texture. Sprites, hit flashes, screen shake, muzzle flashes, death effects, and
sound. None of it touches the simulation — this is entirely `src/view/`.
## Verification surface
**3. A second boss.** The format claims to be reusable; the way to find out is
to use it. A mobile boss will exercise `BossDef.stationary`, which the runtime
already reads but no content uses yet.
What "everything passes" currently means. Numbers move; the shape does not.
**4. Progression.** Loot, character stats, persistence. Needs a decision on
storage: for a dedicated server, player state belongs server-side in a database,
not in a client save file.
| Gate | Covers | Runtime |
| --- | --- | --- |
| `tools/check.sh` | every script parses and type-checks | ~5s |
| `tools/test.sh` | 576 GUT tests, no SceneTree | ~4s |
| `tools/smoke.sh` | 27 assertions over a real ENet socket: handshake, auth, character creation and persistence, all three dungeons, escape, hard kill, polite disconnect, a duplicate account refused with its reason | ~40s |
| `diag_prediction.tscn` | client-prediction gap, with injected clock drift | ~10s |
| `diag_progression.tscn` | kill → xp → level → health, death → retire → roster, swap guards | ~10s |
| `diag_loot.tscn` | drop → snapshot → pick up → persist → use → drop, and both loot visibilities on the wire | ~10s |
| `diag_upgrades.tscn` | level → banked choice → refused in a dungeon and away from the NPC → taken → new stats → persisted → **survives every zone change** | ~10s |
**5. Netcode hardening.** In the order they will actually matter:
- DTLS on `ENetMultiplayerPeer` before any public server.
- Authentication; `peer_names` is currently client-supplied and trusted for
display only, which is fine now and will not be once there is progression.
- Snapshot delta compression, once enemy counts grow.
- Interest management inside an instance, once arenas are larger than a
screen.
`tools/screenshot.tscn` is a **manual** check, not a gate: it needs a display,
and it is the only thing in the project that can tell you the interface
actually rendered. Run it after touching `src/ui/` or `src/view/`. It captures
the connect screen, the hub standing on a portal, the pause menu, settings,
credits, the upgrade choice, the roster after a death, a dungeon arrival, a
tunnel (with and without F1), a staged boss fight, and the escape banner. It runs
on **its own character store and account** — it used to host on the player's
real ones — and stages what it cannot earn (an offer, a party, a death).
Timings are frame counts and frames run as fast as the display allows, so
anything tied to server ticks is captured by condition instead (see the escape
shot).
**6. Scale.** The simulation costs ~1.4% of a core per instance, so the ceiling
is bandwidth and process supervision, not CPU. A shard manager that runs several
server processes behind a lobby-of-lobbies is the shape, but it is premature
until there is a game to fill it.
It has now caught four bugs that every other gate passed: the theme applying to
nothing, the test suite rewriting the player's settings, an invisible slider
track and an invisible scrollbar.
## Deliberately not done
The four diagnostics exist because the smoke test structurally cannot reach
what they cover: bots are poor shots (so they neither level up, produce drops,
nor walk to the quartermaster), and a listen server cannot drift its own clock
against itself.
- **Lag compensation.** Discussed in [NETCODE.md](NETCODE.md): rewinding for a
shooter's view would mean a player who dodged still gets hit, which is the
wrong trade for this genre.
- **Pattern-level bullet replication.** A large bandwidth win that couples the
client to emitter behaviour. Not worth it until bandwidth binds.
---
## Stage 1 — World and exploration · *done*
| Feature | State | Where |
| --- | --- | --- |
| Tile grid: movement / bullet / sight blocking | done | [src/sim/map_grid.gd](../src/sim/map_grid.gd) |
| Dungeon generation from (seed, depth) | done | [src/sim/map_gen.gd](../src/sim/map_gen.gd) |
| Hand-authored boss arenas and hub | done | [src/content/rooms.gd](../src/content/rooms.gd) |
| Walls, pillars, pits, barricades | done | `MapGrid.Kind` + the three `BLOCKS_*` tables |
| Enemies placed per room at creation | done | `Instance._populate()` |
| HUD: player panel, party health, message feed, low-health edge | done | [src/ui/hud.gd](../src/ui/hud.gd) — stacked by a running cursor, so nothing has a fixed row to collide on |
| Prompts name the keys actually bound | done | `Settings.short_label()` |
| Health bar: dark track, number inside, damage trail | done | `Hud._bar`, `Hud._watch_health` |
| Centre banners: level-ups, boss phases, leaving | done | `Hud.banner`, `Hud.track_boss` |
| Bullets fade out beyond sight, never sooner than is safe | done | `BulletRenderer.visibility()` — every enemy bullet is drawn at least 0.75s before it could land |
| Markers pointing to party members out of sight | done | `Hud._draw_party_markers` |
| Connect screen: live boss pattern behind it, port validation, credits | done | [src/ui/main_menu.gd](../src/ui/main_menu.gd), [src/ui/menu_backdrop.gd](../src/ui/menu_backdrop.gd) |
| Boss bar waits until the boss has been seen | done | `Hud._draw_boss_bar`, reading the fog's `Visibility` |
| A death is announced on the roster screen | done | `ClientRuntime.last_fallen`, `GameScene.fallen_line` |
| Occasional enemy groups in tunnels | done | `Instance._populate_tunnels()` — about one depth-1 run in three, never within aggro range of the entrance |
| Scrolling camera | done | [src/view/game_scene.gd](../src/view/game_scene.gd) |
| Hard fog of war, lit by rays rather than per tile | done | [src/view/visibility.gd](../src/view/visibility.gd), `WorldView._draw_darkness` |
| Per-peer map streaming (anti map-hack) | done | `ServerRuntime._stream_map` |
| Aggro: range **and** line of sight | done | `SimWorld._aggro_target` |
| Cursor-to-world aiming under a scrolling camera | done | `ClientRuntime.screen_to_world` |
| Boss confined to its room | done | `SimWorld._step_boss` clamps to `SimBoss.room`. A no-op while bosses are stationary — established now so Stage 5's movement cannot quietly break it. |
| Actor interest management | done | `NetCodec.encode_snapshot(world, countdown, for_peer)`; per-peer encode in `ServerRuntime`. Bullet spawns filtered separately, see below. |
### The invariants that matter most here
**Maps are streamed per peer, and the generation seed is never sent.** See
[NETCODE.md](NETCODE.md#maps-are-streamed-never-sent). `MAP_STREAM_RADIUS` must
stay wider than `FOG_VIEW_RADIUS`, or the client predicts movement against
terrain it does not have.
**Three radii, deliberately different, and each has a floor it must respect:**
| Radius | Value | Must exceed | Why |
| --- | --- | --- | --- |
| `FOG_VIEW_RADIUS` | 460 | — | What the player can see. |
| `ACTOR_INTEREST_RADIUS` | 800 | fog radius | Enemies beyond it are never sent. Above the fog radius so nothing pops in at the edge of sight. |
| `MAP_STREAM_RADIUS` | 900 | fog radius | Client predicts movement and simulates bullets against terrain it cannot see. |
| `BULLET_INTEREST_RADIUS` | 2900 | longest bullet travel + fog radius | **A ceiling only.** Spawns are culled per bullet — can *this* one reach visible ground (`ServerRuntime._bullet_concerns`). As a flat cull it exceeded the depth-1 map and located every firing enemy for everyone. |
| `XP_AWARD_RADIUS` | 506 | 1.1 × fog radius | Experience goes to the living players within it of the kill, undivided. |
Fog is a *rendering* rule and defends nothing on its own — a modified client
draws whatever it holds. The defence is what the server declines to send.
---
## Dungeon kinds · *done*
Three entrances stand side by side in the hub, labelled with the fight each one
ends in.
| Dungeon | `Dungeons` id | Arena / boss | Enemy HP | Boss HP | Loot chance |
| --- | --- | --- | --- | --- | --- |
| Warden's Descent | `warden_descent` | `warden_hall` → the Warden | ×1 | ×1 | ×1 (trash 8%) |
| The Choir Vault | `choir_vault` | `choir_vault` → the Cantor | ×1 | ×1 | ×1 |
| Proving Grounds | `proving_grounds` | *whichever the seed rolls* | ×0.2 | ×0.08 | ×10 (clamped) |
**A dungeon names its arena, and the arena decides its boss.** Which boss you
are about to fight is the one thing a player chooses before walking into a
dungeon, so it must not be rolled for them. Only the Proving Grounds leaves
`arena` empty — it exists to reach *a* fight fast, and runs there last a couple
of minutes, so the one you want is one re-entry away.
The Proving Grounds is a **test harness you can walk into**: same generator,
same rooms, same enemies, but everything dies far faster and drops far more. A
manual pass over loot, the inventory, dropping and all four boss phases takes a
couple of minutes instead of a quarter of an hour, and because it is a portal
rather than a launch flag you can compare it to a real run without restarting
the server.
**It is a debugging tool and does not ship.** It pays exactly the same
experience a real run does — `Progression.xp_for_boss` ignores its argument and
`xp_for_enemy` keys on the enemy id, which the difficulty multipliers do not
touch — while its boss has 8% of its health and its trash drops ten times as
often. That makes it strictly the best place to level and to farm, which is
correct for a harness and would be ruinous in a release. Remove it from
`Dungeons.ORDER` (and its `P` marker from the lobby stamp) before shipping, or
give `DungeonDef` an `xp_mult` alongside the three multipliers it already has.
Adding a dungeon is one entry in `Dungeons.ORDER` plus one more `P` marker in
the lobby stamp — the Nth marker, in reading order, opens the Nth entry, so the
two have to stay in the same order.
Two more things worth knowing:
- **It is multipliers over the shared content, not a copy of it.** A duplicated
`Content` would drift the moment anything was tuned, and "identical but
easier" would quietly stop being true.
- **Which dungeon you enter comes from where you stand.** `SimWorld.portal_at()`
resolves the server-side position; the `PORTAL_USED` event carries the answer.
No client message names a dungeon, which is what stops anyone picking the
generous loot table from a real run. `accepts_new_party_member` compares
dungeon ids too, so walking into one entrance can never drop you into
another's forming run on timing alone.
---
## Art and audio · *first pass done*
Placeholders are gone: terrain, actors and bullets are sprites, and four sounds
play off server events. Enough to prove the pipeline, not a finished look.
| Feature | State | Where |
| --- | --- | --- |
| Atlas/sound table in one place | done | [src/view/art.gd](../src/view/art.gd) |
| Terrain, actors, boss from the 0x72 atlas | done | `WorldView._draw_tile` / `_draw_sprite` |
| Animated bullet sprites, one MultiMesh per kind | done | [src/view/bullet_renderer.gd](../src/view/bullet_renderer.gd) |
| SFX pool driven by server events | done | [src/view/sfx.gd](../src/view/sfx.gd) |
| Rects validated without a display | done | `tests/unit/test_art.gd` |
| Impact/death VFX animation | todo | `Art.IMPACT` is loaded and validated but nothing plays it yet |
| Directional sprites, hit flashes, screen shake | todo | |
| Rebindable controls | done | [src/ui/settings_screen.gd](../src/ui/settings_screen.gd), [src/core/settings.gd](../src/core/settings.gd) |
| Themed controls from a UI pack | done | [src/view/ui_theme.gd](../src/view/ui_theme.gd), art in `assets/sprites/ui/` |
| Dialogs on panels, sliders, scrollbars | done | `UiTheme.dialog_panel`, `HSlider` / `VScrollBar` theme entries |
| Upgrade cards framed by rarity | done | `UiTheme.card_panel`, `UpgradeScreen._make_card` |
| Textured HUD bars and inventory slots | done | `HUD._bar`, `HUD._draw_inventory` |
| Audio buses and a volume setting | done | `Settings`, an SFX bus created at runtime, sliders in the settings screen |
| **In-game credits screen** | **done** | Settings → Credits, from [src/core/credits.gd](../src/core/credits.gd). Two packs are CC BY 4.0 and attribution is a licence *requirement* — [CREDITS.md](../CREDITS.md) is not reachable by a player. |
| Replace the two non-redistributable packs | todo | Bullet and FX art is local-only and non-commercial. CC0 replacements would let them into the repo and unblock a commercial release. See [ASSETS.md](ASSETS.md). |
## Stage 2 — Characters, persistence, levels · *done*
| Feature | State | Where |
| --- | --- | --- |
| Steam-shaped identity abstraction | done | [src/meta/auth_provider.gd](../src/meta/auth_provider.gd), [local_auth_provider.gd](../src/meta/local_auth_provider.gd) |
| Character store, JSON, survives restart | done | [src/meta/character_store.gd](../src/meta/character_store.gd) |
| Up to 5 living characters, random colour | done | `CharacterStore.MAX_ACTIVE`, `Character.create` |
| Last-played auto-selected on login | done | `CharacterStore.last_played` |
| Permadeath → retired, never deleted | done | `ServerRuntime._on_player_died` |
| Roster screen: pick or create | done | [src/ui/character_select.gd](../src/ui/character_select.gd) |
| Levels 1–15, +10 max HP each | done | [src/meta/progression.gd](../src/meta/progression.gd) |
| XP from kills, bosses worth far more | done | `ServerRuntime._award_kill` |
| Colour visible in world and on the HUD | done | snapshot carries it; `WorldView._draw_ship` tints |
| Swap character from the hub | done | Esc menu → Change character; refused server-side in a dungeon |
| XP percentage to next level | done | `HUD._draw_xp_bar`, fed live from the snapshot |
| Dead characters hidden from the roster | done | `ServerRuntime._send_characters` sends living only |
| Suggested name when creating | done | `Character.random_name` |
| Passive health regeneration | done | `SimPlayer.regenerate`, 0.5%/s of maximum |
Verified end to end by `tools/diag_progression.tscn`, which drives the real
server through kill → xp → level → health and death → retire → roster. That
path cannot be covered by the bot smoke test, because bots are poor shots.
### Still open in this area
- **The local identity provider is insecure by design.** Any client can claim
any account id. Fine for a LAN; must be replaced before the game is reachable
from the internet. Swapping in Steam is one `AuthProvider` subclass and no
schema change.
- `--account` and `--store` exist so several clients and test runs can coexist
on one machine. A real provider makes `--account` unnecessary.
## Stage 3 — Inventory and loot · *done*
| Feature | State | Where |
| --- | --- | --- |
| 4 slots, permanently on screen | done | `SimConfig.INVENTORY_SLOTS`, `HUD._draw_inventory` |
| Items defined as data, not code | done | [src/content/items.gd](../src/content/items.gd), [src/actors/items/item_def.gd](../src/actors/items/item_def.gd) |
| Loot tables on enemies and bosses | done | `EnemyDef.loot` / `BossDef.loot`, rolled in `SimWorld._drop_loot` |
| Health potion, rare from trash | done | `Content.TRASH_POTION_CHANCE` = 0.08 |
| …guaranteed from the boss | done | `Content.warden()` loot table, chance 1.0 |
| World-shared loot | done | `SimLoot.owner_peer == 0` |
| Player-instanced loot | done | one `SimLoot` per living player, filtered per peer in `NetCodec.encode_snapshot` |
| Warden's Ration — useless, instanced | done | `Items.wardens_ration()` |
| Pick up, use, drop | done | `SimWorld._try_pickup` / `_use_slot` / `_drop_slot` |
| Inventories persist | done | stored on `Character`, written by `ServerRuntime._persist_inventory` |
| Ground loot drawn with a pickup prompt | done | `WorldView._draw_loot`, `HUD._draw_pickup_prompt` |
Controls: **E** picks up, **1–4** use a slot, **shift+1–4** drop one.
### The decisions worth knowing before touching this
**Item actions ride the input frame; they are not new messages.** `InputFrame`
gained `BTN_USE`, `BTN_DROP` and a slot byte. That buys the redundancy that
covers a dropped packet, the replay guard on `last_input_tick`, ordering against
movement on the same tick, and a natural rate limit of one action per tick — all
of which a separate reliable RPC would have needed bolted back on.
**Item actions are edge-triggered; movement and fire are not.** The client
repeats its last few frames every tick and a starved server coasts on the last
one it was given, so a level-triggered read empties the whole inventory in four
ticks. `SimPlayer.prev_buttons` and `prev_slot` hold the edge, and the *slot* is
part of it — tapping 2 while 1 is held is a second, distinct action.
**Instanced loot is enforced on the wire, not in the client.** A peer is never
told another player's copy exists. That makes it an interest-management rule of
the same kind as `ACTOR_INTEREST_RADIUS`, and it is why the ration is worth
having: every boss kill exercises the path.
**Anything dropped becomes world-shared, even if it arrived instanced.** That is
what makes dropping worth having — a trophy you do not want should be able to
reach someone who does.
**A potion at full health is refused rather than spent.** Nobody drinks one on
purpose at full health, so a mistimed keypress must not do it for them.
**A full bag leaves the item on the floor** and does not block the portal, which
shares the interact key.
### Known gaps
- **Nothing sells items.** Loot only comes from kills; the hub has no source.
The Stage 4 upgrade NPC is the natural place, and is the reason this is a gap
rather than a decision.
- **No stacking.** Four potions take four slots. A count byte per slot is cheap
to add; nothing needed it yet, so the wire, the save record and the HUD all
stayed simpler for not having one.
- **Ground loot never expires**, it is only capped at
`SimConfig.MAX_LOOT_PER_INSTANCE` per world, oldest evicted. Dungeons close
and take their litter with them; only the hub can realistically reach the cap.
- **The ration draws as a gold flask.** The tileset has no food sprite. See
[ASSETS.md](ASSETS.md).
---
## The scale of health and damage
Health and damage are an order of magnitude larger than they started. A shot is
60, a fresh character has 1000, the Warden has 36000. **Every ratio is
unchanged** — enemy health, boss health and every emitter's damage were scaled
together, so time to kill is exactly what it was.
The reason is rounding. At the old base of 6 damage, the +5% every upgrade
carries computed to 6.3 and rounded straight back to 6, so a player's first
upgrade visibly did nothing. At 60 it is +3, and every percentage in the game
now has somewhere to land.
`tests/unit/test_content.gd` pins both halves of that: that a single upgrade
changes damage, and that shots-to-kill for each enemy is what it was before.
Do not tidy these numbers back down without scaling `content.gd` with them.
The hub's practice dummy stopped relying on a huge health pool at the same
time: it is `indestructible` by flag now. The old 100000 was already past the
u16 the snapshot sends enemy health in, and after the rescale a patient player
could have destroyed the hub's only practice target for everyone.
---
## Stage 4 — Upgrades · *done*
Every level gained banks one choice. Choices are spent at the **quartermaster**,
an NPC in the hub: walk to it, press E, pick one of three.
| Feature | State | Where |
| --- | --- | --- |
| Upgrades as data, no engine branches | done | [src/content/upgrades.gd](../src/content/upgrades.gd), [upgrade_def.gd](../src/actors/upgrades/upgrade_def.gd) |
| Per-player derived stats | done | [src/sim/player_stats.gd](../src/sim/player_stats.gd) |
| 3 weighted options per choice, held until spent | done | `Upgrades.roll_offer`, stored on `Character.offer` |
| Choices queue across levels | done | `Character.pending_choices` |
| Spent only at the NPC, enforced server-side | done | `SimWorld.at_upgrade_npc`, `ServerRuntime.on_choose_upgrade` |
| Choice screen showing the +5% and each effect | done | [src/ui/upgrade_screen.gd](../src/ui/upgrade_screen.gd) |
| List of upgrades already taken, plus resulting stats | done | lower half of the same panel |
| Upgrades persist, die with the character | done | `Character.upgrades`, permadeath retires the record |
### The seven upgrades
| Upgrade | Rarity | Effect |
| --- | --- | --- |
| Split Shot | common | On hitting something, two more of the same shot leave it at ±45°. One charge each; stacks. |
| Glass Cannon | common | +100% damage, ×0.5 levelled max health. |
| Spread | common | +2 shots in a cone, −10% damage. |
| Sniper | common | ×2 damage (multiplicative), ×0.5 fire rate, ×2 bullet speed. |
| Doubleshot | uncommon | +1 parallel shot, −50% damage. |
| Poison | rare | Each hit deals another 50% of its damage over 10s. Doses stack independently. |
| Eraser | legendary | 1% chance per tick to delete an enemy projectile a shot is passing through. |
Draw weights: common 100, uncommon 45, rare 18, legendary 4.
### The decisions behind the numbers
All four settled with the user; the reasoning is in
[DECISIONS.md](DECISIONS.md).
- **Damage is `base × (1 + Σ additive) × Π multiplicative`.** The flat +5% every
upgrade carries, Spread's −10%, Doubleshot's −50% and Glass Cannon's +100%
pool additively; Sniper multiplies the result, which is why two Snipers is 4×
and not +200%.
- **Glass Cannon halves the LEVELLED maximum**, so the price does not fade to a
rounding error by level 15. Taken twice it multiplies again.
- **Poison doses are independent**, not refreshed. `PoisonTrack` makes that free:
every dose lasts the same number of ticks, so they expire in insertion order,
the pending expiries are a plain FIFO, and the per-tick cost is O(1) however
many are live.
- **Split Shot's children leave at ±45° from the original heading**, born just
past the target so they cannot be resolved against it again on the same tick.
### Constraints this collided with, and what it cost
- **Bullet speed now has a ceiling** (`SimConfig.MAX_BULLET_SPEED`, 1500 u/s).
Wall collision samples once per tick, so anything over one tile per tick
(1920 u/s) tunnels. Two Snipers would have asked for 2480.
- **`BULLET_INTEREST_RADIUS` rose to 2900**, because an upgraded player shot is
now the longest-travelling bullet in the game. `test_interest.gd` measures the
worst case from the ceiling rather than from static content, which is what it
used to do and what upgrades quietly invalidated.
- **Poison ticks do not emit `ENEMY_HIT`.** They land many times a second on a
reliable channel; the client learns hp from the snapshot. Death is still
announced, because the experience award is keyed on it.
- **The eraser's bullet-vs-bullet scan costs nothing until someone owns it** —
it runs only for shots carrying a non-zero chance.
### Known gaps
- **One panel, not two screens.** The brief asked for a separate screen listing
upgrades taken; it is the lower half of the choice panel instead, because the
two are read together.
- **No respec, and no way to see another player's build.**
---
## Stage 5 — Boss features and new bosses · *done*
| Feature | State | Where |
| --- | --- | --- |
| Stationary phases | done | every Warden phase |
| Boss confined to its room | done | `SimWorld._step_boss` clamps after moving |
| Roaming / chasing within the boss room | done | `BossPhase.Move.CHASE`, holds a standoff |
| Phases that move to preset locations | done | `BossPhase.Move.WAYPOINTS`, room-relative |
| Orbiting the arena | done | `BossPhase.Move.ORBIT` |
| Attacks spawned at a distance with a telegraph | done | [telegraphed_strike_emitter.gd](../src/sim/patterns/telegraphed_strike_emitter.gd), `SimEvent.Type.TELEGRAPH` |
| A second boss | done | **Cantor of the Vault**, fights in `Rooms.choir_vault()` |
### Boss movement
Movement is a property of the **phase**, not of the boss: a fight that stands
still and then starts hunting you is one boss with two phases. Four modes —
`STATIC`, `ORBIT`, `CHASE`, `WAYPOINTS` — all handled generically in
`SimWorld._move_boss`, so adding a boss that moves is still writing data.
- **Waypoints are fractions of the arena**, not absolute positions, so one phase
works in the Warden's hall and the Choir Vault alike.
- **CHASE holds a distance rather than closing.** A boss standing on top of you
is a boss whose bullets cannot be read.
- **Every mode is speed-clamped in one place.** `ORBIT` computes an absolute
destination and would otherwise snap onto its circle on the first tick.
- **Movement slides against geometry**, so a boss cannot walk through the
pillars its own arena was designed around.
- **The room clamp is now load-bearing.** It was a no-op while every boss stood
still — which is exactly when an invariant is cheapest to establish. Boss
rooms deliberately do not lock, and that is only an escape if the boss cannot
follow you out.
### Telegraphed strikes
`TelegraphedStrikeEmitter` marks spots in the arena and fills them a moment
later. The moment in between is the whole feature: a burst that appears at your
feet is a coin flip, the same burst with a second of notice is a question.
- **The emitter is stateless**, like every other one — they are shared resources
and two bosses of the same kind must not stomp each other. Strike positions
are *derived* from the volley number, so the announcement and the strike
compute the same points with nothing stored between them.
- **`EmitContext` gained an `events` list**, the only thing an emitter can do
besides spawn bullets.
- **Markers are drawn through fog and through walls.** Everything else in the
view is covered by the darkness; a warning you cannot see is not a warning. See
`WorldView._draw_telegraphs`.
### The Cantor of the Vault
| Phase | Movement | Idea |
| --- | --- | --- |
| Call to Prayer | static | Teaches the marker, and nothing else. |
| Processional | waypoints, four corners | The safe half of the room keeps moving. |
| Antiphon | chase at 220u | It comes for you; walls punish running straight. |
| Final Cadence | orbit | Movement and telegraphs at once. |
**The Cantor has its own portal in the hub.** Which boss a run ends with follows
from the arena its dungeon names, not from the seed and certainly not from
`--depth`, which nothing in play raises.
`test_every_boss_has_a_dungeon_that_reaches_it` pins that adding a boss without
giving it a way in is a failing test rather than a boss nobody meets.
`Content.ALL_ENEMIES` / `ALL_BOSSES` exist because five places were
hand-maintaining their own copy of the content list, and `tools/export_content.gd`
had already gone stale and silently stopped writing the second boss.
### Known gaps
- **Nothing tests how the fight *looks*.** The boss was drawn straight from the
newest snapshot with no interpolation — a visible step at 20 Hz — and every
test passed for as long as bosses stood still. There is still no automated
check that a moving actor renders smoothly; the ones added cover the
interpolation call, not the impression.
- **No boss-specific music, intro or death sequence.** A boss dies like an
enemy, only louder.
- **Telegraph markers are one shape.** A circle is the only warning the client
can draw; a lane or a cone would need another event field.
- **`Rooms` has two arenas.** A third boss needs a third stamp, which is where
the generator's "boss arena on the right-hand side" assumption will be tested.
---
## Deliberate omissions
Not oversights — each was considered and rejected for now, with the reasoning in
[NETCODE.md](NETCODE.md) or [DECISIONS.md](DECISIONS.md):
- **Lag compensation.** Rewinding to a shooter's view means a player who dodged
still gets hit; wrong trade for this genre.
- **Snapshot delta compression.** Fine at current actor counts.
- **DTLS / encryption.** `ENetMultiplayerPeer` supports it. Required before any
public server, not before then.
- **Pattern-level bullet replication.** A real bandwidth win that couples the
client to emitter behaviour.
- **`MultiplayerSynchronizer` / `MultiplayerSpawner`.** Right tools, wrong shape
for a bullet hell. See [ARCHITECTURE.md](ARCHITECTURE.md).
for a bullet hell — see [ARCHITECTURE.md](ARCHITECTURE.md).
---
## Open questions for the user
Genuinely unspecified. **Do not guess at these** — each changes the design, and
several have no obvious default. The seven questions that blocked Stage 4 were
answered and are recorded in [DECISIONS.md](DECISIONS.md#upgrades); these are
what is left.
1. **What advances dungeon depth?** `--depth` is a dev flag and nothing raises
it in play. Depth already drives map size and could drive difficulty and
rewards. Without an answer there is no progression past level 15.
2. **Where do items come from outside a dungeon?** Loot only drops from kills.
If the hub should sell potions, that is the quartermaster's second job.
3. **Is there a currency?** Nothing drops money and nothing costs anything. A
shop needs one; so does any reward for a cleared run beyond experience.
4. **Should items stack?** Four potions currently take four slots, which makes a
four-slot bag small. Stacking is a count byte per slot plus a rule for
splitting one — neither is hard, and both change the UI.
5. **Does a dungeon ever get harder within a run?** Enemies are placed once at
generation and never reinforce. That is a deliberate decision
([DECISIONS.md](DECISIONS.md#world)), but it means a slow party is never
punished for it.
6. **What happens to a party when one member dies?** Permadeath removes them to
the roster screen while the run continues without them. Nobody has said
whether that is right.
+142 -2
View File
@@ -106,10 +106,16 @@ Runs the simulation with no transport and no rendering and prints milliseconds
per tick. Current numbers, 4 players, 60s of play:
```
boss fight 0.237 ms/tick peak 352 bullets headroom x70
trash wave 0.179 ms/tick peak 145 bullets headroom x93
boss fight 0.26 ms/tick peak ~300 bullets headroom x64
trash wave 0.16 ms/tick peak ~82 bullets headroom x103
```
Note what this does *not* cover: it measures the simulation only, with no
transport. Per-peer snapshot encoding was measured separately (95.6us for four
filtered snapshots against 24.9us for one shared, or 0.032 ms/tick amortised).
Quoting a bench number for something the bench does not exercise is its own
version of the trap below.
A 60 Hz tick has a 16.6 ms budget, so one instance uses ~1.4% of one core. That
is the measurement that says a single server process can host dozens of
concurrent dungeons, and it took ten seconds to get because the simulation has
@@ -164,6 +170,94 @@ auto-reload racing against agent file writes is a known source of stale state.
The honest recommendation: **start with the headless CLI loop, add the MCP when
scene inspection becomes the bottleneck.** It is one command to add later.
## Verification traps, learned the hard way
Every entry below cost at least one round trip of reporting something as fixed
when it was not. They are specific to this setup, and they generalise.
### Verify the behaviour, not the artefact you produced
Bullet sprites were reported fixed three times before they were. Each time the
*atlas* was checked and found correct. The actual fault was one layer down: a
`MultiMeshInstance2D` cannot display a sub-region of a texture, so the renderer
was structurally incapable of showing one cell no matter what the atlas held.
Checking the input to a broken stage will confirm the input forever.
Ask what the *player* would see, and find a way to check that.
### An ASCII dump only proves what you dumped
The same sprites were "verified" by rendering their alpha channel. The pack
animates as a colour shimmer over a fixed silhouette, so identical-looking
frames proved nothing at all. The check was real; it was measuring the wrong
channel.
### Measure the configuration that has the bug
The client-prediction gap was measured at 0.1px on a listen server and reported
fixed. A listen server runs both halves in one process on one physics tick, so
the quantity that had drifted — two independent clocks — was constant *by
construction*. The bug needed two processes to exist at all.
If a bug is described as appearing "after a while", ask what accumulates, and
make sure your harness lets it accumulate.
### Have the test tell you the setup was valid
`test_firing_is_reported_even_when_the_bullet_dies_instantly` asserts that no
`BULLET_SPAWN` event occurred, purely to prove the interesting condition was
actually reached. The first version placed a turret as the victim — and turrets
shoot, so the assertion passed on the turret's own bullets while testing
nothing. A setup check inside the test caught it.
### `git checkout` to clean up a probe reverts real work too
A one-file revert to remove a temporary debug hook also discarded a fix made to
the same file earlier in the session, and it had already been verified and
reported. Only `git status` showing the file *missing* from the staged set
caught it. Prefer editing the probe back out, or stash.
### A pattern-based edit can match twice
A `replace()` intended for the snapshot decoder also matched inside the
character decoder, which then read a field its encoder never wrote. Both are
codecs and both had the same trailing lines. Check the match count when patching
by pattern, not by line.
### Slicing between two headings deletes whatever was between them
Replacing a section by cutting from its heading to "the next big heading" took
**451 lines** of the roadmap with it — every stage section sat between the two —
and it was committed and pushed before anything noticed, because no gate reads
prose. End a slice at the marker that actually closes the section (here the
`---` rule after it), and before committing any doc edit, check
`git diff --numstat` for a deletion count bigger than the edit you meant.
### A mutation that survives is a test that cannot fail
Twice in one session a new test passed with its fix reverted. One staged a turret
behind a barricade — a turret's muzzle offset cannot clear its own radius plus a
tile, so the scenario could never have failed. Another computed its sample point
*from* the constant under test, so zeroing the constant moved the sample instead
of failing it. Reverting the fix and watching the test go red is the only way to
know it tests anything.
### A flaky gate needs its logs kept, not rerun
The smoke test failed intermittently for weeks, always in the pre-push hook,
which discards logs; every rerun passed and the trail went cold. Pushing with
`KEEP=1 git push` kept the third failure's logs, which named the cause in one
grep (a bot walking through the wrong portal). Keep the evidence first; rerun
second.
### A tool that hosts a server must not use the player's data
`screenshot.tscn` hosted a listen server with the default character store and
account — the player's own — and walked their character into dungeons for
pictures. Every diagnostic already set `GameOpts.store_path` and
`GameOpts.account_override`; the screenshot tool had simply never been asked to.
Anything new that calls `Net.host()` sets both first.
## CI
`.github/workflows/ci.yml` runs check → test → smoke on a container image with
@@ -175,3 +269,49 @@ Godot preinstalled. All three are headless, so nothing extra is needed.
- [GUT](https://github.com/bitwes/Gut) · [gdUnit4](https://github.com/godot-gdunit-labs/gdUnit4)
- [Godot: high-level multiplayer](https://docs.godotengine.org/en/stable/tutorials/networking/high_level_multiplayer.html)
- [Godot: command line tutorial](https://docs.godotengine.org/en/stable/tutorials/editor/command_line_tutorial.html)
### A green suite is not a quiet one
`tools/test.sh` and `tools/smoke.sh` both passed while a client logged twelve
engine errors at every startup. `ConfigFile.get_value(section, key, null)` does
not mean "no default" — it means the key is absent *and* no default was given,
so the engine logs an error for each one. Nothing caught it because the smoke
test's refutations matched `SCRIPT ERROR|Parse Error|USER ERROR`, and a plain
`ERROR:` is none of those.
It surfaced from actually running the client (`--listen --autoquit`) and reading
the output, which is worth doing after any change to startup.
`smoke.sh` now asserts no plain engine errors either, excluding by name the one
line Godot prints on every clean exit — naming it means anything else that turns
up is a real finding rather than noise to be squinted past.
### Nothing automated can see the screen
Every gate passed — 458 tests, a clean smoke run, no engine errors — while the
entire interface rendered in Godot's default style and none of the UI pack was
visible. A Control inherits its theme from Control *ancestors only*, and the
chain breaks at the first plain `Node` or `CanvasLayer`; every screen in this
game hangs off one. `get_window().theme = ...` set the property, changed
nothing, and looked correct in the code.
It took `tools/screenshot.tscn` — boot the client, save a PNG, open it — to
find. The same run then showed **Fire bound to the right mouse button**, which
was the test suite writing the player's real `user://settings.cfg`, because
rebinding calls `save()` and nothing had redirected the path.
Styling the rest of the interface then produced two more of exactly the same
shape: an invisible slider track and an invisible scrollbar. Both resolved the
right stylebox, reported the right texture, and drew nothing, because a
`Slider` and a `ScrollBar` take their thickness from the stylebox's *minimum
size* — its content margins — and mine were zero. Neither was findable without
looking.
Three lessons, and the last is the general one:
- After touching `src/ui/`, look at it. There is no substitute.
- When a control resolves the right theme item and still looks wrong, check
what Godot derives from that item's *size* rather than its picture.
- A test that exercises a code path which writes to user state **will write to
user state**. Redirect the path in the fixture, and assert the default is
still the real one so the redirect cannot escape.
+25
View File
@@ -84,6 +84,31 @@ system_menu={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
debug_overlay={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194332,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
use_slot_1={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":49,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
use_slot_2={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":50,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
use_slot_3={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":51,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
use_slot_4={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":52,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[rendering]
+163
View File
@@ -0,0 +1,163 @@
[gd_resource type="Resource" script_class="BossDef" format=3]
[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="1_mkon8"]
[ext_resource type="Script" path="res://src/actors/boss/boss_phase.gd" id="2_08s2a"]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="3_i3ijt"]
[ext_resource type="Script" path="res://src/sim/patterns/telegraphed_strike_emitter.gd" id="4_c737k"]
[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="5_s4e7m"]
[ext_resource type="Script" path="res://src/sim/patterns/ring_emitter.gd" id="6_1xgol"]
[ext_resource type="Script" path="res://src/sim/patterns/wall_gap_emitter.gd" id="7_jvosp"]
[ext_resource type="Script" path="res://src/actors/boss/boss_def.gd" id="8_vujs6"]
[sub_resource type="Resource" id="Resource_y5tlt"]
script = ExtResource("1_mkon8")
item = &"health_potion"
[sub_resource type="Resource" id="Resource_vaonm"]
script = ExtResource("1_mkon8")
item = &"wardens_ration"
instanced = true
[sub_resource type="Resource" id="Resource_ldxsn"]
script = ExtResource("4_c737k")
warn_ticks = 80
burst_count = 10
blast_radius = 66.0
interval = 180
speed = 130.0
lifetime = 240
damage = 130
kind = 3
[sub_resource type="Resource" id="Resource_ik5lx"]
script = ExtResource("5_s4e7m")
count = 3
spread_deg = 18.0
muzzle_offset = 40.0
start_tick = 40
interval = 120
speed = 190.0
radius = 6.0
damage = 110
kind = 2
[sub_resource type="Resource" id="Resource_cfxp7"]
script = ExtResource("2_08s2a")
name = "Call to Prayer"
loop_ticks = 360
telegraph_ticks = 60
emitters = Array[ExtResource("3_i3ijt")]([SubResource("Resource_ldxsn"), SubResource("Resource_ik5lx")])
[sub_resource type="Resource" id="Resource_mrfx8"]
script = ExtResource("6_1xgol")
count = 3
spin_per_shot_deg = 27.0
muzzle_offset = 40.0
interval = 12
speed = 125.0
lifetime = 400
damage = 120
[sub_resource type="Resource" id="Resource_oppcg"]
script = ExtResource("4_c737k")
strikes = 2
warn_ticks = 90
blast_radius = 70.0
pattern_seed = 17
start_tick = 60
interval = 200
speed = 145.0
lifetime = 240
damage = 140
kind = 3
[sub_resource type="Resource" id="Resource_eg17u"]
script = ExtResource("2_08s2a")
name = "Processional"
enter_at_hp_fraction = 0.75
loop_ticks = 480
emitters = Array[ExtResource("3_i3ijt")]([SubResource("Resource_mrfx8"), SubResource("Resource_oppcg")])
move = 3
move_speed = 105.0
waypoints = Array[Vector2]([Vector2(0.18, 0.2), Vector2(0.82, 0.2), Vector2(0.82, 0.8), Vector2(0.18, 0.8)])
waypoint_dwell = 70
[sub_resource type="Resource" id="Resource_bffie"]
script = ExtResource("5_s4e7m")
spread_deg = 34.0
jitter_deg = 2.5
muzzle_offset = 40.0
interval = 90
speed = 200.0
radius = 6.0
damage = 120
kind = 2
[sub_resource type="Resource" id="Resource_xcmnn"]
script = ExtResource("7_jvosp")
direction = 1
count = 16
gap_width = 4
gap_step = 6
start_tick = 70
interval = 160
speed = 165.0
radius = 8.0
damage = 150
kind = 3
[sub_resource type="Resource" id="Resource_31prv"]
script = ExtResource("2_08s2a")
name = "Antiphon"
enter_at_hp_fraction = 0.45
loop_ticks = 540
telegraph_ticks = 50
damage_taken_mult = 1.12
emitters = Array[ExtResource("3_i3ijt")]([SubResource("Resource_bffie"), SubResource("Resource_xcmnn")])
move = 2
move_speed = 120.0
move_param = 220.0
[sub_resource type="Resource" id="Resource_wuthe"]
script = ExtResource("6_1xgol")
spin_per_shot_deg = 15.0
muzzle_offset = 40.0
interval = 40
speed = 140.0
lifetime = 380
damage = 130
[sub_resource type="Resource" id="Resource_phxte"]
script = ExtResource("4_c737k")
strikes = 4
warn_ticks = 70
burst_count = 14
blast_radius = 72.0
pattern_seed = 91
interval = 150
lifetime = 240
damage = 150
kind = 3
[sub_resource type="Resource" id="Resource_p6400"]
script = ExtResource("2_08s2a")
name = "Final Cadence"
enter_at_hp_fraction = 0.18
loop_ticks = 420
telegraph_ticks = 55
damage_taken_mult = 1.25
emitters = Array[ExtResource("3_i3ijt")]([SubResource("Resource_wuthe"), SubResource("Resource_phxte")])
move = 1
move_speed = 150.0
move_param = 150.0
[resource]
script = ExtResource("8_vujs6")
id = &"cantor"
display_name = "Cantor of the Vault"
max_hp = 28000
radius = 36.0
spawn_pos = Vector2(0, -150)
visual = 1
phases = Array[ExtResource("2_08s2a")]([SubResource("Resource_cfxp7"), SubResource("Resource_eg17u"), SubResource("Resource_31prv"), SubResource("Resource_p6400")])
loot = Array[ExtResource("1_mkon8")]([SubResource("Resource_y5tlt"), SubResource("Resource_vaonm")])
+63 -48
View File
@@ -1,15 +1,25 @@
[gd_resource type="Resource" script_class="BossDef" format=3]
[ext_resource type="Script" path="res://src/actors/boss/boss_phase.gd" id="1_7m6h6"]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="2_b0fvw"]
[ext_resource type="Script" path="res://src/sim/patterns/ring_emitter.gd" id="3_f5abt"]
[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="4_tmo3e"]
[ext_resource type="Script" path="res://src/sim/patterns/wall_gap_emitter.gd" id="5_rswgd"]
[ext_resource type="Script" path="res://src/sim/patterns/arc_sweep_emitter.gd" id="6_v02no"]
[ext_resource type="Script" path="res://src/actors/boss/boss_def.gd" id="7_x6ufy"]
[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="1_b0fvw"]
[ext_resource type="Script" path="res://src/actors/boss/boss_phase.gd" id="2_f5abt"]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="3_tmo3e"]
[ext_resource type="Script" path="res://src/sim/patterns/ring_emitter.gd" id="4_rswgd"]
[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="5_v02no"]
[ext_resource type="Script" path="res://src/sim/patterns/wall_gap_emitter.gd" id="6_x6ufy"]
[ext_resource type="Script" path="res://src/sim/patterns/arc_sweep_emitter.gd" id="7_bbcbd"]
[ext_resource type="Script" path="res://src/actors/boss/boss_def.gd" id="8_axg03"]
[sub_resource type="Resource" id="Resource_bbcbd"]
script = ExtResource("3_f5abt")
[sub_resource type="Resource" id="Resource_s17mk"]
script = ExtResource("1_b0fvw")
item = &"health_potion"
[sub_resource type="Resource" id="Resource_0452f"]
script = ExtResource("1_b0fvw")
item = &"wardens_ration"
instanced = true
[sub_resource type="Resource" id="Resource_1p7x0"]
script = ExtResource("4_rswgd")
count = 14
spin_per_shot_deg = 9.0
muzzle_offset = 46.0
@@ -17,54 +27,56 @@ interval = 45
speed = 135.0
radius = 8.0
lifetime = 420
damage = 14
damage = 140
[sub_resource type="Resource" id="Resource_axg03"]
script = ExtResource("4_tmo3e")
[sub_resource type="Resource" id="Resource_a0mrn"]
script = ExtResource("5_v02no")
spread_deg = 26.0
muzzle_offset = 46.0
start_tick = 120
interval = 150
speed = 210.0
radius = 6.0
damage = 120
kind = 2
[sub_resource type="Resource" id="Resource_s17mk"]
script = ExtResource("1_7m6h6")
[sub_resource type="Resource" id="Resource_pfjxl"]
script = ExtResource("2_f5abt")
name = "Opening Fold"
loop_ticks = 480
telegraph_ticks = 60
emitters = Array[ExtResource("2_b0fvw")]([SubResource("Resource_bbcbd"), SubResource("Resource_axg03")])
emitters = Array[ExtResource("3_tmo3e")]([SubResource("Resource_1p7x0"), SubResource("Resource_a0mrn")])
[sub_resource type="Resource" id="Resource_0452f"]
script = ExtResource("3_f5abt")
[sub_resource type="Resource" id="Resource_txml3"]
script = ExtResource("4_rswgd")
count = 4
spin_per_shot_deg = 23.0
muzzle_offset = 46.0
interval = 9
speed = 120.0
lifetime = 480
damage = 120
[sub_resource type="Resource" id="Resource_1p7x0"]
script = ExtResource("5_rswgd")
[sub_resource type="Resource" id="Resource_k1e8f"]
script = ExtResource("6_x6ufy")
count = 20
gap_step = 7
start_tick = 90
interval = 170
speed = 175.0
radius = 8.0
damage = 16
damage = 160
kind = 3
[sub_resource type="Resource" id="Resource_a0mrn"]
script = ExtResource("1_7m6h6")
[sub_resource type="Resource" id="Resource_4sp1g"]
script = ExtResource("2_f5abt")
name = "Sliding Lattice"
enter_at_hp_fraction = 0.72
telegraph_ticks = 50
emitters = Array[ExtResource("2_b0fvw")]([SubResource("Resource_0452f"), SubResource("Resource_1p7x0")])
emitters = Array[ExtResource("3_tmo3e")]([SubResource("Resource_txml3"), SubResource("Resource_k1e8f")])
[sub_resource type="Resource" id="Resource_pfjxl"]
script = ExtResource("6_v02no")
[sub_resource type="Resource" id="Resource_aldxl"]
script = ExtResource("7_bbcbd")
arms = 3
bullets_per_arm = 2
arm_spacing = 26.0
@@ -73,10 +85,10 @@ sweep_period = 5.0
interval = 5
speed = 165.0
lifetime = 400
damage = 13
damage = 130
[sub_resource type="Resource" id="Resource_txml3"]
script = ExtResource("4_tmo3e")
[sub_resource type="Resource" id="Resource_3fm5w"]
script = ExtResource("5_v02no")
count = 7
spread_deg = 40.0
jitter_deg = 3.0
@@ -85,10 +97,11 @@ start_tick = 60
interval = 110
speed = 195.0
radius = 6.0
damage = 120
kind = 2
[sub_resource type="Resource" id="Resource_k1e8f"]
script = ExtResource("3_f5abt")
[sub_resource type="Resource" id="Resource_20c8f"]
script = ExtResource("4_rswgd")
count = 18
spin_per_shot_deg = -14.0
start_tick = 240
@@ -96,17 +109,18 @@ end_tick = 600
interval = 60
speed = 105.0
lifetime = 420
damage = 120
[sub_resource type="Resource" id="Resource_4sp1g"]
script = ExtResource("1_7m6h6")
[sub_resource type="Resource" id="Resource_ymr56"]
script = ExtResource("2_f5abt")
name = "Reaping Arms"
enter_at_hp_fraction = 0.42
loop_ticks = 720
damage_taken_mult = 1.15
emitters = Array[ExtResource("2_b0fvw")]([SubResource("Resource_pfjxl"), SubResource("Resource_txml3"), SubResource("Resource_k1e8f")])
emitters = Array[ExtResource("3_tmo3e")]([SubResource("Resource_aldxl"), SubResource("Resource_3fm5w"), SubResource("Resource_20c8f")])
[sub_resource type="Resource" id="Resource_aldxl"]
script = ExtResource("3_f5abt")
[sub_resource type="Resource" id="Resource_7xac7"]
script = ExtResource("4_rswgd")
count = 5
spin_per_shot_deg = 31.0
muzzle_offset = 46.0
@@ -114,44 +128,45 @@ interval = 11
speed = 130.0
turn_deg = 0.55
lifetime = 400
damage = 15
damage = 150
[sub_resource type="Resource" id="Resource_3fm5w"]
script = ExtResource("5_rswgd")
[sub_resource type="Resource" id="Resource_3n6oq"]
script = ExtResource("6_x6ufy")
direction = 2
count = 16
start_tick = 40
interval = 130
speed = 200.0
radius = 8.0
damage = 18
damage = 180
kind = 3
[sub_resource type="Resource" id="Resource_20c8f"]
script = ExtResource("4_tmo3e")
[sub_resource type="Resource" id="Resource_8ppic"]
script = ExtResource("5_v02no")
count = 3
spread_deg = 10.0
start_tick = 100
interval = 80
speed = 300.0
radius = 5.0
damage = 16
damage = 160
kind = 2
[sub_resource type="Resource" id="Resource_ymr56"]
script = ExtResource("1_7m6h6")
[sub_resource type="Resource" id="Resource_yqxmm"]
script = ExtResource("2_f5abt")
name = "Collapse"
enter_at_hp_fraction = 0.16
loop_ticks = 420
telegraph_ticks = 60
damage_taken_mult = 1.3
emitters = Array[ExtResource("2_b0fvw")]([SubResource("Resource_aldxl"), SubResource("Resource_3fm5w"), SubResource("Resource_20c8f")])
emitters = Array[ExtResource("3_tmo3e")]([SubResource("Resource_7xac7"), SubResource("Resource_3n6oq"), SubResource("Resource_8ppic")])
[resource]
script = ExtResource("7_x6ufy")
script = ExtResource("8_axg03")
id = &"warden"
display_name = "Warden of the Fold"
max_hp = 3600
max_hp = 36000
radius = 42.0
spawn_pos = Vector2(0, -150)
phases = Array[ExtResource("1_7m6h6")]([SubResource("Resource_s17mk"), SubResource("Resource_a0mrn"), SubResource("Resource_4sp1g"), SubResource("Resource_ymr56")])
phases = Array[ExtResource("2_f5abt")]([SubResource("Resource_pfjxl"), SubResource("Resource_4sp1g"), SubResource("Resource_ymr56"), SubResource("Resource_yqxmm")])
loot = Array[ExtResource("1_b0fvw")]([SubResource("Resource_s17mk"), SubResource("Resource_0452f")])
+16 -9
View File
@@ -1,23 +1,30 @@
[gd_resource type="Resource" script_class="EnemyDef" format=3]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_801wv"]
[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="2_bjhtk"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="3_e6oj1"]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_bjhtk"]
[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="2_e6oj1"]
[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="3_vpd04"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="4_k36hi"]
[sub_resource type="Resource" id="Resource_vpd04"]
script = ExtResource("2_bjhtk")
[sub_resource type="Resource" id="Resource_rsrg2"]
script = ExtResource("2_e6oj1")
count = 3
spread_deg = 20.0
interval = 120
radius = 6.0
lifetime = 240
damage = 10
damage = 100
[sub_resource type="Resource" id="Resource_lnbnj"]
script = ExtResource("3_vpd04")
item = &"health_potion"
chance = 0.08
[resource]
script = ExtResource("3_e6oj1")
script = ExtResource("4_k36hi")
id = &"drifter"
display_name = "Drifter"
contact_damage = 8
max_hp = 400
speed = 55.0
emitters = Array[ExtResource("1_801wv")]([SubResource("Resource_vpd04")])
emitters = Array[ExtResource("1_bjhtk")]([SubResource("Resource_rsrg2")])
pattern_loop_ticks = 120
loot = Array[ExtResource("3_vpd04")]([SubResource("Resource_lnbnj")])
+6 -5
View File
@@ -1,14 +1,15 @@
[gd_resource type="Resource" script_class="EnemyDef" format=3]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_ajtny"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="2_i7w5w"]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_i7w5w"]
[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="2_74ern"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="3_2tv1l"]
[resource]
script = ExtResource("2_i7w5w")
script = ExtResource("3_2tv1l")
id = &"dummy"
display_name = "Target Dummy"
max_hp = 100000
max_hp = 1000
radius = 20.0
contact_damage = 0
move = 0
visual = 3
indestructible = true
+25 -5
View File
@@ -1,16 +1,36 @@
[gd_resource type="Resource" script_class="EnemyDef" format=3]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_dkoo8"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="2_t1lyl"]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_e7xon"]
[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="2_ehenj"]
[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="3_tpjo4"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="4_yp71e"]
[sub_resource type="Resource" id="Resource_oeksl"]
script = ExtResource("2_ehenj")
spread_deg = 62.0
muzzle_offset = 10.0
interval = 45
speed = 260.0
radius = 6.0
lifetime = 18
damage = 140
kind = 3
[sub_resource type="Resource" id="Resource_ep81w"]
script = ExtResource("3_tpjo4")
item = &"health_potion"
chance = 0.08
[resource]
script = ExtResource("2_t1lyl")
script = ExtResource("4_yp71e")
id = &"stalker"
display_name = "Stalker"
max_hp = 30
max_hp = 300
radius = 12.0
contact_damage = 16
move = 3
speed = 95.0
retarget_interval = 20
visual = 2
emitters = Array[ExtResource("1_e7xon")]([SubResource("Resource_oeksl")])
pattern_loop_ticks = 45
loot = Array[ExtResource("3_tpjo4")]([SubResource("Resource_ep81w")])
+16 -9
View File
@@ -1,25 +1,32 @@
[gd_resource type="Resource" script_class="EnemyDef" format=3]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_asksf"]
[ext_resource type="Script" path="res://src/sim/patterns/ring_emitter.gd" id="2_7m5d5"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="3_5udip"]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_7m5d5"]
[ext_resource type="Script" path="res://src/sim/patterns/ring_emitter.gd" id="2_5udip"]
[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="3_hc01i"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="4_6rb75"]
[sub_resource type="Resource" id="Resource_hc01i"]
script = ExtResource("2_7m5d5")
[sub_resource type="Resource" id="Resource_kv0ny"]
script = ExtResource("2_5udip")
count = 10
spin_per_shot_deg = 18.0
interval = 150
speed = 130.0
damage = 120
[sub_resource type="Resource" id="Resource_fttpl"]
script = ExtResource("3_hc01i")
item = &"health_potion"
chance = 0.08
[resource]
script = ExtResource("3_5udip")
script = ExtResource("4_6rb75")
id = &"turret"
display_name = "Turret"
max_hp = 70
max_hp = 700
radius = 16.0
contact_damage = 0
move = 0
speed = 0.0
visual = 1
emitters = Array[ExtResource("1_asksf")]([SubResource("Resource_hc01i")])
emitters = Array[ExtResource("1_7m5d5")]([SubResource("Resource_kv0ny")])
pattern_loop_ticks = 150
loot = Array[ExtResource("3_hc01i")]([SubResource("Resource_fttpl")])
+17 -3
View File
@@ -7,11 +7,25 @@ extends Resource
@export var display_name: String = "Boss"
@export var max_hp: int = 4000
@export var radius: float = 44.0
## Stationary bosses are the MVP shape; the field exists so a later boss can
## move without changing the runtime.
@export var stationary: bool = true
@export var spawn_pos := Vector2(0.0, -140.0)
## Index into the renderer's boss sprite table. Same idea as EnemyDef.visual --
## the simulation never learns that art exists.
@export var visual: int = 0
@export var phases: Array[BossPhase] = []
## What the kill leaves behind. Bosses are the guaranteed source: a run that
## reaches the end should always be worth something, so unlike trash loot these
## entries are normally chance 1.0.
@export var loot: Array[LootDrop] = []
## Whether any phase moves this boss. Derived from the phases rather than
## stored alongside them: a flag that said "stationary" while a phase walked
## around would be a second source of truth, and the wrong one.
func moves() -> bool:
for phase in phases:
if phase != null and phase.moves():
return true
return false
## Index of the phase that matches [param hp_fraction]. Later entries win, so a
+35
View File
@@ -16,3 +16,38 @@ extends Resource
## phases rather than adding hit points, so the fight length stays predictable.
@export var damage_taken_mult: float = 1.0
@export var emitters: Array[BulletEmitter] = []
@export_group("Movement")
## How the boss moves during this phase. Movement is a property of the PHASE,
## not of the boss: a fight that stands still and then starts hunting you is
## one boss with two phases, and expressing it any other way would put a
## per-boss branch in the simulation.
@export var move: Move = Move.STATIC
## Units per second. Zero is equivalent to STATIC.
@export var move_speed: float = 0.0
## ORBIT: radius around the arena's centre. CHASE: the distance it tries to
## hold from you -- closing all the way would mean a boss you cannot see past.
@export var move_param: float = 120.0
## WAYPOINTS: points in the arena as fractions of it, so one phase works in any
## room. (0,0) is the top-left corner of the fightable area, (1,1) the bottom
## right.
@export var waypoints: Array[Vector2] = []
## Ticks spent standing at each waypoint before moving on.
@export var waypoint_dwell: int = 60
enum Move {
## Never moves. Every phase written before bosses could move.
STATIC,
## Circles the centre of its arena at [member move_param] radius.
ORBIT,
## Closes on the nearest player, holding [member move_param] distance.
CHASE,
## Walks a fixed circuit of [member waypoints], pausing at each.
WAYPOINTS,
}
## Whether this phase actually moves the boss. Derived rather than stored, so a
## phase cannot claim to move and then sit still.
func moves() -> bool:
return move != Move.STATIC and move_speed > 0.0
+49
View File
@@ -0,0 +1,49 @@
class_name DungeonDef
extends Resource
## A kind of dungeon run. Same generator, same rooms, same enemies -- what
## differs is how tough its contents are and how freely they drop.
##
## Deliberately a set of multipliers over the shared content rather than a
## parallel copy of it: a second dungeon that duplicated `Content` would drift
## from the first the moment anything was tuned, and then "identical but
## easier" would quietly stop being true.
@export var id: StringName = &"dungeon"
@export var display_name: String = "Dungeon"
## One line for the portal label, so a player standing in the hub can tell the
## entrances apart without reading a wiki.
@export var subtitle: String = ""
## Which hand-authored boss arena this run ends in, and therefore which boss it
## has -- see [method Rooms.boss_for_arena]. Empty means "whichever the seed
## rolls", which only the test harness wants.
@export var arena: StringName = &""
@export var enemy_hp_mult: float = 1.0
@export var boss_hp_mult: float = 1.0
## Multiplies every loot chance, clamped at certain. Guaranteed drops stay
## guaranteed; there is nothing above 1.0 to reach for.
@export var loot_chance_mult: float = 1.0
## Portal colour, and the tint of the dungeon's name on the HUD.
@export var tint := Color(0.5, 0.9, 1.0)
## Scale a freshly built [EnemyDef] in place.
##
## Safe to mutate because every caller of `Content.enemy()` gets a new object --
## the content functions construct one per call. If that ever changes, this has
## to duplicate first, or one easy dungeon would nerf every hard one.
func apply_to_enemy(def: EnemyDef) -> EnemyDef:
def.max_hp = maxi(1, roundi(float(def.max_hp) * enemy_hp_mult))
_scale_loot(def.loot)
return def
func apply_to_boss(def: BossDef) -> BossDef:
def.max_hp = maxi(1, roundi(float(def.max_hp) * boss_hp_mult))
_scale_loot(def.loot)
return def
func _scale_loot(table: Array[LootDrop]) -> void:
for entry in table:
if entry != null:
entry.chance = clampf(entry.chance * loot_chance_mult, 0.0, 1.0)
+1
View File
@@ -0,0 +1 @@
uid://cqert4nr662vl
+11 -1
View File
@@ -21,7 +21,6 @@ enum Move {
@export var display_name: String = "Drone"
@export var max_hp: int = 40
@export var radius: float = 14.0
@export var contact_damage: int = 10
@export var move: Move = Move.DRIFT
@export var speed: float = 60.0
## ORBIT radius, or STRAFE preferred distance.
@@ -29,8 +28,19 @@ enum Move {
## Ticks between direction re-evaluations for APPROACH/STRAFE, so enemies read
## as deliberate rather than twitchy.
@export var retarget_interval: int = 30
## How far this enemy notices a player, in world units. It also needs line of
## sight -- range alone would mean shooting through walls.
@export var aggro_range: float = 420.0
## Index the client renderer uses to pick a shape/colour.
@export var visual: int = 0
@export var emitters: Array[BulletEmitter] = []
## The emitter timeline wraps at this many ticks.
@export var pattern_loop_ticks: int = 240
## Never takes damage. Exists for the hub's practice target, whose whole job is
## to still be there tomorrow -- expressing that as a flag rather than as a
## large health pool means it cannot be worn down by a patient player, and
## keeps its health inside the u16 the snapshot sends.
@export var indestructible: bool = false
## What this enemy may leave behind. Rolled once per entry on death, against
## the world's own RNG. Empty for anything that should drop nothing.
@export var loot: Array[LootDrop] = []
+30
View File
@@ -0,0 +1,30 @@
class_name ItemDef
extends Resource
## Data-only description of an item, in the same spirit as [EnemyDef]: adding an
## item is a table entry in [Items], never a branch in the simulation.
##
## Items are deliberately thin. A slot holds an id and nothing else -- no
## charges, no durability, no stack count -- because everything the game
## currently needs is expressible as "one id per slot", and the wire format,
## the persistence record and the UI all get simpler for it. When something
## needs a count, add it here rather than teaching four layers about it.
enum Effect {
## Consumed and does nothing. Not a placeholder: the boss ration exists to
## prove the player-instanced loot path works end to end, and it can only
## do that if using it is a real, observable transaction.
NONE,
## Restores [member effect_value] percent of MAXIMUM health, so a potion
## keeps its value at level 15 instead of becoming a rounding error.
HEAL,
}
@export var id: StringName = &"item"
@export var display_name: String = "Item"
@export var effect: Effect = Effect.NONE
@export var effect_value: float = 0.0
## Index into the renderer's item icon table. Same idea as [member
## EnemyDef.visual]: the simulation never learns that art exists.
@export var visual: int = 0
## HUD tint. View-only, kept here so one table describes the whole item.
@export var tint := Color(0.85, 0.85, 0.9)
+1
View File
@@ -0,0 +1 @@
uid://t2fhc2hd1575
+28
View File
@@ -0,0 +1,28 @@
class_name LootDrop
extends Resource
## One entry in an enemy's or boss's loot table.
##
## [member instanced] is the interesting field, and it is the reason loot has
## two visibilities rather than one:
##
## - false -- a single entity in the instance that everyone can see and the
## first to reach takes. This is the default, and it is what makes loot a
## thing a party negotiates over.
## - true -- one entity per eligible player, each visible only to its owner.
## Nobody competes, nobody is denied. The server filters these out of every
## other peer's snapshot, so it is an interest-management rule and not merely
## a UI convention: a modified client is not told the others exist.
@export var item: StringName = &""
## Probability in [0, 1], rolled once per kill against the world's own RNG.
@export var chance: float = 1.0
@export var instanced: bool = false
static func make(item_id: StringName, drop_chance: float,
player_instanced: bool = false) -> LootDrop:
var d := LootDrop.new()
d.item = item_id
d.chance = drop_chance
d.instanced = player_instanced
return d
+1
View File
@@ -0,0 +1 @@
uid://de021jelm6hcg
+59
View File
@@ -0,0 +1,59 @@
class_name UpgradeDef
extends Resource
## One upgrade, as data. Every field here is a modifier the simulation already
## knows how to apply, so adding an upgrade is a table entry and never a branch
## in [SimWorld] -- the same rule bosses follow.
##
## Note what is NOT here: the flat +5% damage every upgrade carries. That is a
## property of *taking an upgrade*, not of any particular one, so it lives in
## [PlayerStats] where it is applied once per upgrade held. Baking it into each
## definition would mean seven places to change it and seven places to get it
## wrong.
enum Rarity { COMMON, UNCOMMON, RARE, LEGENDARY }
@export var id: StringName = &"upgrade"
@export var display_name: String = "Upgrade"
## Shown on the choice card. Says what it does, in the player's terms.
@export var description: String = ""
@export var rarity: Rarity = Rarity.COMMON
# --- Damage -----------------------------------------------------------------
## Added to the additive pool: base x (1 + sum of these) x product of the
## multiplicative ones. Negative for the upgrades that trade damage away.
@export var damage_add: float = 0.0
## Multiplied in after the pool. Sniper is the only thing that uses this, and
## the brief calls it out as multiplicative specifically so it stays sharp no
## matter how many additive percentages have piled up.
@export var damage_mult: float = 1.0
# --- Everything else --------------------------------------------------------
## Multiplies the character's levelled maximum health, so the cost of trading
## health away scales instead of fading out by level 15.
@export var max_hp_mult: float = 1.0
## Below 1.0 means slower. Applied to the cooldown as a division, so 0.5 here is
## genuinely half the shots per second.
@export var fire_rate_mult: float = 1.0
@export var bullet_speed_mult: float = 1.0
## Extra projectiles fanned out to the sides of the aim.
@export var side_shots: int = 0
## Extra projectiles parallel to the aim, offset sideways.
@export var parallel_shots: int = 0
## How many times one shot may split on hitting something. The brief's "cannot
## split twice unless the upgrade is taken again" is exactly this being a count
## rather than a flag.
@export var split_charges: int = 0
## Fraction of a hit's damage dealt again over the poison window.
@export var poison_fraction: float = 0.0
## Chance, per tick, that a shot deletes an enemy projectile it is passing
## through.
@export var erase_chance: float = 0.0
static func rarity_name(r: Rarity) -> String:
match r:
Rarity.COMMON: return "common"
Rarity.UNCOMMON: return "uncommon"
Rarity.RARE: return "rare"
Rarity.LEGENDARY: return "legendary"
return "?"
+1
View File
@@ -0,0 +1 @@
uid://d0mhvxc26ff0
+156 -13
View File
@@ -13,6 +13,11 @@ signal state_changed(new_state: State)
enum State { OFFLINE, CONNECTING, ONLINE, FAILED }
## How long a refused peer is left connected so its rejection can actually go
## out. Long enough to survive a frame's flush and the round trip, short enough
## that nobody notices; see [method reject_and_kick].
const REJECT_LINGER := 0.5
## Peer id the engine gives the host. On a listen server this is also a real
## player, which is why every send goes through the helpers below.
const LOCAL_PEER := 1
@@ -22,6 +27,10 @@ var server: ServerRuntime = null
var client: ClientRuntime = null
var last_error: String = ""
## How this build establishes identity. One line to swap for a Steam provider:
## everything downstream deals only in the 64-bit account id it produces.
var auth: AuthProvider = LocalAuthProvider.new()
func _set_state(s: State) -> void:
state = s
@@ -93,9 +102,32 @@ func shutdown() -> void:
_set_state(State.OFFLINE)
## Tell a peer why it is not welcome, then close the socket.
##
## The kick is DEFERRED, which is the whole point of this existing. An RPC is
## queued and only leaves on the frame's flush, so disconnecting in the same
## call throws the reason away with the packet -- a client turned away for a
## protocol mismatch was told only that the server closed the connection. The
## peer can do nothing during the wait: it has no account bound, and every
## message that needs one already refuses it.
func reject_and_kick(peer_id: int, reason: String) -> void:
send_reject(peer_id, reason)
if _is_local(peer_id):
return
get_tree().create_timer(REJECT_LINGER).timeout.connect(
func() -> void: kick(peer_id))
func kick(peer_id: int) -> void:
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
(multiplayer.multiplayer_peer as ENetMultiplayerPeer).disconnect_peer(peer_id)
if multiplayer.multiplayer_peer is not ENetMultiplayerPeer:
return
# The peer may have hung up first. A rejected client closes its own socket
# as soon as it reads the reason, and reject_and_kick's timer lands after
# that -- asking ENet to disconnect someone already gone is an engine error,
# not a quiet no-op.
if not multiplayer.get_peers().has(peer_id):
return
(multiplayer.multiplayer_peer as ENetMultiplayerPeer).disconnect_peer(peer_id)
func is_server() -> bool:
@@ -116,7 +148,7 @@ func _on_peer_disconnected(peer_id: int) -> void:
func _on_connected() -> void:
_set_state(State.ONLINE)
c_hello.rpc_id(1, Protocol.VERSION, GameOpts.player_name)
c_hello.rpc_id(1, Protocol.VERSION, auth.get_ticket())
func _on_connect_failed() -> void:
@@ -126,8 +158,14 @@ func _on_connect_failed() -> void:
func _on_server_disconnected() -> void:
last_error = "server closed the connection"
GameLog.warn("net", last_error)
# A rejection lands immediately before the socket closes, and it is the
# useful half of the pair: "protocol mismatch: server 9, client 8" is
# actionable and "server closed the connection" is not. Overwriting it here
# made every reject reason in the game unreachable -- the reason was sent,
# decoded, stored, and then clobbered a frame later by this line.
if state != State.FAILED:
last_error = "server closed the connection"
GameLog.warn("net", last_error)
shutdown()
@@ -142,7 +180,7 @@ func start_local_client() -> void:
client = ClientRuntime.new()
client.name = "Client"
add_child(client)
server.on_hello(LOCAL_PEER, Protocol.VERSION, GameOpts.player_name)
server.on_hello(LOCAL_PEER, Protocol.VERSION, auth.get_ticket())
func _is_local(peer_id: int) -> bool:
@@ -160,12 +198,25 @@ func send_welcome(peer_id: int) -> void:
s_welcome.rpc_id(peer_id, peer_id, Protocol.VERSION)
## Deliberately carries the map's SIZE and not its seed. Handing over the seed
## would let any client regenerate the whole dungeon locally, which is a map
## hack with no work required; tiles are streamed instead (send_map_chunks).
func send_enter_instance(peer_id: int, id: int, kind: int, server_tick: int,
boss_id: String, spawn: Vector2) -> void:
boss_id: String, spawn: Vector2, map_w: int, map_h: int,
portals: PackedByteArray, dungeon: String, npc: Vector2) -> void:
if _is_local(peer_id):
client.on_enter_instance(id, kind, server_tick, boss_id, spawn)
client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w,
map_h, portals, dungeon, npc)
else:
s_enter_instance.rpc_id(peer_id, id, kind, server_tick, boss_id, spawn)
s_enter_instance.rpc_id(peer_id, id, kind, server_tick, boss_id, spawn,
map_w, map_h, portals, dungeon, npc)
func send_map_chunks(peer_id: int, instance_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id):
client.on_map_chunks(instance_id, data)
else:
s_map_chunks.rpc_id(peer_id, instance_id, data)
func send_snapshot(peer_id: int, data: PackedByteArray) -> void:
@@ -182,6 +233,27 @@ func send_events(peer_id: int, data: PackedByteArray) -> void:
s_events.rpc_id(peer_id, data)
func send_characters(peer_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id):
client.on_characters(data)
else:
s_characters.rpc_id(peer_id, data)
func send_select_result(peer_id: int, result: int, reason: String) -> void:
if _is_local(peer_id):
client.on_select_result(result, reason)
else:
s_select_result.rpc_id(peer_id, result, reason)
func send_upgrades(peer_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id):
client.on_upgrades(data)
else:
s_upgrades.rpc_id(peer_id, data)
func send_roster(peer_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id):
client.on_roster(data)
@@ -196,6 +268,30 @@ func send_reject(peer_id: int, reason: String) -> void:
s_reject.rpc_id(peer_id, reason)
func select_character(character_id: String) -> void:
if server != null:
server.on_select_character(LOCAL_PEER, character_id)
elif state == State.ONLINE:
c_select_character.rpc_id(1, character_id)
## Spend a level-up. Intent only: the index names one of the three options the
## SERVER put on the table, and the server checks the player is standing at the
## NPC before it means anything.
func choose_upgrade(index: int) -> void:
if server != null:
server.on_choose_upgrade(LOCAL_PEER, index)
elif state == State.ONLINE:
c_choose_upgrade.rpc_id(1, index)
func create_character(character_name: String) -> void:
if server != null:
server.on_create_character(LOCAL_PEER, character_name)
elif state == State.ONLINE:
c_create_character.rpc_id(1, character_name)
func send_input(data: PackedByteArray) -> void:
if server != null:
server.on_input(LOCAL_PEER, data) # listen server: no transport at all
@@ -206,10 +302,31 @@ func send_input(data: PackedByteArray) -> void:
# --- Client -> server -------------------------------------------------------
@rpc("any_peer", "call_remote", "reliable", 1)
func c_hello(version: int, display_name: String) -> void:
func c_hello(version: int, ticket: PackedByteArray) -> void:
if server == null:
return
server.on_hello(multiplayer.get_remote_sender_id(), version, display_name)
server.on_hello(multiplayer.get_remote_sender_id(), version, ticket)
@rpc("any_peer", "call_remote", "reliable", 1)
func c_select_character(character_id: String) -> void:
if server == null:
return
server.on_select_character(multiplayer.get_remote_sender_id(), character_id)
@rpc("any_peer", "call_remote", "reliable", 1)
func c_create_character(character_name: String) -> void:
if server == null:
return
server.on_create_character(multiplayer.get_remote_sender_id(), character_name)
@rpc("any_peer", "call_remote", "reliable", 1)
func c_choose_upgrade(index: int) -> void:
if server == null:
return
server.on_choose_upgrade(multiplayer.get_remote_sender_id(), index)
@rpc("any_peer", "call_remote", "unreliable_ordered", 4)
@@ -231,10 +348,18 @@ func s_welcome(peer_id: int, _version: int) -> void:
@rpc("authority", "call_remote", "reliable", 1)
func s_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
spawn: Vector2) -> void:
spawn: Vector2, map_w: int, map_h: int, portals: PackedByteArray,
dungeon: String, npc: Vector2) -> void:
if client == null:
return
client.on_enter_instance(id, kind, server_tick, boss_id, spawn)
client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w,
map_h, portals, dungeon, npc)
@rpc("authority", "call_remote", "reliable", 1)
func s_map_chunks(instance_id: int, data: PackedByteArray) -> void:
if client != null:
client.on_map_chunks(instance_id, data)
@rpc("authority", "call_remote", "reliable", 1)
@@ -244,6 +369,24 @@ func s_reject(reason: String) -> void:
_set_state(State.FAILED)
@rpc("authority", "call_remote", "reliable", 1)
func s_characters(data: PackedByteArray) -> void:
if client != null:
client.on_characters(data)
@rpc("authority", "call_remote", "reliable", 1)
func s_select_result(result: int, reason: String) -> void:
if client != null:
client.on_select_result(result, reason)
@rpc("authority", "call_remote", "reliable", 1)
func s_upgrades(data: PackedByteArray) -> void:
if client != null:
client.on_upgrades(data)
@rpc("authority", "call_remote", "reliable", 1)
func s_roster(data: PackedByteArray) -> void:
if client != null:
+268 -24
View File
@@ -13,6 +13,22 @@ const ENEMY_TURRET := &"turret"
const ENEMY_STALKER := &"stalker"
const ENEMY_DUMMY := &"dummy"
const BOSS_WARDEN := &"warden"
const BOSS_CANTOR := &"cantor"
## Every id this file defines. Kept here so the export tool, the renderer and
## the tests all iterate the same list -- each of them used to hand-maintain
## its own copy, and the export tool had already gone stale and stopped writing
## the second boss.
const ALL_ENEMIES: Array[StringName] = [
ENEMY_DRIFTER, ENEMY_TURRET, ENEMY_STALKER, ENEMY_DUMMY,
]
const ALL_BOSSES: Array[StringName] = [
BOSS_WARDEN, BOSS_CANTOR,
]
## How often an ordinary enemy leaves a potion. "Rare" is the design brief: a
## dungeon run should be survivable on what it hands you, but never comfortably.
const TRASH_POTION_CHANCE := 0.08
static func enemy(id: StringName) -> EnemyDef:
@@ -28,6 +44,7 @@ static func enemy(id: StringName) -> EnemyDef:
static func boss(id: StringName) -> BossDef:
match id:
BOSS_WARDEN: return warden()
BOSS_CANTOR: return cantor()
push_error("unknown boss id: %s" % id)
return warden()
@@ -39,9 +56,8 @@ static func drifter() -> EnemyDef:
var d := EnemyDef.new()
d.id = ENEMY_DRIFTER
d.display_name = "Drifter"
d.max_hp = 40
d.max_hp = 400
d.radius = 14.0
d.contact_damage = 8
d.move = EnemyDef.Move.DRIFT
d.speed = 55.0
d.visual = 0
@@ -54,10 +70,11 @@ static func drifter() -> EnemyDef:
fan.spread_deg = 20.0
fan.speed = 150.0
fan.radius = 6.0
fan.damage = 10
fan.damage = 100
fan.lifetime = 240
fan.kind = SimConfig.KIND_ORB
d.emitters = [fan]
d.loot = [LootDrop.make(Items.HEALTH_POTION, TRASH_POTION_CHANCE)]
return d
@@ -66,9 +83,8 @@ static func turret() -> EnemyDef:
var d := EnemyDef.new()
d.id = ENEMY_TURRET
d.display_name = "Turret"
d.max_hp = 70
d.max_hp = 700
d.radius = 16.0
d.contact_damage = 0
d.move = EnemyDef.Move.STATIC
d.speed = 0.0
d.visual = 1
@@ -81,36 +97,65 @@ static func turret() -> EnemyDef:
ring.spin_per_shot_deg = 18.0
ring.speed = 130.0
ring.radius = 7.0
ring.damage = 12
ring.damage = 120
ring.lifetime = 300
ring.kind = SimConfig.KIND_ORB
d.emitters = [ring]
d.loot = [LootDrop.make(Items.HEALTH_POTION, TRASH_POTION_CHANCE)]
return d
## Walks at you and does nothing else. The pressure enemy.
## Walks at you and fires a point-blank burst. The pressure enemy.
##
## It used to deal contact damage and nothing else. Nothing in this game hurts
## you by touching you -- every threat is a bullet you can see coming and dodge,
## which is the whole contract of the genre. So the Stalker's melee is a
## shotgun with a deliberately tiny lifetime: ~78px of travel, which only
## threatens once it has closed the distance, and leaves nothing lingering in
## the arena afterwards.
static func stalker() -> EnemyDef:
var d := EnemyDef.new()
d.id = ENEMY_STALKER
d.display_name = "Stalker"
d.max_hp = 30
d.max_hp = 300
d.radius = 12.0
d.contact_damage = 16
d.move = EnemyDef.Move.APPROACH
d.speed = 95.0
d.retarget_interval = 20
d.visual = 2
d.pattern_loop_ticks = 45
var lunge := AimedSpreadEmitter.new()
lunge.start_tick = 0
lunge.interval = 45
lunge.count = 5
lunge.spread_deg = 62.0
lunge.speed = 260.0
lunge.radius = 6.0
lunge.damage = 140
# 18 ticks at 260 u/s is about 78px of reach -- shorter than the muzzle-to-
# player distance at any range you would call "not point blank".
lunge.lifetime = 18
lunge.muzzle_offset = 10.0
lunge.kind = SimConfig.KIND_HEAVY
d.emitters = [lunge]
d.loot = [LootDrop.make(Items.HEALTH_POTION, TRASH_POTION_CHANCE)]
return d
## Lobby target dummy: inert, tough, so players can feel out the gun.
## Lobby target dummy: inert, indestructible, so players can feel out the gun.
##
## Indestructible by flag rather than by a huge health pool. The old 100000 was
## already past the u16 the snapshot sends enemy health in, and after the x10
## rescale a determined player could have destroyed the hub's only practice
## target for everyone until the server restarted.
static func dummy() -> EnemyDef:
var d := EnemyDef.new()
d.id = ENEMY_DUMMY
d.display_name = "Target Dummy"
d.max_hp = 100000
d.max_hp = 1000
d.indestructible = true
d.radius = 20.0
d.contact_damage = 0
d.move = EnemyDef.Move.STATIC
d.visual = 3
return d
@@ -125,11 +170,19 @@ static func warden() -> BossDef:
var b := BossDef.new()
b.id = BOSS_WARDEN
b.display_name = "Warden of the Fold"
b.max_hp = 3600
b.max_hp = 36000
b.radius = 42.0
b.stationary = true
b.spawn_pos = Vector2(0.0, -150.0)
b.visual = 0
b.phases = [_warden_p1(), _warden_p2(), _warden_p3(), _warden_p4()]
# Guaranteed, and deliberately one of each visibility. The potion is shared,
# so a party still has something to divide up; the ration is instanced, so
# every player who survived the fight leaves with the trophy and nobody has
# to race for it. Between them they exercise both loot paths on every kill.
b.loot = [
LootDrop.make(Items.HEALTH_POTION, 1.0),
LootDrop.make(Items.WARDENS_RATION, 1.0, true),
]
return b
@@ -148,7 +201,7 @@ static func _warden_p1() -> BossPhase:
ring.spin_per_shot_deg = 9.0
ring.speed = 135.0
ring.radius = 8.0
ring.damage = 14
ring.damage = 140
ring.lifetime = 420
ring.muzzle_offset = 46.0
@@ -159,7 +212,7 @@ static func _warden_p1() -> BossPhase:
fan.spread_deg = 26.0
fan.speed = 210.0
fan.radius = 6.0
fan.damage = 12
fan.damage = 120
fan.kind = SimConfig.KIND_NEEDLE
fan.muzzle_offset = 46.0
@@ -182,7 +235,7 @@ static func _warden_p2() -> BossPhase:
spiral.spin_per_shot_deg = 23.0
spiral.speed = 120.0
spiral.radius = 7.0
spiral.damage = 12
spiral.damage = 120
spiral.lifetime = 480
spiral.muzzle_offset = 46.0
@@ -195,7 +248,7 @@ static func _warden_p2() -> BossPhase:
wall.gap_step = 7
wall.speed = 175.0
wall.radius = 8.0
wall.damage = 16
wall.damage = 160
wall.lifetime = 300
wall.kind = SimConfig.KIND_HEAVY
@@ -222,7 +275,7 @@ static func _warden_p3() -> BossPhase:
arms.sweep_period = 5.0
arms.speed = 165.0
arms.radius = 7.0
arms.damage = 13
arms.damage = 130
arms.lifetime = 400
var fan := AimedSpreadEmitter.new()
@@ -234,7 +287,7 @@ static func _warden_p3() -> BossPhase:
fan.speed = 195.0
fan.edge_speed_bonus = 0.25
fan.radius = 6.0
fan.damage = 12
fan.damage = 120
fan.kind = SimConfig.KIND_NEEDLE
var counter_ring := RingEmitter.new()
@@ -245,7 +298,7 @@ static func _warden_p3() -> BossPhase:
counter_ring.spin_per_shot_deg = -14.0
counter_ring.speed = 105.0
counter_ring.radius = 7.0
counter_ring.damage = 12
counter_ring.damage = 120
counter_ring.lifetime = 420
p.emitters = [arms, fan, counter_ring]
@@ -269,7 +322,7 @@ static func _warden_p4() -> BossPhase:
curve.speed = 130.0
curve.turn_deg = 0.55
curve.radius = 7.0
curve.damage = 15
curve.damage = 150
curve.lifetime = 400
curve.muzzle_offset = 46.0
@@ -282,7 +335,7 @@ static func _warden_p4() -> BossPhase:
walls.gap_step = 5
walls.speed = 200.0
walls.radius = 8.0
walls.damage = 18
walls.damage = 180
walls.kind = SimConfig.KIND_HEAVY
var snipe := AimedSpreadEmitter.new()
@@ -292,8 +345,199 @@ static func _warden_p4() -> BossPhase:
snipe.spread_deg = 10.0
snipe.speed = 300.0
snipe.radius = 5.0
snipe.damage = 16
snipe.damage = 160
snipe.kind = SimConfig.KIND_NEEDLE
p.emitters = [curve, walls, snipe]
return p
# --- The Cantor of the Vault ------------------------------------------------
## The second boss, and the one that proves the format stretched. The Warden is
## four phases of standing still and filling space; the Cantor walks.
##
## It fights in [method Rooms.choir_vault] -- tighter, with barricades you can
## see over but not shoot through -- so its patterns are about position rather
## than about density. Everything below is data: the simulation gained a
## movement mode and a telegraphing emitter, and no line anywhere knows this
## boss exists.
static func cantor() -> BossDef:
var b := BossDef.new()
b.id = BOSS_CANTOR
b.display_name = "Cantor of the Vault"
b.max_hp = 28000
b.radius = 36.0
b.spawn_pos = Vector2(0.0, -150.0)
b.visual = 1
b.phases = [_cantor_p1(), _cantor_p2(), _cantor_p3(), _cantor_p4()]
b.loot = [
LootDrop.make(Items.HEALTH_POTION, 1.0),
LootDrop.make(Items.WARDENS_RATION, 1.0, true),
]
return b
## Phase 1 -- introduces the telegraph and nothing else. Stands still, marks
## three spots, fills them. The whole phase exists to teach the marker.
static func _cantor_p1() -> BossPhase:
var p := BossPhase.new()
p.name = "Call to Prayer"
p.enter_at_hp_fraction = 1.0
p.loop_ticks = 360
p.telegraph_ticks = 60
var strikes := TelegraphedStrikeEmitter.new()
strikes.interval = 180
strikes.warn_ticks = 80
strikes.strikes = 3
strikes.burst_count = 10
strikes.blast_radius = 66.0
strikes.speed = 130.0
strikes.radius = 7.0
strikes.damage = 130
strikes.lifetime = 240
strikes.kind = SimConfig.KIND_HEAVY
var fan := AimedSpreadEmitter.new()
fan.start_tick = 40
fan.interval = 120
fan.count = 3
fan.spread_deg = 18.0
fan.speed = 190.0
fan.radius = 6.0
fan.damage = 110
fan.kind = SimConfig.KIND_NEEDLE
fan.muzzle_offset = 40.0
p.emitters = [strikes, fan]
return p
## Phase 2 -- adds movement. It walks the four corners of the vault, dwelling
## at each, so the safe half of the room keeps changing while a spiral fills
## whatever it has left behind.
static func _cantor_p2() -> BossPhase:
var p := BossPhase.new()
p.name = "Processional"
p.enter_at_hp_fraction = 0.75
p.loop_ticks = 480
p.telegraph_ticks = 45
p.move = BossPhase.Move.WAYPOINTS
p.move_speed = 105.0
p.waypoint_dwell = 70
# Fractions of the arena, so the same walk works in a room of any size.
p.waypoints = [
Vector2(0.18, 0.2), Vector2(0.82, 0.2),
Vector2(0.82, 0.8), Vector2(0.18, 0.8),
]
var spiral := RingEmitter.new()
spiral.interval = 12
spiral.count = 3
spiral.spin_per_shot_deg = 27.0
spiral.speed = 125.0
spiral.radius = 7.0
spiral.damage = 120
spiral.lifetime = 400
spiral.muzzle_offset = 40.0
var strikes := TelegraphedStrikeEmitter.new()
strikes.start_tick = 60
strikes.interval = 200
strikes.warn_ticks = 90
strikes.strikes = 2
strikes.burst_count = 12
strikes.blast_radius = 70.0
strikes.pattern_seed = 17
strikes.speed = 145.0
strikes.radius = 7.0
strikes.damage = 140
strikes.lifetime = 240
strikes.kind = SimConfig.KIND_HEAVY
p.emitters = [spiral, strikes]
return p
## Phase 3 -- it comes for you. Holds a distance rather than closing, so the
## fight stays readable, and the aimed fire punishes standing still while the
## walls punish running in a straight line.
static func _cantor_p3() -> BossPhase:
var p := BossPhase.new()
p.name = "Antiphon"
p.enter_at_hp_fraction = 0.45
p.loop_ticks = 540
p.telegraph_ticks = 50
p.damage_taken_mult = 1.12
p.move = BossPhase.Move.CHASE
p.move_speed = 120.0
p.move_param = 220.0
var fan := AimedSpreadEmitter.new()
fan.interval = 90
fan.count = 5
fan.spread_deg = 34.0
fan.jitter_deg = 2.5
fan.speed = 200.0
fan.radius = 6.0
fan.damage = 120
fan.kind = SimConfig.KIND_NEEDLE
fan.muzzle_offset = 40.0
var walls := WallGapEmitter.new()
walls.start_tick = 70
walls.interval = 160
walls.direction = 1
walls.count = 16
walls.gap_width = 4
walls.gap_step = 6
walls.speed = 165.0
walls.radius = 8.0
walls.damage = 150
walls.lifetime = 300
walls.kind = SimConfig.KIND_HEAVY
p.emitters = [fan, walls]
return p
## Phase 4 -- it circles the vault while marking it. Movement and telegraphs at
## once, which is the point: both were added for this fight and this is where
## they have to work together.
static func _cantor_p4() -> BossPhase:
var p := BossPhase.new()
p.name = "Final Cadence"
p.enter_at_hp_fraction = 0.18
p.loop_ticks = 420
p.telegraph_ticks = 55
p.damage_taken_mult = 1.25
p.move = BossPhase.Move.ORBIT
p.move_speed = 150.0
p.move_param = 150.0
var ring := RingEmitter.new()
ring.interval = 40
ring.count = 12
ring.spin_per_shot_deg = 15.0
ring.speed = 140.0
ring.radius = 7.0
ring.damage = 130
ring.lifetime = 380
ring.muzzle_offset = 40.0
var strikes := TelegraphedStrikeEmitter.new()
strikes.interval = 150
strikes.warn_ticks = 70
strikes.strikes = 4
strikes.burst_count = 14
strikes.blast_radius = 72.0
strikes.pattern_seed = 91
strikes.speed = 150.0
strikes.radius = 7.0
strikes.damage = 150
strikes.lifetime = 240
strikes.kind = SimConfig.KIND_HEAVY
p.emitters = [ring, strikes]
return p
+104
View File
@@ -0,0 +1,104 @@
class_name Dungeons
extends RefCounted
## The kinds of dungeon run, defined in code like everything else in
## `src/content/`.
##
## [constant ORDER] does double duty: it is the wire order for the dungeon id,
## and it is the order the hub's portals are assigned. The Nth `P` marker in the
## lobby stamp (reading order) opens the Nth entry here, so adding a dungeon is
## one entry plus one marker.
const STANDARD := &"warden_descent"
const VAULT := &"choir_vault"
const PROVING := &"proving_grounds"
const ORDER: Array[StringName] = [
STANDARD,
VAULT,
PROVING,
]
static func default_id() -> StringName:
return STANDARD
static func get_def(id: StringName) -> DungeonDef:
match id:
STANDARD: return standard()
VAULT: return vault()
PROVING: return proving_grounds()
return null
## Falls back to the standard run rather than to null. An id off the wire has to
## resolve to something playable, and "the normal dungeon" is the safe answer.
static func get_or_default(id: StringName) -> DungeonDef:
var d := get_def(id)
return d if d != null else standard()
static func index_of(id: StringName) -> int:
return maxi(ORDER.find(id), 0)
static func by_index(index: int) -> StringName:
if index < 0 or index >= ORDER.size():
return default_id()
return ORDER[index]
# --- The dungeons -----------------------------------------------------------
## The real thing. Every multiplier is 1.0, which is the point: this is the
## baseline the other definitions are described against.
static func standard() -> DungeonDef:
var d := DungeonDef.new()
d.id = STANDARD
d.display_name = "Warden's Descent"
d.subtitle = "the Warden of the Fold"
d.arena = &"warden_hall"
d.tint = Color(0.5, 0.9, 1.0)
return d
## The second boss's own run. Same generator and the same enemies as the
## Descent; what differs is the arena it ends in, and therefore who is waiting.
##
## A dedicated entrance rather than a coin flip on the run's seed: 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.
static func vault() -> DungeonDef:
var d := DungeonDef.new()
d.id = VAULT
d.display_name = "The Choir Vault"
d.subtitle = "the Cantor of the Vault"
d.arena = &"choir_vault"
d.tint = Color(0.75, 0.6, 1.0)
return d
## A test harness you can walk into.
##
## Same generator, same rooms, same enemies and the same Warden -- everything
## dies far faster and drops far more often, so a manual pass over the loot,
## the inventory and all four boss phases takes a couple of minutes instead of a
## quarter of an hour. Being reachable from the hub rather than hidden behind a
## launch flag is most of the value: you can compare the two back to back in one
## session without restarting the server.
static func proving_grounds() -> DungeonDef:
var d := DungeonDef.new()
d.id = PROVING
d.display_name = "Proving Grounds"
d.subtitle = "for testing -- fragile, generous"
# Deliberately left to the seed. The harness exists to reach a fight fast,
# and either fight will do -- runs here last a couple of minutes, so the one
# you want is one re-entry away.
d.arena = &""
d.enemy_hp_mult = 0.2
d.boss_hp_mult = 0.08
# 0.08 -> 0.8 for trash. High enough that a handful of kills fills a bag,
# which is what makes the four-slot limit and dropping testable at all.
d.loot_chance_mult = 10.0
d.tint = Color(1.0, 0.75, 0.35)
return d
+1
View File
@@ -0,0 +1 @@
uid://dw7j78sn8obvw
+81
View File
@@ -0,0 +1,81 @@
class_name Items
extends RefCounted
## Every item in the game, defined in code for the same reasons [Content] is.
##
## [constant ORDER] is load-bearing: an item's position in it is the byte that
## rides the snapshot and every item event. Item ids are strings and a slot is
## sent 20 times a second per player, so the wire carries the index instead.
## Append to the list, never reorder it -- and if you do reorder it, bump
## [constant Protocol.VERSION], because every client would otherwise decode a
## potion as a ration.
const NONE := &""
const HEALTH_POTION := &"health_potion"
const WARDENS_RATION := &"wardens_ration"
## Wire order. Index 0 is reserved for "empty slot" / "no item", so an id's
## wire value is its position here plus one.
const ORDER: Array[StringName] = [
HEALTH_POTION,
WARDENS_RATION,
]
static func get_def(id: StringName) -> ItemDef:
match id:
HEALTH_POTION: return health_potion()
WARDENS_RATION: return wardens_ration()
return null
## Wire value for an id. 0 for an empty slot or an id this build does not know,
## which is the safe direction: an unknown item reads as nothing rather than as
## whatever happens to sit at that index.
static func index_of(id: StringName) -> int:
var at := ORDER.find(id)
return at + 1 if at >= 0 else 0
static func by_index(index: int) -> StringName:
if index <= 0 or index > ORDER.size():
return NONE
return ORDER[index - 1]
static func display_name_of(id: StringName) -> String:
var def := get_def(id)
return def.display_name if def != null else ""
# --- The items --------------------------------------------------------------
## The only item with an effect. Percentage-based so it stays meaningful across
## the whole level range -- a flat 40 hp would be a full heal at level 1 and
## noise at level 15.
static func health_potion() -> ItemDef:
var d := ItemDef.new()
d.id = HEALTH_POTION
d.display_name = "Health Potion"
d.effect = ItemDef.Effect.HEAL
d.effect_value = 40.0
d.visual = 0
d.tint = Color(0.95, 0.4, 0.42)
return d
## Dropped by every boss, one per player who was alive for the kill, and does
## nothing whatsoever when used.
##
## It is not filler. Player-instanced loot is a different code path from shared
## loot -- a separate entity per owner, filtered per peer on the wire -- and the
## cheapest way to keep that path honest is to have something in the game that
## uses it every single boss kill. A trophy nobody has to fight over is exactly
## the right shape for that job.
static func wardens_ration() -> ItemDef:
var d := ItemDef.new()
d.id = WARDENS_RATION
d.display_name = "Warden's Ration"
d.effect = ItemDef.Effect.NONE
d.visual = 1
d.tint = Color(0.95, 0.82, 0.35)
return d
+1
View File
@@ -0,0 +1 @@
uid://d07ue5tca3xt8
+142
View File
@@ -0,0 +1,142 @@
class_name Rooms
extends RefCounted
## Hand-authored room stamps, as text.
##
## Boss arenas are authored rather than generated: a boss fight is a designed
## space -- sightlines, cover, room to dodge a wall pattern -- and a generator
## that produces "a room" produces a bad one about as often as a good one. The
## corridors and filler rooms around them are generated; the fights are not.
##
## Legend:
## # wall o pillar ~ pit = barricade
## . floor B boss spawn D doorway (floor, corridors connect here)
const LEGEND := {
"#": MapGrid.Kind.WALL,
"o": MapGrid.Kind.PILLAR,
"~": MapGrid.Kind.PIT,
"=": MapGrid.Kind.BARRICADE,
".": MapGrid.Kind.FLOOR,
"B": MapGrid.Kind.FLOOR,
"D": MapGrid.Kind.FLOOR,
"P": MapGrid.Kind.FLOOR,
"S": MapGrid.Kind.FLOOR,
"T": MapGrid.Kind.FLOOR,
"U": MapGrid.Kind.FLOOR,
}
## Legend characters that record a position rather than only painting a tile.
const MARKERS := ["B", "D", "P", "S", "T", "U"]
## Wide, with pillars to break the Warden's rings and pits that shape where you
## can retreat to. The door is deliberately on one wall only: the boss cannot
## follow you out, so the exit is a real decision rather than an accident.
static func warden_hall() -> PackedStringArray:
return PackedStringArray([
"#########################",
"#.......................#",
"#..~~~...........~~~....#",
"#..~~~....o.o....~~~....#",
"#.........................",
"#....o.................o#",
"#.............B.........#",
"#....o.................o#",
"D.........................",
"#..~~~....o.o....~~~....#",
"#..~~~..........~~~.....#",
"#.......................#",
"#########################",
])
## Tighter and more claustrophobic, with barricades you can see over but not
## shoot through -- a room that rewards knowing where the boss is without
## giving you a firing lane to it.
static func choir_vault() -> PackedStringArray:
return PackedStringArray([
"#####################",
"#...................#",
"#..===.......===....#",
"#...................#",
"#....o.......o......#",
"D.........B.........#",
"#....o.......o......#",
"#...................#",
"#..===.......===....#",
"#...................#",
"#####################",
])
## Which boss each arena belongs to. Kept beside the stamps because the pairing
## is a content decision, not a generation one: the Cantor's patterns assume
## the vault's barricades the way the Warden's assume the hall's pits.
static func boss_for_arena(arena: StringName) -> StringName:
match arena:
&"warden_hall": return Content.BOSS_WARDEN
&"choir_vault": return Content.BOSS_CANTOR
return Content.BOSS_WARDEN
static func size_of(stamp: PackedStringArray) -> Vector2i:
if stamp.is_empty():
return Vector2i.ZERO
var w := 0
for row in stamp:
w = maxi(w, row.length())
return Vector2i(w, stamp.size())
## Paint a stamp into [param grid] with its top-left at [param origin].
## Returns the marker positions found, keyed by their legend character.
static func stamp(grid: MapGrid, s: PackedStringArray, origin: Vector2i) -> Dictionary:
var markers := {}
for m in MARKERS:
markers[m] = []
for y in s.size():
var row := s[y]
for x in row.length():
var ch := row[x]
if not LEGEND.has(ch):
continue
var tx := origin.x + x
var ty := origin.y + y
grid.set_tile(tx, ty, LEGEND[ch])
if markers.has(ch):
markers[ch].append(Vector2i(tx, ty))
return markers
## The hub. Hand-authored like the boss arenas.
##
## P a dungeon portal S player spawn T practice target
## U the upgrade NPC
##
## Each `P`, in reading order, opens the matching entry in Dungeons.ORDER: the
## Warden's Descent, the Choir Vault, and the Proving Grounds. Adding a dungeon
## means adding an entry there and a marker here, and they have to stay in the
## same order.
static func lobby() -> PackedStringArray:
return PackedStringArray([
"#########################################",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#.........P.........P.........P.........#",
"#.......................................#",
"#.....o...........................o.....#",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#.........T...................U.........#",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#.....o...........................o.....#",
"#...................S...................#",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#########################################",
])
+1
View File
@@ -0,0 +1 @@
uid://cb5nycudm0o6e
+165
View File
@@ -0,0 +1,165 @@
class_name Upgrades
extends RefCounted
## Every upgrade in the game, in code, like the rest of `src/content/`.
##
## [constant ORDER] is a wire format: an upgrade's index is the byte that rides
## the upgrade-state message and the character save. Append, never reorder.
const SPLIT_SHOT := &"split_shot"
const GLASS_CANNON := &"glass_cannon"
const SPREAD := &"spread"
const SNIPER := &"sniper"
const DOUBLESHOT := &"doubleshot"
const POISON := &"poison"
const ERASER := &"eraser"
const ORDER: Array[StringName] = [
SPLIT_SHOT,
GLASS_CANNON,
SPREAD,
SNIPER,
DOUBLESHOT,
POISON,
ERASER,
]
## Draw weights by rarity. Relative, not percentages -- what matters is that a
## legendary is a story and a common is Tuesday.
const WEIGHTS := {
UpgradeDef.Rarity.COMMON: 100,
UpgradeDef.Rarity.UNCOMMON: 45,
UpgradeDef.Rarity.RARE: 18,
UpgradeDef.Rarity.LEGENDARY: 4,
}
static func get_def(id: StringName) -> UpgradeDef:
match id:
SPLIT_SHOT: return split_shot()
GLASS_CANNON: return glass_cannon()
SPREAD: return spread()
SNIPER: return sniper()
DOUBLESHOT: return doubleshot()
POISON: return poison()
ERASER: return eraser()
return null
## Wire value. 0 is reserved for "no upgrade", so an id's value is its position
## plus one -- same convention as [Items].
static func index_of(id: StringName) -> int:
var at := ORDER.find(id)
return at + 1 if at >= 0 else 0
static func by_index(index: int) -> StringName:
if index <= 0 or index > ORDER.size():
return &""
return ORDER[index - 1]
## Pick [param count] distinct upgrades, weighted by rarity.
##
## Distinct within one offer only -- an upgrade you already hold can and should
## come up again, because taking Split Shot twice is how a shot splits twice.
static func roll_offer(rng: RandomNumberGenerator, count: int) -> Array[StringName]:
var pool := ORDER.duplicate()
var picked: Array[StringName] = []
while picked.size() < count and not pool.is_empty():
var total := 0
for id in pool:
total += _weight_of(id)
var roll := rng.randi_range(1, maxi(total, 1))
for i in pool.size():
roll -= _weight_of(pool[i])
if roll <= 0:
picked.append(pool[i])
pool.remove_at(i)
break
return picked
static func _weight_of(id: StringName) -> int:
var def := get_def(id)
return int(WEIGHTS.get(def.rarity, 1)) if def != null else 1
# --- The upgrades -----------------------------------------------------------
static func split_shot() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = SPLIT_SHOT
u.display_name = "Split Shot"
u.description = "Hitting an enemy spawns two more of the same shot, at 45° to either side, behind it. Take it again to split again."
u.rarity = UpgradeDef.Rarity.COMMON
u.split_charges = 1
return u
static func glass_cannon() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = GLASS_CANNON
u.display_name = "Glass Cannon"
u.description = "Double damage, half health. The health is half of whatever your level gives you, so it stays a real price."
u.rarity = UpgradeDef.Rarity.COMMON
u.damage_add = 1.0
u.max_hp_mult = 0.5
return u
static func spread() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = SPREAD
u.display_name = "Spread"
u.description = "Two more shots in a cone either side of your aim. −10% damage."
u.rarity = UpgradeDef.Rarity.COMMON
u.side_shots = 2
u.damage_add = -0.10
return u
## The only multiplicative damage source, and the only thing that touches bullet
## speed. Both are capped downstream: PlayerStats clamps the speed under the
## tunnelling threshold, because a bullet that moves more than a tile per tick
## walks straight through walls.
static func sniper() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = SNIPER
u.display_name = "Sniper"
u.description = "Double damage, multiplied on top of everything else. Half the fire rate, double the bullet speed."
u.rarity = UpgradeDef.Rarity.COMMON
u.damage_mult = 2.0
u.fire_rate_mult = 0.5
u.bullet_speed_mult = 2.0
return u
static func doubleshot() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = DOUBLESHOT
u.display_name = "Doubleshot"
u.description = "One more shot travelling parallel to the rest. −50% damage."
u.rarity = UpgradeDef.Rarity.UNCOMMON
u.parallel_shots = 1
u.damage_add = -0.50
return u
static func poison() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = POISON
u.display_name = "Poison"
u.description = "Every shot deals another 50% of its damage over the next 10 seconds. Stacks with itself — every hit adds another dose."
u.rarity = UpgradeDef.Rarity.RARE
u.poison_fraction = 0.5
return u
static func eraser() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = ERASER
u.display_name = "Eraser"
u.description = "Your shots have a 1% chance to delete an enemy projectile they pass through."
u.rarity = UpgradeDef.Rarity.LEGENDARY
u.erase_chance = 0.01
return u
+1
View File
@@ -0,0 +1 @@
uid://c4s2g0bi42sko
+76
View File
@@ -0,0 +1,76 @@
class_name Credits
extends RefCounted
## Every third-party asset the game ships, and the terms it ships under.
##
## This is not decoration. Two of these packs are CC BY 4.0, which requires
## attribution "in any reasonable manner" -- a file in the repository is not
## reasonable for a player who downloaded a build, so the game has to say so
## itself. See [CreditsScreen].
##
## `tests/unit/test_credits.gd` asserts every entry here also appears in
## CREDITS.md, so the two cannot drift apart.
## Each entry: what it is, who made it, the licence, a link, and what it is
## used for. `required` marks the ones the licence obliges us to show.
const SOURCES: Array[Dictionary] = [
{
"name": "16x16 DungeonTileset II",
"author": "0x72 (Robert Norenberg)",
"licence": "CC0 1.0 (public domain)",
"url": "https://0x72.itch.io/dungeontileset-ii",
"used_for": "Terrain, characters, bosses, item icons",
"required": false,
},
{
"name": "Complete UI Essential Pack",
"author": "Crusenho Agus Hennihuno",
"licence": "CC BY 4.0",
"url": "https://crusenho.itch.io/complete-ui-essential-pack",
"used_for": "Buttons, panels, bars, inventory slots",
"required": true,
},
{
"name": "Pixel Combat SFX",
"author": "Helton Yan",
"licence": "CC BY 4.0",
"url": "https://heltonyan.itch.io/pixelcombat",
"used_for": "All sound effects",
"required": true,
},
{
"name": "Fire Pixel Bullet 16x16",
"author": "bdragon1727",
"licence": "Custom - not redistributable",
"url": "https://bdragon1727.itch.io/fire-pixel-bullet-16x16",
"used_for": "Bullet sprites (local builds only)",
"required": false,
},
{
"name": "750+ Effect and FX Pixel All",
"author": "bdragon1727",
"licence": "Custom - not redistributable",
"url": "https://bdragon1727.itch.io/750-effect-and-fx-pixel-all",
"used_for": "Impact effects (local builds only)",
"required": false,
},
]
## CC BY asks for a link to the licence itself, not only to the work.
const LICENCE_LINKS := {
"CC BY 4.0": "https://creativecommons.org/licenses/by/4.0/",
"CC0 1.0 (public domain)": "https://creativecommons.org/publicdomain/zero/1.0/",
}
## The packs whose licence actually obliges us to credit them. Everything else
## in [constant SOURCES] is there because the work deserves it.
static func required() -> Array[Dictionary]:
var out: Array[Dictionary] = []
for entry in SOURCES:
if bool(entry["required"]):
out.append(entry)
return out
static func licence_url(licence: String) -> String:
return String(LICENCE_LINKS.get(licence, ""))
+1
View File
@@ -0,0 +1 @@
uid://drpadmmsrvoax
+33
View File
@@ -17,11 +17,28 @@ static var bot_client: bool = false
static var autoquit_ticks: int = 0
## Skip the menu and connect straight away. Implied by --bot.
static var autojoin: bool = false
## Bot harness only: gracefully disconnect after this many ticks inside a
## dungeon, the way the in-game menu's "Disconnect" button does. Lets the smoke
## test cover the clean-exit path as well as a hard kill -- both must be caught
## by the same server-side escape channel.
static var bot_leave_after: int = 0
## Skip the menu and start a listen server (host + local player).
static var listen: bool = false
## Server-side dev switch: new dungeons open straight onto the boss, skipping
## the trash waves. For iterating on a pattern without clearing two waves first.
static var boss_rush: bool = false
## Dev switch: depth of newly opened dungeons, which drives map size. Depth
## progression is a later-stage concern; this makes big maps testable now.
static var dungeon_depth: int = 1
## Override the local account id. Two clients on one machine would otherwise
## read the same user:// id file, land on the same account, and fight over one
## roster of characters -- which is exactly what the smoke test does. A real
## identity provider makes this unnecessary.
static var account_override: int = 0
## Where the server keeps characters. Overridable so a test run cannot read or
## write the characters someone is actually playing -- and so repeated runs
## start from a known state instead of resuming each other's progress.
static var store_path: String = CharacterStore.SAVE_PATH
static var parsed: bool = false
@@ -38,10 +55,26 @@ static func parse(argv: PackedStringArray = PackedStringArray()) -> void:
autojoin = true
"--join":
autojoin = true
"--leave-after":
i += 1
if i < argv.size():
bot_leave_after = int(argv[i])
"--listen":
listen = true
"--boss-rush":
boss_rush = true
"--account":
i += 1
if i < argv.size():
account_override = int(argv[i])
"--store":
i += 1
if i < argv.size():
store_path = argv[i]
"--depth":
i += 1
if i < argv.size():
dungeon_depth = int(argv[i])
"--port":
i += 1
if i < argv.size():
+34 -9
View File
@@ -12,11 +12,15 @@ static func sanitize_move(raw: Vector2) -> Vector2:
## One tick of player locomotion. Returns the new position.
static func step_player(pos: Vector2, move: Vector2, speed: float, bounds: Vector2) -> Vector2:
var next := pos + sanitize_move(move) * speed * SimConfig.TICK_DELTA
next.x = clampf(next.x, -bounds.x, bounds.x)
next.y = clampf(next.y, -bounds.y, bounds.y)
return next
##
## Collision resolution lives in [MapGrid.slide_circle], and the client runs
## this exact call during prediction and reconciliation -- so the map the client
## holds has to match the server's, which is why maps are replicated in full
## even where fog hides them.
static func step_player(pos: Vector2, move: Vector2, speed: float, map: MapGrid,
radius: float = SimConfig.PLAYER_RADIUS) -> Vector2:
var delta := sanitize_move(move) * speed * SimConfig.TICK_DELTA
return map.slide_circle(pos, delta, radius)
## Circle-vs-circle overlap test used for every hit in the game.
@@ -25,7 +29,28 @@ static func circles_overlap(a: Vector2, ar: float, b: Vector2, br: float) -> boo
return a.distance_squared_to(b) <= r * r
## True when a point has drifted outside the arena plus the cull margin.
static func outside_arena(p: Vector2, margin: float = SimConfig.BULLET_CULL_MARGIN) -> bool:
return absf(p.x) > SimConfig.ARENA_HALF.x + margin \
or absf(p.y) > SimConfig.ARENA_HALF.y + margin
## Outside the map's extent. Derivable on both sides from the map's dimensions
## alone, so it needs no announcement even when tiles have not been streamed.
static func outside_map(p: Vector2, map: MapGrid) -> bool:
if map == null:
return false
return not map.world_rect().grow(SimConfig.BULLET_CULL_MARGIN).has_point(p)
## The furthest a bullet with these parameters could travel before it expires.
##
## Used to decide whether a spawn is worth telling a client about. Deliberately
## an OVER-estimate: acceleration is integrated at face value while a negative
## one is ignored, and a turning bullet curves, so the straight-line answer is
## always at least the real path. Over-estimating costs a packet; under-
## estimating is a bullet that arrives invisible.
static func bullet_reach(speed: float, accel: float, life_ticks: int) -> float:
var t := float(maxi(life_ticks, 0)) * SimConfig.TICK_DELTA
return maxf(speed, 0.0) * t + 0.5 * maxf(accel, 0.0) * t * t
## Outside the map, or stopped by geometry.
static func bullet_stopped(p: Vector2, map: MapGrid) -> bool:
if map == null:
return false
return outside_map(p, map) or map.bullet_blocked(p)
+296
View File
@@ -0,0 +1,296 @@
class_name Settings
extends RefCounted
## Player preferences: key bindings and volumes.
##
## Client-side and local. Nothing here reaches the server or the simulation --
## which key you press to fire is not something the server has an opinion about,
## and the input frame it eventually produces looks identical either way.
##
## Static rather than an autoload for the same reason [GameOpts] is: a `--script`
## tool has no main loop and cannot resolve autoload names, and the settings
## file is something a headless test may well want to read.
const DEFAULT_PATH := "user://settings.cfg"
## Where preferences are read from and written to. A variable rather than a
## constant so tests can point it at a scratch file: rebinding calls save(),
## and a suite run was otherwise rewriting the player's real settings -- which
## it did, silently, until a screenshot showed Fire bound to the right mouse
## button.
static var path: String = DEFAULT_PATH
## Actions the settings screen offers, in the order it lists them. Anything not
## here keeps whatever `tools/setup_input_map.gd` gave it and cannot be changed
## -- which is the right default for a binding nobody should be reassigning.
const REBINDABLE: Array = [
["move_up", "Move up"],
["move_down", "Move down"],
["move_left", "Move left"],
["move_right", "Move right"],
["fire", "Fire"],
["interact", "Interact / pick up"],
["emergency_escape", "Emergency escape"],
["use_slot_1", "Use slot 1"],
["use_slot_2", "Use slot 2"],
["use_slot_3", "Use slot 3"],
["use_slot_4", "Use slot 4"],
["system_menu", "Menu"],
["debug_overlay", "Hitbox overlay"],
]
## Bus every sound effect plays on. Created at runtime rather than shipped as a
## bus layout resource, so there is one fewer file to keep in step with the code
## that reads it.
const SFX_BUS := "SFX"
static var master_volume: float = 0.8
static var sfx_volume: float = 0.8
## The server last joined, so the connect screen does not ask for the same
## address every launch. Empty until the first join. Client-local like
## everything else here -- it is where you play, not who you are.
static var last_address: String = ""
static var last_port: int = 0
## action -> a serialisable description of its event. See _describe().
static var bindings: Dictionary[String, Dictionary] = {}
## What the project shipped, captured before anything is overridden. Without it
## "reset to defaults" would restore whatever the last session happened to save.
static var _defaults: Dictionary[String, Dictionary] = {}
static var _defaults_captured: bool = false
# --- Lifecycle ---------------------------------------------------------------
## Read the file and apply everything. Safe to call with no file present, which
## is every first run.
static func load_and_apply() -> void:
_capture_defaults()
var cfg := ConfigFile.new()
if cfg.load(path) == OK:
master_volume = clampf(float(cfg.get_value("audio", "master", master_volume)), 0.0, 1.0)
sfx_volume = clampf(float(cfg.get_value("audio", "sfx", sfx_volume)), 0.0, 1.0)
last_address = String(cfg.get_value("connection", "address", ""))
last_port = clampi(int(cfg.get_value("connection", "port", 0)), 0, 65535)
bindings.clear()
for action in REBINDABLE:
var key := String(action[0])
# has_section_key first: passing null as the default to get_value()
# does NOT mean "no default", it means the key is absent AND no
# default was given, and the engine logs an error for every missing
# one. A settings file written before an action existed is normal.
if not cfg.has_section_key("input", key):
continue
var stored: Variant = cfg.get_value("input", key)
# A binding this build cannot make sense of is dropped, not
# guessed at: an unusable control is worse than the default one.
if typeof(stored) == TYPE_DICTIONARY and _event_from(stored) != null:
bindings[key] = stored
apply_audio()
apply_input()
static func save() -> void:
var cfg := ConfigFile.new()
cfg.set_value("audio", "master", master_volume)
cfg.set_value("audio", "sfx", sfx_volume)
if not last_address.is_empty():
cfg.set_value("connection", "address", last_address)
cfg.set_value("connection", "port", last_port)
for action in bindings:
cfg.set_value("input", action, bindings[action])
var err := cfg.save(path)
if err != OK:
GameLog.warn("settings", "could not write %s (error %d)" % [path, err])
## Remember where the player just tried to connect.
static func remember_server(address: String, port: int) -> void:
var clean := address.strip_edges()
if clean.is_empty() or port <= 0 or port > 65535:
return
last_address = clean
last_port = port
save()
# --- Audio -------------------------------------------------------------------
## Ensure the SFX bus exists and push both volumes onto the mixer.
static func apply_audio() -> void:
var sfx := AudioServer.get_bus_index(SFX_BUS)
if sfx < 0:
AudioServer.add_bus()
sfx = AudioServer.bus_count - 1
AudioServer.set_bus_name(sfx, SFX_BUS)
AudioServer.set_bus_send(sfx, "Master")
AudioServer.set_bus_volume_db(0, linear_to_db_clamped(master_volume))
AudioServer.set_bus_mute(0, master_volume <= 0.0)
AudioServer.set_bus_volume_db(sfx, linear_to_db_clamped(sfx_volume))
AudioServer.set_bus_mute(sfx, sfx_volume <= 0.0)
## Godot's linear_to_db(0) is -inf, which serialises badly and reads as a bug
## when it turns up in a log. The clamp is what prevents that -- 0.0001 comes
## out at exactly -80dB, far below audible -- so a zero or a negative from a
## corrupt settings file lands there rather than at negative infinity. Actual
## silence is the bus mute flag, set alongside this.
static func linear_to_db_clamped(linear: float) -> float:
return linear_to_db(clampf(linear, 0.0001, 1.0))
# --- Input -------------------------------------------------------------------
## Snapshot the project's own bindings. Called before anything overrides them,
## and only once -- a second call after an override would capture the override.
static func _capture_defaults() -> void:
if _defaults_captured:
return
_defaults_captured = true
for entry in REBINDABLE:
var action := String(entry[0])
if not InputMap.has_action(action):
continue
var events := InputMap.action_get_events(action)
if not events.is_empty():
_defaults[action] = _describe(events[0])
## Rewrite the InputMap from [member bindings]. Actions with no override are
## restored to what the project shipped, so clearing one binding cannot leave a
## previous session's choice behind.
static func apply_input() -> void:
_capture_defaults()
for entry in REBINDABLE:
var action := String(entry[0])
if not InputMap.has_action(action):
continue
var described: Dictionary = bindings.get(action, _defaults.get(action, {}))
var event := _event_from(described)
if event == null:
continue
# Replacing every event rather than the first: an action that kept its
# alternates would still answer to the key the player just moved away
# from, which reads as the rebind not having worked.
InputMap.action_erase_events(action)
InputMap.action_add_event(action, event)
## Assign [param event] to [param action]. Returns the action it collided with,
## or an empty string on success -- one key doing two things is a broken
## control scheme, so it is refused rather than silently accepted.
static func rebind(action: String, event: InputEvent) -> String:
var described := _describe(event)
if described.is_empty():
return action
for entry in REBINDABLE:
var other := String(entry[0])
if other == action:
continue
if current_binding(other) == described:
return other
bindings[action] = described
apply_input()
save()
return ""
static func reset_bindings() -> void:
bindings.clear()
apply_input()
save()
## What [param action] is bound to right now: the override if there is one,
## otherwise what the project shipped.
static func current_binding(action: String) -> Dictionary:
_capture_defaults()
return bindings.get(action, _defaults.get(action, {}))
static func binding_label(action: String) -> String:
return describe_label(current_binding(action))
## The binding as short as it can be said, for prompts on screen: "LMB" rather
## than "Mouse Left", "Esc" rather than "Escape". Every key the game names to the
## player goes through this, because every one of them is rebindable and a
## prompt that names the default is wrong for anyone who changed it.
static func short_label(action: String) -> String:
var label := binding_label(action)
match label:
"Mouse Left": return "LMB"
"Mouse Right": return "RMB"
"Mouse Middle": return "MMB"
"Escape": return "Esc"
return label
static func describe_label(described: Dictionary) -> String:
match String(described.get("type", "")):
"key":
var code := int(described.get("code", 0))
# 0 is not a key. _event_from refuses to build one, so a stored 0
# never reaches the input map -- but it would render as a blank
# button, which looks like a bound key with no name.
if code == 0:
return "unbound"
return OS.get_keycode_string(_layout_keycode(code))
"mouse":
match int(described.get("code", 0)):
MOUSE_BUTTON_LEFT: return "Mouse Left"
MOUSE_BUTTON_RIGHT: return "Mouse Right"
MOUSE_BUTTON_MIDDLE: return "Mouse Middle"
return "Mouse %d" % int(described["code"])
return "unbound"
## Bindings are stored as PHYSICAL keycodes, so they follow key position rather
## than layout -- the same choice tools/setup_input_map.gd makes. The label is
## translated back through the active layout so an AZERTY player reads "A" for
## the key their fingers are on, rather than the QWERTY name of that position.
##
## The translation is skipped on a display server that has no keyboard, which
## is headless: the call is not merely unsupported there, it pushes an engine
## error and hands the argument straight back, so asking is worse than not.
## There is no feature flag to test for it.
static func _layout_keycode(physical: int) -> int:
if physical == 0 or DisplayServer.get_name() == "headless":
return physical
return DisplayServer.keyboard_get_keycode_from_physical(physical)
## Only keys and mouse buttons are accepted. A rebind listener that took any
## InputEvent would happily capture mouse MOTION the instant the player moved
## the mouse, which is not a binding anyone meant to make.
static func is_bindable(event: InputEvent) -> bool:
return not _describe(event).is_empty()
static func _describe(event: InputEvent) -> Dictionary:
if event is InputEventKey:
var key := event as InputEventKey
var code := key.physical_keycode if key.physical_keycode != 0 else key.keycode
if code == 0:
return {}
return {"type": "key", "code": int(code)}
if event is InputEventMouseButton:
return {"type": "mouse", "code": int((event as InputEventMouseButton).button_index)}
return {}
static func _event_from(described: Dictionary) -> InputEvent:
match String(described.get("type", "")):
"key":
var k := InputEventKey.new()
# device -1 is the only value that matches input from a real
# device; a freshly constructed event defaults to 16, which
# silently matches nothing. See tools/setup_input_map.gd.
k.device = -1
k.physical_keycode = int(described.get("code", 0))
return k if k.physical_keycode != 0 else null
"mouse":
var m := InputEventMouseButton.new()
m.device = -1
m.button_index = int(described.get("code", 0))
return m if m.button_index != 0 else null
return null
+1
View File
@@ -0,0 +1 @@
uid://pdbmt03hxfum
+139 -10
View File
@@ -13,8 +13,7 @@ const SNAPSHOT_INTERVAL := 3
const INTERPOLATION_DELAY_TICKS := 6
# --- Arena ------------------------------------------------------------------
const ARENA_HALF := Vector2(620.0, 340.0)
## Bullets are culled once they leave the arena by this margin.
## Bullets are culled once they leave the map by this margin.
const BULLET_CULL_MARGIN := 64.0
# --- Player -----------------------------------------------------------------
@@ -36,12 +35,19 @@ const PLAYER_VISUAL_RADIUS := 13.0
## the ship -- and on a remote client, the ship is also drawn a tick or two
## ahead of the server, so this margin is what absorbs that too.
const PLAYER_MUZZLE_OFFSET := PLAYER_VISUAL_RADIUS + 6.0
const PLAYER_MAX_HP := 100
const PLAYER_FIRE_COOLDOWN := 7 # ticks
## Health and damage are deliberately an order of magnitude larger than the
## numbers they started as. Every ratio in the game is unchanged -- enemy
## health, boss health and every emitter's damage were scaled with them -- but
## percentages now have somewhere to land. At the old base of 6 damage, the +5%
## that every upgrade carries computed to 6.3 and rounded straight back to 6,
## so a player's first upgrade visibly did nothing at all. At 60 it is +3.
## Do not "tidy" these back down without scaling content.gd with them.
const PLAYER_MAX_HP := 1000
const PLAYER_FIRE_COOLDOWN := 14 # ticks (~4.3 shots/sec)
const PLAYER_BULLET_SPEED := 620.0
const PLAYER_BULLET_RADIUS := 4.0
const PLAYER_BULLET_LIFETIME := 90 # ticks
const PLAYER_BULLET_DAMAGE := 6
const PLAYER_BULLET_DAMAGE := 60
## There are deliberately NO invulnerability frames after a hit. In a bullet
## hell the wall of bullets IS the threat, and i-frames turn a dense pattern
## into a single cheap hit -- you get punished for the first bullet and gifted
@@ -52,10 +58,30 @@ const PLAYER_BULLET_DAMAGE := 6
## live bullet field is survivable. Both halves matter: invulnerability alone
## would make the spawn point a free firing position.
const SPAWN_GRACE_TICKS := 120 # 2 seconds
## A downed player cannot leave for the hub until this has elapsed. Enforced
## here rather than only by grey-ing out the button, because a disabled button
## is a suggestion -- the server is the only thing a modified client cannot
## argue with.
## Passive healing, as a percentage of MAXIMUM health per second -- so it
## scales with level rather than becoming irrelevant at level 15. There is
## deliberately no out-of-combat gate: a slow trickle that never stops is
## simpler to reason about than a timer players have to learn, and at this rate
## it cannot outpace anything that is actually shooting at you.
const HP_REGEN_PERCENT_PER_SEC := 0.5
## What the hub heals at instead. Arriving anywhere used to snap you to full,
## which made health free: enter a dungeon, take what you could, leave, walk
## back in whole. Recovering it takes ten seconds of standing in the hub now,
## so the run you just left cost you something -- and the cost is time in the
## one place nothing is shooting at you, not a resource to manage.
const LOBBY_HP_REGEN_PERCENT_PER_SEC := 10.0
## A downed player cannot leave for the hub until this has elapsed. Enforced in
## the simulation rather than only by grey-ing out the button, because a
## disabled button is a suggestion.
##
## Worth knowing what actually reaches it: a player whose peer is still
## connected never waits, because ServerRuntime unbinds it on the same tick it
## dies and leaves it at the roster screen -- that is a settled decision, not an
## oversight (docs/DECISIONS.md, "Death unbinds the character entirely"). What
## the lockout governs is the LINKDEAD body, which has nobody to show a roster
## to and asks for itself; the countdown is what stops it vanishing out of a
## fight the instant it is killed.
const RESPAWN_LOCKOUT_TICKS := 180 # 3 seconds
# --- Input timing -----------------------------------------------------------
@@ -83,6 +109,25 @@ const INPUT_MAX_LEAD := 40
## backstop that makes the failure above self-healing whatever its cause.
const INPUT_ACK_STALL_LIMIT := 8
# --- Inventory and loot -----------------------------------------------------
## Slots a character carries. Small enough to sit on screen permanently, which
## is the whole design: an inventory you have to open is a menu, and a menu in
## a bullet hell is a death. Growing this is a one-line change here -- the wire
## format, the HUD and the persistence record all read it.
const INVENTORY_SLOTS := 4
## How close you have to stand to pick something up. Comfortably larger than
## PLAYER_RADIUS so walking "onto" an item is enough; well under the distance
## at which you could grab loot you cannot see.
const LOOT_PICKUP_RADIUS := 34.0
## Radius of the ring player-instanced boss drops are laid out on. Each player
## only ever sees their own, so this is purely so a debug view of all of them
## is legible rather than one pile.
const LOOT_INSTANCED_SPREAD := 34.0
## Hard ceiling on ground loot in one instance. Dungeons are short-lived, so in
## practice this only ever bites in the hub, where players can drop things and
## nothing ever closes to clean up. Oldest goes first.
const MAX_LOOT_PER_INSTANCE := 64
# --- Emergency escape -------------------------------------------------------
const ESCAPE_CHANNEL_TICKS := 60 # 1 second
## Taking damage does NOT interrupt the channel. It used to, which sounds like
@@ -92,8 +137,43 @@ const ESCAPE_CHANNEL_TICKS := 60 # 1 second
## connection now runs the same one-second channel (see SimPlayer.linkdead),
## which only works if being shot cannot cancel it.
# --- Upgrades ---------------------------------------------------------------
## Damage every upgrade adds on top of whatever else it does, into the additive
## pool. A property of taking an upgrade rather than of any one upgrade, so it
## lives here and is applied once per upgrade held.
const UPGRADE_DAMAGE_BONUS := 0.05
## Options offered per level gained.
const UPGRADE_CHOICES := 3
## Angle between consecutive shots in a Spread cone.
const SPREAD_STEP_DEG := 13.0
## Sideways gap between Doubleshot's parallel projectiles.
const PARALLEL_OFFSET := 15.0
## How far to either side a Split Shot child leaves the enemy it was born on.
const SPLIT_ANGLE_DEG := 45.0
## How long one dose of Poison takes to deliver its damage.
const POISON_DURATION_TICKS := 600 # 10 seconds
## How near its preferred distance a CHASE boss counts as "there".
##
## Without a dead band the sign of the correction flips every tick once it
## arrives, and the boss buzzes on the spot at 60Hz. Small in world units, and
## unmistakable on screen.
const BOSS_CHASE_DEADBAND := 8.0
## How close to the hub's upgrade NPC you must stand to spend a choice.
## Enforced on the server, like the portal: standing somewhere is the only
## thing a client cannot lie about.
const UPGRADE_NPC_RADIUS := 70.0
# --- Bullets ----------------------------------------------------------------
const MAX_BULLETS := 4096
## Hard ceiling on any bullet, however many speed multipliers stack up.
##
## Wall collision samples a bullet's position once per tick, so anything faster
## than one tile (MapGrid.TILE = 32px) per tick tunnels straight through
## geometry. At 60Hz that threshold is 1920 u/s; this leaves a margin for the
## sampling to stay honest. Two Snipers would ask for 2480 and get this instead.
## Pinned by test_bullet_pool.gd.
const MAX_BULLET_SPEED := 1500.0
const TEAM_PLAYER := 0
const TEAM_ENEMY := 1
@@ -113,6 +193,55 @@ const DUNGEON_FORMING_TICKS := 300
## player through the portal opens a fresh instance.
const DUNGEON_CLEARED_EXIT_TICKS := 1800 # 30 seconds
# --- Fog of war -------------------------------------------------------------
## How far a player can see. Comfortably inside MAP_STREAM_RADIUS so the client
## always holds the terrain it is about to reveal.
const FOG_VIEW_RADIUS := 460.0
# --- Interest management ----------------------------------------------------
## How near a kill you have to be to be paid for it. Ten percent beyond what a
## player can see, so someone who watched it die is always inside and someone
## parked at the entrance never is. Experience stays undivided among everyone
## who qualifies (docs/DECISIONS.md) -- this decides who was there, not how much
## each of them gets.
##
## Worth knowing: an upgraded shot outranges this several times over, so a kill
## made far enough away that you cannot see it pays nothing.
const XP_AWARD_RADIUS := FOG_VIEW_RADIUS * 1.1
## Actors further than this from a player are not sent to that player at all.
## Comfortably beyond FOG_VIEW_RADIUS so nothing visibly pops in at the edge of
## sight, but far short of a dungeon, so a modified client cannot read enemy
## positions across the map.
const ACTOR_INTEREST_RADIUS := 800.0
## Bullet spawns are filtered far more generously than actors, and deliberately
## so. A bullet is announced once, at spawn, and then simulated locally -- if we
## withhold it and it later flies into view, it is invisible damage, which is a
## gameplay bug rather than a cosmetic one. An enemy appearing at the edge of
## sight is neither.
##
## The floor is (longest bullet travel + FOG_VIEW_RADIUS): a bullet spawned
## further away than that cannot reach visible ground before it expires.
## test_interest.gd computes that from the real content and asserts this covers
## it, so adding a faster or longer-lived bullet fails a test instead of
## producing bullets that wink into existence.
## Raised for upgrades: a Sniper's shot travels MAX_BULLET_SPEED * lifetime,
## which is further than anything the enemy content fires.
const BULLET_INTEREST_RADIUS := 2900.0
# --- Map streaming ----------------------------------------------------------
## How far around a player the server streams map tiles. Comfortably wider than
## the viewport so movement prediction and bullet simulation always run against
## known geometry, and comfortably narrower than a dungeon so a modified client
## still cannot draw the whole thing.
const MAP_STREAM_RADIUS := 900.0
## Chunks sent to one peer per snapshot tick. Caps the burst when a player
## arrives or sprints into virgin territory.
const MAP_CHUNKS_PER_TICK := 6
# --- Portal -----------------------------------------------------------------
const PORTAL_POS := Vector2(0.0, -220.0)
## Positions are per-world (SimWorld.portals), taken from the hub's map. Two
## entrances must be placed further apart than twice this, or their catchment
## areas overlap and which one you get stops being obvious from where you stand.
const PORTAL_RADIUS := 60.0
+22 -2
View File
@@ -1,10 +1,15 @@
[gd_scene load_steps=6 format=3]
[gd_scene load_steps=11 format=3]
[ext_resource type="Script" path="res://src/view/game_scene.gd" id="1"]
[ext_resource type="Script" path="res://src/view/world_view.gd" id="2"]
[ext_resource type="Script" path="res://src/view/bullet_renderer.gd" id="3"]
[ext_resource type="Script" path="res://src/ui/hud.gd" id="4"]
[ext_resource type="Script" path="res://src/ui/game_menu.gd" id="5"]
[ext_resource type="Script" path="res://src/view/sfx.gd" id="6"]
[ext_resource type="Script" path="res://src/ui/character_select.gd" id="7"]
[ext_resource type="Script" path="res://src/ui/upgrade_screen.gd" id="8"]
[ext_resource type="Script" path="res://src/ui/settings_screen.gd" id="9"]
[ext_resource type="Script" path="res://src/ui/credits_screen.gd" id="10"]
[node name="Game" type="Node2D"]
script = ExtResource("1")
@@ -13,7 +18,7 @@ script = ExtResource("1")
position = Vector2(640, 360)
script = ExtResource("2")
[node name="Bullets" type="MultiMeshInstance2D" parent="WorldView"]
[node name="Bullets" type="Node2D" parent="WorldView"]
script = ExtResource("3")
[node name="HUD" type="CanvasLayer" parent="."]
@@ -21,3 +26,18 @@ script = ExtResource("4")
[node name="GameMenu" type="CanvasLayer" parent="."]
script = ExtResource("5")
[node name="Sfx" type="Node" parent="."]
script = ExtResource("6")
[node name="CharacterSelect" type="CanvasLayer" parent="."]
script = ExtResource("7")
[node name="UpgradeScreen" type="CanvasLayer" parent="."]
script = ExtResource("8")
[node name="SettingsScreen" type="CanvasLayer" parent="."]
script = ExtResource("9")
[node name="CreditsScreen" type="CanvasLayer" parent="."]
script = ExtResource("10")
+217 -50
View File
@@ -20,11 +20,35 @@ var peers: Array[int] = []
var state: State = State.ACTIVE
var age: int = 0
var seed_value: int = 0
## Drives dungeon size and difficulty. The hub is always depth 0.
var depth: int = 0
## Which kind of run this is -- see [Dungeons]. Empty for the hub.
var dungeon_id: StringName = &""
## Dungeon progression. -1 is the pre-fight breather.
var stage: int = -1
var stage_delay: int = 0
var boss_id: StringName = &""
## Generated layout, used to place enemies room by room.
var rooms: Array[Rect2i] = []
## The rectangle each tunnel leg was carved over -- see MapGen. Overlaps the
## rooms at its ends, so _tunnel_tiles() decides what is actually tunnel.
var tunnels: Array[Rect2i] = []
var boss_spawn := Vector2.ZERO
var boss_room := Rect2i()
## Chance that any one tunnel leg has a group of enemies in it. Rolled per leg,
## and most legs are too short or too near the entrance to qualify anyway, so a
## depth-1 dungeon has a tunnel group about one run in three. Rare on purpose:
## tunnels are where you go between fights, and a tunnel that is usually empty
## is what makes the one that is not a surprise.
const TUNNEL_GROUP_CHANCE := 0.1
const TUNNEL_GROUP_MIN := 2
const TUNNEL_GROUP_MAX := 3
## Members stand within this many tiles of the group's centre, so a group reads
## as one encounter rather than a tunnel's worth of strays.
const TUNNEL_GROUP_SPREAD := 2
static func make_lobby(instance_id: int) -> Instance:
@@ -33,33 +57,207 @@ static func make_lobby(instance_id: int) -> Instance:
inst.kind = Protocol.InstanceKind.LOBBY
inst.seed_value = 1
inst.world = SimWorld.new(inst.seed_value)
inst.world.portal_enabled = true
inst.world.spawn_point = Vector2(0.0, 120.0)
var built := MapGen.build(Protocol.InstanceKind.LOBBY, inst.seed_value, 0)
inst.world.set_map(built["grid"])
inst.world.portals = built["portals"]
inst.world.upgrade_npc = built["npc"]
inst.world.has_upgrade_npc = true
inst.world.spawn_point = built["spawn"]
# Nothing here to escape from, and completing the channel in the hub means
# transferring to the hub -- see SimWorld.escape_enabled.
inst.world.escape_enabled = false
# The hub is where a run's damage is paid off. Arriving used to heal you to
# full, which made health free between runs; ten seconds of standing here
# does the same job at a price.
inst.world.regen_percent_per_sec = SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC
inst.state = State.ACTIVE
# A single inert dummy so players can feel out the gun before committing to
# a run. It has no emitters and no contact damage.
inst.world.spawn_enemy(Content.dummy(), Vector2(-220.0, -40.0))
# A single inert practice target so players can feel out the gun before
# committing to a run.
inst.world.spawn_enemy(Content.dummy(), built["dummy"])
return inst
static func make_dungeon(instance_id: int, dungeon_seed: int) -> Instance:
## [param dungeon] picks the flavour of run. The map, the rooms, the enemies and
## the boss are the same whichever is chosen; only how much health they have and
## how freely they drop differs. See [Dungeons].
static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int = 1,
dungeon: StringName = &"") -> Instance:
var inst := Instance.new()
inst.id = instance_id
inst.kind = Protocol.InstanceKind.DUNGEON
inst.seed_value = dungeon_seed
inst.depth = maxi(dungeon_depth, 1)
inst.dungeon_id = dungeon if not dungeon.is_empty() else Dungeons.default_id()
inst.world = SimWorld.new(dungeon_seed)
inst.world.spawn_point = Vector2(0.0, 260.0)
var flavour := Dungeons.get_or_default(inst.dungeon_id)
var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed,
inst.depth, flavour.arena)
inst.world.set_map(built["grid"])
inst.rooms = built["rooms"]
inst.tunnels = built["tunnels"]
inst.boss_spawn = built["boss_pos"]
inst.boss_room = built["boss_room"]
inst.world.spawn_point = built["spawn"]
# Arriving into a fight already in progress needs a moment of protection;
# arriving in the hub does not.
inst.world.spawn_grace_ticks = SimConfig.SPAWN_GRACE_TICKS
inst.boss_id = Content.BOSS_WARDEN
inst.boss_id = built["boss_id"]
inst.state = State.FORMING
if GameOpts.boss_rush:
# Dev switch: the next stage advance lands on the boss.
inst.stage = 1
inst._populate()
return inst
## Enemies are placed once, per room, at generation -- not spawned in waves.
## A dungeon you explore has to be populated before you arrive in it: waves
## would make the map's contents depend on when you walk in rather than where,
## and aggro range would have nothing left to gate.
func _populate() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = seed_value ^ 0x5eed
var flavour := Dungeons.get_or_default(dungeon_id)
var boss := world.spawn_boss(flavour.apply_to_boss(Content.boss(boss_id)))
boss.pos = boss_spawn
boss.room = _room_rect_world(boss_room)
GameLog.info("instance", "BOSS_SPAWNED %s in instance %d" % [boss_id, id])
if GameOpts.boss_rush:
return # dev switch: an empty dungeon with just the fight in it
for i in rooms.size():
# Room 0 holds the entrance; arriving into a fight you cannot see yet
# is not a decision, it is an ambush.
if i == 0:
continue
var r: Rect2i = rooms[i]
var count := rng.randi_range(1, 3 + depth / 2)
for _n in count:
var def := flavour.apply_to_enemy(_pick_enemy(rng))
var tx := rng.randi_range(r.position.x + 1, r.end.x - 2)
var ty := rng.randi_range(r.position.y + 1, r.end.y - 2)
var at := world.map.tile_centre(tx, ty)
if world.map.circle_blocked(at, def.radius):
continue
if _would_see_the_entrance(def, at):
continue
world.spawn_enemy(def, at, rng.randi_range(0, 120))
# After every room, so adding tunnel groups leaves each room's population
# exactly what that seed always produced.
_populate_tunnels(rng, flavour)
## Now and then, a group of enemies in a tunnel.
##
## Never within aggro range of the entrance, by the same reasoning room 0 is
## left empty: arriving into a fight you cannot see yet is not a decision, it
## is an ambush. And never a lone straggler -- if a leg cannot fit at least
## TUNNEL_GROUP_MIN, it gets nobody.
func _populate_tunnels(rng: RandomNumberGenerator, flavour: DungeonDef) -> void:
var clear_of_spawn := _widest_aggro() + MapGrid.TILE * 2.0
for leg in tunnels:
# Every leg rolls the same three numbers whether or not it is used, so
# one tunnel's dice never depend on another tunnel's shape.
var roll := rng.randf()
var pick := rng.randi()
var size := rng.randi_range(TUNNEL_GROUP_MIN, TUNNEL_GROUP_MAX)
if roll >= TUNNEL_GROUP_CHANCE:
continue
var open := _tunnel_tiles(leg)
if open.size() < TUNNEL_GROUP_MAX * 2:
continue
var centre: Vector2i = open[pick % open.size()]
var def := flavour.apply_to_enemy(_pick_enemy(rng))
var spots: Array[Vector2] = []
for t in open:
if absi(t.x - centre.x) > TUNNEL_GROUP_SPREAD \
or absi(t.y - centre.y) > TUNNEL_GROUP_SPREAD:
continue
var at := world.map.tile_centre(t.x, t.y)
# Per member, not per group: members stand up to two tiles off the
# centre, so a centre that clears the entrance does not mean every
# member does.
if at.distance_to(world.spawn_point) < clear_of_spawn:
continue
if not world.map.circle_blocked(at, def.radius):
spots.append(at)
if spots.size() < TUNNEL_GROUP_MIN:
continue
for i in range(spots.size() - 1, 0, -1):
var j := rng.randi_range(0, i)
var held := spots[i]
spots[i] = spots[j]
spots[j] = held
for i in mini(size, spots.size()):
# A fresh definition per member: apply_to_enemy mutates in place,
# and sharing one object would scale it once per member.
var member := def if i == 0 else flavour.apply_to_enemy(Content.enemy(def.id))
world.spawn_enemy(member, spots[i], rng.randi_range(0, 120))
## Whether an enemy placed at [param at] would be awake the moment the party
## arrives: within its aggro range of the entrance, with nothing in the way --
## the same two questions SimWorld._aggro_target asks.
##
## Room 0 is left empty for exactly this reason, but that only ever covered room
## 0. A neighbouring room in sight of the entrance woke on arrival in one run in
## eight, and once tunnels were widened -- more open ground, more lines of sight
## -- in three runs in eight. Skipping these removes about one enemy in sixteen;
## clearing everything within aggro range instead would have removed nearly a
## third, which is a different dungeon rather than a fixed one.
##
## The rng draws for the skipped enemy have already been made, so every other
## enemy in the dungeon is exactly where that seed always put it.
func _would_see_the_entrance(def: EnemyDef, at: Vector2) -> bool:
if at.distance_to(world.spawn_point) > def.aggro_range:
return false
return world.map.has_line_of_sight(at, world.spawn_point)
## Floor tiles of [param leg] that are tunnel rather than room: clear of every
## room by a tile, so a group is never standing in a doorway, and outside the
## boss arena.
func _tunnel_tiles(leg: Rect2i) -> Array[Vector2i]:
var out: Array[Vector2i] = []
for ty in range(leg.position.y, leg.end.y):
for tx in range(leg.position.x, leg.end.x):
if world.map.at(tx, ty) != MapGrid.Kind.FLOOR:
continue
var here := Vector2i(tx, ty)
if boss_room.has_point(here):
continue
var in_room := false
for r in rooms:
if r.grow(1).has_point(here):
in_room = true
break
if not in_room:
out.append(here)
return out
## The furthest any enemy in the game notices a player from.
static func _widest_aggro() -> float:
var widest := 0.0
for id in Content.ALL_ENEMIES:
widest = maxf(widest, Content.enemy(id).aggro_range)
return widest
func _pick_enemy(rng: RandomNumberGenerator) -> EnemyDef:
var roll := rng.randf()
if roll < 0.4:
return Content.drifter()
if roll < 0.75:
return Content.turret()
return Content.stalker()
## Tile-space room rect to world space, for confining a boss to its arena.
func _room_rect_world(r: Rect2i) -> Rect2:
if r.size == Vector2i.ZERO:
return Rect2()
var lo := world.map.tile_centre(r.position.x + 1, r.position.y + 1)
var hi := world.map.tile_centre(r.end.x - 2, r.end.y - 2)
return Rect2(lo, hi - lo)
func add_peer(peer_id: int, display_name: String) -> void:
if not peers.has(peer_id):
peers.append(peer_id)
@@ -105,8 +303,12 @@ func exit_countdown_seconds() -> int:
Protocol.COUNTDOWN_NONE - 1)
func accepts_new_party_member() -> bool:
## [param dungeon] has to match: walking into the Proving Grounds portal must
## never drop you into a standard run that happens to still be forming, however
## conveniently timed.
func accepts_new_party_member(dungeon: StringName) -> bool:
return kind == Protocol.InstanceKind.DUNGEON \
and dungeon_id == dungeon \
and state == State.FORMING \
and peers.size() < SimConfig.DUNGEON_PARTY_MAX
@@ -118,11 +320,9 @@ func step() -> void:
_step_dungeon()
## Straight-line progression: two trash waves, then the boss. Waves gate on
## "everything dead" rather than a timer so a slow party is never overrun.
## The dungeon is fully populated at generation, so all that is left to track
## is the party forming and the boss dying.
func _step_dungeon() -> void:
# The delay has to tick down before the CLEARED check, or a cleared dungeon
# would sit on its exit timer forever and never release its party.
if stage_delay > 0:
stage_delay -= 1
return
@@ -132,31 +332,13 @@ func _step_dungeon() -> void:
if state == State.FORMING:
if age >= SimConfig.DUNGEON_FORMING_TICKS or peers.size() >= SimConfig.DUNGEON_PARTY_MAX:
state = State.ACTIVE
# stage is left as make_dungeon set it -- resetting it here would
# silently undo the --boss-rush dev switch.
stage_delay = 90
return
if stage >= 0 and _live_enemy_count() > 0:
return
if stage == 2:
if world.boss != null and world.boss.alive:
return
if world.boss != null and not world.boss.alive:
state = State.CLEARED
stage_delay = SimConfig.DUNGEON_CLEARED_EXIT_TICKS
GameLog.info("instance", "instance %d CLEARED, returning party in %ds" % [
id, SimConfig.DUNGEON_CLEARED_EXIT_TICKS / SimConfig.TICK_RATE])
return
stage += 1
stage_delay = 60
match stage:
0: _spawn_wave_one()
1: _spawn_wave_two()
2:
world.spawn_boss(Content.boss(boss_id))
GameLog.info("instance", "BOSS_SPAWNED %s in instance %d" % [boss_id, id])
GameLog.debug("instance", "instance %d entered stage %d" % [id, stage])
func _live_enemy_count() -> int:
@@ -165,18 +347,3 @@ func _live_enemy_count() -> int:
if e.alive:
n += 1
return n
func _spawn_wave_one() -> void:
for i in 3:
world.spawn_enemy(Content.drifter(), Vector2(-320.0 + 320.0 * float(i), -120.0), i * 40)
world.spawn_enemy(Content.turret(), Vector2(-420.0, -220.0), 0)
world.spawn_enemy(Content.turret(), Vector2(420.0, -220.0), 75)
func _spawn_wave_two() -> void:
for i in 4:
world.spawn_enemy(Content.stalker(), Vector2(-300.0 + 200.0 * float(i), -260.0), i * 15)
for i in 3:
world.spawn_enemy(Content.turret(), Vector2(-380.0 + 380.0 * float(i), -60.0), i * 50)
world.spawn_enemy(Content.drifter(), Vector2(0.0, -300.0), 20)
+42 -7
View File
@@ -10,6 +10,8 @@ const GAME_SCENE := preload("res://src/game.tscn")
const CONNECT_TIMEOUT_SEC := 8.0
var _menu: Control = null
var _settings: CanvasLayer = null
var _credits: CanvasLayer = null
var _game: Node = null
var _ticks: int = 0
var _connecting: bool = false
@@ -23,6 +25,10 @@ func _ready() -> void:
# engine default and would silently desync server and client if that
# default ever changed.
Engine.physics_ticks_per_second = SimConfig.TICK_RATE
# Bindings and volumes, before anything can read the input map or play a
# sound. A dedicated server has neither, so it does not pay for them.
if not GameOpts.is_server:
Settings.load_and_apply()
Net.state_changed.connect(_on_net_state)
if GameOpts.is_server:
@@ -58,13 +64,19 @@ func _run_dedicated_server() -> void:
func _show_menu() -> void:
if _menu != null:
return
_clear_game()
_menu = preload("res://src/ui/main_menu.gd").new()
_menu.join_requested.connect(_join)
_menu.host_requested.connect(_host_and_play)
add_child(_menu)
if _menu == null:
_menu = preload("res://src/ui/main_menu.gd").new()
_menu.join_requested.connect(_join)
_menu.host_requested.connect(_host_and_play)
_menu.settings_requested.connect(_open_settings)
_menu.credits_requested.connect(_open_credits)
add_child(_menu)
# Unconditional, and the reason this is not an early-return when the menu
# already exists: a failed connection returns here with the buttons still
# disabled from _menu_busy(true), and skipping this left the player locked
# out of both joining and hosting with no way back short of a restart.
_menu.set_busy(false)
## Note what this deliberately does NOT do: enter the game. Net.join() only
@@ -74,6 +86,8 @@ func _show_menu() -> void:
## acknowledged them. The game scene now waits for the server to actually place
## us in an instance.
func _join(address: String, port: int) -> void:
if _connecting:
return
GameOpts.host_address = address
GameOpts.port = port
if Net.join(address, port) != OK:
@@ -106,7 +120,7 @@ func _abort_connect(reason: String) -> void:
get_tree().quit(1)
return
Net.shutdown()
_show_menu()
_show_menu() # re-enables the connect buttons
_menu_status(reason)
@@ -114,6 +128,8 @@ func _abort_connect(reason: String) -> void:
## connected over loopback. The client takes no shortcuts because of it -- it
## still only sends input and still only learns outcomes from snapshots.
func _host_and_play(port: int) -> void:
if _connecting:
return
if Net.host(port) != OK:
_menu_status(Net.last_error)
return
@@ -131,6 +147,25 @@ func _enter_game() -> void:
add_child(_game)
## The main menu's copy of the settings screen. The in-game menu has its own --
## they are the same screen, and neither exists while the other is on display.
func _open_settings() -> void:
if _settings == null:
_settings = preload("res://src/ui/settings_screen.gd").new()
_settings.closed.connect(func() -> void: _settings.visible = false)
_settings.credits_requested.connect(_open_credits)
add_child(_settings)
_settings.open()
func _open_credits() -> void:
if _credits == null:
_credits = preload("res://src/ui/credits_screen.gd").new()
_credits.closed.connect(func() -> void: _credits.visible = false)
add_child(_credits)
_credits.open()
func _clear_game() -> void:
if _game != null:
_game.queue_free()
+28
View File
@@ -0,0 +1,28 @@
@abstract
class_name AuthProvider
extends RefCounted
## How the server learns who a client is.
##
## Modelled on Steamworks deliberately, so swapping to it later is one subclass
## and no schema change: the client obtains an opaque ticket, sends it with the
## handshake, and the server validates it into a stable 64-bit account id --
## exactly the shape of GetAuthSessionTicket / BeginAuthSession / SteamID64.
##
## The account id is the only identity anything downstream sees. Nothing stores
## a ticket, and nothing outside a provider interprets one.
## Reserved: never a valid account.
const NO_ACCOUNT := 0
## Client side: a ticket to present at handshake.
@abstract func get_ticket() -> PackedByteArray
## Server side: validate a ticket and return the account it proves, or
## NO_ACCOUNT to reject the connection.
@abstract func validate(ticket: PackedByteArray) -> int
## Human-readable, for logs and for the connect screen.
@abstract func provider_name() -> String
+1
View File
@@ -0,0 +1 @@
uid://fteslbujpkuo
Loaded 100 of 237 files, more files were not shown because too many files have changed in this diff. Show more