The entire game is one self-contained index.html: markup, CSS, and a single inline script with no build step and one external dependency (peerjs from a CDN). It runs anywhere a modern browser can open the file.
State lives in a single game object. The defining design choice in this version is that players are an array, not fixed p1/p2 slots — this is what makes 2-to-4-player support and teams possible without special-casing.
const game = { players: [], // array of ship objects, index = player slot scores: [], // per-player (FFA) or per-team (Team) win counts elapsed, round, phase, // 'countdown' | 'play' | 'scored' | 'over' timer, flash, shake, particles, pulses, bombs, msg, winner };
Each entry in players carries position and velocity (x,y,vx,vy), spawn point (sx,sy), charge (0→1 weapon energy), an alive flag, the assigned color/glow, a team id, and a private input bag _in:{u,d,l,r}.
A separate roster array (parallel to players by index) holds each slot's display name and whether it is a bot. Keeping identity (roster) separate from physics (players) means the simulation never has to know or care whether a slot is a human, a remote peer, or AI.
The simulation always runs in a fixed 1000×640 virtual space with the hole at the center (500, 320). The canvas is scaled and letterboxed to fit any screen, so physics is resolution-independent and every client agrees on positions regardless of window size. Rendering is immediate-mode canvas 2D, redrawn every frame.
A toggle in the top corner switches between dark and light visual themes at any time, including mid-match. The toggle flips a class on document.body and swaps the CSS custom properties for background, arena fill, and ring colors. Ship colors and glow tints are adjusted in the same pass so they stay legible on a white arena floor.
A single requestAnimationFrame loop drives everything. Each frame computes a clamped delta time (max 50 ms to survive tab-switches) and branches on whether this client is authoritative:
const authoritative = (mode !== 'online') || (myRole === 'host'); if (authoritative) { applyLocalWeaponEdges(); // host/practice resolves its own pulse/bomb tickClock(dt); // countdown / scored timers simulate(dt); // the actual physics step if (mode === 'online') hostBroadcast(); // push state to all guests } else { guestSend(); // send my inputs to the host animateCosmeticsOnly(); // pulses/particles for smoothness; no physics } draw();
The host (and any single-player practice session) is the only place the simulation actually runs. Guests never integrate physics — they send inputs up and render whatever authoritative snapshot comes back down. This eliminates an entire class of desync bugs by construction: there is exactly one source of truth.
Each ship integrates thrust, gravity, damping, and a speed clamp every step. Gravity points toward the center and grows with proximity, capped to keep the math stable:
function gravAccel(r, e) { return Math.min(GRAV_CAP, GM(e) / Math.max(r, 8)²); }
The clever part is the capture radius. The constant GM is derived so that at a specific distance from the hole — the capture ring — gravitational acceleration exactly equals the player's maximum thrust. Inside that ring, your engines can no longer win against the pull. This is why the dashed red ring is a true point of no return rather than a cosmetic decoration:
const captureRadius = holeRadius + CAP_GAP; // CAP_GAP = 95 px GM = HOLE_STR · captureRadius²; // ⇒ gravity == thrust there
BLACK_HOLE_STRENGTH and PLAYER_SPEED default to the same value (950), the capture ring sits exactly where gravity overtakes thrust. Raising hole strength above player speed makes the hole inescapable sooner than the drawn ring — an intentional, intuitive behavior of that dial.The hole's radius grows slowly during normal play (GROW px/s) so corners don't stay safe forever. The host can arm a timer (30 s, 1, 1.5, or 2 min); when it elapses the round enters a 3-second silent flash countdown (banner only, no alarm, hole still normal pace) then snaps into sudden death — the flashing countdown banner clears, a looping alarm kicks in, and the hole grows at SUDDEN_DEATH_GROW (25 px/s) until someone is squeezed in, and an sdElapsed accumulator advances from that instant, so the fast term is simply added on top of the normal curve:
Independently of the host timer, sudden death also auto-fires the instant every human has been eliminated and only bots remain (totalHumans()>0 && aliveHumans()===0), so a bots-only finish never drags on. In that auto-fired case the squeeze is three times faster again: a per-game multiplier sdRate is set to 3 and the accumulator advances as sdElapsed += dt·sdRate (the host-timed trigger leaves sdRate at 1). Because sdElapsed rides in the snapshot, guests render the accelerated collapse with no extra syncing, and each client drives the flashing banner and the seamless looping alarm off the synced sdPhase. sdRate resets to 1 each new round.
function Rbh(e) { // black-hole radius at elapsed time e const base = R0 + GROW·e; // always-on slow growth const fast = sdElapsed > 0 ? SUDDEN_GROW·sdElapsed : 0; return base + fast; // fast term accrues only while active (phase: none→active) }
Ship-vs-ship uses circle separation plus an impulse along the contact normal, scaled by COLLISION_STRENGTH and restitution E. Walls reflect velocity with restitution WALL. All pairs are tested each frame (O(n²), trivial at n≤4).
The primary fire is either the pulse or the mini-gun (a per-player Settings choice); the bomb is always available alongside whichever is chosen. All three draw from one shared charge (0→1, refilled over RECHARGE_DURATION). A pulse or bomb resets charge to 0; the mini-gun nibbles it per bullet.
A pulse iterates every other living ship and applies a radial impulse that is strongest at point-blank and tapers to a floor at the edge of range. The falloff is a power curve so the "get close" incentive is steep:
function pulseScale(d) { // d = distance to target if (d >= PULSE_R) return 0; const t = 1 − d / PULSE_R; // 1 at center → 0 at edge return PULSE_MIN + (1 − PULSE_MIN) · t^PULSE_FALLOFF; } // applied force = PULSE_FORCE · pulseScale(d)
A bomb is stationary, lives BOMB_TIMER seconds, and detonates on contact, on timeout, or when a pulse goes off within BOMB_DETONATE_R. The key subtlety is per-player arming so a planter is never blown up by their own freshly placed bomb:
armed flag per player slot.BOMB_DISARM_R (90 px) away from it.BOMB_TRIGGER_R, or by pulse). Others who started far away are armed immediately and can walk into it normally.BOMB_ARM_DELAY (0.5 s) is a safety floor on top of that.The blast applies an inverse-distance impulse to all living ships within BOMB_BLAST_R — friendly fire included.
The mini-gun is the alternative primary fire to the pulse (a per-player Settings choice) — core arsenal, not a side mode. While the trigger is held, the ship emits bullets from its nose along its current heading at MINIGUN_RATE. Bullets are integrated under the same gravity as ships (so they curve toward the hole), expire after MINIGUN_LIFE or on leaving the arena, and apply a MINIGUN_STRENGTH impulse to the first ship they strike, and detonate any live bomb they touch. Each bullet draws from the shared recharge — it costs 1/MINIGUN_SHOTS of the charge, so a full ring is roughly MINIGUN_SHOTS bullets and the same ring still gates the bomb. The weapon mode never has to be transmitted: a pulse sends a one-shot edge, the mini-gun sends a held-trigger bit, so the host simply spawns bullets for whichever ships report the trigger down. Bullets ride in the state snapshot and render as short white lines (black in light mode). Two refinements sharpen the weapon: each bullet applies a homing nudge — within MINIGUN_HOME_R (220 px) of an enemy ship or a live bomb, and only when that target sits inside a ~75° cone (MINIGUN_HOME_CONE) of the bullet's heading, it rotates its velocity toward the target by up to MINIGUN_HOME_RATE (3.0 rad/s). The cone is what keeps homing an assist rather than a magnet — a shot already roughly on target curves into a hit, while a wildly-off shot is ignored, so accuracy still comes from aim. Homing is now uniform for humans and bots (bot difficulty lives in accuracy and fire-rate, not a homing handicap). A pulse or bomb blast also deflects bullets caught in it, pushing them outward by a BULLET_DEFLECT impulse scaled by the same falloff used on ships, with homing briefly suppressed (_noHome) so a swatted bullet doesn't immediately curl back. All firing — pulse, bomb, and mini-gun — is now gated to the play phase, so nothing can be deployed during the 3-2-1 countdown (movement was already gated; the direct human pulse/bomb path had slipped through). Bots can wield the mini-gun too (see AI bots): because a ship fires along its facing, a mini-gun bot sets an aim override (_aimGoal) so it pivots to lead its target without altering its thrust — letting it kite (retreat while firing) with no cost to its survival logic.
When meteors are enabled (host toggle), the host periodically spawns volleys of small projectiles that enter from a random arena edge and travel across the field. Meteors apply a weak physics impulse on contact — not strong enough to eliminate a ship directly, but enough to nudge one that is already drifting toward the capture ring into a fatal trajectory. They are generated and resolved exclusively on the host; guests see their positions in the normal s snapshot. The spawn rate is configurable via meteorFreq, and meteors also detonate live bombs they strike. Meteors are now knocked around by weapons too: a pulse or bomb blast shoves any meteor in range, and a mini-gun bullet deflects one it strikes — each scaled by that weapon's own force and then damped by a MET_PUSH factor (meteors are heavy, so they move less than a ship would), letting a well-aimed shot redirect an incoming rock.
An ship dies when it crosses the event horizon (radius < Rbh(e) + BALL·0.5). The round resolver differs by format:
Round ends when ≤1 ship remains alive. The survivor scores. Zero survivors (a true simultaneous wipe) is a mutual K.O. with no point.
Round ends when only one team has any living member. That team scores. Both teams wiped in the same instant is a mutual K.O.
When sudden death squeezes the arena, multiple ships can cross the horizon on the same frame. Rather than calling every such case a mutual K.O., deaths are processed deepest-first, and if killing everyone inside would empty the arena (or wipe the last contesting team), the shallowest ship is spared and declared the winner. This makes near-simultaneous endings decisive instead of anticlimactic.
Match ends when any score reaches WIN (3). Results are presented as a leaderboard sorted by win count. In Practice a lightweight session tally tracks how many whole matches the human has taken versus the bots; it is shown on the results screen, persists across rematches, and resets on return to the menu. (Every practice format now plays out a full best-of-3: rounds are won by the last ship alive, a bot can win a round, you respawn each round, and the match-winning side — yours or the bots' — is credited, so the tally moves for both.)
During play, each player's name and current round-win count are displayed along the edges of the screen in that player's color. In Team mode, the two teammates share an edge (left side = blue team, right side = red team) with both names grouped together. This lets everyone track the score at a glance without leaving the action.
Keyboard (arrows/WASD, Space, Shift) drives desktop play. On touch devices the client detects a coarse pointer and shows an on-screen virtual joystick — dynamic-origin, so it spawns wherever the thumb first lands, and analog: past a deadzone the stick's exact angle drives thrust and aim continuously (full strength in any direction), rather than quantizing to 8-way — while the entire right half is divided into two large tap zones — top = bomb, bottom = pulse — so there are no small targets to cover the field. During each countdown the two action quadrants themselves light up — translucent, colour-coded, with icon and label — as a reminder of which half does what. Either half can be swapped left/right for left-handed players. Touch controls render only on mobile and are positioned to stay clear of the edge-mounted name/score HUD, and a portrait overlay prompts the player to rotate to landscape. All interface glyphs are inline SVG rather than emoji, so they render identically across devices with no network fetch. The build also ships a Capacitor configuration (capacitor.config.json / package.json), so the same static files can be wrapped unchanged into native Android and iOS apps — still with no server or runtime build step. The light/dark theme and the control-side preference live in a Settings (⚙️) panel rather than an always-on toggle, so one control surface serves both desktop and mobile. The gear that opens the panel appears only in the menus and lobby — it is hidden during an active match so it never overlaps the controls or the field.
On-screen control hints. Both platforms get a start-of-round reminder that flashes only during the 3-2-1 countdown. On mobile the joystick half lights up (“tap to steer” with a joystick glyph) alongside the existing bomb/pulse action quadrants; on desktop a pair of low, flashing key clusters appears — Space → pulse/mini-gun and Shift → bomb on the left, the arrow keys → fly on the right — with the Space label tracking the selected weapon. The desktop cluster is hidden on touch devices and vice-versa.
Full screen. Desktop shows an expand button beside the gear (menus/lobby only, hidden in play); entering full screen flashes a “press Esc to exit” toast that fades after ~3 s. It uses the standard Fullscreen API with WebKit-prefixed fallbacks for Safari. On mobile the game requests full screen on the first in-match tap to shed the browser chrome, and exits automatically when the device is rotated back to portrait. (iPhone Safari does not grant true element full screen to web pages; there the call is a no-op and an “Add to Home Screen” PWA is the way to a chromeless view — Android Chrome honours it directly.)
Touch robustness. Joystick move/end tracking is bound to the window rather than the joystick element, so a thumb that slides out of the zone keeps steering and still releases cleanly; a fresh touch also clears any finger id that was never released — a genuine reliability improvement. (The specific “third player's controls are dead” report turned out not to be a touch problem at all but the netcode slot-identity desync fixed under Multiplayer above.)
Player ships are drawn as vector darts on the canvas — no image assets — filled with each player's core colour and haloed in their glow so colour stays the dominant identifier. Each ship is rotated to a heading (p._aim) with a two-mode goal: while thrusting, the heading tracks the current thrust vector; while coasting above COAST_FACE_SPEED, it tracks the velocity vector instead, so a ship whipping around the hole visibly sweeps along its actual curved path rather than freezing at its last thrust angle (slow drift holds the last heading so the nose doesn't wander). The heading is never snapped — it slews toward its goal at up to TURN_RATE rad/s (shortest-path, wrap-aware), deliberately slow enough (~0.45 s for a 180°) to read as a real rotation; a turning mini-gun rakes its stream across the arc. Navigation is untouched (thrust still drives acceleration directly); only facing — and therefore mini-gun aim — is smoothed. Input direction resolution differs by device: the arrow keys yield eight thrust directions, while the mobile joystick is analog — past a deadzone its exact angle drives thrust (normalized to full strength, so speed is identical to keyboard), giving continuous 360° steering and aim. The thrust() helper caps the input vector at unit length, which reproduces the old ×0.7071 diagonal exactly for keyboard and bots — their movement is bit-for-bit unchanged. The heading rides in the authoritative state snapshot, so every client renders the correct facing for every ship.
PeerJS provides point-to-point WebRTC data channels. To support more than two players, the host acts as a hub: every guest holds a single connection to the host, and the host holds one connection per guest. Guests never talk to each other directly — the host is both the relay and the authority. Peers are created with an explicit ICE configuration: Google STUN plus a TURN relay. A relay is mandatory for peers behind symmetric / carrier-grade NAT (typical on cellular) — STUN alone cannot punch through, so a phone on mobile data reaches a desktop host only via TURN. Because Cloudflare TURN credentials are time-limited (they expire within 48 hours), they are fetched fresh rather than hard-coded. This build ships with the TURN_API constant pointing at a deployed Cloudflare TURN Worker (blackhole-turn.marksulzen.workers.dev), so the relay is live: an async refreshICE() call runs immediately before each host or join, fetches a short-lived credential set from that Worker, and prepends a STUN entry — meaning real, current Cloudflare TURN credentials are in use on every connection, not the public fallback. The Worker itself holds the permanent TURN_KEY_ID / TURN_API_TOKEN as encrypted secrets and mints a 24-hour credential per request, so no secret ever ships in the page. The fetch is best-effort — on any failure it silently keeps the previous ICE list (a public best-effort relay is bundled as the default fallback), so online play degrades rather than breaks; clearing TURN_API disables the fetch entirely and reverts to those bundled relays. See docs/CLOUDFLARE_TURN_SETUP.md for the Worker setup.
The host opens a PeerJS peer whose id is derived from a 4-character code (blackhole-v4-XXXX). When a guest connects and sends a join, the host assigns the next free slot index, replies with a wel (welcome: your slot, the format, the roster), and broadcasts a ros roster update to everyone. The host starts the match manually once at least two humans are present; any unfilled slots are backfilled with AI on the host.
join from a connection that already holds a slot re-sends its existing wel instead of allocating a second one — previously a duplicate join (a retry on the unreliable channel) could seat one guest in two slots at once. (2) Slot binding is re-asserted at match start: each guest's idx is sent with the start message and hard-applied, so a dropped lobby slot/wel (sent over the unreliable channel during reordering) can no longer leave a guest's myIdx pointing at the wrong ship — which is what produced “my controls do nothing” for the player whose binding had drifted.Departures are now explicit rather than inferred. A leaving guest sends the host a bye; the host frees that slot, marks the ship dead (it vanishes from the field), re-broadcasts the roster so the name disappears everywhere, and sends all remaining clients a left notice that pops a “Name left the session” toast. A leaving host sends every guest a hostleft (carrying an inGame flag) before destroying its peer, so guests get an immediate “The host left the session/lobby” message instead of silently freezing. This is the core of the freeze fix: previously a host teardown left guests waiting on snapshots that never came, because PeerJS does not reliably fire close when the remote peer is destroyed. As a backstop for a truly silent drop, guests also run a watchdog — if no s snapshot arrives for ~4.5 s mid-match, the guest routes itself out with a “Lost connection” notice.
Where you land is decided by a single rule (exitDestination): the host always returns to the main menu (the page most relevant to what it was last doing — creating a game); a guest returns to the public browse list if it joined via that list, otherwise to the main menu. The same rule fires whether you leave voluntarily, are pushed out by a host departure, or are the last human standing (when a drop leaves only the host, the host sees “All players have left” and exits to the menu). Intent: send everyone to the most navigationally meaningful master page, never literally back to a mid-flow screen like the code-entry box.
Results → lobby. The end-of-match screen now offers Return to Lobby instead of an instant rematch: the host broadcasts tolobby, reopens the bot slots, and returns to its lobby (re-listing if public); guests drop back into the waiting room, still connected, for the host to start again. Host Start also plays a synced “GAME STARTING” 3-2-1 pre-roll (host on send, guests on start) before the in-match intro countdown. Guests see a read-only settings summary in the waiting room, kept live by folding meteorOn/meteorFreq/suddenDeathAt into every wel/ros. A public host also gets a listing indicator that polls the lobby endpoint and flips from a yellow “listing…” to a green “Available to join” once the game actually appears (KV propagation can lag the 20 s heartbeat by a few seconds).
Sound effects are synthesized in-browser with the Web Audio API — a small hand-built engine (sfx(name,vol)) builds each effect from oscillators and filtered noise, so there are no SFX files to host and nothing external to load. Per-sound base volumes tame the hot/frequent ones (the mini-gun pew is quiet and rate-throttled; the sudden-death cues are boosted to cut through the music). Music is a set of streaming <audio> files in a music/ folder beside index.html (kept out of the HTML so the page stays small and tracks stream rather than block load): a 7-track gameplay playlist, a looping menu/lobby track, and two match-over fanfares — a winner track and a loser track. Two independent toggles (Music, Sound effects) live in Settings; all audio unlocks on the first user gesture (browser requirement, especially iOS).
Music cues. The menu track simply loops and resumes wherever it left off. Gameplay music is timed deliberately: silent through the pre-round countdown, it starts exactly on the FIGHT! flash of a match's first round, plays across every round of the set, and when a track finishes it rolls to a different one (never an immediate repeat). The host picks the starting track and ships its index in the start message so every player hears the same song; each fresh match re-picks (again avoiding a repeat). On match over the gameplay track stops and a fanfare plays from the top — and each client chooses the winner or loser fanfare from its own result (first place in FFA, or membership in the winning team). Music volume is uniform and kept low so effects sit clearly on top.
Who hears what. Only a couple of sounds are local-only — your own charge-full ping fires from local input, so only you hear it. Everything else meaningful is a world event: weapon fire (pulses, bomb drops, mini-gun), explosions, eliminations, meteor waves and impacts, countdown beeps, the FIGHT cue, and the sudden-death alarm/collapse all run on the authoritative side and reach guests through a compact ev array folded into the state snapshot. Each client filters them — a ship collision plays for whoever's involved, the shooter hears their own shot locally (and skips the echoed copy), and the round/match result plays the win or loss cue by your outcome — so players hear each other's action without it turning into a wall of noise.
| Type | Direction | Payload / meaning |
|---|---|---|
| join | guest → host | player name; requests a slot |
| wel | host → guest | assigned idx, fmt, size, lobby roster |
| ros | host → all | updated lobby roster + format |
| slot | host → guest | updated idx after a lobby reorder |
| start | host → all | final roster + this guest's authoritative idx; begin the match |
| i | guest → host | input bag {u,d,l,r} + pulse/bomb edge flags |
| s | host → all | authoritative snapshot (see below) |
| rm | host → all | rematch — restart with the same lineup |
| full | host → guest | lobby is at capacity |
Every host frame broadcasts a compact s message. Player data is packed as fixed-order arrays rather than objects to keep packets small at 60 Hz:
{ t:'s', e, ph, tm, rd, fl, sh, mg, sc, wn,
pl:[ [x,y,charge,alive], … ], // one row per player slot
pu:[ [x,y,colorHex], … ], // active pulse rings
bo:[ [x,y,age], … ] } // live bombs
Guests apply this snapshot directly to their local game object each time it arrives, then render. Cosmetic effects (expanding pulse rings, particles) are advanced locally between snapshots for smoothness, but never affect outcomes.
Entity interpolation. Applying each snapshot's positions directly made remote ships visibly jump whenever packets arrived unevenly — most noticeable on a desktop sharing a match with a phone, whose relayed link has higher, more variable latency. Guests now treat each incoming position as a glide target (_tx,_ty) and ease every ship toward it each frame with an exponential smoother (1−e−dt·16), snapping only on the first frame or on a jump over 200 px (a respawn or sudden-death teleport). The authority model is unchanged — this is purely a render-side smoothing that absorbs network jitter.
'disconnected' event plus an error of type network. This is recoverable, not fatal: already-open data channels are direct WebRTC and keep working, but the peer id is unregistered, so new joins fail until re-registered. Both host and guest now treat it that way — peer.reconnect() is called on disconnected / network (re-asserting the same peer id, so the lobby code stays valid) and again on visibilitychange when the tab returns, throttled to once per second. Two guards keep the recovery clean: the host's re-fired open no longer double-registers the public-lobby heartbeat interval, and a guest's re-fired open skips connect() when its data channel already exists (a duplicate connection object would bypass the idempotent-join check and seat the guest in two slots).WebRTC peers cannot discover one another on their own — joining still requires knowing the host's 4-character code. To let players browse open games, the build adds an optional rendezvous layer: a tiny stateless Cloudflare Worker backed by a KV namespace, reachable at the URL set in the LOBBY_API constant. If that constant is left blank, the whole feature self-hides and the game runs purely peer-to-peer as before.
The discovery layer never touches gameplay traffic — it only stores small listing records. When a host opens a game marked Public, the client POSTs a listing (code, host name, format, seats filled/max) to /lobby; the Worker writes it to KV with a short expirationTtl. The host re-posts on every roster change and on a ~20 second heartbeat, so a host that crashes or quits simply stops refreshing and its listing expires on its own. Browse games does a GET /lobbies, which lists the live records; tapping one calls the ordinary join-by-code path, so the actual connection is still plain PeerJS. Starting the match or leaving the lobby fires POST /lobby/remove to de-list immediately.
| Method / path | Purpose |
|---|---|
| GET /lobbies | return the array of currently-open public games |
| POST /lobby | create or refresh a listing (auto-expires via KV TTL) |
| POST /lobby/remove | de-list a game (match started / host left) |
Empty slots are filled with bots — in Practice, and on the host side of an Online match (the host is authoritative, so it runs the AI for every bot slot). There are three difficulty levels. The controlling principle, validated across thousands of headless simulations, is that survival is sacrosanct: a hard veto runs every frame and a bot abandons any attack to save itself. This is what stopped the previous build's bots from flying straight into the hole — even at the highest level.
Mini-gun bots. A gunner keeps the entire survival/positioning brain unchanged and only swaps its offensive act: it faces the lead-predicted foe via the _aimGoal override and holds the trigger (_mg) when the foe is inside range and beyond a per-level minimum distance and its smoothed aim is lined up within a tolerance. Because aiming never touches thrust, a gunner can fire while fleeing — the survival veto always wins.
Why accuracy is launch scatter, not aim jitter. An earlier design added random jitter to the bot's aim goal, but that did almost nothing: the facing is slewed toward the goal at TURN_RATE, which acts as a low-pass filter — zero-mean per-frame jitter averages out and the smoothed aim still tracks true. The fix is to scatter each bullet's launch angle directly in fireMinigun (after smoothing), via a per-level mgSpread cone that genuinely misses. Knockback power (mgPow) is stamped on every bullet at spawn: human shots hit at 1.0, and all three bot levels now hit at 0.75 power (75% of human) — so when a bot connects, it feels dangerous rather than feeble. Homing is uniform — bots and humans alike use the full assist (mgHome 1.0), so a bot's bullets curve toward you exactly as a human's would. The unfairness a bot's pixel-perfect aim would otherwise create is instead held in check by the levers that gate how often it connects — fire rate, launch scatter, firing range, and how close it's willing to get — not by making its hits weak.
The levers, all per level: firing range (mgRange) and a minimum fire distance (mgMinRange) so low levels refuse to shoot up close; lead (mgLead); fire tolerance (mgAlign); launch scatter (mgSpread); homing × and power ×; fire-interval × (mgRateMult, higher = slower cadence on the shared recharge); a mini-gun standoff (mgStandoff) added to the bot's orbit goal; and an aggression pad (aggrPad) added to that goal for all weapons. The aggression pad is the key to taming low levels without touching their survival brain: since ship-to-ship collisions are symmetric (no per-bot advantage to scale), the only way to make a bot bump you less is to keep it farther away — so a larger pad means it hangs back, shoves less on contact, and (for a gunner) often sits beyond its own firing range. The current tuning: level 1 is docile but not inert — a modest standoff and pad let it drift into firing range and occasionally bump you, and with no minimum-range floor it will even shoot when you walk right up to it; but its 2.2-rad spray and slow cadence mean it sprays wide and lands only the occasional (full-strength) hit, so it stays easy. Level 2 is a real but beatable threat — full 0.75 power, but 20% wider scatter and 20% slower cadence than before plus a moderate pad, so a skilled player can outmanoeuvre it. Level 3 is intentionally brutal — tight aim, fast cadence, full standoff, light homing, and now the same 0.75 power, so its accurate stream genuinely shoves you toward the hole; juking or scattering it with a pulse is your main defence.
Pulse-bot aggression (independent of the mini-gun). The dominant lever is pursuit commitment (pursue): the fraction of time the bot actually hunts you versus ignoring you and coasting on its own safe orbit. It runs as a duty cycle — on a rolling 3-second clock (with a per-bot random phase offset) the bot hunts for pursue of the time and otherwise abandons the foe entirely to circle the hole, so a low value yields brief lunges separated by long relaxed stretches. (An earlier attempt blended the goal toward the bot's own position, which did nothing — the reference moved with the bot, so it still shadowed you; the duty cycle is what actually backs a bot off.) This, not the shooting levers, is what made low levels feel aggressive, since every level used to hunt 100% of the time. It is gated on r.weapon, so mini-gun bots always fully commit and are untouched. Four more pulse-only levers shape the shot itself: pulse range (pulseFrac × PULSE_R); shove selectivity (alignCos, min “aimed at the hole” score to fire); a positioning gap (pulseGap, negative = in the foe's face); and timing (pulseChance, probability it pulls the trigger on a given chance — lower = mistimes and whiffs). A power multiplier pulsePow exists but is held at 1.0 across all levels (low levels are bad at the pulse, not weak with it). The tiers: level 1 hunts only ~35% of the time (0.35), hangs back so it rarely bumps, and pulls the trigger only ~28% of the time — a docile floater that lands the odd full-strength shove but mostly leaves you alone. Level 2 commits moderately (0.68) and hesitates on timing (0.62) — a measured mid-tier with clear daylight below 3. Level 3 commits fully (1.0), never hesitates, parks on top of you (−40 px gap), and bombs freely — relentless. This is separate from aggrPad, which nudges all weapons back at the low levels.
Bomb frequency & the mini-gun charge conflict. A bomb costs the entire recharge ring (the same ~4.5 s pool as a pulse) and only fires at full charge, so bomb cadence is naturally capped at one per recharge. Per-level bombChance is the probability, evaluated each decision tick while the foe sits in the bomb's sweet-spot ring, of choosing to bomb once charged — now spread 0 · 0.10 · 0.40 so bombs are a real level-3 signature rather than a rarity. A mini-gun bot, however, is perpetually draining that same ring on bullets and almost never reaches a full charge, so it used to bomb essentially never. It now banks: when it rolls a bomb it sets _bombHold, stops firing to let the ring fill, then drops the bomb and resumes — self-clearing if the foe leaves the ring, so it never freezes holding fire.
Anti-wall-camp. To stop bots (pulse or gunner) from clinging to the arena edges, the steering vector gets a stateless inward nudge whenever a bot is within ~75 px of a wall, scaled by how close it is — no timers or state to track. A bot can still approach an edge to chase a wall-hugging foe, but it can't settle there; the push holds it a short distance off.
| Trait | Values |
|---|---|
| Nav margin (px) | 120 · 134 · 146 |
| Panic margin (px) | 110 · 114 · 118 |
| Decision interval (s) | 0.16 · 0.09 · 0.05 |
| Foe prediction (s) | 0.15 · 0.34 · 0.52 |
Pulse range used (×PULSE_R) | 0.45 · 0.72 · 1.00 |
| Pulse shove selectivity (min align) | 0.55 · 0.30 · −0.10 |
| Pulse positioning gap (px) | +35 · +10 · −40 |
| Uses bombs | — · yes · yes |
| Pulse pursuit commitment (duty cycle) | 0.35 · 0.68 · 1.00 |
| Pulse trigger chance (timing) | 0.28 · 0.62 · 1.00 |
| Bomb chance / decision tick | 0 · 0.10 · 0.40 |
| Mini-gun range (px) | 300 · 450 · 450 |
| Mini-gun min fire distance (px) | 0 · 0 · 0 |
| Mini-gun target lead (s) | 0.00 · 0.06 · 0.16 |
| Mini-gun fire tolerance (rad) | 0.70 · 0.40 · 0.18 |
| Mini-gun launch scatter (rad) | 2.20 · 1.20 · 0.10 |
| Mini-gun homing × | 1.00 · 1.00 · 1.00 |
| Mini-gun power × (human = 1.0) | 0.75 · 0.75 · 0.75 |
| Mini-gun fire-interval × | 7.0 · 3.75 · 1.4 |
| Mini-gun standoff (px) | 110 · 80 · 150 |
| Aggression pad — all weapons (px) | 50 · 60 · 0 |
All bots in a session share one difficulty. The brain is N-player and team aware, so the same code drives 1, 2, or 3 opponents in any format. In simulation this tuning drives self-elimination toward zero at the top level while keeping bots highly aggressive (double-digit pulses per round); against a human, level 3 reacts roughly three times faster than level 1, predicts movement, crowds you with its pulse, and bombs freely — intentionally brutal — while level 1 hangs back, fires selectively, and never bombs.
The top of the script exposes a CONFIG block of plain-language dials. A TUNE object below maps these into the names the engine uses and adds the finer-grained knobs. Edit CONFIG to change feel; edit TUNE for ranges and radii.
| Constant | Default | Effect |
|---|---|---|
| PLAYER_SPEED | 950 | thruster acceleration |
| PLAYER_MAX_SPEED | 760 | top speed clamp |
| TURN_RATE | 7 rad/s | how fast the ship's facing slews toward its goal — a visible sweep, ~0.45 s for a 180° (navigation unaffected) |
| COAST_FACE_SPEED | 60 px/s | coasting faster than this swings the nose to follow the flight path; slower drift holds the last heading |
| PULSE_STRENGTH | 2000 | pulse shove at point-blank |
| BOMB_STRENGTH | 2000 | bomb blast knockback |
| MINIGUN_STRENGTH | 1000 | per-bullet knockback for the mini-gun |
| BOMB_TIMER | 30 s | bomb fuse before auto-detonation |
| RECHARGE_DURATION | 4.5 s | time to refill pulse/bomb charge |
| BLACK_HOLE_STRENGTH | 950 | gravity strength (see capture note) |
| COLLISION_STRENGTH | 2 | ship-vs-ship knockback multiplier |
| SUDDEN_DEATH_COUNTDOWN | 5 s | warning before sudden death activates (the trigger time itself is host-set) |
| SUDDEN_DEATH_GROW | 14 px/s | fast growth rate once sudden death is active |
| Constant | Default | Effect |
|---|---|---|
| DAMP | 0.992 | per-frame velocity damping (space drag) |
| CAP_GAP | 95 | capture ring = hole radius + this |
| GRAV_CAP | 6000 | max gravitational acceleration (stability) |
| GROW | 1.6 px/s | normal hole growth rate |
| WALL | 0.7 | wall bounce restitution |
| E | 0.9 | ship collision restitution |
| WIN | 3 | round wins needed to take the match |
| PULSE_R | 340 | pulse reach (px) |
| PULSE_FALLOFF | 1.7 | distance falloff exponent |
| PULSE_MIN | 0.12 | force fraction at the very edge of range |
| BOMB_BLAST_R | 150 | explosion radius (px) |
| BOMB_TRIGGER_R | 30 | touch-detonation radius |
| BOMB_DISARM_R | 90 | distance a planter must clear to arm their bomb |
| BOMB_ARM_DELAY | 0.5 s | global safety delay before any detonation |
| BOMB_DETONATE_R | 240 | pulse-detonation range |
| BALL | 16 | ship radius (px) |
W=1000 · H=640, center (500, 320), base hole radius R0=12. These define the fixed simulation space that every client shares.