Project source

Memory Match source

Read the rules, shared scheduler, and browser controls. Download the runnable project ZIP.

Back to demo

assets/js/engines/core.js

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

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

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();
}