Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Play at [arena.littledill.app](https://arena.littledill.app) or enter from the i
- Gadget sounds follow the same synthesis and timing on both platforms. Four shared pickup clips rotate at a quieter, limited cadence; losing a piece or being eaten uses a crunch.
- Brine jar drops: 90 seconds after a garden starts, then every 210 seconds, the server marks a spot away from edges and gadgets with a 10-second warning on the minimap and in the garden. The jar then spills a ring of 40 bonus pellets at a 260 radius. The spill moves existing pellets, so the food count and older clients stay unchanged.
- Leader bounty: the heaviest live player with at least 500 mass, bots included, wears a gold crown and is highlighted on the leaderboard. Eating one of their pieces adds 10% of the absorbed mass, counted as earned mass.
- Run goals: each human spawn rolls three goals (crunch pickles, reach a mass, survive, eat pellets, or touch a jar spill). Reconnects keep the same goals. The death and leave screens show a recap with peak mass, best rank, kills, time alive, and goals done. On iOS each completed goal pays 5 coins within the same 1,000-coin daily cap.
- Run goals: each human spawn rolls three goals (crunch pickles, reach a mass, survive, eat pellets, or touch a jar spill). Reconnects keep the same goals. A short toast names each goal as it completes. The death and leave screens show a recap with peak mass, best rank, kills, time alive, and goals done. On iOS each completed goal pays 5 coins within the same 1,000-coin daily cap.
- Reconnect within 30 seconds to recover the same run, pieces, mass, and paused timers. One-second Durable Object checkpoints support server restart recovery. Explicitly leaving ends recovery; there is no fixed session-length limit.

The rule source is `server/arena-engine.mjs`. Web/native parity checks share `tests/fixtures/arena-parity.json`; run them with the test commands below. The arena requires a connection; pet care and native solo games work offline.
Expand Down
2 changes: 1 addition & 1 deletion arena-web/arena-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { bodyProfile, bodyRadius, bodyPoints } from './pickle-body.mjs';
import { WARDROBE_IDS, drawWardrobe } from './arena-wardrobe.mjs';
import { EAT_OVERLAP, drawSmile, tearPose } from './arena-expression.mjs';
export { EAT_OVERLAP, SMILES, skinSmile, pickupCue } from './arena-expression.mjs';
export { JAR, EVENT_COLORS, goalLabel, goalDone, goalProgress, eventState, jarSpilled, recapStats, recapRows, clockLabel, drawJar, drawBountyCrown, bountyCrownWidth, drawMapJar } from './arena-events.mjs';
export { JAR, EVENT_COLORS, goalLabel, goalDone, goalProgress, eventState, jarSpilled, goalsCompleted, recapStats, recapRows, clockLabel, drawJar, drawBountyCrown, bountyCrownWidth, drawMapJar } from './arena-events.mjs';

export const BRINES = ['classic', 'garlic', 'spicy'];
export const OUTFITS = ['original', 'sprout', 'bow', 'shades', 'crown', 'party', ...WARDROBE_IDS];
Expand Down
1 change: 1 addition & 0 deletions arena-web/arena-events.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function eventState(snapshot, ownID) {
};
}
export const jarSpilled = (previous, next) => Boolean(previous && next && previous.warn > 0 && next.warn === 0);
export const goalsCompleted = (previous = [], next = []) => next.filter(goal => goalDone(goal) && previous.some(([kind, target, progress]) => kind === goal[0] && target === goal[1] && progress < target));

