Why this section exists. Jar Boys has been built through an extended back-and-forth chat with Josh, turn by turn. This section is written for whoever (or whatever — a fresh Cowork session, a new Claude Code session, another developer) picks this project up without that chat history. It captures the current build state, what's actively in progress, and the hard-won lessons that got silently reintroduced more than once before they were written down here. Read this whole section before making any change.
Jar Boys is a live, playable, feature-complete top-down peer-to-peer shooter with six game modes, three maps, bots, and full online lobby/matchmaking. It's built on the Black Hole Arena shell (see §2) — that framework layer is stable and shouldn't need touching. All active work at this point is Jar Boys-specific gameplay, netcode robustness, and polish.
Josh's family repeatedly could not get more than 4 players into public games larger than 4 (e.g. 4v4 / 8 players). The browse list always showed the lobby as "4/4 Full" even when the host clearly had more open slots.
Root cause (confirmed, not guessed): the public lobby storage endpoint is shared with Black Hole Arena, a fixed 4-player game. Read-backs from GET /api/lobbies show Jar Boys' published tf (team format, e.g. "2v2v2v2") field stripped entirely, and players/max both clamped to 4 regardless of what the client published. This is a server/storage-side bug, not something fixable from index.html.
What's already done (client-side, shipped in 1.7.0–1.8.4): the browse list no longer trusts the store's max/players at all. capOf(g) derives capacity only from fields that could plausibly be ours (tf, or a size larger than the legacy clamp); if capacity can't be determined trustworthily it returns null and the UI shows "N+ in lobby" with Join left enabled rather than falsely graying it out. The host is the single source of truth for whether a lobby is actually full — it already rejects a join attempt with {t:'full'} → "That game is full." So nobody is blocked from attempting to join anymore, but the browse list still can't show accurate live counts until the server side is fixed.
What still needs to happen — this is the actual fix, and it's on Allen's side (Vercel), not something to attempt in this file: the lobby storage function needs to (1) stop clamping players/max to 4, (2) persist and return the tf field, (3) persist size as sent for FFA lobbies. A full write-up with the exact request/response JSON is in NOTE_FOR_ALLEN_lobby_api.md (should be in this repo, or ask Josh for it) — hand that to Allen rather than re-deriving it. Do not attempt to "fix" this by changing more client-side heuristics — the client already does everything it reasonably can with untrustworthy data; the real fix is server-side.
UPDATE — v1.8.5 (Cowork switchover, 2026-07-11): fixed server + client, pending deploy + device verify. Jar Boys now lives in the same repo as its Vercel functions (sulzie-bros), so the fix was made directly instead of handed to Allen as a note. Server (app/api/lobby/route.js): the POST handler no longer clamps size/players/max to 4 (bounded 2–16) and persists tf/gm/mp/game, so our records survive the shared store intact. Client (capOf()): now that the store returns honest capacity it trusts it again (team cap from tf, FFA cap from size/max) — so the browse list shows real counts and correctly shows Full at true capacity, including FFA, which the 1.7–1.8.4 safety workaround could never mark full. The host is still the final authority on joins. Cross-listing: BHA’s browse now filters by game tag and no longer lists Jar Boys lobbies (BHA → 2.24.2). Verified headless (server record-building + client capOf/fullOf: 4v4 → 8-cap, Join-enabled at 1/8 and Full at 8/8; FFA 8 → Full at 8/8; BHA 4/4 still Full; browse filters isolate each game). NOT yet verified end-to-end: needs a Vercel deploy against real Upstash Redis and a real hosted 8-player game showing 1/8 — a push to main auto-deploys, so coordinate with Allen.
p.core/p.light) was keyed to array position while their spawn/flag were keyed to team number — after the practice shuffle these disagreed, producing a player whose color, spawn location, and flag all pointed at different teams (a literal "hybrid green-red spawn"). (2) Separately, teamColor(t) itself was hardwired to the 4-entry TEAM_COLORS palette with a %4 wrap, so an 8-player FFA showed duplicate colors for teams 4–7 (spawns/flags/HUD chips, not just soldiers) even after (1) was fixed. The fix that actually holds: teamColor(t) is format-aware — FFA maps into the 8-color FFA_COLORS palette, team mode uses the 4-color TEAM_COLORS palette — and every single draw site (spawns, flags, tags, mines, HUD chips, results, name tags) funnels through that one function. Never let any code path derive a player-facing color from anything other than teamColor(player.team).shuffleRoster() used to only permute among currently-filled slots (e.g. 2 humans in a 4-slot FFA could only ever land in slots 0–1), so certain colors were structurally unreachable. Fixed to shuffle the full 0..cap-1 range and place humans into a random subset of it, with a best-effort "avoid your last color" pass tracked via _prevTeam/_hostPrevTeam.game.oShare[]), accumulated live, and resolved into whole numbers only once, on the results page (standings()), with any remainder going to the higher-ranked player. PTS for these two modes is the resolved share ONLY — kills are tracked in a separate K column, never folded in. Recon/DM/Capture/Bacon are simpler (each point is earned by exactly one player's own action) and keep kills+objective combined in PTS, which is correct for those modes specifically and should not be "fixed" to match Hardzone/Territory's approach.while loop would keep crediting additional zones/ticks in the same call after checkWin() had already set game.phase='over', causing the score to overshoot the target. Both now guard with a game.phase==='play' check before crediting each additional unit of work.MAPS[] data. reflect2/reflect4 push the original struct object (not a copy) as the first of their mirrored set. Vehicle placement stamping (unique color/heading per wreck) mutated cx/cy/w/h directly on structs — since the first copy IS the original map data, every single buildMap() call (every round, every mode change, every return-to-lobby) shifted vehicles a little further from their intended position. Different clients that had rebuilt the map a different number of times ended up with genuinely different vehicle positions AND different collision (WALLS is derived from struct positions) — this is what caused the desktop-vs-mobile map misalignment screenshots. Fix: buildMap() clones every struct (Object.assign({}, s)) before assembling STRUCTS. Any future per-instance mutation of map structs must clone first.window.resize left stale pixels bleeding past the map boundary when the page is embedded in a panel/iframe that resizes its container without firing a window-level event. A later attempt (a ResizeObserver) fixed that but introduced a worse regression: it also fires when the container is mid-animation or hidden, at which point getBoundingClientRect() returns a near-zero rect — which briefly collapsed the canvas to ~1px (a white flash) and slammed the camera zoom to its floor. The fix that actually holds, because it doesn't depend on getting any one trigger event right: verifyViewport() runs inside the main game loop itself (~10×/sec, cheap), continuously checks the canvas against its container, REJECTS any implausible measurement (<80px), and self-corrects within a frame if it drifted — regardless of which external event did or didn't fire correctly. Multiple resize-adjacent listeners (window resize, orientationchange, visibilitychange, pageshow, ResizeObserver) all just call a debounced requestResize(); the in-loop check is the real safety net.w/h/cx/cy should be checked against neighboring structs and map bounds, not just eyeballed.This project has a headless Node smoke-test harness (extract the <script> block, stub the DOM/canvas, run targeted drivers). It's what caught the vehicle-color-duplication bug, the shuffle-slot-range bug, the territory/hardzone sum-mismatch bugs, and the map-drift bug — in each case by writing a small script that runs many simulated matches/rebuilds and asserts an invariant (all colors reachable, PTS sums equal team score, struct positions stable across rebuilds), rather than trusting a single playtest or code read-through. Known blind spot: the canvas is stubbed as a Proxy in this harness, so real pixel/canvas-sizing bugs (like the resize issues above) genuinely cannot be verified numerically headless — those need an actual browser/device test. Be honest about this distinction rather than claiming headless verification proves something it can't.
index.html, JarBoys_UsersGuide.html, JarBoys_TechnicalGuide.html (this file). Keep this file's version-history table (§9) and this guide's player-facing counterpart both accurate and current — stale descriptions here have caused real confusion (e.g. this file once described Territory's win target inconsistently in two different places).APP_VERSION (top of the <script>) on every shipped change, with a concise changelog line in §9.index.html the game (rename stays index.html when you ship) peerjs.min.js the PeerJS library — local copy (already referenced) music/ optional audio (see §8) JarBoys_UsersGuide.html player-facing guide JarBoys_TechnicalGuide.html this file
Must be served over http(s), not file:// — WebRTC (online play) and the WebAudio unlock both need a real origin. Local file open = online + sound silently disabled; single-player still runs.
Project constants live at the top of the <script>: API_ORIGIN / LOBBY_API / TURN_API (point at your backend; code-only online still works via public STUN/TURN without them) and APP_VERSION.
The split from the shell holds. Framework (leave alone): screen flow, the PeerJS star topology (host authoritative, guests send input), lobby/browse/join, the audio engine, the 3-2-1 countdown, HUD plumbing, the mobile control containers, settings/theme. Game (all Jar Boys logic) lives in a handful of functions the framework calls by name.
| Function | Role |
|---|---|
CONFIG / TUNE | All gameplay numbers (top of script). |
stepShip(p,e,dt) | Soldier movement. Direct twin-stick walking; aim is handled separately. |
simulate(dt) | Authoritative tick: bots → move → collide → bullets → respawns. Host/practice only. |
firePulse(i) | Fire a round from soldier i (repurposed PRIMARY action). Cooldown-gated. |
dropBomb(i) | Plant a claymore at soldier i's feet (one per life). Guests request it via the bf input edge. |
botThink(dt) | Enemy AI: writes me._in (move) + me._aim (facing), calls firePulse. |
draw() / drawShip(p) | Scrolling map + the bent-arm soldier art + floating name tags. |
hostBroadcast / applyState | The snapshot: players, aim, bullets, kills/deaths, scores. |
The shell drew a fixed 1000×640 arena scaled to fit. Jar Boys keeps a fixed zoom and scrolls a camera over a big world.
WORLD_W × WORLD_H = 2400 × 1600 (≈4× a screen). Sim + draw all happen in world coordinates.updateCamera(dt) centers on the local soldier (with a little velocity lead), clamped to the world edges, and computes scale from VIEW_H_WORLD.worldToScreen / screenToWorld convert between the two (the latter turns the mouse position into an aim angle).MAPS[] holds three fixed, hand-authored battlefields (palette + structures + decorative props), each built with 4-fold mirror symmetry. curMap is the selection (−1 = Random); resolveMapIndex() picks the concrete mapIndex at match start and syncs it (mp) so all peers share one map. Props are decorative only — no collision.Black Hole Arena tied facing to thrust direction. Jar Boys fully decouples them:
stepShip): target velocity = input × MOVE_SPEED, approached at MOVE_ACCEL — responsive, minimal slide. Soldiers stop at world edges (no bounce).updateLocalAim(): mouse angle on desktop, right-stick angle on mobile. It syncs as _aim and is preserved client-side for the local player (no rubber-banding).firing). The authoritative loop calls firePulse for any soldier whose trigger is held; firePulse self-gates on _cd (= FIRE_COOLDOWN), so holding auto-repeats at a fixed cadence.stepBullets(dt) (authoritative) advances each round in a dead-straight line — velocity is fixed at spawn and never re-steered (no homing, by design). It substeps the ray so a fast round can't tunnel through a soldier, tests against players (respecting spawn-invulnerability and the friendly-fire rule), and expires at world edges or BULLET_RANGE.
killPlayer(victim, killerIdx) flags the victim dead, starts their RESPAWN_DELAY timer, credits the killer (kills + team score; a team-kill costs a point instead), and calls checkWin(). respawnPlayer drops them back in their team's spawn zone with brief invulnerability. checkWin ends the match at KILL_TARGET.
Continuous match, no rounds. The shell's round/best-of-3 system is replaced: tickClock only runs the opening countdown, checkRoundEnd/endRound are no-op stubs, and the win comes from kill totals.
Star topology is unchanged. What changed is the packet contents.
{t:'i'}{ u,d,l,r, // movement (analog on mobile)
am, // aim angle (radians)
f, // trigger held (0/1)
bf } // claymore edge (v0.2)
{t:'s'}pl: [ [x, y, alive, aim, invulnFlag], ... ] // one row per soldier bu: [ [x, y, vx, vy], ... ] // live rounds (guests extrapolate between snapshots) sc, kl, de, wn, ph, tm, e, fl, sh, ev // scores, kills, deaths, winner, phase, timer, clock, flash, shake, sound-events
Adding a synced entity (claymores, tags…) follows the same two-line pattern: serialize compactly in hostBroadcast, rebuild in applyState. Keep payloads small (round coordinates, drop anything guests can recompute).
The table below reflects the numbers actually live in CONFIG/mode-target constants at the top of the script as of v1.9.1 — the weapon model changed from an auto-fire cooldown to a magazine+reload since this table was first written, so check here rather than assume.
| Key | Meaning | Current |
|---|---|---|
MOVE_SPEED | Top walking speed (world u/s) | 340 |
MOVE_ACCEL | How snappily you reach top speed | 14 |
BULLET_SPEED | Round velocity (fast — crosses a screen in a blink) | 2600 |
BULLET_RANGE | Distance a round travels before fizzling | 2200 |
MAG_SIZE | Rounds before you must reload | 1 (bolt-action feel) |
RELOAD_SECONDS | Auto-reload time after firing | 3s |
RESPAWN_DELAY | Seconds down before redeploying | 2.5 |
SPAWN_PROTECT | Post-respawn invulnerability | 1.2 |
COLLISION_STRENGTH | Soldier-vs-soldier bump | 1.4 |
| Mode | Target | Notes |
|---|---|---|
Deathmatch (KILL_TARGET) | 10 kills | FFA or teams |
Recon (RECON_TARGET) | 20 points | Kills+tags combined; tags expire after RECON_EXPIRE=10s; teams only |
Hardzone (HARDZONE_TARGET) | 25 points | Pure zone-share only (kills NOT folded in — see §0 lessons); HZ_RADIUS=230, HZ_TICK=1.0s, relocates every HZ_MOVE=30s to a fully random valid spot |
Capture / Bacon (FLAG_TARGET) | 10 captures | Kills+captures combined in PTS; FFA (Capture/Bacon) or teams (Capture) |
Territory (TERR_TARGET) | 75 points | Pure zone-share only (same reasoning as Hardzone); TERR_RADIUS=170, TERR_CAP=8s to flip uncontested, TERR_TICK=1.5s; teams only |
Teams use TEAM_COLORS (4, dark/light each); free-for-all uses FFA_COLORS (8 distinct hues). Both are always accessed through teamColor(t), which is format-aware — never index either palette array directly (see the §0 lessons-learned entry on this). At draw time, shadeFor(p) renders you in your dark shade with a white ring, teammates in the light shade, and enemies in their own dark shade — a per-viewer decision.
The engine calls sounds by name; the actual SFX are placeholders to be replaced with your own. Every hook is already wired — here's the master list of what each event is and when it fires. World events go through snd(...) (play locally on the authoritative side + ride the snapshot's ev queue to guests via playEvent); UI sounds call sfx(...) locally.
| Event | Fires when | Scope |
|---|---|---|
shot | A round is fired | World (+ instant local on the shooter) |
elim | A soldier is killed | World |
beat | Each 3-2-1 countdown tick | Local |
go | Match starts ("Fight!") | Local + starts gameplay music |
over | Match ends — win vs. loss sting | Local (per-player win/loss) |
click / woosh | Button press / screen change | UI |
keystroke | Typing name / code | UI |
connect / lobbyLeave | A player joins / leaves | UI |
error | Failed join / full lobby | UI |
Lobby_and_main_menu_song.mp3)Game_play_song*.mp3); the host syncs which playsmatch_over_fanfare*.mp3)To drive these from your own audio files instead of the synth placeholders, we swap the SFX_GEN generators for a small Audio-element player keyed by event name — a quick v0.2 wiring task. Hand off the files and it's a drop-in.
| Phase | Scope | |
|---|---|---|
| 1.9.1 | REMATCH DROPPED PLAYERS FIXED. On starting the next match, some players were left behind (stuck on results or in the lobby). Two causes: (1) no keepalive on the peer data channel — while players lingered on the results screen the idle WebRTC channel got dropped by NAT, so they never received tolobby/start. The host now pings all guests every 2.5s whenever online but not running (during a match the snapshot stream keeps it warm); guests echo it. (2) hostReturnToLobby and shuffleRoster filtered on c.open, which flickers false for live guests and dropped them from the rebuild — replaced with "include every connection still in conns" (hostDropConn already removes genuinely-closed ones; the v1.8.9 .left handling covers any unclean straggler). Verified headless (include-set + keepalive gating); needs real multi-device rematch testing. | this build |
| 1.9.0 | CLIENT-SIDE PREDICTION + INPUT DECOUPLING. (1) Input decoupled from myIdx: keyboard (setLocalMove) and mobile joystick (setMoveVec/clearMove) now write a standalone localMove object, and guestSend transmits it directly instead of reading game.players[myIdx]._in. A slot/binding race can no longer source your input from the wrong or missing player and send zeros — the "couldn't move or shoot the whole match" bug (intermittent public-join). Each frame the loop copies localMove onto the local soldier's _in for the host sim / prediction / mobile aim-from-walk. (2) Prediction: guests now advance their OWN soldier locally each frame (predictLocal = the exact stepShip movement model + resolveWalls/clampToBounds), then softly reconcile toward the host's authoritative position (snap on >180u = respawn/teleport). Removes the run-stop-run-stop jolt from waiting a round-trip for your own movement. Movement-only; firing/kills/scoring stay host-authoritative. Verified headless (movement + reconcile math); real multi-device testing still needed for feel and to confirm the binding fix. | shipped |
| 1.8.9 | NETCODE ROBUSTNESS + MOBILE LAG. (1) Ghost fix: a booted or departed player no longer lingers as a frozen soldier that still respawns — hostDropConn now marks the slot .left (respawn loop and draw loop both skip it; guests hide it on the left message), so they vanish from play instantly. (2) Rate-gate: hostBroadcast now sends at ~25Hz (BCAST_MS) instead of every frame (~60Hz). Per-frame broadcast flooded guests — badly on mobile in 5–6 player matches (delayed kills, own-player jolting, remote disappear/reappear). Guests interpolate between snapshots so 25Hz stays smooth; the claymore-edge apply still runs every sim frame, and match-over forces an immediate send. (3) Flicker fix: applyState drops stale/out-of-order play snapshots (guarded on elapsed e), so a just-killed player can’t be resurrected for a frame by a reordered packet on the unreliable channel. Host-leaves-ends-for-all already worked (hostleft). Verified headless (logic + syntax); true smoothness/feel still needs real multi-device testing. Note: the guest’s own soldier still has no client-side prediction — the rate-gate should cut the congestion-driven jolt a lot, but full local smoothness is a later prediction/reconciliation task. | shipped |
| 1.8.8 | LOBBY-LIST vs LOBBY MISMATCH FIXED (FFA). An FFA lobby still published a leftover team-format tf (e.g. "2v2"), and capOf() trusted tf for any game, so a 2-player FFA listed as "4p / 1/4". Now the client only sends tf for team games, and capOf() derives capacity from tf only when fmt==='team' (FFA uses the real size/max) — a 2-player FFA now lists as "FFA · 2p · 1/2" and shows Full at 2. A guard also makes stale FFA records that still carry tf read correctly during the deploy transition. Separately, a server HOTFIX restored the /api/lobby POST handler, which had been truncated by a tooling glitch and silently broke ALL public lobby publishing. Verified headless end-to-end (publish → store → browse). | shipped |
| 1.8.7 | BRANDING: the sulziebros.com app-card icon and the in-game browser favicon were switched from the old gun-sight optic to the game's own top-down soldier — green camo helmet with bent arms and rifle, aimed toward the corner, on a Dustbowl dirt background (rendered from the same sprite recipe drawShip() uses). Cosmetic only; no gameplay or netcode change. | shipped |
| 1.8.6 | GUEST SMOOTHNESS + EFFICIENCY TRIM. Guests now render remote soldiers with true time-based interpolation: each snapshot is buffered with an arrival timestamp and remote entities are drawn ~100ms (INTERP_DELAY) in the past, lerped between the two bracketing snapshots (teleport/respawn jumps >200u snap; buffer-starve holds the newest). The LOCAL soldier is exempt — it keeps the responsive glide-to-latest so input never lags. Replaces the old chase-the-latest exponential glide that stuttered under irregular snapshot arrival. Trim (dead code, verified no readers): removed the defunct game.msg/mg pipeline (snapshot field, applyState, render path — #centerMsg kept, still used for countdown + disconnect); deleted slotColor() (was byte-equivalent to teamColor(), callers repointed); deleted the COLORS Proxy shim (slotGlow() repointed to teamColor().light); removed legacy BHA game.pulses/game.meteors arrays. Silent audio scaffolding intentionally kept. Headless-verified (interpolation math + node --check); guest smoothness itself needs real two-device testing. | shipped |
| 1.8.5 | LOBBY CAPACITY FIX (server + client). Server (app/api/lobby/route.js) no longer clamps size/players/max to a 4-player shape and now persists tf/gm/mp/game, so Jar Boys lobbies bigger than 4 (e.g. 4v4 / 8) survive the shared BHA store intact. Client capOf() trusts the now-honest capacity again (team cap from tf, FFA cap from size/max), so the public games list shows real counts AND correctly shows "Full" / greys out Join when a lobby is truly at cap — including FFA, which the 1.7–1.8.4 workaround could never mark full. Host stays the final authority on joins. Cross-listing fixed: Black Hole Arena’s browse now filters by game tag and no longer lists Jar Boys lobbies (BHA bumped to 2.24.2). Verified headless; NOT yet device-verified — needs a deploy + a real hosted 8-player game showing 1/8. | shipped |
| 1.8.4 | MAP DRIFT FIXED: reflect2/4 push the ORIGINAL struct object and vehicle stamping mutated cx/cy/w/h on it, so every buildMap (each round/mode change/lobby return) shifted wrecks another ±nudge and WALLS drifted with them — clients that had rebuilt different numbers of times had visibly different maps AND collision (the desktop-vs-mobile misalignment). buildMap now clones every struct; verified byte-identical positions across 6 rebuilds on all 3 maps. Resize made SELF-HEALING: verifyViewport() polls the #stage rect ~10×/sec in the game loop and re-syncs if drifted (rejecting <80px), plus visibilitychange/pageshow re-measure. Mobile "TAP ANYWHERE TO SHOOT" moved below the countdown (was hidden behind the mode banner at 15–16%). Public list: store data can no longer mark a game Full (server clamps records — see §0); unknown capacity shows "N+ in lobby" with Join enabled. Guests keep a 66ms input pulse while their tab is hidden (browsers freeze rAF in background tabs). | shipped |
| 1.8.3 | White flash + sudden zoom-out fixed (1.8.1 regression): ResizeObserver fires while the container is mid-animation/hidden, when getBoundingClientRect() is near-zero; the 1px clamp collapsed the canvas (flash) and drove scale to its 0.55 floor (zoom-out). resize() now rejects measurements <80px and coalesces callbacks to one/frame. Sports-car rear wing was a rect hanging 54px off the flank (placed at −0.43W with height 1.72W); rebuilt centred (0.90W blade on stanchions). Truck tailgate had the identical defect; centred at 0.74W. | shipped |
| 1.8.2 | Vehicles REDRAWN with curved bezier hulls (tapered nose, cabin shoulder, narrowed tail, tyres under the fenders) — the rounded-rect look read as "cybertrucks". Per-type detail: sedan (greenhouse, raked glass, door seams), sports (nose taper, haunches, canopy, stripe, wing), truck (ribbed bed, proud cab, grille), jeep (fender flares, open tub, roll bar, tail spare). Every wreck got a PERMANENT random heading (0/90/180/270); the collision AABB swaps with the turn; two Dustbowl jeeps nudged ±40y clear of the central building. Headings across all 10: S,W,E,N,N,E,S,S,W,N. | shipped |
| 1.8.1 | Stale pixels bleeding past the map boundary fixed: draw() now resets the transform and clears + void-fills the ENTIRE backing store in device px (clearRect(0,0,vw,vh) missed the edge strip when the canvas outgrew stale vw/vh); resize() measures the #stage container (fixed; inset:0) with a ResizeObserver + orientationchange re-measure, since embedded hosts resize the container without firing window resize. | shipped |
| 1.8.0 | VEHICLE SYSTEM: four top-down templates (sedan/truck/sports/jeep) replace the single grey blob. Shape and colour are separate: a 10-entry palette gives EVERY wreck on EVERY map its own permanent colour (no repeats anywhere, even same-template); mirrored copies stamped individually. Distribution: Dustbowl 3 jeeps + 1 truck, Overgrowth 2 sedans + 2 sports, Sandstorm 2 trucks. Cosmetic only — collision unchanged. | shipped |
| 1.7.2 | DUPLICATE ID fixed: the pre-match banner was given id="modeTitle", already used by the setup screen's h2 — the banner CSS splashed "Choose a mode" across the Teams button and getElementById grabbed the wrong node. Renamed to #gmBanner; full audit — 0 duplicate IDs. Territory target 40 → 75 (constant, setup button, guides). Corrected two self-contradicting/stale Users-Guide rows (Territory said 40 and 25; Hardzone described removed fairness-weighting). | shipped |
| 1.7.1 | Feed flag glyphs: "[Name in their colour] took/dropped/scored [flag tinted to the flag-owner]" — Bacon's flag neutral white; Territory stays text-only. Pre-match MODE TITLE (DEATHMATCH/RECON/…) in large letters above the countdown, fading in sync with FIGHT!, shown to host and guests. | shipped |
| 1.7.0 | UNIFIED MESSAGE SYSTEM: one colour-coded event feed (top-right, fades ~3.5s) for kills/flags/bacon/territory events, synced to guests; persistent Territory A/B/C strip (top-left, letters tinted by owner, pulsing while contested); persistent Hardzone status chip (top-right, always visible, with relocation countdown) replacing canvas text you could only read next to the zone. Public "4/4 Full" bug: browse capacity derived from published size/team-format via capOf(g) instead of the store's stale legacy max. Lobby DOM had a stray </div> closing .lobbySettings early — the mobile-landscape squish; fixed + roster scrolls. Pre-match countdown 5s and guests now SEE their shuffled colour + teammates during it. Default bot level 1. | shipped |
| 1.6.3 | The REAL FFA colour-duplication fix: teamColor() wrapped through the 4-entry TEAM_COLORS palette (%4), so 8-player FFA painted teams 4–7 the same as 0–3 (duplicate spawn circles even after 1.6.2). teamColor() is now format-aware (FFA → 8-colour palette, team → 4-colour) and every draw site funnels through it. | shipped |
| 1.6.2 | FFA colour/spawn desync ("hybrid green-red spawn"): FFA colours were keyed to array POSITION while team/spawn/flag keyed to team NUMBER — after the shuffle they disagreed. slotColor now keys FFA colour off the team number like everything else. | shipped |
| 1.6.1 | Settings button icon swapped from a gear to the three-slider mixer matching Black Hole Arena's. | shipped |
| 1.6.0 | Removed 1v1 team format (was a loophole giving 2-player games access to team-only modes). Disconnect messaging fixed: host now sees "You left the session", connected players see "The host ended the session"; a leaving guest sees "You left the session" on their own screen (was a peer.destroy() side-effect showing false "X left" toasts to the host). Color shuffle bug fixed: humans could previously only land in the first N roster slots (missing colors entirely e.g. never Blue/Amber in a 4p FFA) — now scatters across the FULL slot range, plus remembers each player's last color to reduce repeats. Territory: fixed a bug where capturers were wiped one frame after a steal, causing multi-holder over-counting; rebuilt using the same fractional-share+resolve-once pattern as Hardzone — sum of players' points now always equals the team score exactly (was 37 vs 40). Hardzone: fixed a tick-loop overshoot bug (checkWin didn't stop extra ticks in the same frame) and removed kills from the displayed PTS for Hardzone/Territory specifically (kills stay in their own K column) so team PTS always sums exactly to the real score instead of ballooning past it (was 60-something in a 25-point game) | shipped |
| 1.5.9 | Hardzone point-split fix: multiple holders no longer each get the full team point (which double-counted). Each tick's point is now accrued as a fractional share (game.oShare), then resolved to whole numbers ONLY on the results page so each team's players sum exactly to the team's zone score, no decimals, remainder to the higher-ranked player. Synced to guests. Kills still add on top as whole points. | this build |
| 1.5.8 | Territory attribution corrected: presence matters only AT CAPTURE TIME — capturers keep earning after they leave or die, and only lose it when another team takes the zone (reverting an over-strict 1.5.7 change). Remainder point now rotates among co-capturers so splits are even over time, decimal-free, with no points lost vs the team score | this build |
| 1.5.7 | INTEGRITY AUDIT: fixed territory attribution to match spec — points now only go to capturers STILL inside the zone that tick (leaving or dying forfeits your share, as designed); verified all targets/mechanics/descriptions against every request in the build history | this build |
| 1.5.6 | PERF PASS: cached scoreboard .k elements (no getElementById/querySelector per player per frame); cached reloadHint + skip redundant innerHTML writes; snapshot only sends scores/kills/deaths/points when they change (~117 bytes/frame/guest saved when static, forced resend every 2s for late joiners); mode-gated objective arrays (tags/flags/zones/hz only sent in their mode); removed dead code (randomZonePoint, drawDeployRingLabel) | this build |
| 1.5.5 | NETCODE FIXES: guest lobby showed only 4 slots in 4v4 (hardcoded cap -> capacity()); snapshot fl key collision (flash vs flags) corrupting online flag/capture sync -> renamed flags to fg; 3s start desync that booted guests (start now sent at end of countdown, not beginning); clean lobby rebuild on return/rematch (fixes false "full" + stale slots); shuffleRoster skips dead conns; removed flag marker from Hardzone center | this build |
| 1.5.4 | Dustbowl top awning tb; Bacon ring capped to map inset (no out-of-bounds spawns); Bacon desc "first to 10"; practice full shuffle (all slots); lobby countdown dot colors fixed; Recon/Hardzone/Territory kills add to PTS; Territory capturer attribution (split points among who was present at cap, clear on steal); territory zone-empty always resets cap progress | this build |
| 1.4.1 | Kill scoring gated to DM+Recon only; Capture→10 caps; Recon→30 tags; Territory→40 pts, no-score-during-cap, multi-teammate cap speed, multi-zone bonus; Bacon equidistant ring spawns + center wall removed for flag access; flag drawn above helmet; winner text colored; Hardzone starts at center+spawn-distance fairness heuristic; reload 3s; bot accuracy recalibrated; lobby 3-2-1 with team colors; territory zone spread algorithm (covers full map area) | this build |
| v0.2 | Randomized team/color assignment, bot claymore use, more maps + polish | shipped (superseded by 1.x line) |
| v0.1 | Fixed dirt map with buildings/cover + wall collision (players & bullets), twin-stick move + independent aim, one-shot-one-kill with auto 7s reload, claymores (one per life, single wall-aware trigger/blast radius, device visible to all but ring only to your team), respawns; three themed maps with distinct SHAPES — rectangle (Dustbowl), circle (Overgrowth), triangle (Sandstorm) — each its own world size with void past the boundary; per-map spawns; concealing foliage; roofed "go-under" buildings (one-way concealment from enemies, bot saw-you-enter awareness, hidden claymores); host map picker; FFA (2–8) + six team formats to 15 kills, host friendly-fire toggle (COD-style: no enemy score, honest KDR, 3-strike boot; claymore blasts exempt), per-level bot accuracy, camo-helmet soldiers, K/D/KDR results | shipped |
| v0.9 | All music + procedural SFX stripped (sfx/snd/playMusic/fanfare/drone now no-ops, mp3 sources removed) with the settings mute/volume UI kept as infrastructure; Recon target 15 + 10s dog-tag expiry (remaining life synced for guest fade); results "session record — won/lost" relabel | this build |
| v0.8 | Three new modes: Capture (CTF, flags at spawns), Bacon (single center flag), Territory (3-zone Domination with capture bars); shared flag-carry system (drop-on-death, home-return, own-spawn scoring); per-mode bot objective AI; public search now publishes + shows game type AND map; 6-mode lobby/setup pickers with FFA gating | this build |
| v0.7 | Capture renamed to Hardzone (frees "Capture" for CTF); target 25; zone now spawns at a random valid spot (full circle in-bounds, center not wall-buried) and relocates every 30s; per-level bot objective motivation (obj weight) so difficulty + game type both shape how hard bots chase the objective | this build |
| v0.6 | Game type moved into the lobby (host-editable, guests read-only); on Start: random slot shuffle -> coherent team assignment + 3-2-1; lobby team dots/labels/reorder removed; claymore chain-detonation; deploy ring drawn over roofs; Vercel/API_ORIGIN + jarboys lobby tag confirmed | this build |
| v0.5 | Deathmatch / Recon (Kill-Confirmed dog tags) / Capture (central hardpoint) — team-only for the objective modes; per-player points + points-aware leaderboard; game type on the setup page, map moved to the lobby | this build |
| v0.4 | Bot map-navigation/pathfinding, sound design pass, mobile + balance tuning, map art polish | planned |
simulate/firePulse — they run on host/practice only; guests overwrite state from snapshots every frame.tf/ff in the roster/start messages.format stays the coarse 'ffa'|'team' flag; applyTeamFmt('2v2v2') derives teamCount/teamSize/pcount. layoutFor() packs teamSize contiguous slots per team; spawn zones cycle the four map corners.index.html, verify with node --check on the extracted script, then reload over a local http server. Every version ships all three files (index + both guides) together.