Build a Game with JavaScript DOM Target Reaction Game

JavaScript DOM Game Dev (Beginner-Friendly): Build a “Reaction + Score” Click Game

If you can select an element, listen for a click, and update text on the page, you can build a game.

The DOM (Document Object Model) is the browser’s “live” representation of your HTML. When you write JavaScript that changes what’s on screen—moving objects, updating scores, showing timers—you’re manipulating the DOM. That’s basically game development at its simplest: a loop of input → rules → update → render.

In this post you’ll build a complete DOM-based mini-game from scratch:

  • A target appears at random positions inside a play area
  • You click it to gain points
  • Miss-clicks cost you time
  • A timer runs down
  • You can start, pause, reset, and track your best score
  • Difficulty increases as you score more

And you’ll get the full, single-file source code you can paste into an index.html and run.


How DOM Games Work (The Simple Pattern)

Almost every DOM game uses this loop:

  1. State (data): score, time, level, gameRunning
  2. Input: click, keypress, touch
  3. Rules: hit = +points, miss = -time, speed up, etc.
  4. Render (DOM updates): move target, update score text, update timer
  5. Timing: setInterval / setTimeout / requestAnimationFrame

We’ll use:

  • DOM selection: document.querySelector(...)
  • Events: addEventListener("click", ...)
  • Timers: setInterval for the countdown, setTimeout for spawning targets
  • Positioning: absolute positioning inside a relative container
  • LocalStorage: save best score between page refreshes

The Game: “Target Tap” (Reaction + Score)

Rules

  • Click the target = +1 point
  • Click the play area but not the target = -1 second
  • You have 30 seconds
  • As your score increases, the target appears faster and smaller (harder!)

Full Source Code (Single File)