export function recapStats(own) {
const { run, goals } = eventState({ players: own ? [own] : [] }, own?.id);
Expand Down
16 changes: 5 additions & 11 deletions arena-web/arena.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 25 additions & 14 deletions arena-web/arena.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BRINES, OUTFITS, VARIETIES, varietyOf, clamp, radius, viewportBounds, cameraFrame, playerCells, flattenCells, cellKey, threatenedCellIDs, splitState, massLabel, roomCode, direction, stickInput, playerName, socketURL, applyFoodUpdate, readResumeRecord, refreshResumeRecord, resumeFromWelcome, resumeRetryDelay, shareURL, interpolate, drawPickle, easeZoom, killRings, bodyShape, membraneSize, resizeMembrane, stepMembrane, membraneOutline, hazardFor, drainDroplets, spitFlight, drawDroplet, drawHazard, hazardKind, scheduleStrokes, pickupCue, JAR, goalLabel, goalDone, goalProgress, eventState, jarSpilled, recapStats, recapRows, drawJar, drawBountyCrown, bountyCrownWidth, drawMapJar } from './arena-core.mjs';
import { BRINES, OUTFITS, VARIETIES, varietyOf, clamp, radius, viewportBounds, cameraFrame, playerCells, flattenCells, cellKey, threatenedCellIDs, splitState, massLabel, roomCode, direction, stickInput, playerName, socketURL, applyFoodUpdate, readResumeRecord, refreshResumeRecord, resumeFromWelcome, resumeRetryDelay, shareURL, interpolate, drawPickle, easeZoom, killRings, bodyShape, membraneSize, resizeMembrane, stepMembrane, membraneOutline, hazardFor, drainDroplets, spitFlight, drawDroplet, drawHazard, hazardKind, scheduleStrokes, pickupCue, JAR, goalLabel, eventState, jarSpilled, goalsCompleted, recapStats, recapRows, drawJar, drawBountyCrown, bountyCrownWidth, drawMapJar } from './arena-core.mjs';

const $ = id => document.getElementById(id);
const canvas = $('arena'), ctx = canvas.getContext('2d'), map = $('minimap').getContext('2d');
Expand All @@ -25,8 +25,18 @@ let threatened = new Set(), membranes = new Map(), membraneClock = 0, spits = ne
let width = innerWidth, height = innerHeight, camera = { x: 1200, y: 900 }, zoom = null, lastFrame = performance.now();
let viewCenter = { x: width / 2, y: height / 2 }, cameraInsets = {};
let audio = null, audioOut = null, noiseBuffer = null, drainLoop = null, pickupState = null, pickupBuffers = [], pickupLoading = null, leaderboardSignature = '', lastSavedBest = settings.best;
let events = eventState(null, null), lastAliveOwn = null, deathRecap = null, jarSpillAt = null, goalsSignature = '';
let events = eventState(null, null), lastAliveOwn = null, deathRecap = null, jarSpillAt = null, goalToasts = [], goalToastTimer = null;

