Project source
Memory Match 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/memory.js
import { WIDTH, HEIGHT, background } from './core.js';
// excerpt:deck:start
export function makeDeck(pairCount, random = Math.random) {
const symbols = ['★', '◆', '●', '▲', '☀', '♣', '☂', '♥'];
const deck = symbols.slice(0, pairCount).flatMap(symbol => [
{ symbol, matched: false }, { symbol, matched: false }
]);
// Fisher–Yates: each remaining position has the same chance of being picked.
for (let i = deck.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
[deck[i], deck[j]] = [deck[j], deck[i]];
}
return deck;
}
// excerpt:deck:end
export function cardRect(index, count) {
const columns = 4, rows = Math.ceil(count / columns), gap = 12;
const width = (WIDTH - 160 - gap * (columns - 1)) / columns;
const height = (HEIGHT - 44 - gap * (rows - 1)) / rows;
return { x: 80 + (index % columns) * (width + gap),
y: 22 + Math.floor(index / columns) * (height + gap), width, height };
}
export function createMemory(difficulty = 0, random = Math.random) {
const pairCount = 6 + difficulty % 3;
const state = { deck: makeDeck(pairCount, random), selected: [],
moves: 0, pairs: 0, hideIn: 0, finished: false, won: false };
// excerpt:choose:start
function choose(index) {
const card = state.deck[index];
if (!card || state.finished || state.hideIn > 0 || card.matched ||
state.selected.includes(index)) return;
state.selected.push(index);
if (state.selected.length !== 2) return;
state.moves++;
const [a, b] = state.selected;
if (state.deck[a].symbol === state.deck[b].symbol) {
state.deck[a].matched = state.deck[b].matched = true;
state.selected = [];
state.pairs++;
state.finished = state.won = state.pairs === pairCount;
} else {
state.hideIn = 0.85;
}
}
// excerpt:choose:end
// excerpt:memory-update:start
function update(dt) {
if (state.hideIn <= 0) return;
state.hideIn = Math.max(0, state.hideIn - dt);
if (state.hideIn === 0) state.selected = [];
}
// excerpt:memory-update:end
// excerpt:memory-draw:start
function draw(ctx, color) {
background(ctx);
state.deck.forEach((card, index) => {
const r = cardRect(index, state.deck.length);
const visible = card.matched || state.selected.includes(index);
ctx.fillStyle = visible ? color : '#334766';
ctx.fillRect(r.x, r.y, r.width, r.height);
ctx.fillStyle = visible ? '#101d30' : '#ffffff';
ctx.font = '36px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(visible ? card.symbol : '?', r.x + r.width / 2,
r.y + r.height / 2);
});
}
// excerpt:memory-draw:end
return { state, choose, update, draw,
status: () => `${state.pairs}/${pairCount} pairs · ${state.moves} moves` };
}
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();
}