Project source
Gem Collector source
Read the rules, shared scheduler, and browser controls. Download the runnable project ZIP.
assets/js/engines/core.js
// Shared logical coordinates. CSS scales the canvas without changing game rules.
export const WIDTH = 960;
export const HEIGHT = 540;
export const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
// excerpt:loop:start
export function createLoop(update, render, schedule = requestAnimationFrame,
cancel = cancelAnimationFrame) {
let frame = null;
let last = null;
let running = false;
function tick(now) {
if (!running) return;
// The first frame establishes an origin; long gaps never become giant steps.
const dt = last === null ? 0 : Math.min((now - last) / 1000, 0.05);
last = now;
update(dt);
render();
if (running) frame = schedule(tick);
}
return {
start() {
if (running) return;
running = true;
last = null;
frame = schedule(tick);
},
stop() {
running = false;
if (frame !== null) cancel(frame);
frame = null;
last = null;
},
get running() { return running; }
};
}
// excerpt:loop:end
export function background(ctx) {
ctx.fillStyle = '#101d30';
ctx.fillRect(0, 0, WIDTH, HEIGHT);
}
// Pointer positions use logical coordinates even on narrow screens.
export function canvasPoint(event, canvas) {
const rect = canvas.getBoundingClientRect();
return {
x: (event.clientX - rect.left) * WIDTH / rect.width,
y: (event.clientY - rect.top) * HEIGHT / rect.height
};
}
assets/js/engines/collector.js
import { WIDTH, HEIGHT, clamp, background } from './core.js';
export function createCollector(difficulty = 0, random = Math.random) {
const goal = 18 + difficulty * 3;
const state = { x: WIDTH / 2, objects: [], score: 0, lives: 3,
time: 0, spawn: 0, finished: false, won: false };
// excerpt:collector-update:start
function update(dt, direction = 0) {
if (state.finished) return;
state.time += dt;
state.spawn += dt;
state.x = clamp(state.x + direction * 500 * dt, 45, WIDTH - 45);
if (state.spawn >= 0.52 - difficulty * 0.035) {
state.spawn = 0;
state.objects.push({ x: 40 + random() * (WIDTH - 80), y: -25,
bad: random() < 0.23 + difficulty * 0.015 });
}
for (const object of state.objects) {
object.y += (150 + difficulty * 25 + state.time * 1.2) * dt;
if (object.y > HEIGHT - 78 && object.y < HEIGHT - 25 &&
Math.abs(object.x - state.x) < 52) {
object.y = HEIGHT + 60; // Consume once, then remove below.
if (object.bad) state.lives--;
else state.score++;
}
}
state.objects = state.objects.filter(object => object.y < HEIGHT + 30);
state.won = state.score >= goal;
state.finished = state.won || state.lives <= 0 || state.time >= 75;
}
// excerpt:collector-update:end
function draw(ctx, color) {
background(ctx);
for (const object of state.objects) {
ctx.fillStyle = object.bad ? '#ff839b' : color;
ctx.beginPath();
ctx.arc(object.x, object.y, object.bad ? 15 : 12, 0, Math.PI * 2);
ctx.fill();
// Hazards have a cross as well as a different color.
if (object.bad) {
ctx.fillStyle = '#101d30'; ctx.font = 'bold 22px system-ui';
ctx.textAlign = 'center'; ctx.fillText('×', object.x, object.y + 7);
}
}
ctx.fillStyle = color;
ctx.fillRect(state.x - 45, HEIGHT - 60, 90, 18);
}
return { state, update, draw,
pointer: point => { state.x = clamp(point.x, 45, WIDTH - 45); },
status: () => `${state.score}/${goal} gems · ${state.lives} lives · ${Math.floor(state.time)}s / 75s` };
}
assets/js/demo.js
import { createLoop, canvasPoint } from './engines/core.js';
import { createMemory, cardRect } from './engines/memory.js';
import { createCollector } from './engines/collector.js';
import { createRacer } from './engines/racer.js';
import { createTiming } from './engines/timing.js';
const arena = document.querySelector('.game');
if (arena) {
const mode = arena.dataset.mode;
const factories = { memory: createMemory, collector: createCollector,
racer: createRacer, timing: createTiming };
const canvas = arena.querySelector('canvas'), ctx = canvas.getContext('2d');
const status = arena.querySelector('[data-status]');
const pause = arena.querySelector('[data-pause]');
const cards = arena.querySelector('.card-inputs');
const difficulty = arena.querySelector('[data-difficulty]');
const palette = arena.querySelector('[data-palette]');
const held = new Set();
let game, started = false, pointerStart = null, lastStatus = '';
function report(message) {
if (message !== lastStatus) { status.textContent = message; lastStatus = message; }
}
function render() {
game.draw(ctx, palette.value);
if (mode === 'memory') {
// A matched card becomes disabled. Move focus within the game first so
// completing a pair does not look like the player leaving the arena.
const focused = [...cards.children].indexOf(document.activeElement);
if (focused >= 0 && game.state.deck[focused].matched) {
const next = game.state.deck.findIndex(card => !card.matched);
(next >= 0 ? cards.children[next] : arena.querySelector('[data-start]'))
.focus({ preventScroll: true });
}
game.state.deck.forEach((card, index) => {
const visible = card.matched || game.state.selected.includes(index);
const button = cards.children[index];
button.disabled = !started || card.matched;
button.setAttribute('aria-label', `Card ${index + 1}: ${visible ? card.symbol : 'face down'}${card.matched ? ', matched' : ''}`);
});
}
}
const loop = createLoop(dt => {
const direction = Number(held.has('right')) - Number(held.has('left'));
game.update(dt, direction);
// Announce discrete changes, not every animation frame.
report(game.status() + (game.state.finished ? (game.state.won ? ' · Complete!' : ' · Round ended.') : ''));
if (game.state.finished) { loop.stop(); held.clear(); pause.disabled = true; }
}, render);
function reset(play = false) {
loop.stop(); held.clear(); started = play;
game = factories[mode](Number(difficulty.value));
cards.replaceChildren();
if (mode === 'memory') game.state.deck.forEach((card, index) => {
const button = document.createElement('button');
const rect = cardRect(index, game.state.deck.length);
button.style.cssText = `left:${rect.x / 9.6}%;top:${rect.y / 5.4}%;width:${rect.width / 9.6}%;height:${rect.height / 5.4}%`;
button.onclick = () => { if (loop.running) { game.choose(index); render(); } };
cards.append(button);
});
pause.textContent = 'Pause'; pause.disabled = !play;
report(play ? game.status() : 'Choose options, then press Start / Restart.');
render();
if (play) loop.start();
}
arena.querySelector('[data-start]').onclick = () => reset(true);
pause.onclick = () => {
if (!started || game.state.finished) return;
held.clear();
if (loop.running) { loop.stop(); pause.textContent = 'Resume'; report('Paused.'); }
else { loop.start(); pause.textContent = 'Pause'; }
};
difficulty.onchange = () => reset(false);
palette.onchange = render;
// Leaving the page or moving focus away must never leave movement held down.
function autoPause() { held.clear(); if (loop.running) pause.click(); }
window.addEventListener('blur', autoPause);
document.addEventListener('visibilitychange', () => { if (document.hidden) autoPause(); });
arena.addEventListener('focusout', event => {
if (!arena.contains(event.relatedTarget)) autoPause();
});
arena.addEventListener('keydown', event => {
if (event.target.matches('select') || !loop.running) return;
const direction = ['ArrowLeft', 'a', 'A'].includes(event.key) ? 'left' :
['ArrowRight', 'd', 'D'].includes(event.key) ? 'right' : null;
if (direction && mode !== 'memory') {
event.preventDefault();
if (mode === 'racer' && !held.has(direction)) game.move(direction === 'left' ? -1 : 1);
held.add(direction);
}
if (mode === 'timing' && event.target === canvas && [' ', 'Enter'].includes(event.key)) {
event.preventDefault(); if (!event.repeat) game.tap();
}
});
window.addEventListener('keyup', event => {
if (['ArrowLeft', 'a', 'A'].includes(event.key)) held.delete('left');
if (['ArrowRight', 'd', 'D'].includes(event.key)) held.delete('right');
});
canvas.onpointerdown = event => {
if (!loop.running) return;
canvas.focus(); canvas.setPointerCapture(event.pointerId);
pointerStart = event.clientX;
if (mode === 'collector') game.pointer(canvasPoint(event, canvas));
if (mode === 'timing') game.tap();
};
canvas.onpointermove = event => {
if (loop.running && mode === 'collector' && pointerStart !== null)
game.pointer(canvasPoint(event, canvas));
};
canvas.onpointerup = event => {
if (loop.running && mode === 'racer' && pointerStart !== null && Math.abs(event.clientX - pointerStart) > 15)
game.move(event.clientX > pointerStart ? 1 : -1);
pointerStart = null;
};
canvas.onpointercancel = () => { pointerStart = null; held.clear(); };
arena.querySelectorAll('[data-move]').forEach(button => {
const direction = button.dataset.move;
button.onpointerdown = event => {
event.preventDefault(); if (!loop.running) return;
button.setPointerCapture(event.pointerId);
if (mode === 'racer') game.move(direction === 'left' ? -1 : 1);
else held.add(direction);
};
// Keyboard activation uses the same visible controls.
button.onclick = event => {
if (event.detail === 0 && loop.running) {
if (mode === 'racer') game.move(direction === 'left' ? -1 : 1);
else game.pointer({ x: game.state.x + (direction === 'left' ? -45 : 45) });
}
};
button.onpointerup = button.onpointercancel = button.onlostpointercapture = () => held.delete(direction);
});
reset();
}