Compare commits
3
Commits
6e872e2cf5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08f58af1a8 | ||
|
|
94ad6b3429 | ||
|
|
170eb409dd |
No files matched your search
@@ -13,8 +13,8 @@ the obvious default.
|
||||
|
||||
```bash
|
||||
tools/check.sh # parse-check every script (~5s) -- run after every edit
|
||||
tools/test.sh # 551 GUT tests, headless (~4s)
|
||||
tools/smoke.sh # real server + 4 bot clients over ENet (~40s)
|
||||
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 --listen # host and play, no menu
|
||||
```
|
||||
@@ -209,6 +209,14 @@ ticks in milliseconds with no SceneTree.
|
||||
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
|
||||
@@ -254,6 +262,12 @@ ticks in milliseconds with no SceneTree.
|
||||
`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.
|
||||
|
||||
@@ -7,7 +7,7 @@ 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.
|
||||
@@ -25,8 +25,9 @@ hitbox overlay. Every one of those is rebindable in Settings.
|
||||
| **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. |
|
||||
| **Characters** | Up to five living per account, permadeath, levels 1–15, experience shared across the party undivided. |
|
||||
| **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. |
|
||||
@@ -36,8 +37,8 @@ hitbox overlay. Every one of those is rebindable in Settings.
|
||||
|
||||
```bash
|
||||
tools/check.sh # parse-check every script ~5s
|
||||
tools/test.sh # 468 GUT tests, headless ~4s
|
||||
tools/smoke.sh # server + 4 bot clients over ENet ~40s
|
||||
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
|
||||
|
||||
+20
-2
@@ -144,6 +144,8 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.**
|
||||
| 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/sim/player_stats.gd` | Combat numbers derived from a character's upgrades. |
|
||||
@@ -172,6 +174,7 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.**
|
||||
| `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
|
||||
@@ -190,13 +193,28 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.**
|
||||
| --- | --- |
|
||||
| `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. |
|
||||
| `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` | Bars, inventory, prompts. Drawn, not built from controls. |
|
||||
| `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. |
|
||||
+107
-2
@@ -108,8 +108,21 @@ 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, as they always were —
|
||||
a shot out of the black is still one you can dodge.
|
||||
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.
|
||||
@@ -530,3 +543,95 @@ 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.
|
||||
+48
-11
@@ -165,7 +165,7 @@ Two consequences fall out of partial knowledge:
|
||||
|
||||
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 (next stage) actors outside it.
|
||||
and actors outside the interest radius (below).
|
||||
|
||||
## Actors are scoped per peer
|
||||
|
||||
@@ -188,13 +188,24 @@ Two rules fall out of it:
|
||||
about a bullet must always be told it died, or it keeps a phantom until the
|
||||
lifetime expires.
|
||||
|
||||
Bullet spawns use a much wider radius (`BULLET_INTEREST_RADIUS`, 2200u) than
|
||||
actors, because 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. The floor is *longest bullet travel + fog radius* — 1500 +
|
||||
460 for the Warden's "Collapse" snipe — and `test_interest.gd` recomputes that
|
||||
from the live content, so adding a faster or longer-lived bullet fails a test
|
||||
rather than producing bullets that wink into existence.
|
||||
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
|
||||
|
||||
@@ -318,9 +329,11 @@ transition, not a combat mechanic.
|
||||
| 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()`
|
||||
@@ -411,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
|
||||
|
||||
+481
@@ -23,6 +23,23 @@ 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.**
|
||||
|
||||
@@ -35,6 +52,18 @@ candidates, none of which the user has chosen — **ask before starting one.**
|
||||
| **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 |
|
||||
| --- | --- |
|
||||
| **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). |
|
||||
|
||||
Each stage below also carries its own **Known gaps** section — those are smaller
|
||||
and more specific than the table above.
|
||||
|
||||
@@ -51,6 +80,458 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## Verification surface
|
||||
|
||||
What "everything passes" currently means. Numbers move; the shape does not.
|
||||
|
||||
| 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 |
|
||||
|
||||
`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).
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
|
||||
---
|
||||
|
||||
## Open questions for the user
|
||||
|
||||
Genuinely unspecified. **Do not guess at these** — each changes the design, and
|
||||
|
||||
@@ -224,6 +224,40 @@ 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
|
||||
|
||||
@@ -17,6 +17,21 @@ extends Node2D
|
||||
|
||||
## Used only in fallback mode, where every bullet is the same generated dot and
|
||||
## colour is the only thing distinguishing a kind.
|
||||
## How long an enemy bullet is guaranteed to be drawn at full strength before
|
||||
## it could possibly reach you. The whole point of fading bullets with distance
|
||||
## is to stop drawing shots that cannot matter -- and the one thing that must
|
||||
## never come of it is a hit from a bullet you were not shown.
|
||||
##
|
||||
## So each enemy bullet is drawn out to however far it, plus you running straight
|
||||
## at it, could close in this long -- or to the edge of sight, whichever is
|
||||
## further. With today's content the edge of sight is always the further: the
|
||||
## fastest enemy bullet does 300 u/s, so even head-on it is on screen for 0.83s
|
||||
## before it lands. The speed term is what keeps that true for anything faster.
|
||||
const WARNING_TIME := 0.75
|
||||
## Past the distance a bullet is drawn to, it fades out over this much more
|
||||
## rather than vanishing on a line -- a hard edge reads as a rendering fault.
|
||||
const FADE_OUT := 80.0
|
||||
|
||||
const _FALLBACK_COLOURS: Array[Color] = [
|
||||
Color(0.45, 0.95, 1.0), # KIND_PLAYER_SHOT
|
||||
Color(1.0, 0.35, 0.75), # KIND_ORB
|
||||
@@ -99,13 +114,47 @@ func _slice_still_frames(sheet: Texture2D, kinds: int) -> Array[Texture2D]:
|
||||
return out
|
||||
|
||||
|
||||
func render_pool(pool: BulletPool) -> void:
|
||||
## How strongly to draw a bullet [param dist] from the eye: 1 is fully, 0 is not
|
||||
## at all. See [constant WARNING_TIME] for why the distance depends on the bullet.
|
||||
##
|
||||
## Shots by players fade at the edge of sight and no further, however fast they
|
||||
## are: they cannot hurt you, so there is nothing to warn about, and a Sniper's
|
||||
## 1500 u/s shot was the most common thing streaking across the dark.
|
||||
##
|
||||
## Distance only -- not shadow. A bullet behind a wall within sight range is
|
||||
## still drawn, over the darkness, because you can step round the corner into
|
||||
## it; hiding it would be the invisible hit this is careful not to cause.
|
||||
static func visibility(dist: float, speed: float, accel: float, team: int,
|
||||
sight_radius: float) -> float:
|
||||
var reach := sight_radius
|
||||
if team == SimConfig.TEAM_ENEMY:
|
||||
# Top speed over the warning window, times the window: an over-estimate
|
||||
# of how far it can come, which is the safe direction to be wrong in.
|
||||
var top := speed + maxf(accel, 0.0) * WARNING_TIME
|
||||
reach = maxf(sight_radius, (top + SimConfig.PLAYER_SPEED) * WARNING_TIME)
|
||||
if dist <= reach:
|
||||
return 1.0
|
||||
return clampf(1.0 - (dist - reach) / FADE_OUT, 0.0, 1.0)
|
||||
|
||||
|
||||
## Push the pool into the MultiMeshes. With an [param eye], bullets far beyond
|
||||
## [param sight_radius] are left out; without one -- the connect screen's
|
||||
## backdrop -- everything is drawn.
|
||||
func render_pool(pool: BulletPool, eye: Vector2 = Vector2.INF,
|
||||
sight_radius: float = 0.0) -> void:
|
||||
var counts := PackedInt32Array()
|
||||
counts.resize(_layers.size())
|
||||
counts.fill(0)
|
||||
var cull := eye.is_finite()
|
||||
for i in pool.high_water:
|
||||
if pool.alive[i] == 0:
|
||||
continue
|
||||
var alpha := 1.0
|
||||
if cull:
|
||||
alpha = visibility((pool.pos[i] as Vector2).distance_to(eye),
|
||||
(pool.vel[i] as Vector2).length(), pool.accel[i], pool.team[i], sight_radius)
|
||||
if alpha <= 0.0:
|
||||
continue
|
||||
var kind := clampi(pool.kind[i], 0, _layers.size() - 1)
|
||||
var n := counts[kind]
|
||||
if n >= SimConfig.MAX_BULLETS:
|
||||
@@ -121,8 +170,9 @@ func render_pool(pool: BulletPool) -> void:
|
||||
+ float(pool.uid[i] % 64) * (TAU / 64.0)
|
||||
_layers[kind].multimesh.set_instance_transform_2d(
|
||||
n, Transform2D(angle, Vector2(d, d), 0.0, pool.pos[i]))
|
||||
_layers[kind].multimesh.set_instance_color(n,
|
||||
_FALLBACK_COLOURS[kind] if _fallback else Color.WHITE)
|
||||
var tint: Color = _FALLBACK_COLOURS[kind] if _fallback else Color.WHITE
|
||||
tint.a *= alpha
|
||||
_layers[kind].multimesh.set_instance_color(n, tint)
|
||||
counts[kind] = n + 1
|
||||
for kind in _layers.size():
|
||||
_layers[kind].multimesh.visible_instance_count = counts[kind]
|
||||
@@ -57,7 +57,8 @@ func _process(delta: float) -> void:
|
||||
_anim_time += delta
|
||||
client = Net.client
|
||||
if client != null:
|
||||
bullets.render_pool(client.world.pool)
|
||||
bullets.render_pool(client.world.pool, client.predicted_pos,
|
||||
SimConfig.FOG_VIEW_RADIUS)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
@@ -70,8 +71,9 @@ func _process(delta: float) -> void:
|
||||
## darkness because a warning you cannot see is no warning (see
|
||||
## _draw_telegraphs). The local player goes on top because it is the eye. And
|
||||
## bullets are a child node, so they are drawn after all of this, over the dark
|
||||
## -- as they always were: a shot coming out of the black is a shot you can
|
||||
## still dodge.
|
||||
## -- a shot coming out of the black is a shot you can still dodge. Out past the
|
||||
## edge of sight they fade away, but only as far as is safe: see
|
||||
## BulletRenderer.visibility().
|
||||
func _draw() -> void:
|
||||
if client == null:
|
||||
return
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
extends GutTest
|
||||
## Which bullets are drawn, and the promise that bounds it: no enemy bullet can
|
||||
## hit you without having been on screen, at full strength, for at least
|
||||
## BulletRenderer.WARNING_TIME first. Fading distant bullets is only worth doing
|
||||
## if that holds for every bullet the game can fire.
|
||||
|
||||
const RENDERER := preload("res://src/view/bullet_renderer.gd")
|
||||
const SIGHT := SimConfig.FOG_VIEW_RADIUS
|
||||
|
||||
|
||||
## The worst case for warning: the bullet coming straight at you and you running
|
||||
## straight at it. Stepped the way BulletPool integrates, from far enough out
|
||||
## that it starts undrawn. Returns seconds it was fully drawn before it hit.
|
||||
func _warning(speed: float, accel: float) -> float:
|
||||
var dt := SimConfig.TICK_DELTA
|
||||
var gap := 6000.0
|
||||
var v := speed
|
||||
var t := 0
|
||||
var first_full := -1
|
||||
var reach := SimConfig.PLAYER_RADIUS + 7.0
|
||||
while gap > reach and t < 60 * 120:
|
||||
if first_full < 0 and RENDERER.visibility(gap, v, accel,
|
||||
SimConfig.TEAM_ENEMY, SIGHT) >= 1.0:
|
||||
first_full = t
|
||||
v = maxf(v + accel * dt, 0.0)
|
||||
gap -= (v + SimConfig.PLAYER_SPEED) * dt
|
||||
t += 1
|
||||
assert_gte(first_full, 0, "the bullet was drawn at all before it arrived")
|
||||
return float(t - first_full) * dt
|
||||
|
||||
|
||||
func test_every_enemy_bullet_in_the_game_is_seen_in_time() -> void:
|
||||
var emitters: Array[BulletEmitter] = []
|
||||
for id in Content.ALL_ENEMIES:
|
||||
emitters.append_array(Content.enemy(id).emitters)
|
||||
for id in Content.ALL_BOSSES:
|
||||
for phase in Content.boss(id).phases:
|
||||
emitters.append_array(phase.emitters)
|
||||
assert_gt(emitters.size(), 0)
|
||||
for e in emitters:
|
||||
var warned := _warning(e.speed, e.accel)
|
||||
assert_gte(warned, RENDERER.WARNING_TIME - SimConfig.TICK_DELTA,
|
||||
"a %.0f u/s bullet was on screen %.2fs before it could land" % [e.speed, warned])
|
||||
|
||||
|
||||
func test_a_bullet_far_faster_than_anything_today_is_still_seen_in_time() -> void:
|
||||
# The sight radius covers today's content on its own. This is the case the
|
||||
# speed term exists for: content added later, faster than the radius allows.
|
||||
for case in [[1200.0, 0.0], [600.0, 900.0], [2000.0, 0.0]]:
|
||||
var warned := _warning(case[0], case[1])
|
||||
assert_gte(warned, RENDERER.WARNING_TIME - SimConfig.TICK_DELTA,
|
||||
"%.0f u/s, accelerating %.0f: on screen %.2fs" % [case[0], case[1], warned])
|
||||
|
||||
|
||||
func test_inside_sight_everything_is_drawn_fully() -> void:
|
||||
for team in [SimConfig.TEAM_ENEMY, SimConfig.TEAM_PLAYER]:
|
||||
assert_eq(RENDERER.visibility(SIGHT - 1.0, 130.0, 0.0, team, SIGHT), 1.0)
|
||||
|
||||
|
||||
func test_a_slow_shot_far_beyond_sight_is_not_drawn() -> void:
|
||||
assert_eq(RENDERER.visibility(SIGHT + 400.0, 130.0, 0.0, SimConfig.TEAM_ENEMY, SIGHT),
|
||||
0.0, "out in the black and nowhere near you")
|
||||
|
||||
|
||||
func test_the_edge_fades_rather_than_cuts() -> void:
|
||||
var mid := RENDERER.visibility(SIGHT + RENDERER.FADE_OUT * 0.5, 130.0, 0.0,
|
||||
SimConfig.TEAM_ENEMY, SIGHT)
|
||||
assert_between(mid, 0.3, 0.7)
|
||||
|
||||
|
||||
func test_player_shots_fade_at_the_edge_of_sight_however_fast() -> void:
|
||||
# They cannot hurt you, so there is nothing to warn about -- and a Sniper's
|
||||
# shot streaking across the dark was the commonest case of all.
|
||||
assert_eq(RENDERER.visibility(SIGHT + 200.0, SimConfig.MAX_BULLET_SPEED, 0.0,
|
||||
SimConfig.TEAM_PLAYER, SIGHT), 0.0)
|
||||
assert_gt(RENDERER.visibility(SIGHT + 200.0, SimConfig.MAX_BULLET_SPEED, 0.0,
|
||||
SimConfig.TEAM_ENEMY, SIGHT), 0.0,
|
||||
"an enemy bullet that fast is drawn further out, because it could reach you")
|
||||
|
||||
|
||||
# --- The renderer actually applies it ---------------------------------------
|
||||
|
||||
func _drawn(r: Node2D) -> int:
|
||||
var n := 0
|
||||
for layer in r._layers:
|
||||
n += (layer as MultiMeshInstance2D).multimesh.visible_instance_count
|
||||
return n
|
||||
|
||||
|
||||
func test_the_renderer_leaves_out_what_is_too_far_to_matter() -> void:
|
||||
var r: Node2D = RENDERER.new()
|
||||
add_child_autofree(r)
|
||||
var pool := BulletPool.new()
|
||||
var eye := Vector2(100.0, 100.0)
|
||||
pool.spawn(eye + Vector2(200.0, 0.0), Vector2(-130.0, 0.0), 7.0, 300, 10,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
pool.spawn(eye + Vector2(SIGHT + 500.0, 0.0), Vector2(-130.0, 0.0), 7.0, 300, 10,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
r.render_pool(pool, eye, SIGHT)
|
||||
assert_eq(_drawn(r), 1, "the near bullet only")
|
||||
|
||||
|
||||
func test_with_no_eye_everything_is_drawn() -> void:
|
||||
# The connect screen's backdrop has no player and wants the whole pattern.
|
||||
var r: Node2D = RENDERER.new()
|
||||
add_child_autofree(r)
|
||||
var pool := BulletPool.new()
|
||||
pool.spawn(Vector2.ZERO, Vector2.RIGHT, 7.0, 300, 10, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
pool.spawn(Vector2(5000.0, 0.0), Vector2.RIGHT, 7.0, 300, 10,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
r.render_pool(pool)
|
||||
assert_eq(_drawn(r), 2)
|
||||
@@ -0,0 +1 @@
|
||||
uid://noaqo4x7so77
|
||||
+3
-1
@@ -105,7 +105,9 @@ func _process(_delta: float) -> void:
|
||||
# High until just before the picture and low after, so the damage trail
|
||||
# -- the part of the bar the drop took -- is still draining when it is taken.
|
||||
if _frame > TO_BOSS and not _left_shot:
|
||||
_hold_health(0.62 if _frame < AT_BOSS - 10 else 0.22)
|
||||
# Forty frames, not ten: at a high frame rate ten is shorter than one
|
||||
# snapshot, and the picture showed the health from before the drop.
|
||||
_hold_health(0.62 if _frame < AT_BOSS - 40 else 0.22)
|
||||
# Captured by progress rather than by frame count. The channel is one second
|
||||
# of server ticks and frames run as fast as the display allows, so a fixed
|
||||
# frame number lands anywhere from a quarter of the way in to after it has
|
||||
|
||||
Reference in new issue
Block a user