Public: anyone can read this; the crew can change it.

Migrated from github.com/amirmushichge/pixel-plays

Add Apple Bobbing: the playable page and its place in the catalogue, on the rules in `apple-bobbing/tub.js` #365

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/pixel-plays:vesper/360-add-apple-bobbing-the-playable-page-and → main 1 commit fork

  • Merged. vesper/360-add-apple-bobbing-the-playable-page-and was merged into main as 4d4af0c.

Review

Sign in to review this change.
9 files changed +443 −2
README.md +3 −2

Diff not shown — a line 4,007 characters wide (limit 2,000). +3 −2

View file
apple-bobbing/index.html added +339
@@ -0,0 +1,339 @@
1 <!doctype html>
2 <html lang="en">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6 <title>Apple Bobbing</title>
7 <!-- og:begin -->
8 <meta name="description" content="Dunk for each bobbing apple at the top of its bob before your breaths run out">
9 <link rel="canonical" href="https://pixel-plays--forge-trevato-apps.open-platform.sh/apple-bobbing/">
10 <meta property="og:type" content="website">
11 <meta property="og:site_name" content="Pixel Plays">
12 <meta property="og:title" content="Apple Bobbing">
13 <meta property="og:description" content="Dunk for each bobbing apple at the top of its bob before your breaths run out">
14 <meta property="og:url" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/apple-bobbing/">
15 <meta property="og:image" content="https://pixel-plays--forge-trevato-apps.open-platform.sh/og/apple-bobbing.png">
16 <meta property="og:image:type" content="image/png">
17 <meta property="og:image:width" content="1200">
18 <meta property="og:image:height" content="630">
19 <meta property="og:image:alt" content="Apple Bobbing: the opening scene of the game">
20 <meta name="twitter:card" content="summary_large_image">
21 <!-- og:end -->
22 <style>
23 :root { --ink: #1c1b19; --muted: #6b675f; --paper: #fff; --accent: #c0392b; }
24 * { box-sizing: border-box; }
25 html, body { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }
26 html, body { height: 100%; margin: 0; background: var(--paper); color: var(--ink); }
27 body { font: 13px/1.35 ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace; overflow: hidden; }
28 #stage { position: fixed; inset: 0; display: grid; place-items: center; }
29 canvas { display: block; image-rendering: pixelated; touch-action: none; cursor: pointer; outline: none; }
30 canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
31 .hud { position: fixed; left: max(16px, env(safe-area-inset-left)); right: max(16px, env(safe-area-inset-right)); display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; pointer-events: none; }
32 #scores { position: fixed; top: max(14px, env(safe-area-inset-top)); left: max(16px, env(safe-area-inset-left)); display: grid; gap: 2px; pointer-events: none; }
33 #bottom { bottom: max(20px, env(safe-area-inset-bottom)); }
34 .row { display: flex; align-items: center; gap: 8px; }
35 .row b { font-size: 16px; font-weight: 600; min-width: 1ch; font-variant-numeric: tabular-nums; }
36 .row span { color: var(--muted); }
37 #status { color: var(--muted); min-height: 2.7em; max-width: 60ch; }
38 button { pointer-events: auto; font: inherit; color: var(--muted); background: none; border: 0; padding: 13px 10px; margin: -13px -10px; min-height: 44px; min-width: 44px; cursor: pointer; }
39 button:hover, button:focus-visible { color: var(--ink); }
40 button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
41 noscript p { position: fixed; inset: 40% 16px auto; margin: 0; text-align: center; color: var(--muted); }
42 </style>
43 </head>
44 <body>
45 <div id="stage"><canvas id="c" tabindex="0" aria-label="Apple Bobbing: a fete water tub with five apples bobbing in it on their own beats. Press the left and right arrow keys, or tap an apple, to aim at it; the aimed apple has a ring round it. Dunk with Space or Enter, or tap the aimed apple again, when it is at the top of its bob, marked by a sparkle above it. A miss costs a breath. R starts a fresh tub."></canvas></div>
46 <noscript><p>Apple Bobbing needs JavaScript to play.</p></noscript>
47 <div id="scores">
48 <div class="row"><b id="score">0</b><span>score</span><b id="caught">0</b><span>apples caught</span></div>
49 <div class="row"><b id="breaths">3</b><span>breaths</span><b id="ticks">60</b><span>ticks left</span></div>
50 </div>
51 <div class="hud" id="bottom">
52 <div id="status" role="status" aria-live="polite"></div>
53 <button id="mute" type="button" aria-pressed="false">sound on</button>
54 </div>
55 <script src="../shell.js"></script>
56 <script>
57 PixelPlays.mount({
58 id: 'apple-bobbing',
59 rules: [
60 'Five apples bob in the tub, each on its own beat; the tub ticks on every half second. Aim with the arrows or a tap.',
61 'Dunk with Space, Enter or a second tap when the aimed apple is at the top of its bob (a sparkle): 30 points, 10 one tick off.',
62 'Any other dunk is a splash and costs a breath of three. Catch all five in 60 ticks. R starts a fresh tub.',
63 ],
64 });
65 </script>
66 <script>window.PP_SCENE = { originY: 6, shadowX: 1.3, shadowY: -.12 };</script>
67 <script src="../engine.js"></script>
68 <script src="tub.js"></script>
69 <script>
70 'use strict';
71
72 const M = window.AppleBobbing;
73 const TICK_S = .5; // the tub's pace: seconds per tick
74 const LEAD_S = 1.5; // a moment to look before the first tick
75 const GLIDE_S = .2; // apples slide to their new height this long
76 const LAWN = 8.4, TUB_R = 6, RING_R = 3.4, APPLE_R = .75, PICK_PX = 22;
77 const LOW = .35, RISE = 1.5; // the lowest an apple sits, and how far it climbs to the top of its bob
78 const NAMES = ['red', 'green', 'gold', 'pink', 'russet'];
79 const FRUIT = [[200, 48, 40], [120, 170, 60], [222, 182, 52], [226, 120, 140], [150, 70, 44]];
80 const STALK = [84, 62, 44], LEAF = [70, 130, 60], WOOD = [150, 104, 64], WATER = [[70, 140, 196], [86, 156, 206]];
81 const LAWN_C = [[120, 164, 84], [112, 156, 78]], SPARK = [255, 252, 220], RING = [250, 236, 110];
82
83 /* ---------- geometry ---------- */
84 const spot = i => { const a = Math.PI * 2 * i / M.APPLES - Math.PI / 2; return [Math.cos(a) * RING_R, Math.sin(a) * RING_R]; };
85 const basketSpot = k => [-4.6 + k * 1.9, 7.2];
86 // the canvas pixel of an apple at the water
87 function screenOf(i) { const c = spot(i), p = project(c[0], c[1], LOW); return [p.sx, p.sy]; }
88
89 /* ---------- state ---------- */
90 const nodes = {};
91 for (const k of ['score', 'caught', 'breaths', 'ticks', 'status']) nodes[k] = document.getElementById(k);
92 const shown = {};
93 function setText(key, text) {
94 if (shown[key] === text) return;
95 shown[key] = text;
96 nodes[key].textContent = text;
97 }
98 const fresh = () => M.start(Math.floor(Math.random() * 4294967296));
99 let P = fresh();
100 // time: the page clock, tickAt: when the last tick ran, nextAt: when the next one does, prevTick: the tick before it
101 const G = { time: 0, tickAt: -9, nextAt: LEAD_S, prevTick: 0, overAt: 0, wind: -1, dirty: true };
102
103 function renderHud() {
104 setText('score', String(P.score));
105 setText('caught', String(P.caught));
106 setText('breaths', String(P.breaths));
107 setText('ticks', String(P.ticks - P.tick));
108 }
109 function say(text) { setText('status', text); }
110
111 /* ---------- the tub ---------- */
112 function groundColor(x, y, ix, iy) {
113 const r = Math.hypot(x, y);
114 if (Math.abs(x) > LAWN || Math.abs(y) > LAWN) return 0;
115 const t = (bayerAt(ix, iy) - .5) * .06 + (noise(x * .8, y * .8) - .5) * .06;
116 const c = r < TUB_R ? WATER[(Math.floor(x * 1.5) + Math.floor(y * 1.5)) & 1] : LAWN_C[(Math.floor(x / 1.2) + Math.floor(y / 1.2)) & 1];
117 return rgb(c[0] * (1 + t), c[1] * (1 + t), c[2] * (1 + t));
118 }
119 function bake() {
120 ground.fill(0); shadowMask.fill(0); staticShadow.fill(0);
121 zb.fill(-1e9);
122 pix.fill(PAPER);
123 for (let iy = 0; iy < H; iy++) for (let ix = 0; ix < W; ix++) {
124 const g = unproject(ix + .5, iy + .5), i = iy * W + ix, c = groundColor(g.x, g.y, ix, iy);
125 if (!c) continue;
126 pix[i] = c; ground[i] = 1;
127 zb[i] = (g.x + g.y) / R2 * COS;
128 }
129 groundZ.set(zb);
130 // the tub's rim: a ring of staves round the water
131 for (let k = 0; k < 56; k++) {
132 const a = Math.PI * 2 * k / 56, x = Math.cos(a) * (TUB_R + .3), y = Math.sin(a) * (TUB_R + .3);
133 sphere(x, y, .5, .55, k & 1 ? WOOD : [132, 90, 56], 'rough');
134 if (k % 2) castShadow(x, y, .6, .55, 1, .3);
135 }
136 staticShadow.set(shadowMask);
137 applyShadows(groundZ);
138 bakedPix.set(pix);
139 bakedZ.set(zb);
140 }
141
142 /* ---------- apples ---------- */
143 const bone = (a, b, r0, r1, c) => dynamicAdd(a, b, r0, r1, () => c);
144 // the height of an apple: from the offset the rules give at a tick, blended from the previous tick while it glides
145 function heightOf(apple) {
146 const k = clamp((G.time - G.tickAt) / GLIDE_S, 0, 1);
147 const half = apple.period / 2;
148 const now = M.offset(apple, P.tick) / half, before = M.offset(apple, G.prevTick) / half;
149 const o = k >= 1 ? now : lerp(before, now, ease(k));
150 return LOW + (1 - o) * RISE;
151 }
152 function drawApple(i) {
153 const a = P.apples[i], c = spot(i), z = heightOf(a);
154 sphere(c[0], c[1], z, APPLE_R, FRUIT[i], 'gloss');
155 bone([c[0], c[1], z + APPLE_R * .7], [c[0] + .08, c[1], z + APPLE_R * 1.5], .07, .05, STALK);
156 sphere(c[0] + .3, c[1], z + APPLE_R * 1.4, .2, LEAF, 'solid');
157 castShadow(c[0], c[1], z, APPLE_R, 1, .2);
158 // an apple at the top of its bob wears a sparkle above it: more than colour says "now"
159 if (M.offset(a, P.tick) === 0) {
160 const top = z + APPLE_R * 2.4;
161 for (const [dx, dz] of [[0, 0], [.55, 0], [-.55, 0], [0, .55], [0, -.55], [.3, .3], [-.3, -.3]]) sphere(c[0] + dx, c[1] + dx * .4, top + dz, .14, SPARK, 'solid');
162 }
163 // the aimed apple has a ring round it at the water
164 if (P.aimed === i) {
165 for (let k = 0; k < 14; k++) {
166 const t = Math.PI * 2 * k / 14;
167 sphere(c[0] + Math.cos(t) * 1.45, c[1] + Math.sin(t) * 1.45, .22, .16, RING, 'solid');
168 }
169 }
170 }
171 function draw() {
172 pix.set(bakedPix);
173 zb.set(bakedZ);
174 pendingShadows.length = 0;
175 // ripples come and go on the water (ambient: still under reduced motion)
176 if (!STILL) {
177 for (let k = 0; k < 7; k++) if ((Math.floor(G.time * 2) + k) % 3 === 0) {
178 const t = k * 2.4 + 1, r = 1.6 + (k % 3) * 1.3;
179 sphere(Math.cos(t) * r, Math.sin(t) * r, .05, .12, [190, 224, 244], 'solid');
180 }
181 }
182 P.apples.forEach((a, i) => { if (!a.caught) drawApple(i); });
183 // caught apples sit on the lawn in a row
184 let n = 0;
185 P.apples.forEach((a, i) => {
186 if (!a.caught) return;
187 const b = basketSpot(n++);
188 sphere(b[0], b[1], APPLE_R * .8, APPLE_R * .8, FRUIT[i], 'gloss');
189 castShadow(b[0], b[1], APPLE_R * .8, APPLE_R * .8, 1, .2);
190 });
191 flushShadows();
192 applyShadows(bakedZ);
193 shadeEdges();
194 ctx.putImageData(image, 0, 0);
195 }
196
197 /* ---------- game: every rule is the rules script's ---------- */
198 const WHY = {
199 'round-over': 'The round is over. Press R to play again.',
200 'nothing-aimed': 'Aim at an apple first: tap one or press an arrow key.',
201 'unknown-apple': 'There is no such apple.',
202 'already-caught': 'That apple is already caught.',
203 };
204 const ticksWord = n => `${n} tick${n === 1 ? '' : 's'}`;
205 function tick() {
206 const r = M.step(P);
207 if (!r.ok) return;
208 G.prevTick = P.tick; G.tickAt = G.time; G.dirty = true;
209 P = r.state;
210 if (P.status !== 'playing') finish();
211 }
212 function finish() {
213 G.overAt = G.time;
214 say(`${P.line}. Tap or press R to play again.`);
215 sound(P.status === 'won' ? 880 : 110, .4, P.status === 'won' ? 'triangle' : 'sawtooth', .15);
216 }
217 function aimAt(i) {
218 ensureAudio();
219 const r = M.aim(P, i);
220 if (!r.ok) { say(WHY[r.why] || `Cannot aim: ${r.why}.`); return false; }
221 P = r.state; G.dirty = true;
222 say(`You aim at the ${NAMES[i]} apple. Dunk when it is at the top of its bob.`);
223 sound(500 + i * 40, .05, 'square');
224 return true;
225 }
226 function dunk() {
227 ensureAudio();
228 const r = M.dunk(P);
229 if (!r.ok) { say(WHY[r.why] || `Cannot dunk: ${r.why}.`); return; }
230 P = r.state; G.dirty = true;
231 const l = P.last, name = NAMES[l.apple];
232 if (l.hit) {
233 say(`You dunk the ${name} apple ${l.offset === 0 ? 'at the top of its bob' : 'one tick from the top'}: ${l.offset === 0 ? M.EXACT_POINTS : M.WINDOW_POINTS} points. ${P.line}.`);
234 sound(l.offset === 0 ? 760 : 620, .2, 'triangle');
235 } else {
236 say(`Splash: the ${name} apple was ${ticksWord(l.offset)} from the top. You lose a breath. ${P.line}.`);
237 sound(140, .25, 'sawtooth');
238 }
239 if (P.status !== 'playing') finish();
240 }
241 // the next apple still in the water in a direction, wrapping round
242 function cycle(dir) {
243 const n = M.APPLES;
244 let i = P.aimed === null ? (dir > 0 ? -1 : 0) : P.aimed;
245 for (let k = 0; k < n; k++) {
246 i = (i + dir + n) % n;
247 if (!P.apples[i].caught) return aimAt(i);
248 }
249 say(WHY['round-over']);
250 }
251 function update(dt) {
252 G.time += dt;
253 if (P.status === 'playing' && G.time >= G.nextAt) { G.nextAt += TICK_S; tick(); }
254 if (G.time - G.tickAt < GLIDE_S + .05) G.dirty = true;
255 const wind = STILL ? 0 : Math.floor(G.time * 2);
256 if (wind !== G.wind) { G.wind = wind; G.dirty = true; }
257 renderHud();
258 }
259 function restart() {
260 P = fresh();
261 G.prevTick = 0; G.tickAt = -9; G.nextAt = G.time + LEAD_S; G.dirty = true;
262 say(`${P.line}. Aim with the arrows or a tap, then dunk.`);
263 }
264
265 /* ---------- input: tap an apple to aim, tap it again to dunk; arrows aim, Space dunks ---------- */
266 function appleAt(px, py) {
267 let best = -1, top = PICK_PX;
268 for (let i = 0; i < M.APPLES; i++) {
269 const [sx, sy] = screenOf(i), d = Math.hypot(px - sx, py - sy);
270 if (d < top) { top = d; best = i; }
271 }
272 return best;
273 }
274 canvas.addEventListener('pointerdown', e => {
275 canvas.focus({ preventScroll: true });
276 ensureAudio();
277 if (P.status !== 'playing') { if (G.time - G.overAt > .6) restart(); return; }
278 const r = canvas.getBoundingClientRect();
279 const i = appleAt((e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H);
280 if (i < 0) { say('Tap an apple to aim at it.'); return; }
281 if (P.apples[i].caught) { say(WHY['already-caught']); return; }
282 if (P.aimed === i) dunk(); else aimAt(i);
283 });
284 canvas.addEventListener('contextmenu', e => e.preventDefault());
285 canvas.addEventListener('keydown', e => {
286 if (e.ctrlKey || e.metaKey || e.altKey) return;
287 const k = e.key;
288 if (k === 'r' || k === 'R') { if (!e.repeat) restart(); }
289 else if (k === 'ArrowLeft' || k === 'ArrowUp' || k === 'a' || k === 'A' || k === 'w' || k === 'W') { if (!e.repeat) { if (P.status === 'playing') cycle(-1); else say(WHY['round-over']); } }
290 else if (k === 'ArrowRight' || k === 'ArrowDown' || k === 'd' || k === 'D' || k === 's' || k === 'S') { if (!e.repeat) { if (P.status === 'playing') cycle(1); else say(WHY['round-over']); } }
291 else if (k === ' ' || k === 'Enter') { if (!e.repeat) dunk(); }
292 else if (k >= '1' && k <= String(M.APPLES)) { if (!e.repeat) aimAt(Number(k) - 1); }
293 else return;
294 e.preventDefault();
295 });
296
297 /* ---------- sound ---------- */
298 let audio = null, muted = false;
299 function ensureAudio() {
300 if (audio) { if (audio.state === 'suspended') audio.resume(); return; }
301 const AC = window.AudioContext || window.webkitAudioContext;
302 if (AC) audio = new AC();
303 }
304 function sound(freq, life, type, delay = 0) {
305 if (!audio || muted) return;
306 const now = audio.currentTime + delay, gain = audio.createGain(), osc = audio.createOscillator();
307 osc.type = type;
308 osc.frequency.setValueAtTime(freq, now);
309 gain.gain.setValueAtTime(.0001, now);
310 gain.gain.exponentialRampToValueAtTime(.06, now + .01);
311 gain.gain.exponentialRampToValueAtTime(.0005, now + life);
312 osc.connect(gain).connect(audio.destination);
313 osc.start(now); osc.stop(now + life + .02);
314 }
315 const muteButton = document.getElementById('mute');
316 muteButton.addEventListener('click', () => {
317 muted = !muted;
318 muteButton.textContent = muted ? 'sound off' : 'sound on';
319 muteButton.setAttribute('aria-pressed', String(muted));
320 });
321
322 /* ---------- fit and boot ---------- */
323 addEventListener('resize', fit);
324 fit();
325 bake();
326 renderHud();
327 say(`${P.line}. The tub ticks on every ${TICK_S} s: aim with the arrows or a tap, dunk with Space or a second tap.`);
328 canvas.focus({ preventScroll: true });
329 let last = performance.now();
330 function frame(now) {
331 update(Math.min(.04, (now - last) / 1000));
332 last = now;
333 if (G.dirty) { G.dirty = false; draw(); }
334 requestAnimationFrame(frame);
335 }
336 requestAnimationFrame(frame);
337 </script>
338 </body>
339 </html>
apple-bobbing/page.test.mjs added +81
@@ -0,0 +1,81 @@
1 import { test, expect } from 'bun:test';
2 import { boot } from '../playtest.mjs';
3 import M from './tub.js';
4
5 const state = g => JSON.parse(g.read('JSON.stringify(P)'));
6 // a tub with fixed apples: apple 0 is at its top on even ticks of a 2-tick-free period of 8, the rest are far off
7 const fixed = g => g.read(`P = { ...P, apples: [0, 1, 2, 3, 4].map(i => ({ period: 8, phase: i === 0 ? 0 : 4, caught: false })) }`);
8
9 test('the tub ticks on by itself, and the HUD reads the rules state', () => {
10 const g = boot('apple-bobbing');
11 expect(state(g).tick).toBe(0);
12 g.run(1.5 + .5 * 4);
13 const s = state(g);
14 expect(s.tick).toBeGreaterThanOrEqual(3);
15 expect(g.el('ticks').textContent).toBe(String(s.ticks - s.tick));
16 expect(g.el('breaths').textContent).toBe(String(s.breaths));
17 expect(g.el('caught').textContent).toBe('0');
18 expect(g.el('score').textContent).toBe('0');
19 });
20
21 test('arrows and a tap aim, a second tap or Space dunks, each announced; a refusal says why', () => {
22 const g = boot('apple-bobbing');
23 g.keyDown(' ');
24 expect(g.el('status').textContent).toContain('Aim at an apple first');
25 g.keyDown('ArrowRight');
26 expect(g.read('P.aimed')).toBe(0);
27 expect(g.el('status').textContent).toContain('You aim at the red apple');
28 g.keyDown('ArrowRight');
29 expect(g.read('P.aimed')).toBe(1);
30 const [x, y] = g.read('screenOf(3)');
31 g.tap(x, y);
32 expect(g.read('P.aimed')).toBe(3);
33 expect(g.el('status').textContent).toContain('pink apple');
34 fixed(g); g.read('P = { ...P, aimed: 3 }');
35 g.tap(x, y); // dunk: apple 3 is at the bottom of its bob
36 expect(g.read('P.breaths')).toBe(2);
37 expect(g.el('status').textContent).toContain('Splash');
38 expect(g.el('breaths').textContent).toBe('2');
39 });
40
41 test('a dunk at the top scores and is announced; the caught apple cannot be aimed again', () => {
42 const g = boot('apple-bobbing');
43 fixed(g);
44 g.keyDown('1');
45 g.keyDown(' ');
46 expect(state(g).caught).toBe(1);
47 expect(state(g).score).toBe(M.EXACT_POINTS);
48 expect(g.el('status').textContent).toContain('at the top of its bob: 30 points');
49 g.keyDown('1');
50 expect(g.el('status').textContent).toContain('already caught');
51 g.keyDown('ArrowRight');
52 expect(g.read('P.aimed')).toBe(1);
53 });
54
55 test('the aimed apple and the apple at its top are drawn differently from the rest', () => {
56 const g = boot('apple-bobbing');
57 fixed(g);
58 g.run(.1);
59 const plain = g.picture();
60 g.keyDown('2'); g.run(.1);
61 const ringed = g.picture();
62 expect(ringed).not.toBe(plain);
63 g.read('P = { ...P, apples: P.apples.map((a, i) => i === 0 ? { ...a, phase: 4 } : a) }; G.dirty = true'); g.run(.1);
64 expect(g.picture()).not.toBe(ringed); // apple 0 is no longer at its top: no sparkle
65 });
66
67 test('the round ends with the line, a tap plays again, and R starts a fresh seed', () => {
68 const g = boot('apple-bobbing');
69 g.read('P = { ...P, status: "out-of-breath", breaths: 0, line: "Out of breath: score 0, 0 apples caught of 5, 0 breaths left" }');
70 g.read('finish()');
71 expect(g.el('status').textContent).toContain('Out of breath');
72 g.keyDown('ArrowRight');
73 expect(g.el('status').textContent).toContain('The round is over');
74 g.run(1);
75 g.tap(100, 100);
76 expect(state(g).status).toBe('playing');
77 const seed = state(g).seed;
78 g.press('r', .02);
79 expect(state(g).seed).not.toBe(seed);
80 expect(state(g).tick).toBe(0);
81 });
index.html +1
@@ -140,6 +140,7 @@ footer a::after { content: none; }
140140 <li data-kind="Sport" data-input="Tap" style="--accent:#b5412f"><a href="village-darts/">Village Darts</a><span>Count down from 301 and finish on a double before the village rival does</span><span>Game forged by the crew</span><span class="tags">Sport · Tap</span></li>
141141 <li data-kind="Arcade" data-input="Keys" style="--accent:#d9731f"><a href="carrot-pull/">Carrot Pull</a><span>Pull each carrot while it is ripe, before the rabbit nibbles it</span><span>Game forged by the crew</span><span class="tags">Arcade · Keys</span></li>
142142 <li data-kind="Arcade" data-input="Keys" style="--accent:#c0392b"><a href="bumper-cars/">Bumper Cars</a><span>Steer your car round the fairground floor and shove the rivals into the wall without a head-on bump</span><span>Game forged by the crew</span><span class="tags">Arcade · Keys</span></li>
143 <li data-kind="Timing" data-input="Tap" style="--accent:#c0392b"><a href="apple-bobbing/">Apple Bobbing</a><span>Dunk for each bobbing apple at the top of its bob before your breaths run out</span><span>Game forged by the crew</span><span class="tags">Timing · Tap</span></li>
143144 <li data-kind="Puzzle" data-input="Keys" style="--accent:#2a6f97"><a href="tide-crossing/">Tide Crossing</a><span>Step across the stones to the island before the tide covers them</span><span>Game forged by the crew</span><span class="tags">Puzzle · Keys</span></li>
144145 </ul>
145146 <footer>Concept by <a href="https://x.com/awwbhi2">abhi</a>. Game by <a href="https://x.com/AmirMushich">AmirMushich</a>. Visual reference for the style: shawn.'s <a href="https://x.com/zzzzshawn/status/2111546123546661289">Small plays</a>; no assets or subjects were copied.<br><a href="stats/">Most played</a></footer>
og/apple-bobbing.png added
Binary file not shown.
og/home.png
Binary file not shown.
rounds.mjs +11
@@ -15,6 +15,7 @@ import Sweep from './chimney-sweep/roofs.js';
1515 import Field from './fence-posts/field.js';
1616 import Carrot from './carrot-pull/bed.js';
1717 import Cars from './bumper-cars/floor.js';
18 import Bobbing from './apple-bobbing/tub.js';
1819 import Tide from './tide-crossing/causeway.js';
1920
2021 const MID = [180, 140];
@@ -531,6 +532,16 @@ export const ROUNDS = {
531532 const best = bestTurn(s);
532533 if (best !== 'straight') g.press(best === 'left' ? 'ArrowLeft' : 'ArrowRight', .02);
533534 } },
535 // aim the apple whose next top comes soonest by the rules' own `offset`, wait for that tick, then dunk
536 'apple-bobbing': { over: /play again/, outcome: /You win: every apple is caught; score [1-9]\d*, 5 apples caught/, limit: 40, beat: g => {
537 const s = JSON.parse(g.read('JSON.stringify(P)'));
538 if (s.status !== 'playing') { g.run(.3); return; }
539 const wait = a => { let t = 0; while (Bobbing.offset(a, s.tick + t) !== 0) t++; return t; };
540 let pick = -1, soonest = Infinity;
541 s.apples.forEach((a, i) => { if (!a.caught && wait(a) < soonest) { soonest = wait(a); pick = i; } });
542 if (s.aimed !== pick) { g.press(String(pick + 1), .02); return; }
543 if (soonest === 0) g.press(' ', .02); else g.run(.04);
544 } },
534545 // wait for the tick whose marker is nearest the rival's own stated target for this score, then throw
535546 'village-darts': { over: /play again/, outcome: /(You win|The rival wins) the leg in \d+ darts?, finishing on/, limit: 140, beat: g => {
536547 if (g.read('P.status') !== 'playing' || g.read('P.turn') !== 'you' || g.read('!!G.flight')) { g.run(.2); return; }
serve.test.mjs +7
@@ -776,6 +776,13 @@ test('Bumper Cars and its rules script are served without a track', async () =>
776776 expect((await fetch(base + '/bumper-cars/floor.js')).status).toBe(200);
777777 });
778778
779 test('Apple Bobbing and its rules script are served without a track', async () => {
780 const page = await fetch(base + '/apple-bobbing/');
781 expect(page.status).toBe(200);
782 expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
783 expect((await fetch(base + '/apple-bobbing/tub.js')).status).toBe(200);
784 });
785
779786 test('Pumpkin Patch and its rules script are served without a track', async () => {
780787 const page = await fetch(base + '/pumpkin-patch/');
781788 expect(page.status).toBe(200);
shell.js +1
@@ -76,6 +76,7 @@ window.PixelPlays = (() => {
7676 { id: 'fence-posts', title: 'Fence Posts', blurb: 'Take turns fencing the meadow and claim every pen you close', kind: 'Strategy', input: 'Tap', accent: '#6b8e23', music: false, forged: true },
7777 { id: 'welly-toss', title: 'Welly Toss', blurb: 'Let the boot go at the top of the swing and land it past the flags', kind: 'Throwing', input: 'Tap', accent: '#2f6b3a', music: false, forged: true },
7878 { id: 'bumper-cars', title: 'Bumper Cars', blurb: 'Steer your car round the fairground floor and shove the rivals into the wall without a head-on bump', kind: 'Arcade', input: 'Keys', accent: '#c0392b', music: false, forged: true },
79 { id: 'apple-bobbing', title: 'Apple Bobbing', blurb: 'Dunk for each bobbing apple at the top of its bob before your breaths run out', kind: 'Timing', input: 'Tap', accent: '#c0392b', music: false, forged: true },
7980 { id: 'ticket-booth', title: 'Ticket Booth', blurb: 'Hand each pier visitor exact change from a limited till', kind: 'Puzzle', input: 'Tap', accent: '#2b7a8c', music: false, forged: true },
8081 { id: 'village-darts', title: 'Village Darts', blurb: 'Count down from 301 and finish on a double before the village rival does', kind: 'Sport', input: 'Tap', accent: '#b5412f', music: false, forged: true },
8182 { id: 'carrot-pull', title: 'Carrot Pull', blurb: 'Pull each carrot while it is ripe, before the rabbit nibbles it', kind: 'Arcade', input: 'Keys', accent: '#d9731f', music: false, forged: true },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs