Project source

Timing Tap 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/timing.js

assets/js/engines/timing.js
import { WIDTH, HEIGHT, background } from './core.js';

export function createTiming(difficulty = 0) {
  const goal = 12 + difficulty * 2, zone = 140 - difficulty * 8;
  const state = { ring: WIDTH / 2, score: 0, lives: 3, time: 0,
    finished: false, won: false };
  function tap() {
    if (state.finished) return;
    if (Math.abs(state.ring - WIDTH / 2) < zone / 2) state.score++;
    else state.lives--;
    state.won = state.score >= goal;
    state.finished = state.won || state.lives <= 0;
  }
  function update(dt) {
    if (state.finished) return;
    state.time += dt;
    state.ring = WIDTH / 2 + Math.sin(state.time * (1.8 + difficulty * 0.2)) * (WIDTH / 2 - 80);
    if (state.time >= 90) state.finished = true;
  }
  function draw(ctx, color) {
    background(ctx);
    ctx.fillStyle = '#295747'; ctx.fillRect(WIDTH / 2 - zone / 2, 180, zone, 180);
    ctx.strokeStyle = color; ctx.lineWidth = 5;
    ctx.beginPath(); ctx.arc(state.ring, HEIGHT / 2, 35, 0, Math.PI * 2); ctx.stroke();
    ctx.fillStyle = '#ffffff'; ctx.textAlign = 'center'; ctx.font = '24px system-ui';
    ctx.fillText('Tap when the ring center is inside the zone', WIDTH / 2, 100);
  }
  return { state, update, draw, tap,
    status: () => `${state.score}/${goal} hits · ${state.lives} lives · ${Math.floor(state.time)}s / 90s` };
}

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