Create a file named index.html, paste everything below, and open it in your browser:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>DOM Game: Target Tap</title>
  <style>
    :root{
      --bg:#0f172a; --panel:#111827; --card:#1f2937;
      --text:#e5e7eb; --muted:#9ca3af; --accent:#60a5fa;
      --good:#10b981; --bad:#ef4444;
    }
    *{box-sizing:border-box}
    body{
      margin:0; min-height:100vh;
      background:linear-gradient(120deg,var(--bg),#0b1022);
      color:var(--text);
      font:16px/1.4 system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
      display:grid; place-items:center; padding:20px;
    }
    .wrap{width:min(980px,100%); display:grid; gap:16px}
    .top{
      display:flex; gap:12px; flex-wrap:wrap; align-items:center; justify-content:space-between;
      background:rgba(17,24,39,0.75);
      border:1px solid rgba(255,255,255,0.08);
      border-radius:14px; padding:14px;
    }
    .stats{display:flex; gap:14px; flex-wrap:wrap; align-items:center}
    .stat{
      background:rgba(31,41,55,0.7);
      border:1px solid rgba(255,255,255,0.06);
      border-radius:12px;
      padding:10px 12px;
      min-width:120px;
    }
    .label{font-size:12px; color:var(--muted)}
    .value{font-size:22px; font-weight:700}
    .value.good{color:var(--good)}
    .value.bad{color:var(--bad)}
    .controls{display:flex; gap:10px; flex-wrap:wrap}
    button{
      border:0;
      border-radius:12px;
      padding:10px 14px;
      background:var(--card);
      color:var(--text);
      font-weight:600;
      cursor:pointer;
      border:1px solid rgba(255,255,255,0.10);
      transition:transform .08s ease, background .2s ease;
    }
    button:hover{background:#263244}
    button:active{transform:translateY(1px)}
    button.primary{background:rgba(96,165,250,0.20); border-color:rgba(96,165,250,0.5)}
    button.primary:hover{background:rgba(96,165,250,0.28)}
    button:disabled{opacity:.5; cursor:not-allowed}
    .gameRow{display:grid; grid-template-columns:1.2fr .8fr; gap:16px}
    @media (max-width: 860px){ .gameRow{grid-template-columns:1fr} }
    .arenaCard, .helpCard{
      background:rgba(17,24,39,0.75);
      border:1px solid rgba(255,255,255,0.08);
      border-radius:14px;
      padding:14px;
    }
    .arena{
      position:relative;
      height:420px;
      border-radius:14px;
      background:radial-gradient(circle at 30% 20%, rgba(96,165,250,0.12), transparent 40%),
                 radial-gradient(circle at 70% 80%, rgba(16,185,129,0.10), transparent 45%),
                 rgba(15,23,42,0.8);
      border:1px dashed rgba(255,255,255,0.14);
      overflow:hidden;
      user-select:none;
    }
    .target{
      position:absolute;
      width:64px; height:64px;
      border-radius:999px;
      display:grid; place-items:center;
      background:radial-gradient(circle at 30% 30%, rgba(255,255,255,0.35), rgba(96,165,250,0.45) 45%, rgba(96,165,250,0.15));
      border:2px solid rgba(255,255,255,0.25);
      box-shadow:0 10px 30px rgba(0,0,0,0.35);
      cursor:pointer;
      transform:translate(-50%,-50%);
    }
    .target::after{
      content:"";
      width:16px; height:16px;
      border-radius:999px;
      background:rgba(255,255,255,0.6);
      box-shadow:0 0 0 10px rgba(255,255,255,0.08);
    }
    .toast{
      margin-top:10px;
      font-size:13px;
      color:var(--muted);
    }
    .badge{
      display:inline-block;
      padding:4px 10px;
      border-radius:999px;
      border:1px solid rgba(255,255,255,0.12);
      background:rgba(31,41,55,0.6);
      margin-right:8px;
    }
    .helpCard h2{margin:0 0 8px; font-size:18px}
    .helpCard p{margin:8px 0; color:var(--muted)}
    .helpCard code{color:#dbeafe}
    .small{font-size:13px}
    .line{height:1px; background:rgba(255,255,255,0.08); margin:12px 0}
  </style>
</head>
<body>
  <div class="wrap">
    <div class="top">
      <div class="stats">
        <div class="stat">
          <div class="label">Score</div>
          <div id="scoreEl" class="value good">0</div>
        </div>
        <div class="stat">
          <div class="label">Time Left</div>
          <div id="timeEl" class="value">30.0s</div>
        </div>
        <div class="stat">
          <div class="label">Best</div>
          <div id="bestEl" class="value">0</div>
        </div>
        <div class="stat">
          <div class="label">Difficulty</div>
          <div id="diffEl" class="value">1</div>
        </div>
      </div>

      <div class="controls">
        <button id="startBtn" class="primary">Start</button>
        <button id="pauseBtn" disabled>Pause</button>
        <button id="resetBtn">Reset</button>
      </div>
    </div>

    <div class="gameRow">
      <div class="arenaCard">
        <div class="badge">Click the target</div>
        <div class="badge">Miss-click = -1s</div>
        <div class="badge">30 seconds</div>

        <div id="arena" class="arena" aria-label="Game arena">
          <div id="target" class="target" style="display:none;" aria-label="Target"></div>
        </div>

        <div id="toast" class="toast">
          Tip: start the game, then try to click only the target. The target gets smaller and faster as you score.
        </div>
      </div>

      <div class="helpCard">
        <h2>What you’ll learn</h2>
        <p>How DOM games are built using <code>state</code>, <code>events</code>, and <code>timers</code>.</p>

        <div class="line"></div>

        <p><strong>Core DOM techniques:</strong></p>
        <p class="small">
          • Select elements with <code>querySelector</code><br/>
          • React to clicks with <code>addEventListener</code><br/>
          • Update UI with <code>textContent</code><br/>
          • Move objects with <code>style.left</code> / <code>style.top</code><br/>
          • Use timers: <code>setInterval</code> + <code>setTimeout</code><br/>
          • Save best score with <code>localStorage</code>
        </p>

        <div class="line"></div>

        <p class="small">
          Challenge ideas at the end show how to expand this into levels, combos, and mobile touch support.
        </p>
      </div>
    </div>
  </div>

  <script>
    /************************************************************
     * 1) Grab DOM elements
     ************************************************************/
    const arena = document.querySelector("#arena");
    const target = document.querySelector("#target");

    const scoreEl = document.querySelector("#scoreEl");
    const timeEl  = document.querySelector("#timeEl");
    const bestEl  = document.querySelector("#bestEl");
    const diffEl  = document.querySelector("#diffEl");
    const toast   = document.querySelector("#toast");

    const startBtn = document.querySelector("#startBtn");
    const pauseBtn = document.querySelector("#pauseBtn");
    const resetBtn = document.querySelector("#resetBtn");

    /************************************************************
     * 2) Game state (data)
     ************************************************************/
    const INITIAL_TIME = 30.0;         // seconds
    const MISS_PENALTY = 1.0;          // seconds subtracted for a miss
    const SCORE_PER_HIT = 1;

    let score = 0;
    let timeLeft = INITIAL_TIME;

    let gameRunning = false;           // is the game active?
    let countdownTimerId = null;       // setInterval id
    let spawnTimeoutId = null;         // setTimeout id
    let lastTick = null;               // for precise countdown timing

    // Difficulty changes based on score (you can adjust the formulas below)
    function getDifficulty(score){
      // Difficulty starts at 1 and rises every 5 points
      return Math.floor(score / 5) + 1;
    }

    // How quickly we spawn the next target (milliseconds)
    function getSpawnDelayMs(score){
      // Starts slower, then gets faster; clamp so it doesn't become impossible
      const base = 900;                // ms at score 0
      const speedUp = score * 20;      // faster as score rises
      return Math.max(250, base - speedUp);
    }

    // Target size shrinks as difficulty rises
    function getTargetSizePx(score){
      const base = 64;
      const shrink = getDifficulty(score) * 4;
      return Math.max(28, base - shrink);
    }

    /************************************************************
     * 3) UI helpers (render)
     ************************************************************/
    function render(){
      scoreEl.textContent = score;
      bestEl.textContent = getBestScore();

      const d = getDifficulty(score);
      diffEl.textContent = d;

      // Time formatting
      timeEl.textContent = `${timeLeft.toFixed(1)}s`;

      // Visually warn when time is low
      timeEl.classList.toggle("bad", timeLeft <= 5);

      // Update target size based on difficulty
      const size = getTargetSizePx(score);
      target.style.width = size + "px";
      target.style.height = size + "px";
    }

    function setToast(msg){
      toast.textContent = msg;
    }

    /************************************************************
     * 4) Best score (localStorage)
     ************************************************************/
    const BEST_KEY = "dom_game_best_score";

    function getBestScore(){
      const raw = localStorage.getItem(BEST_KEY);
      const n = Number(raw);
      return Number.isFinite(n) ? n : 0;
    }

    function maybeSaveBest(){
      const best = getBestScore();
      if(score > best){
        localStorage.setItem(BEST_KEY, String(score));
        setToast(`New best score: ${score}!`);
      }
    }

    /************************************************************
     * 5) Random positioning inside the arena
     ************************************************************/
    function moveTargetToRandomSpot(){
      // Get arena size
      const rect = arena.getBoundingClientRect();
      const size = target.offsetWidth;

      // We want to keep the target fully inside the arena.
      // Since we position using translate(-50%, -50%), we need
      // to clamp the center point between size/2 and rect-size/2.
      const padding = size / 2;

      const x = randomBetween(padding, rect.width - padding);
      const y = randomBetween(padding, rect.height - padding);

      target.style.left = x + "px";
      target.style.top  = y + "px";
    }

    function randomBetween(min, max){
      return Math.random() * (max - min) + min;
    }

    /************************************************************
     * 6) Spawning logic
     ************************************************************/
    function showTarget(){
      target.style.display = "grid";
      moveTargetToRandomSpot();
    }

    function hideTarget(){
      target.style.display = "none";
    }

    function scheduleNextSpawn(){
      clearTimeout(spawnTimeoutId);

      const delay = getSpawnDelayMs(score);

      spawnTimeoutId = setTimeout(() => {
        if(!gameRunning) return;
        showTarget();
      }, delay);
    }

    /************************************************************
     * 7) Game loop (countdown)
     *
     * We use setInterval, but track real elapsed time using
     * performance.now() to keep it accurate.
     ************************************************************/
    function startCountdown(){
      clearInterval(countdownTimerId);
      lastTick = performance.now();

      countdownTimerId = setInterval(() => {
        if(!gameRunning) return;

        const now = performance.now();
        const dt = (now - lastTick) / 1000; // ms -> seconds
        lastTick = now;

        timeLeft = Math.max(0, timeLeft - dt);
        render();

        if(timeLeft <= 0){
          endGame();
        }
      }, 50);
    }

    /************************************************************
     * 8) Start / Pause / Reset / End
     ************************************************************/
    function startGame(){
      if(gameRunning) return;

      gameRunning = true;
      startBtn.disabled = true;
      pauseBtn.disabled = false;

      setToast("Game started! Click the target as fast as you can.");

      render();
      startCountdown();

      // Spawn the first target immediately
      showTarget();

      // Also schedule the next spawn after each hit/miss
      // (the schedule is triggered in the event handlers too)
    }

    function pauseGame(){
      if(!gameRunning) return;

      gameRunning = false;
      startBtn.disabled = false;
      pauseBtn.disabled = true;

      clearInterval(countdownTimerId);
      clearTimeout(spawnTimeoutId);
      hideTarget();

      setToast("Paused. Click Start to continue.");
    }

    function resetGame(){
      gameRunning = false;

      clearInterval(countdownTimerId);
      clearTimeout(spawnTimeoutId);

      score = 0;
      timeLeft = INITIAL_TIME;

      startBtn.disabled = false;
      pauseBtn.disabled = true;

      hideTarget();
      render();
      setToast("Reset complete. Click Start when you’re ready.");
    }

    function endGame(){
      gameRunning = false;

      clearInterval(countdownTimerId);
      clearTimeout(spawnTimeoutId);

      hideTarget();
      startBtn.disabled = false;
      pauseBtn.disabled = true;

      maybeSaveBest();

      setToast(`Time! Final score: ${score}. Click Start to play again.`);
      render();
    }

    /************************************************************
     * 9) Input: clicking target = hit, clicking arena = miss
     ************************************************************/
    target.addEventListener("click", (e) => {
      // Stop the click from also counting as an arena miss.
      e.stopPropagation();

      if(!gameRunning) return;

      score += SCORE_PER_HIT;

      // On a hit: move immediately, and schedule the next spawn
      // (We "flash" by hiding briefly, then re-showing)
      hideTarget();

      render();
      scheduleNextSpawn();

      setToast(`Hit! Score: ${score}. Difficulty: ${getDifficulty(score)}.`);
    });

    arena.addEventListener("click", () => {
      if(!gameRunning) return;

      // Miss penalty: subtract time
      timeLeft = Math.max(0, timeLeft - MISS_PENALTY);

      // Also hide and re-spawn to keep the pace moving
      hideTarget();
      render();
      scheduleNextSpawn();

      setToast(`Miss! -${MISS_PENALTY.toFixed(0)}s. Aim for the target.`);
      if(timeLeft <= 0) endGame();
    });

    /************************************************************
     * 10) Control buttons
     ************************************************************/
    startBtn.addEventListener("click", () => startGame());
    pauseBtn.addEventListener("click", () => pauseGame());
    resetBtn.addEventListener("click", () => resetGame());

    /************************************************************
     * 11) Init
     ************************************************************/
    render();
    setToast("Ready. Click Start to begin.");
  </script>
</body>
</html>

Full Explanation (How Each Part Works)

1) The HTML: game UI + arena

  • The arena is a <div> with position: relative;
  • The target is a child <div> with position: absolute;
  • Because the arena is relative, the target’s left/top are measured inside the arena (not the whole page)

That’s a core DOM game trick: relative container + absolutely positioned game objects.


2) The CSS: makes it “feel” like a game

  • Rounded arena, gradients, dashed border = “play area”
  • Target has a radial highlight and “bullseye” dot via ::after
  • transform: translate(-50%, -50%) so left/top represent the center of the target (easier math)

3) The State: variables that represent the game

