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:
- State (data): score, time, level, gameRunning
- Input: click, keypress, touch
- Rules: hit = +points, miss = -time, speed up, etc.
- Render (DOM updates): move target, update score text, update timer
- Timing:
setInterval/setTimeout/requestAnimationFrame
We’ll use:
- DOM selection:
document.querySelector(...) - Events:
addEventListener("click", ...) - Timers:
setIntervalfor the countdown,setTimeoutfor 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>withposition: relative; - The target is a child
<div>withposition: absolute; - Because the arena is relative, the target’s
left/topare 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:
setIntervalevery 50ms to reduce timeLeftsetTimeoutto 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):
- Combo streaks: consecutive hits increase points (2x, 3x…)
- Lives instead of time: miss = lose a life
- Multiple targets: spawn 2–4 targets at once
- Power-ups: a gold target that adds +3 seconds
- Mobile support: add
pointerdownfor touch + mouse - High score table: store an array of best runs with dates
- Animations: fade in/out the target using CSS transitions