function showGoalToast() {
const toast = $('goal-toast');
if (goalToastTimer !== null || !goalToasts.length) return;
toast.textContent = `✓ ${goalLabel(goalToasts.shift())}`; toast.classList.toggle('shown', true);
goalToastTimer = setTimeout(() => {
toast.classList.toggle('shown', false);
goalToastTimer = setTimeout(() => { toast.textContent = ''; goalToastTimer = null; showGoalToast(); }, 450);
}, 2500);
}
function clearGoalToasts() { goalToasts = []; clearTimeout(goalToastTimer); goalToastTimer = null; $('goal-toast').classList.toggle('shown', false); $('goal-toast').textContent = ''; }
function notify(message) { $('toast').textContent = message; $('toast').hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { $('toast').hidden = true; }, 3500); }
function audioOutput() {
if (!audioOut) {
Expand Down Expand Up @@ -176,14 +186,21 @@ function overlay(mode, title, message) {
for (const element of [$('arena'), ...document.querySelectorAll('.game-header button'), $('dash'), $('split')]) element.inert = true;
(mode === 'joining' ? $('overlay-back') : $('overlay-action')).focus({ preventScroll: true });
}
// A disabled button loses focus, so Share holds it during the respawn countdown.
function waitForRespawn(waiting) {
const action = $('overlay-action'), share = $('share-run');
if (waiting && document.activeElement === action) { share.focus({ preventScroll: true }); share.dataset.heldFocus = 'true'; }
action.disabled = waiting;
if (!waiting && share.dataset.heldFocus === 'true') { delete share.dataset.heldFocus; if (document.activeElement === share) action.focus({ preventScroll: true }); }
}
function hideOverlay() { $('overlay').hidden = true; overlayMode = ''; for (const element of [$('arena'), ...document.querySelectorAll('.game-header button'), $('dash'), $('split')]) element.inert = false; }
function join(room) {
settings.name = playerName($('name').value); settings.outfit = $('outfit').value; save(); unlockAudio();
// Dismiss Safari’s keyboard before measuring the live viewport. Resize events
// keep following it while the keyboard and browser chrome finish animating.
document.activeElement?.blur();
releaseRun(); requestedRoom = roomCode(room); ownID = null; state = null; previous.clear(); threatened.clear(); membranes.clear(); spits.clear(); ownPrevious = null; sequence = 0; leaderboardSignature = ''; zoom = null;
events = eventState(null, null); lastAliveOwn = null; deathRecap = null; jarSpillAt = null; goalsSignature = ''; $('goals').hidden = true; $('recap-card').hidden = true;
events = eventState(null, null); lastAliveOwn = null; deathRecap = null; jarSpillAt = null; clearGoalToasts(); $('recap-card').hidden = true;
phase = 'joining'; $('lobby').hidden = true; $('game').hidden = false; document.body.style.overflow = 'hidden';
$('room-badge').hidden = !requestedRoom; $('room-badge').textContent = requestedRoom ? `FRIEND ROOM · ${requestedRoom}` : '';
$('population').textContent = 'Finding your garden…'; $('mass').textContent = '25'; $('rank').textContent = '—'; $('best').textContent = '25'; $('leaders').replaceChildren(); $('protection').hidden = true;
Expand Down Expand Up @@ -220,8 +237,9 @@ function connectSocket(resume = null) {
packet.food = applyFoodUpdate(state?.food || [], packet);
packet.hazards = Array.isArray(packet.hazards) ? packet.hazards : [];
state = packet; arrived = performance.now();
const jarBefore = events.jar; events = eventState(packet, ownID);
const jarBefore = events.jar, goalsBefore = events.goals; events = eventState(packet, ownID);
if (!entering && jarSpilled(jarBefore, events.jar)) { jarSpillAt = arrived; sound('jar'); }
if (!entering) { goalToasts.push(...goalsCompleted(goalsBefore, events.goals)); showGoalToast(); }
if (own.alive) lastAliveOwn = own;
trackSpit(packet, entering); pruneMembranes(packet);
threatened = threatenedCellIDs(packet.players);
Expand Down Expand Up @@ -333,7 +351,7 @@ function fillRecap(list, stats) {
}
function leave() {
const recap = recapStats(ownPrevious?.run ? ownPrevious : lastAliveOwn);
releaseRun(); phase = 'lobby'; save(); $('game').hidden = true; $('lobby').hidden = false; document.body.style.overflow = ''; hideOverlay(); state = null; ownPrevious = null; lastAliveOwn = null; deathRecap = null; refreshLobby();
releaseRun(); phase = 'lobby'; save(); $('game').hidden = true; $('lobby').hidden = false; document.body.style.overflow = ''; hideOverlay(); state = null; ownPrevious = null; lastAliveOwn = null; deathRecap = null; clearGoalToasts(); refreshLobby();
fillRecap($('recap-stats'), recap); $('recap-card').hidden = !recap;
(recap ? $('recap-dismiss') : $('play')).focus({ preventScroll: true });
}
Expand Down Expand Up @@ -362,7 +380,7 @@ function updateHUD(own) {
$('split').setAttribute('aria-label', splitting.enabled ? 'Split forward into pickle slices; regroup automatically after 12 seconds' : `Split unavailable: ${splitting.reason}`);
$('cell-count').textContent = `${splitting.count} / 8 slices`;
$('merge-note').textContent = splitting.count > 1 ? splitting.merge > 0 ? `Regroup in ${Math.ceil(splitting.merge)}s` : 'Regrouping automatically…' : '60 mass per slice to split';
if (!own.alive && overlayMode === 'dead') { $('run-best').textContent = massLabel(own.best); $('run-best').title = own.best.toLocaleString(); $('run-kills').textContent = massLabel(own.kills); $('overlay-action').disabled = own.respawn > 0; $('overlay-action').textContent = own.respawn > 0 ? `Fresh pickle in ${Math.ceil(own.respawn)}…` : 'One more crunch'; }
if (!own.alive && overlayMode === 'dead') { $('run-best').textContent = massLabel(own.best); $('run-best').title = own.best.toLocaleString(); $('run-kills').textContent = massLabel(own.kills); waitForRespawn(own.respawn > 0); $('overlay-action').textContent = own.respawn > 0 ? `Fresh pickle in ${Math.ceil(own.respawn)}…` : 'One more crunch'; }
const leaders = ranked.slice(0, 5), signature = `${events.bounty}|${leaders.map(p => `${p.id}:${Math.floor(p.mass)}`).join('|')}`;
if (signature !== leaderboardSignature) {
leaderboardSignature = signature; $('leaders').replaceChildren(...leaders.map(p => {
Expand All @@ -372,14 +390,6 @@ function updateHUD(own) {
return row;
}));
}
const goalSignature = JSON.stringify(events.goals);
if (goalSignature !== goalsSignature) {
goalsSignature = goalSignature; $('goals').hidden = events.goals.length === 0;
$('goal-list').replaceChildren(...events.goals.map(goal => {
const item = document.createElement('li'), label = document.createElement('span'), progress = document.createElement('b'), done = goalDone(goal);
item.className = done ? 'done' : ''; label.textContent = `${done ? '✓ ' : ''}${goalLabel(goal)}`; progress.textContent = goalProgress(goal); item.append(label, progress); return item;
}));
}
}
function steer() {
if (phase !== 'playing' || overlayMode || !ownPrevious?.alive) { movement = { x: 0, y: 0 }; return; }
Expand Down Expand Up @@ -451,6 +461,7 @@ function measurePlayArea() {
if (phase === 'lobby') return;
const bounds = $('game').getBoundingClientRect(), top = document.querySelector('.game-top').getBoundingClientRect(), bottom = document.querySelector('.game-controls').getBoundingClientRect();
cameraInsets = { top: top.bottom - bounds.top, bottom: bounds.bottom - bottom.top, left: 12, right: 12 };
$('game').style.setProperty('--hud-bottom', `${cameraInsets.top}px`);
}
new ResizeObserver(measurePlayArea).observe(document.querySelector('.game-controls'));
new ResizeObserver(measurePlayArea).observe(document.querySelector('.game-top'));
Expand Down
2 changes: 1 addition & 1 deletion arena-web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ <h2 id="join-heading">Make a little trouble.</h2>
<div class="score-bar"><div class="score-main"><span>MASS</span><strong id="mass">25</strong></div><div><span>RANK</span><strong id="rank">—</strong></div><div><span>BEST</span><strong id="best">25</strong></div></div>
<aside class="leaderboard" aria-label="Leaderboard"><h2>BIG DILLS <span>♛</span></h2><ol id="leaders"></ol></aside>
</div>
<section id="goals" class="goals" aria-label="Run goals" hidden><h2>RUN GOALS</h2><ol id="goal-list"></ol></section>
<div id="room-badge" class="room-badge" hidden></div>
</div>
<div id="goal-toast" class="goal-toast" role="status" aria-live="polite"></div>
<div class="map-wrap"><canvas id="minimap" width="160" height="120" aria-label="Garden map"></canvas><span>THE GARDEN</span></div>
<div id="protection" class="protection" hidden>✦ A little head start · protected</div>
<div class="game-controls">
Expand Down
Loading
Loading