These lines are the “truth” of your game:

let score = 0;
let timeLeft = 30.0;
let gameRunning = false;

The UI is just a reflection of these values.


4) Rendering: update the DOM from state

render() sets text content and updates the target size.

scoreEl.textContent = score;
timeEl.textContent = `${timeLeft.toFixed(1)}s`;

This is the render step of the game loop.


5) Movement: random target positions

We pick a random x,y inside the arena.

We also compute a “padding” so it stays inside:

const padding = size / 2;
const x = randomBetween(padding, rect.width - padding);

Because we position by center, we keep the center away from edges.


6) Timing: countdown and spawning

We use two timers:

  • setInterval every 50ms to reduce timeLeft
  • setTimeout to control when the target appears next

Spawn gets faster as score increases:

return Math.max(250, 900 - score * 20);

This is difficulty scaling using math (simple and effective).


7) Input: hit vs miss

Two click listeners:

  • Clicking the target increases score
  • Clicking the arena (anywhere else) reduces time

Important detail:

e.stopPropagation();

Without it, clicking the target would bubble up and also trigger the arena’s click, counting as a miss.


8) Saving best score with localStorage

localStorage persists across refresh.

localStorage.setItem("dom_game_best_score", String(score));

Perfect for simple games.


Challenges: Level Up Your Game

Here are easy upgrades (each teaches a real game-dev concept):

  1. Combo streaks: consecutive hits increase points (2x, 3x…)
  2. Lives instead of time: miss = lose a life
  3. Multiple targets: spawn 2–4 targets at once
  4. Power-ups: a gold target that adds +3 seconds
  5. Mobile support: add pointerdown for touch + mouse
  6. High score table: store an array of best runs with dates
  7. Animations: fade in/out the target using CSS transitions