Survivor Game
This page is one idea from this project, explored in depth. Source status: write-up only: the code stays private; this page is the public artifact. Withheld: Nothing in the game is sensitive; the repository simply has not been opened, and its public deploy came down in August 2026 when the repository went private. The code on this page is copied by hand from that private source, not linked.
A 2D roguelike survival game in the browser: dodge, level up, and stack upgrades against escalating waves.
A browser roguelike in the survivors mold: Phaser 3 for rendering, bitECS for entities, hundreds of things moving at once, and an upgrade system whose job is to make each level-up feel like a real choice. It shipped to the edge on every push until August 2026, when the repository went private in a security pass and its GitHub Pages deploy went down with it. The game still runs from a local build. The address it lived at still resolves, and until that record is removed it answers with GitHub’s own “site not found” page rather than anything of mine.
This page is a design note: one mechanism from the code, explained so that it can be reused, rather than a tour of the whole game.
The trail that measures distance, not time
Every other weapon in the game fires on a clock, or in one case on damage taken. A cooldown counts down, the weapon attacks, the cooldown resets, whatever the player is doing. The Caustic Wake is the one weapon that keys off movement instead: it lays a damaging ribbon behind the ship as it travels. Stand still and the wake stops growing. Sprint, and you paint a long line the horde has to cross. That inversion is the point of the weapon, and it puts a precise requirement on the code that emits the trail.
The problem and its constraints
The wake is a chain of pooled segments dropped along the ship’s path. Three things had to hold at once:
- Uniform spacing at any speed. Segments should sit the same distance apart whether the ship crawls or dashes, because the ribbon’s damage coverage is its spacing.
- Correct at any frame rate. Phaser hands each update a variable delta in milliseconds. A frame hitch, a background tab, or a dash can move the ship several segment-widths in a single step, and the trail must not develop gaps when that happens.
- Almost no per-frame allocation. The repository’s own guidelines are blunt about garbage-collector stalls with a hundred-plus enemies on screen: pool everything frequent, query the world once per frame. Whatever emits segments has to be cheap and predictable.
Alternatives, and what each one costs
This section is my analysis. The repository records the design it landed on (below), not a bake-off against these, and I have no evidence any of them was tried.
- Emit one segment per frame. The simplest possible code. Spacing then depends on both speed and frame rate: a fast ship at 30 frames per second leaves sparse dots, a slow ship at 120 leaves a smear. The weapon’s balance would change with the player’s monitor.
- Emit on a timer. The natural shape for a weapon in a codebase where every other weapon has a cooldown. Spacing now depends on speed alone, which is better but still wrong for this weapon: a stationary ship would keep laying segments in place, which is exactly the behaviour the design forbids.
- Run the simulation on a fixed timestep. The textbook fix for frame-rate dependence, and the right answer when determinism itself is the requirement (replays, lockstep multiplayer). It is also an engine-wide change to a game whose systems all read Phaser’s delta, and on its own it still ties spacing to speed unless the emitter is distance-gated anyway.
- Gate emission on distance travelled, carrying the remainder across frames. Make the emitter a function of arc length: every time the ship has covered another
spacingpixels, drop a segment at exactly that point along the path, and remember how far past the last one it is. This is what the game does.
The approach in the code
The emitter is a pure module with no Phaser import. It holds three numbers (the last sampled position and the distance travelled since the last segment) and a flag for the first sample. Each frame the weapon class hands it the ship’s new position, and it returns the new state plus every point to drop. The core, copied from the source with its comments trimmed:
const deltaX = x - state.lastX;
const deltaY = y - state.lastY;
const stepDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (stepDistance < 0.0001 || spacing <= 0) {
return { state: { ...state, lastX: x, lastY: y }, emitPoints: [] };
}
const dirX = deltaX / stepDistance;
const dirY = deltaY / stepDistance;
const emitPoints: WakePoint[] = [];
let cursorX = state.lastX;
let cursorY = state.lastY;
let travelled = 0;
let distanceSinceEmit = state.distanceSinceEmit;
let untilNext = spacing - distanceSinceEmit;
while (travelled + untilNext <= stepDistance) {
cursorX += dirX * untilNext;
cursorY += dirY * untilNext;
emitPoints.push({ x: cursorX, y: cursorY });
travelled += untilNext;
distanceSinceEmit = 0;
untilNext = spacing;
}
distanceSinceEmit += stepDistance - travelled;
Two details carry the weight. The loop walks along this frame’s movement vector and emits at every multiple of spacing, so one long step yields every segment it swept over, not one. And the leftover distance after the last emitted point is stored, so the next frame’s first segment lands at the true crossing point rather than restarting the count at the frame boundary.
With spacing at 10, two consecutive frames look like this:
distance along the path: 0 8 10 12
|---------|--+---|
frame 1, moved 8: nothing dropped, carry 8
frame 2, moved 4: one segment at 10 (the crossing point), carry 2
The weapon class around the module owns everything Phaser-shaped: the segment pool, the collision passes, the slow effect, the rendering. That split is not special to this weapon. The sentry turret and the singularity well are built the same way, and the repository’s rule is that pure logic is tested at the module boundary while Phaser-coupled code is verified by play.
Evidence
The emitter ships with eight unit tests, and their names are the specification. Four of them, verbatim:
- “moving less than spacing emits nothing but accumulates distance”
- “a single long step (low FPS / dash) drops every segment it passed over”
- “accumulates carried distance across frames and emits at the crossing point”
- “places segments on the diagonal at the correct arc length”
The last one moves the ship by (30, 40) in a single step and expects five points along the (0.6, 0.8) direction, the last landing exactly at (30, 40). In play, the observable check is the simplest one: a dash lays a full ribbon with no gap, and a slow crawl lays segments at the same spacing as a sprint.
Limitations, and when to do something else
- Segments follow the chord, not the curve. Within one frame the path is assumed straight. At normal frame rates the error is invisible; at very low frame rates a tight turn gets cut across. A game that needs the true curve would sample the input path more finely, at a cost this game did not need to pay.
- Every jump is treated as travel. A dash is movement here, so a dash should paint. A true teleport would paint a line across the gap; a game with blinks would reset the emitter on teleport.
- Spacing is in world pixels. Zoom or resolution changes do not change the coverage, which is what this game wants and not what every game wants.
- The carry is never renormalised. Floating-point drift in the carried distance is far below a pixel over a run, so it is left alone.
Prefer a timer when an effect should keep happening while the actor is still (an aura, smoke from a wreck). Prefer per-frame emission for purely cosmetic trails where uniformity is not part of the gameplay. Prefer a fixed timestep when the requirement is determinism across machines rather than uniform spacing.
What is documented and what is inferred
The module’s own header states the intent: movement-driven output, distance-gated cadence, even arc-length placement, correctness under a low-frame-rate jump, and the deliberate mirroring of the two other pure weapon modules. The commit that added the weapon, in July 2026, says the same in fewer words. Everything in the alternatives section, and the judgement about when another approach wins, is mine.