Setup
Get a folder, a terminal and a running server. Everything after this is code.
1. Download the starter
Grab starter.zip from the course folder and unzip it somewhere you
will find again. Your Desktop is fine. You should end up with:
whack-a-mole/ index.html <- already written for you styles/main.css <- already written for you assets/ <- 60 sprites and 12 sounds src/ config/ <- empty files, waiting for you core/ entities/ systems/ ui/ scenes/ main.js
The .js files are there but empty. Over the next eleven lessons you
will fill every one of them.
You are here to learn to program, not to spend twenty minutes unzipping sprites into the right folder. The artwork and sound are done. Everything that makes the game work is yours to write.
2. Open a terminal in that folder
In VS Code: Terminal → New Terminal. It opens already pointed at your project.
Anywhere else, open your terminal and cd to the folder:
cd ~/Desktop/whack-a-mole
Check you are in the right place. This should list index.html:
ls # macOS / Linux dir # Windows
3. Start a server
npx serve
Say yes if it asks to install. You will see something like
http://localhost:3000 — open it. You get a blank page. That is
correct: you have not written any code yet.
Try it. The page tells you to start a server, and the console
(F12) shows the actual reason: a CORS error.
This project uses ES modules (import / export). Browsers
refuse to load modules from the file:// protocol for security reasons
— a page opened from your disk could otherwise read other files on your disk.
A server gives the browser a real http:// origin, and the rules change.
This is not this project being awkward. Every real front-end project works this way, which is why every one of them has a dev server.
4. Keep it running
Leave that terminal alone for the rest of the course. Every time you save a file, switch to the browser and refresh. That is your loop: paste → save → refresh.
Get npx serve running and the blank page open. Then open the
browser console (F12) and leave it open — for the next hour it is
the first place you look when something breaks.
The numbers
Five files of pure data. No logic anywhere — and that is the point.
We start here because nothing depends on it, and because it shows you the shape of the whole game before you write a line of logic.
Everything a designer might want to change lives in src/config/: colours,
points, timings, how often each mole shows up. Someone could rebalance this entire
game without reading a single if statement.
difficulty.js carefully
Notice the values are pairs: spawnInterval: [0.78, 0.42]. The first
applies at the start of a round, the second at the end, and the game slides between
them as the clock runs down.
That is why a round feels like it tightens. Difficulty is a curve, not a switch — and two numbers in an array is the entire implementation.
moleTypes.js
weight is each mole's share of spawns. The four weights are 68, 11,
11 and 10, so a plain mole turns up about two-thirds of the time.
This one table ends up feeding the spawner, the How to Play screen, the in-game legend and the coloured ring on the board. One source of truth, four places that can never disagree.
Create and paste these 5 files
Each block is one complete file. Create it at the path shown, paste, save.
/** * Colours lifted directly out of the source asset sheet so anything we draw by * hand (panels, HUD, text) sits next to the artwork without clashing. */ export const Palette = { // Page / panel surfaces lavender: "#ede8fe", lavenderDeep: "#dbd4fd", cream: "#fdf7ec", white: "#ffffff", // Button + accent fills (mirrors the sheet's UI ELEMENTS block) mint: "#a2ebcd", mintDark: "#6fcfa8", violet: "#c4bdf1", violetDark: "#9a90d8", peach: "#feccab", peachDark: "#f0a273", coral: "#feb2a4", coralDark: "#ef8172", sage: "#abceae", plum: "#b4aacf", pink: "#e8afb6", // World sky: "#a2d8fa", grass: "#a6dbaf", grassLight: "#d9fbea", soil: "#8a6a4f", soilDark: "#6b4f39", // Characters mole: "#d0a092", gold: "#fdd87d", goldDark: "#e9a93b", heart: "#dd4a5d", // Ink ink: "#4a4358", inkSoft: "#7b7192", outline: "#5b5270", shadow: "rgba(74, 67, 88, 0.22)", }; /** Semantic aliases so scenes read intent, not hex codes. */ export const Theme = { scoreGood: Palette.mint, scoreGreat: Palette.gold, scoreBad: Palette.coral, panelFill: Palette.lavender, panelStroke: Palette.outline, textPrimary: Palette.ink, textMuted: Palette.inkSoft, };
/** * Central tuning file. * * Anything a designer would want to fiddle with lives here rather than being * scattered through gameplay code. Scenes and systems import this; they never * hard-code magic numbers. */ export const GameConfig = { /** * Virtual resolution. * * Height is fixed so gameplay is identical on every screen; width follows the * window's aspect ratio so the canvas fills it edge-to-edge without bars. * See core/Viewport.js. */ view: { designWidth: 1280, designHeight: 720, /** Clamps for very narrow or very wide windows (bars appear beyond these). */ minWidth: 960, maxWidth: 2400, maxDpr: 2, }, /** Upper bound on a single frame's delta, so tab-switching cannot teleport moles. */ loop: { maxDelta: 1 / 20, }, /** * 3x3 board geometry, in virtual pixels. * * Hole sprites keep their own aspect ratio: only `tileWidth` is specified and * the height follows from the artwork, so swapping hole art cannot squash it. */ board: { columns: 3, rows: 3, tileWidth: 248, /** Horizontal / vertical distance between hole centres. */ columnStep: 296, rowStep: 152, /** Lower rows read as "closer": wider apart and slightly larger. */ rowSpread: 26, rowScale: 0.075, /** Viewport width the grid is designed for; narrower screens scale it down. */ fitWidth: 1080, /** Vertical centre of the board; horizontal centre follows the viewport. */ centerY: 442, /** Centre of the hole's mouth, as a fraction of the sprite's height. */ rimRatio: 0.5, /** Where a raised mole's feet rest, relative to the hole centre. Tuned so the * mole's own dirt mound covers the hole's front lip. */ standRatio: 0.34, }, /** Mole rise / retreat animation timings, in seconds. */ mole: { riseTime: 0.22, retreatTime: 0.22, hitTime: 0.42, /** Idle bob applied while a mole is fully up. */ bobAmplitude: 5, bobSpeed: 3.4, /** Squash-and-settle played as a mole finishes rising. */ settleTime: 0.2, settleAmount: 0.07, /** Extra delay before a hole may be reused. */ holeCooldown: 0.25, scale: 1.06, }, /** Hammer cursor behaviour. */ hammer: { swingTime: 0.24, scale: 1.05, /** Pointer-follow rate (1/s). High enough that the hammer never lags a click. */ followRate: 55, /** Nudge applied on top of the per-frame head anchor (see Hammer.js). */ offsetX: 6, offsetY: -4, /** Radius around the pointer that registers a hit on a raised mole. */ hitRadius: 74, powerHitRadius: 132, }, /** Scoring rules. */ scoring: { comboStep: 4, // hits per multiplier step maxMultiplier: 5, /** Combo needed to trigger the power hammer, and how long it lasts. */ powerCombo: 10, powerDuration: 7, powerMultiplier: 2, }, /** Session rules that are not difficulty-specific. */ session: { countdownFrom: 3, endWarningAt: 10, // seconds left when the timer starts pulsing goldenTimeBonus: 1.5, }, /** How many entries the local leaderboard keeps. */ highScores: { limit: 8, }, /** Defaults for the settings screen; persisted overrides live in Storage. */ defaults: { difficulty: "classic", hammerSkin: "standard", musicVolume: 0.6, sfxVolume: 0.8, reducedMotion: false, /** Set once the player has seen the how-to-play card. */ seenRules: false, }, };
import { Palette } from "./palette.js"; /** * The cast of moles. * * `weight` is the base spawn share; the Spawner re-normalises it per difficulty * (see `difficulty.js`, which overrides the bomb share as a run heats up). */ export const MoleTypes = { normal: { id: "normal", label: "Mole", weight: 68, points: 10, /** Multiplies the difficulty's current "time visible" window. */ upTimeScale: 1, scale: 1, riseFrames: ["mole.pop.1", "mole.pop.2", "mole.pop.3", "mole.pop.4", "mole.pop.5"], idleFrames: ["mole.pop.3", "mole.pop.4", "mole.pop.5"], hitFrames: [ "mole.hit.1", "mole.hit.2", "mole.hit.3", "mole.hit.5", "mole.hit.6", "mole.hit.7", "mole.hit.8", ], hitFx: "stars", accent: Palette.mole, sfx: "sfx.whack", }, gold: { id: "gold", label: "Golden Mole", weight: 11, points: 50, upTimeScale: 0.62, scale: 1, /** Rewards a clean hit with extra seconds on the clock. */ timeBonus: true, riseFrames: ["mole.gold.1", "mole.gold.1", "mole.gold.2"], idleFrames: ["mole.gold.1", "mole.gold.2"], hitFrames: ["mole.gold.2"], hitFx: "sparkle", accent: Palette.gold, glow: "rgba(253, 216, 125, 0.55)", sfx: "sfx.gold", }, rocket: { id: "rocket", label: "Rocket Mole", weight: 11, points: 25, upTimeScale: 0.5, scale: 1, /** Bobs much harder — it is meant to be hard to land. */ bobScale: 2.4, /** The only mole that animates its idle: the rocket flap sells the speed. */ idleFps: 5, riseFrames: ["mole.rocket.1", "mole.rocket.2"], idleFrames: ["mole.rocket.1", "mole.rocket.2"], hitFrames: ["mole.hit.4", "mole.hit.9"], hitFx: "stars", accent: Palette.violetDark, sfx: "sfx.whack", }, bomb: { id: "bomb", label: "Grumpy Mole", weight: 10, points: -25, upTimeScale: 1.15, scale: 1, /** Hitting this one costs a life — it is the "do not touch" mole. */ livesDelta: -1, breaksCombo: true, riseFrames: ["mole.angry.1"], idleFrames: ["mole.angry.1"], hitFrames: ["mole.angry.1"], hitFx: "boom", accent: Palette.coralDark, glow: "rgba(239, 129, 114, 0.6)", sfx: "sfx.bomb", }, }; export const MOLE_TYPE_LIST = Object.values(MoleTypes);
import { lerp } from "../core/utils.js"; /** * Difficulty presets. * * Each numeric field is a `[start, end]` pair interpolated across the run, so a * session naturally accelerates from its opening pace to its closing pace. */ export const Difficulties = { chill: { id: "chill", label: "Chill", blurb: "Roomy timing. Good for learning the board.", duration: 75, lives: 3, spawnInterval: [1.15, 0.7], upTime: [1.6, 1.1], maxActive: [2, 3], bombShare: [0.04, 0.1], }, classic: { id: "classic", label: "Classic", blurb: "The arcade pace. Sixty seconds, no mercy.", duration: 60, lives: 3, spawnInterval: [0.95, 0.44], upTime: [1.3, 0.78], maxActive: [2, 4], bombShare: [0.08, 0.18], }, frenzy: { id: "frenzy", label: "Frenzy", blurb: "Five holes hot at once. Bring reflexes.", duration: 45, lives: 3, spawnInterval: [0.7, 0.3], upTime: [1.05, 0.58], maxActive: [3, 5], bombShare: [0.12, 0.24], }, }; export const DIFFICULTY_IDS = Object.keys(Difficulties); export function getDifficulty(id) { return Difficulties[id] ?? Difficulties.classic; } /** * Resolve a preset into the concrete numbers that apply right now. * @param {object} preset one of {@link Difficulties} * @param {number} progress 0..1 through the run */ export function difficultyAt(preset, progress) { const t = Math.min(Math.max(progress, 0), 1); return { spawnInterval: lerp(preset.spawnInterval[0], preset.spawnInterval[1], t), upTime: lerp(preset.upTime[0], preset.upTime[1], t), maxActive: Math.round(lerp(preset.maxActive[0], preset.maxActive[1], t)), bombShare: lerp(preset.bombShare[0], preset.bombShare[1], t), }; }
/** * Asset manifest. * * Keys are stable, meaningful ids used everywhere in game code; values are file * paths produced by `tools/slice_assets.py`. Swapping artwork means editing this * file only. */ const SPRITES = "./assets/sprites"; /** Build `{ prefix.1: path, ... }` for a numbered sprite run. */ function series(prefix, dir, base, count) { const out = {}; for (let i = 1; i <= count; i += 1) out[`${prefix}.${i}`] = `${SPRITES}/${dir}/${base}_${i}.png`; return out; } export const IMAGE_MANIFEST = { // Moles ------------------------------------------------------------------ ...series("mole.pop", "moles", "pop", 5), ...series("mole.hit", "moles", "hit", 9), ...series("mole.gold", "moles", "gold", 2), ...series("mole.rocket", "moles", "rocket", 2), "mole.angry.1": `${SPRITES}/moles/angry_1.png`, // Hammers ---------------------------------------------------------------- ...series("hammer.standard", "hammers", "standard", 4), ...series("hammer.power", "hammers", "power", 4), // Effects ---------------------------------------------------------------- "fx.dirt": `${SPRITES}/fx/dirt_puff.png`, "fx.sparkle": `${SPRITES}/fx/sparkle.png`, "fx.stars": `${SPRITES}/fx/stars.png`, "fx.particles": `${SPRITES}/fx/particles.png`, "fx.boom": `${SPRITES}/fx/boom.png`, // Level ------------------------------------------------------------------ ...series("tile", "level", "tile", 9), ...series("mound", "level", "mound", 3), // Backgrounds ------------------------------------------------------------ "bg.menu": `${SPRITES}/backgrounds/menu.png`, "bg.game": `${SPRITES}/backgrounds/game.png`, // UI --------------------------------------------------------------------- "ui.btn.play": `${SPRITES}/ui/btn_play.png`, "ui.btn.settings": `${SPRITES}/ui/btn_settings.png`, "ui.btn.highscores": `${SPRITES}/ui/btn_highscores.png`, "ui.btn.back": `${SPRITES}/ui/btn_back.png`, "ui.btn.options": `${SPRITES}/ui/btn_options.png`, "ui.tag.gameover": `${SPRITES}/ui/tag_gameover.png`, "ui.panel.result": `${SPRITES}/ui/panel_result.png`, "ui.panel.green": `${SPRITES}/ui/panel_green.png`, "ui.heart": `${SPRITES}/ui/heart.png`, "ui.board.iso": `${SPRITES}/ui/board_iso.png`, "ui.board.result": `${SPRITES}/ui/board_result.png`, }; const AUDIO = "./assets/audio"; /** * Audio manifest — and the game's mix desk. * * Files are produced by `tools/prepare_audio.sh`, which peak-normalises every * effect to the same ceiling. That makes the files *measurably* equal, not * *perceptually* equal: a sharp transient like `sfx.whack` reads as far quieter * than a sustained one like `sfx.bomb` at an identical peak. * * `gain` is where that difference is corrected. Balancing here rather than in * the audio files means the mix can be retuned by editing one number — no * re-encoding, no hunting for a stray `{ volume: 0.5 }` at a call site. It * multiplies with the player's volume setting and with any per-call `volume`, * which is reserved for genuinely dynamic variation. * * A missing file is a no-op rather than a crash, so entries can be added here * before the audio for them exists. */ export const AUDIO_MANIFEST = { // Music sits under the effects on purpose — it is a bed, not a foreground // element. Both tracks are loudness-matched, so they share a gain. "music.menu": { url: `${AUDIO}/music_menu.mp3`, gain: 1 }, "music.game": { url: `${AUDIO}/music_game.mp3`, gain: 1 }, // The payoff sound for the core verb. Loudest thing in the game by design. "sfx.whack": { url: `${AUDIO}/sfx_whack.mp3`, gain: 1 }, // Fires on every spawn — several times a second at high difficulty — so it // is mixed well down. At full gain it becomes a machine gun. "sfx.pop": { url: `${AUDIO}/sfx_pop.mp3`, gain: 0.35 }, // A miss should register without punishing the player's ears. "sfx.miss": { url: `${AUDIO}/sfx_miss.mp3`, gain: 0.6 }, "sfx.gold": { url: `${AUDIO}/sfx_gold.mp3`, gain: 0.85 }, "sfx.bomb": { url: `${AUDIO}/sfx_bomb.mp3`, gain: 0.8 }, "sfx.power": { url: `${AUDIO}/sfx_power.mp3`, gain: 0.9 }, // UI and run bookends. "sfx.click": { url: `${AUDIO}/sfx_click.mp3`, gain: 0.75 }, "sfx.countdown": { url: `${AUDIO}/sfx_countdown.mp3`, gain: 0.65 }, // Cut from the same source as the tick above: see tools/prepare_audio.sh. "sfx.go": { url: `${AUDIO}/sfx_go.mp3`, gain: 0.85 }, "sfx.gameover": { url: `${AUDIO}/sfx_gameover.mp3`, gain: 0.9 }, };
Change Palette.lavender to "#ffe0e9". You will not
see it yet — nothing draws. Make a note to check it in lesson 6.
The toolbox
Four small files the rest of the game leans on constantly.
These are the boring, essential ones. Read the comments as you paste — each solves a specific problem you would otherwise hit later.
utils.js
Maths helpers. clamp keeps a number in range, lerp blends
between two values, the Ease functions turn straight-line motion into
something that feels alive. Ease.outBack is the little overshoot that
makes buttons pop.
Viewport.js
All game code works in a pretend screen that is always 720 tall, with the width following your window's shape. A wider monitor gets a wider world instead of black bars, and because the height never changes, the board and the moles are exactly the same size for everyone.
EventBus.js
Forty-four lines that stop the codebase turning into spaghetti. The scoring system
announces things like combo:milestone; whatever cares subscribes. Neither
side holds a reference to the other.
Storage.js
A wrapper over localStorage that cannot throw. In private browsing
mode, storage is blocked and every read raises an exception — inside a render
loop that kills the frame. This returns a default instead.
Read Viewport.width at the moment you draw. Never save it in a
variable for later, because it changes whenever the window does. Anything positioned
once needs repositioning in a layout() function — you will meet that
pattern in lesson 10, and it is where most layout bugs come from.
Create and paste these 4 files
Each block is one complete file. Create it at the path shown, paste, save.
/** Small maths / random helpers shared across the engine. */ export const clamp = (v, min, max) => (v < min ? min : v > max ? max : v); export const lerp = (a, b, t) => a + (b - a) * t; /** Frame-rate independent approach towards a target ("exponential smoothing"). */ export const damp = (a, b, smoothing, dt) => lerp(a, b, 1 - Math.pow(smoothing, dt)); /** * Frame-rate independent follow, parameterised by a *rate* in units of 1/second * rather than an opaque smoothing constant. Higher = snappier. * * approach(x, target, 30, dt) // ~5% of the gap left after 100ms */ export const approach = (a, b, rate, dt) => lerp(a, b, 1 - Math.exp(-rate * dt)); export const randRange = (min, max) => min + Math.random() * (max - min); export const randInt = (min, max) => Math.floor(randRange(min, max + 1)); export const pick = (list) => list[Math.floor(Math.random() * list.length)]; /** * Weighted pick. * @param {Array<{weight:number}>} items */ export function weightedPick(items) { const total = items.reduce((sum, item) => sum + item.weight, 0); let roll = Math.random() * total; for (const item of items) { roll -= item.weight; if (roll <= 0) return item; } return items[items.length - 1]; } export const shuffle = (list) => { const out = [...list]; for (let i = out.length - 1; i > 0; i -= 1) { const j = randInt(0, i); [out[i], out[j]] = [out[j], out[i]]; } return out; }; /** Easing curves — named after what they feel like, not the maths. */ export const Ease = { linear: (t) => t, outCubic: (t) => 1 - Math.pow(1 - t, 3), inCubic: (t) => t * t * t, inOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2), outBack: (t) => { const c = 1.9; return 1 + (c + 1) * Math.pow(t - 1, 3) + c * Math.pow(t - 1, 2); }, outElastic: (t) => { if (t === 0 || t === 1) return t; return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1; }, /** 0 -> 1 -> 0, handy for one-shot pops. */ pulse: (t) => Math.sin(Math.PI * clamp(t, 0, 1)), }; /** Axis-aligned hit test used by every UI widget. */ export const pointInRect = (px, py, x, y, w, h) => px >= x && px <= x + w && py >= y && py <= y + h; export const distance = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by); /** `74` -> `"01:14"` */ export function formatClock(seconds) { const total = Math.max(0, Math.ceil(seconds)); const m = String(Math.floor(total / 60)).padStart(2, "0"); const s = String(total % 60).padStart(2, "0"); return `${m}:${s}`; } /** `12400` -> `"12,400"` */ export const formatNumber = (value) => Math.round(value).toLocaleString("en-US");
import { GameConfig } from "../config/gameConfig.js"; import { clamp } from "./utils.js"; /** * The live virtual viewport. * * The game is drawn in a virtual coordinate space with a **fixed height** and a * **width that follows the window's aspect ratio**. That is what lets the canvas * fill any screen edge-to-edge with no letterbox bars: on a wide monitor the * world simply gets wider, rather than being pillarboxed. * * Height stays fixed so that gameplay is identical on every screen — the board, * the moles and the hammer are always the same size relative to the play area, * and nobody gets an advantage from a bigger monitor. * * `Renderer.resize()` is the only writer. Everything else reads: * * import { Viewport } from "../core/Viewport.js"; * renderer.text("hi", Viewport.centerX, 100); * * Read it at *render* time, not once at construction, because it changes when * the window changes. */ export const Viewport = { width: GameConfig.view.designWidth, height: GameConfig.view.designHeight, get centerX() { return this.width / 2; }, get centerY() { return this.height / 2; }, /** Extra width beyond the 16:9 design size — useful for placing decoration. */ get overflowX() { return Math.max(0, this.width - GameConfig.view.designWidth); }, /** * Recompute for a window of `cssWidth` x `cssHeight`. * @returns {boolean} true when the virtual size actually changed */ updateFor(cssWidth, cssHeight) { const { designHeight, minWidth, maxWidth } = GameConfig.view; const aspect = cssWidth / Math.max(1, cssHeight); const width = Math.round(clamp(designHeight * aspect, minWidth, maxWidth)); if (width === this.width && this.height === designHeight) return false; this.width = width; this.height = designHeight; return true; }, };
/** * Minimal publish/subscribe bus. * * Used so systems can announce things ("mole:hit", "combo:up") without holding * references to whoever cares — the HUD, audio and particles all listen in. */ export class EventBus { #listeners = new Map(); /** * @param {string} event * @param {(payload:any)=>void} handler * @returns {() => void} unsubscribe */ on(event, handler) { if (!this.#listeners.has(event)) this.#listeners.set(event, new Set()); this.#listeners.get(event).add(handler); return () => this.off(event, handler); } once(event, handler) { const off = this.on(event, (payload) => { off(); handler(payload); }); return off; } off(event, handler) { this.#listeners.get(event)?.delete(handler); } emit(event, payload) { const handlers = this.#listeners.get(event); if (!handlers) return; // Copy first: a handler is allowed to unsubscribe during dispatch. for (const handler of [...handlers]) handler(payload); } clear(event) { if (event) this.#listeners.delete(event); else this.#listeners.clear(); } }
/** * Namespaced localStorage wrapper. * * Every read is defensive: private-mode browsers and corrupted values fall back * to the supplied default instead of throwing mid-frame. */ const PREFIX = "wam:"; export const Storage = { get(key, fallback = null) { try { const raw = window.localStorage.getItem(PREFIX + key); return raw === null ? fallback : JSON.parse(raw); } catch { return fallback; } }, set(key, value) { try { window.localStorage.setItem(PREFIX + key, JSON.stringify(value)); return true; } catch { return false; } }, remove(key) { try { window.localStorage.removeItem(PREFIX + key); } catch { /* ignore */ } }, }; /** * Player settings, persisted between sessions. * Unknown keys from an older build are dropped by the explicit merge. */ export class SettingsStore { constructor(defaults) { this.defaults = { ...defaults }; this.values = { ...defaults, ...(Storage.get("settings", {}) ?? {}) }; } get(key) { return this.values[key] ?? this.defaults[key]; } set(key, value) { this.values[key] = value; Storage.set("settings", this.values); return value; } reset() { this.values = { ...this.defaults }; Storage.set("settings", this.values); } } /** Local leaderboard, newest-best first. */ export class HighScoreStore { constructor(limit = 8) { this.limit = limit; this.entries = Storage.get("highscores", []) ?? []; } /** @returns {number} 0-based rank if the run made the table, else -1 */ submit({ score, difficulty, combo, accuracy }) { const entry = { score: Math.round(score), difficulty, combo, accuracy, date: Date.now(), }; const next = [...this.entries, entry] .sort((a, b) => b.score - a.score) .slice(0, this.limit); const rank = next.indexOf(entry); this.entries = next; Storage.set("highscores", next); return rank; } best(difficulty) { const pool = difficulty ? this.entries.filter((e) => e.difficulty === difficulty) : this.entries; return pool.length ? Math.max(...pool.map((e) => e.score)) : 0; } clear() { this.entries = []; Storage.set("highscores", []); } }
Open utils.js and find Ease.outBack. The
1.70158 in there is a magic number from the standard easing set. Change
it to 4 and remember to look at the menu buttons in lesson 11.
Loading and drawing
The biggest file in the project, and the one that makes everything else short.
Renderer.js is 350 lines and worth every one. It wraps the browser's
canvas API in a small vocabulary so that screen code reads like layout instead of
boilerplate.
| You write | Instead of |
|---|---|
renderer.panel(x, y, w, h, {...}) | ~15 lines of path, arc, fill, stroke and shadow calls |
renderer.text("SCORE", x, y, {...}) | font strings, align, baseline, stroke, fill, letterSpacing |
renderer.withOffset(dx, dy, fn) | save / translate / draw / restore, everywhere you want shake |
spriteClipped is the trick of the
whole game
There is no half-mole sprite. A mole is a complete image, drawn one full sprite-height below the hole, then slid upward — and everything below the hole's rim is clipped away and never drawn.
That is the entire "climbing out of a hole" effect. The seam is hidden because each mole sprite carries its own little mound of dirt.
Device pixel ratio
Renderer.resize() handles retina screens. The canvas gets a backing
store two or three times bigger than its CSS size, then everything is scaled up to
match — which is why the artwork is crisp instead of blurry on a good display.
Create and paste these 2 files
Each block is one complete file. Create it at the path shown, paste, save.
/** * Image loader with progress reporting. * * Loading is deliberately fault tolerant: one broken path should degrade a * single sprite, not black-screen the whole game. Missing keys are reported via * `errors` so the boot scene can surface them during development. */ export class AssetLoader { #images = new Map(); constructor() { this.errors = []; this.loaded = 0; this.total = 0; } /** * @param {Record<string,string>} manifest key -> url * @param {(progress:number, key:string)=>void} [onProgress] 0..1 */ async loadImages(manifest, onProgress) { const entries = Object.entries(manifest); this.total += entries.length; await Promise.all( entries.map(async ([key, url]) => { try { this.#images.set(key, await loadImage(url)); } catch (error) { this.errors.push({ key, url, error }); } finally { this.loaded += 1; onProgress?.(this.total ? this.loaded / this.total : 1, key); } }), ); return this; } /** @returns {HTMLImageElement|undefined} */ image(key) { return this.#images.get(key); } /** Resolve a list of keys to images, dropping any that failed to load. */ images(keys) { return keys.map((key) => this.#images.get(key)).filter(Boolean); } has(key) { return this.#images.has(key); } } function loadImage(url) { return new Promise((resolve, reject) => { const img = new Image(); img.decoding = "async"; img.onload = () => resolve(img); img.onerror = () => reject(new Error(`Failed to load ${url}`)); img.src = url; }); }
import { GameConfig } from "../config/gameConfig.js"; import { Palette } from "../config/palette.js"; import { Viewport } from "./Viewport.js"; /** * Canvas 2D renderer. * * Responsibilities: * - own the device-pixel-ratio + letterbox transform, so game code can think in * a fixed 1280x720 space and never in CSS pixels; * - offer a small drawing vocabulary (sprite / text / panel) that matches the * art style, so scenes stay declarative. */ export class Renderer { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext("2d", { alpha: false }); this.scale = 1; this.offsetX = 0; this.offsetY = 0; this.dpr = 1; this.resize(); } /** Current virtual width — flexes with the window's aspect ratio. */ get width() { return Viewport.width; } /** Current virtual height — fixed, so gameplay never changes with screen size. */ get height() { return Viewport.height; } /** * Resize the backing store and recompute the virtual transform. * * The virtual width tracks the window aspect, so `scale` normally comes out * identical on both axes and the canvas is filled edge-to-edge. Bars only * appear at the extreme aspect ratios clamped by `Viewport`. * * @returns {boolean} true when the virtual size changed and scenes should re-lay out */ resize() { const dpr = Math.min(window.devicePixelRatio || 1, GameConfig.view.maxDpr); const cssWidth = this.canvas.clientWidth || window.innerWidth; const cssHeight = this.canvas.clientHeight || window.innerHeight; const changed = Viewport.updateFor(cssWidth, cssHeight); this.canvas.width = Math.round(cssWidth * dpr); this.canvas.height = Math.round(cssHeight * dpr); this.scale = Math.min(cssWidth / this.width, cssHeight / this.height); this.offsetX = (cssWidth - this.width * this.scale) / 2; this.offsetY = (cssHeight - this.height * this.scale) / 2; this.dpr = dpr; return changed; } /** Convert a DOM pointer position into virtual-canvas coordinates. */ screenToWorld(clientX, clientY) { const rect = this.canvas.getBoundingClientRect(); return { x: (clientX - rect.left - this.offsetX) / this.scale, y: (clientY - rect.top - this.offsetY) / this.scale, }; } /** Begin a frame: clear the letterbox and install the virtual transform. */ begin() { const { ctx } = this; ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); ctx.fillStyle = Palette.lavenderDeep; ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); ctx.save(); ctx.translate(this.offsetX, this.offsetY); ctx.scale(this.scale, this.scale); ctx.beginPath(); ctx.rect(0, 0, this.width, this.height); ctx.clip(); } end() { this.ctx.restore(); } /** Paint the whole virtual viewport. */ clear(color = Palette.lavender) { this.ctx.fillStyle = color; this.ctx.fillRect(0, 0, this.width, this.height); } // ---------------------------------------------------------------- sprites /** * Draw a sprite. * @param {HTMLImageElement} image * @param {number} x * @param {number} y * @param {object} [options] * @param {[number, number]} [options.anchor] 0..1 origin inside the sprite; default bottom-centre * @param {number} [options.scale] * @param {number} [options.width] explicit width (overrides scale) * @param {number} [options.rotate] radians * @param {number} [options.alpha] * @param {boolean} [options.flipX] * @param {string} [options.filter] canvas filter, e.g. "brightness(1.1)" * @param {{blur:number,color:string,y?:number}} [options.shadow] */ sprite(image, x, y, options = {}) { if (!image) return; const { anchor = [0.5, 1], scale = 1, width, height, rotate = 0, alpha = 1, flipX = false, filter, shadow, } = options; const w = width ?? (height ? (image.width / image.height) * height : image.width * scale); const h = height ?? (width ? (image.height / image.width) * width : image.height * scale); const { ctx } = this; ctx.save(); ctx.globalAlpha *= alpha; ctx.translate(x, y); if (rotate) ctx.rotate(rotate); if (flipX) ctx.scale(-1, 1); if (filter) ctx.filter = filter; if (shadow) { ctx.shadowColor = shadow.color; ctx.shadowBlur = shadow.blur; ctx.shadowOffsetY = (shadow.y ?? 0) * this.scale * this.dpr; } ctx.drawImage(image, -w * anchor[0], -h * anchor[1], w, h); ctx.restore(); } /** Draw a sprite clipped to a rectangle — used to make moles rise out of holes. */ spriteClipped(image, x, y, clip, options = {}) { const { ctx } = this; ctx.save(); ctx.beginPath(); ctx.rect(clip.x, clip.y, clip.width, clip.height); ctx.clip(); this.sprite(image, x, y, options); ctx.restore(); } // ------------------------------------------------------------------ shapes roundRectPath(x, y, w, h, radius = 16) { const r = Math.min(radius, w / 2, h / 2); const { ctx } = this; ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } /** * A rounded panel in the asset sheet's style: soft fill, dark outline and a * subtle drop shadow. */ panel(x, y, w, h, options = {}) { const { fill = Palette.lavender, stroke = Palette.outline, lineWidth = 4, radius = 22, alpha = 1, shadow = true, inner, } = options; const { ctx } = this; ctx.save(); ctx.globalAlpha *= alpha; if (shadow) { ctx.shadowColor = Palette.shadow; ctx.shadowBlur = 18; ctx.shadowOffsetY = 6; } this.roundRectPath(x, y, w, h, radius); ctx.fillStyle = fill; ctx.fill(); ctx.shadowColor = "transparent"; if (stroke) { ctx.lineWidth = lineWidth; ctx.strokeStyle = stroke; ctx.stroke(); } if (inner) { this.roundRectPath(x + 8, y + 8, w - 16, h - 16, Math.max(radius - 8, 6)); ctx.fillStyle = inner; ctx.fill(); } ctx.restore(); } circle(x, y, radius, options = {}) { const { fill, stroke, lineWidth = 4, alpha = 1 } = options; const { ctx } = this; ctx.save(); ctx.globalAlpha *= alpha; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); if (fill) { ctx.fillStyle = fill; ctx.fill(); } if (stroke) { ctx.lineWidth = lineWidth; ctx.strokeStyle = stroke; ctx.stroke(); } ctx.restore(); } /** * Flattened ring drawn on the ground — marks a hole holding a special mole. * Far more readable at a glance than a glow alone. */ ellipseRing(x, y, radiusX, radiusY, color, options = {}) { const { lineWidth = 6, alpha = 1, dashed = false } = options; const { ctx } = this; ctx.save(); ctx.globalAlpha *= alpha; ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.lineCap = "round"; if (dashed) ctx.setLineDash([18, 14]); ctx.beginPath(); ctx.ellipse(x, y, radiusX, radiusY, 0, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } /** Soft radial glow, used behind special moles and power-ups. */ glow(x, y, radius, color, alpha = 1) { const { ctx } = this; const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius); gradient.addColorStop(0, color); gradient.addColorStop(1, "rgba(255,255,255,0)"); ctx.save(); ctx.globalAlpha *= alpha; ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } /** Full-screen tint, for pause/game-over dimming. */ veil(alpha, color = "#2b2440") { const { ctx } = this; ctx.save(); ctx.globalAlpha = alpha; ctx.fillStyle = color; ctx.fillRect(0, 0, this.width, this.height); ctx.restore(); } // -------------------------------------------------------------------- text /** * Chunky rounded game text with an optional outline, matching the sheet. * @param {object} [options] * @param {number} [options.size] * @param {number|string} [options.weight] * @param {string} [options.color] * @param {string} [options.align] canvas textAlign * @param {string} [options.baseline] canvas textBaseline * @param {string} [options.stroke] outline colour * @param {number} [options.strokeWidth] * @param {number} [options.alpha] * @param {number} [options.letterSpacing] */ text(value, x, y, options = {}) { const { size = 28, weight = 800, color = Palette.ink, align = "center", baseline = "middle", stroke, strokeWidth = 6, alpha = 1, family = '"Baloo 2", "Arial Rounded MT Bold", "Trebuchet MS", sans-serif', shadow, letterSpacing, } = options; const { ctx } = this; ctx.save(); ctx.globalAlpha *= alpha; ctx.font = `${weight} ${size}px ${family}`; ctx.textAlign = align; ctx.textBaseline = baseline; if (letterSpacing !== undefined && "letterSpacing" in ctx) { ctx.letterSpacing = `${letterSpacing}px`; } if (shadow) { ctx.shadowColor = shadow.color ?? Palette.shadow; ctx.shadowBlur = shadow.blur ?? 8; ctx.shadowOffsetY = shadow.y ?? 3; } if (stroke) { ctx.lineWidth = strokeWidth; ctx.lineJoin = "round"; ctx.miterLimit = 2; ctx.strokeStyle = stroke; ctx.strokeText(value, x, y); } ctx.shadowColor = "transparent"; ctx.fillStyle = color; ctx.fillText(value, x, y); ctx.restore(); } measureText(value, options = {}) { const { size = 28, weight = 800, family = '"Baloo 2", "Arial Rounded MT Bold", "Trebuchet MS", sans-serif', } = options; this.ctx.font = `${weight} ${size}px ${family}`; return this.ctx.measureText(value).width; } // ------------------------------------------------------------- transforms /** Run `draw` with a temporary translation — used for screen shake. */ withOffset(dx, dy, draw) { const { ctx } = this; ctx.save(); ctx.translate(dx, dy); draw(); ctx.restore(); } }
In Renderer.js, find spriteClipped and comment
out the ctx.clip() line. Note what you did — in lesson 8 you will
see whole moles floating in front of the holes, and the illusion becomes obvious.
Put it back afterwards.
Input and sound
Turning clicks into game coordinates, and playing sound that might not exist.
Input.js
Your mouse reports a position in CSS pixels somewhere on a web page. The game thinks in its own 720-tall coordinate space. This file runs every pointer event back through the renderer's transform, so game code only ever sees game coordinates.
It uses pointer events rather than mouse events, which means touch and stylus work for free.
AudioManager.js
Two ideas worth stealing for your own projects.
Missing sound is never an error. Game code calls
audio.play("sfx.whack") without ever checking whether the file loaded. If
it did not, the call quietly does nothing. No if, no crash.
Loading is lazy. Registering the manifest only records URLs. A file is not fetched until the sound is actually needed, so sounds you never trigger are never downloaded — and after three failures the bus concludes there is no audio pack and stops asking, which keeps your console clean.
config/assets.js
Look back at AUDIO_MANIFEST. Each sound has a gain.
All twelve files were normalised to the same peak, so they are measurably
equally loud — but a sharp click and a sustained boom at the same peak sound
nothing alike to a human. gain corrects that.
sfx.pop sits at 0.35 because it fires on every single
spawn. At full volume it is a machine gun.
unlock() exists
Browsers refuse to play audio until the user has interacted with the page — otherwise every site would autoplay noise at you. The game unlocks the audio bus on the first click or keypress.
Create and paste these 2 files
Each block is one complete file. Create it at the path shown, paste, save.
/** * Pointer + keyboard input. * * Events are translated into virtual-canvas coordinates and forwarded to the * active scene. Scenes therefore only implement `onPointerDown(point)` style * hooks and never touch the DOM. */ export class Input { constructor(canvas, renderer, sceneManager) { this.canvas = canvas; this.renderer = renderer; this.scenes = sceneManager; /** Latest pointer position in virtual coordinates. */ this.pointer = { x: renderer.width / 2, y: renderer.height / 2, down: false, inside: false }; this.keys = new Set(); this.#bind(); } #bind() { const { canvas } = this; canvas.addEventListener("pointermove", (event) => { this.#updatePointer(event); this.scenes.active?.onPointerMove?.(this.pointer, event); }); canvas.addEventListener("pointerdown", (event) => { this.#updatePointer(event); this.pointer.down = true; canvas.setPointerCapture?.(event.pointerId); this.scenes.active?.onPointerDown?.(this.pointer, event); }); const release = (event) => { this.#updatePointer(event); if (!this.pointer.down) return; this.pointer.down = false; this.scenes.active?.onPointerUp?.(this.pointer, event); }; canvas.addEventListener("pointerup", release); canvas.addEventListener("pointercancel", release); canvas.addEventListener("pointerenter", () => { this.pointer.inside = true; }); canvas.addEventListener("pointerleave", () => { this.pointer.inside = false; this.pointer.down = false; }); // Stop the browser's own gestures from fighting the game. canvas.addEventListener("contextmenu", (event) => event.preventDefault()); canvas.addEventListener("dragstart", (event) => event.preventDefault()); window.addEventListener("keydown", (event) => { this.keys.add(event.code); if (SWALLOWED_KEYS.has(event.code)) event.preventDefault(); this.scenes.active?.onKeyDown?.(event); }); window.addEventListener("keyup", (event) => { this.keys.delete(event.code); this.scenes.active?.onKeyUp?.(event); }); window.addEventListener("blur", () => { this.keys.clear(); this.pointer.down = false; this.scenes.active?.onBlur?.(); }); } #updatePointer(event) { const { x, y } = this.renderer.screenToWorld(event.clientX, event.clientY); this.pointer.x = x; this.pointer.y = y; this.pointer.inside = true; } isDown(code) { return this.keys.has(code); } } /** Keys the game owns outright, so the page never scrolls mid-run. */ const SWALLOWED_KEYS = new Set([ "Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", ]);
/** * Audio bus. * * Every call is safe to make unconditionally: a sound whose file is missing is * a silent no-op, never an error, so gameplay code calls `audio.play("sfx.whack")` * without guarding. * * **Three volumes multiply** to produce the level of any one sound: * * ``` * sfxVolume the player's setting (Settings screen) * x gain the sound's place in the mix (AUDIO_MANIFEST) * x volume per-call dynamic variation (rare) * ``` * * Keeping the mix in the manifest rather than at the call sites means balancing * the game is one file to read, not a grep across every scene. * * **Loading is lazy.** Registering a manifest only records URLs; a file is not * requested until the sound is actually needed. That keeps a silent install from * firing a dozen 404s on every page load, and means sounds you never trigger are * never downloaded. After `MAX_FAILURES` distinct files fail to load the bus * concludes that no audio pack is installed and stops requesting altogether, so * the console stays clean. * * Browsers block audio until the first user gesture, so playback is unlocked on * the first pointer/key event via `unlock()`. */ /** Distinct load failures tolerated before assuming no audio pack is present. */ const MAX_FAILURES = 3; export class AudioManager { /** @type {Map<string,{url:string,gain:number}>} from the manifest */ #sources = new Map(); /** @type {Map<string,HTMLAudioElement>} key -> element, created on demand */ #buffers = new Map(); #missing = new Set(); /** How many files have ever loaded successfully. */ #loaded = 0; #current = null; #currentKey = null; constructor({ musicVolume = 0.6, sfxVolume = 0.8 } = {}) { this.musicVolume = musicVolume; this.sfxVolume = sfxVolume; this.unlocked = false; this.enabled = true; /** Flipped off once enough files have 404'd to prove there is no audio pack. */ this.available = true; } /** * Record where each sound lives and how loud it sits. Nothing is fetched here. * * An entry may be a bare url string when it needs no mix adjustment, or * `{ url, gain }` to place it in the mix. * * @param {Record<string, string | {url: string, gain?: number}>} manifest */ register(manifest) { for (const [key, entry] of Object.entries(manifest)) { const { url, gain = 1 } = typeof entry === "string" ? { url: entry } : entry; this.#sources.set(key, { url, gain }); } return this; } /** This sound's place in the mix; 1 for anything not in the manifest. */ #gain(key) { return this.#sources.get(key)?.gain ?? 1; } /** * Get (and, first time, create) the element for a key. * @returns {HTMLAudioElement|null} null when unavailable */ #load(key) { if (!this.available) return null; if (this.#buffers.has(key)) return this.#buffers.get(key); if (this.#missing.has(key)) return null; const source = this.#sources.get(key); if (!source) return null; const audio = new Audio(); audio.preload = "auto"; audio.addEventListener("loadeddata", () => { this.#loaded += 1; }, { once: true }); audio.addEventListener( "error", () => { this.#buffers.delete(key); this.#missing.add(key); // Counting *successful* loads rather than the buffer map avoids a race // with requests that are still in flight. if (this.available && this.#missing.size >= MAX_FAILURES && this.#loaded === 0) { // Nothing has ever loaded and several files are missing: no audio pack. this.available = false; console.info( "[audio] no sound files found in assets/audio/ — running silent. " + "See assets/audio/README.md for the file list.", ); } }, { once: true }, ); audio.src = source.url; this.#buffers.set(key, audio); return audio; } /** Call from the first user gesture to satisfy autoplay policies. */ unlock() { if (this.unlocked) return; this.unlocked = true; // A track may have been requested before the gesture arrived; start it now. if (this.#currentKey) this.#current?.play().catch(() => {}); } /** One-shot sound effect. Overlapping calls clone the element. */ play(key, { volume = 1, rate = 1 } = {}) { if (!this.enabled || !this.unlocked || !this.available) return; const source = this.#load(key); if (!source) return; const voice = source.cloneNode(true); voice.volume = clamp01(this.sfxVolume * this.#gain(key) * volume); voice.playbackRate = rate; voice.play().catch(() => {}); } /** Looping background track. Switching tracks stops the previous one. */ playMusic(key, { loop = true, volume = 1 } = {}) { if (this.#currentKey === key) return; this.stopMusic(); // Remember the request even when the file is missing, so repeated calls from // a scene's enter() do not thrash. this.#currentKey = key; if (!this.enabled || !this.available) return; const track = this.#load(key); if (!track) return; track.loop = loop; track.volume = clamp01(this.musicVolume * this.#gain(key) * volume); this.#current = track; if (this.unlocked) track.play().catch(() => {}); } stopMusic() { this.#currentKey = null; if (!this.#current) return; this.#current.pause(); this.#current.currentTime = 0; this.#current = null; } setMusicVolume(value) { this.musicVolume = clamp01(value); // Re-apply through the mix, or dragging the slider would discard the // track's gain and snap it to full level. if (this.#current) this.#current.volume = clamp01(this.musicVolume * this.#gain(this.#currentKey)); } setSfxVolume(value) { this.sfxVolume = clamp01(value); } /** Keys that were requested but failed to load — handy for a dev overlay. */ get missing() { return [...this.#missing]; } } const clamp01 = (v) => Math.min(1, Math.max(0, v));
Nothing to break here yet. Read the comment block at the top of
AudioManager.js — it explains the three volumes that multiply
together to produce what you actually hear.
Screens and the loop
After this lesson, refresh your browser. Something finally happens.
This is the halfway point and the payoff. Paste these five files, save, refresh — and you get a loading bar that fills as the sprites arrive.
The loop
Every game ever made is these three lines repeating sixty times a second. In
Game.js:
const dt = clamp((now - this.lastTime) / 1000, 0, GameConfig.loop.maxDelta); this.scenes.update(dt); // move everything a little this.scenes.render(r); // draw everything
What dt is
Delta time — seconds since the last frame, about 0.016 on a 60Hz
screen. Every bit of movement is multiplied by it. Without it your game runs at double
speed on a 120Hz monitor and half speed on a slow laptop.
It caps dt at 1/20th of a second. Switch tabs for a minute and
now - lastTime is sixty seconds — without the cap, every mole would
live and die inside a single frame. The cap turns a catastrophe into a stutter.
Scenes
A scene is one screen with four functions: enter, update,
render, exit. The manager creates a fresh scene object every
visit, which is why restarting a round is just goto("game") —
there is no leftover state to reset.
Refresh. You should see a loading bar. If you changed Palette.lavender
in lesson 2, the background is now pink.
Then open the console and type game. The whole game object is exposed
on purpose — game.scenes.activeName tells you which screen you are
on.
Console first. Failed to resolve module specifier means a filename is
misspelled. Unexpected token means a paste went wrong — the error
names the file and the line.
Create and paste these 5 files
Each block is one complete file. Create it at the path shown, paste, save.
/** * Base class for every screen in the game. * * A scene owns its own widgets and state, and receives the shared services * (assets, audio, settings, bus) through `game`. Lifecycle: * * enter(params) -> update(dt) / render(r) each frame -> exit() * * Input hooks are optional; the SceneManager only calls what a scene defines. */ export class Scene { /** @param {import("./Game.js").Game} game */ constructor(game) { this.game = game; /** Elapsed seconds since this scene became active. */ this.age = 0; } // Convenience accessors — scenes read a lot and wire very little. get assets() { return this.game.assets; } get audio() { return this.game.audio; } get settings() { return this.game.settings; } get bus() { return this.game.bus; } get input() { return this.game.input; } /** @param {any} [params] payload passed by whoever switched to this scene */ enter(_params) {} exit() {} /** @param {number} dt seconds since last frame */ update(_dt) {} /** @param {import("./Renderer.js").Renderer} _renderer */ render(_renderer) {} /** Switch to another registered scene. */ goto(name, params) { this.game.scenes.switchTo(name, params); } }
import { clamp } from "./utils.js"; /** * Owns the scene registry, the active scene and cross-fade transitions. * * Scenes are registered as factories so each visit gets a clean instance — no * stale state carried between runs. */ export class SceneManager { #factories = new Map(); #instances = new Map(); constructor(game, { fadeDuration = 0.28 } = {}) { this.game = game; this.active = null; this.activeName = null; this.fadeDuration = fadeDuration; /** Transition state: null | { phase: "out"|"in", t, name, params } */ this.transition = null; } /** * @param {string} name * @param {new (game:any)=>import("./Scene.js").Scene} SceneClass * @param {{persist?:boolean}} [options] persist reuses one instance */ register(name, SceneClass, options = {}) { this.#factories.set(name, { SceneClass, persist: options.persist ?? false }); return this; } /** Immediate switch, no fade. Used for the very first scene. */ switchNow(name, params) { const scene = this.#instantiate(name); this.active?.exit?.(); this.active = scene; this.activeName = name; scene.age = 0; scene.enter?.(params); } /** Fade out, swap, fade in. Repeat calls while fading are ignored. */ switchTo(name, params) { if (this.transition) return; if (!this.#factories.has(name)) { console.warn(`[SceneManager] unknown scene "${name}"`); return; } this.transition = { phase: "out", t: 0, name, params }; } /** Tell the active scene the virtual viewport changed, so it can re-lay out. */ resize() { this.active?.onResize?.(); } update(dt) { if (this.transition) { const step = dt / this.fadeDuration; this.transition.t += step; if (this.transition.t >= 1) { if (this.transition.phase === "out") { this.switchNow(this.transition.name, this.transition.params); this.transition = { ...this.transition, phase: "in", t: 0 }; } else { this.transition = null; } } } if (this.active) { this.active.age += dt; this.active.update?.(dt); } } render(renderer) { this.active?.render?.(renderer); if (this.transition) { const t = clamp(this.transition.t, 0, 1); const alpha = this.transition.phase === "out" ? t : 1 - t; renderer.veil(alpha, "#2b2440"); } } #instantiate(name) { const entry = this.#factories.get(name); if (!entry) throw new Error(`Unknown scene "${name}"`); if (entry.persist) { if (!this.#instances.has(name)) this.#instances.set(name, new entry.SceneClass(this.game)); return this.#instances.get(name); } return new entry.SceneClass(this.game); } }
import { GameConfig } from "../config/gameConfig.js"; import { AUDIO_MANIFEST } from "../config/assets.js"; import { AssetLoader } from "./AssetLoader.js"; import { AudioManager } from "./AudioManager.js"; import { EventBus } from "./EventBus.js"; import { Input } from "./Input.js"; import { Renderer } from "./Renderer.js"; import { SceneManager } from "./SceneManager.js"; import { HighScoreStore, SettingsStore } from "./Storage.js"; import { clamp } from "./utils.js"; /** * Composition root. * * Wires the services together, owns the requestAnimationFrame loop, and hands * everything to scenes. Nothing in here knows what a mole is. */ export class Game { constructor(canvas) { this.canvas = canvas; this.renderer = new Renderer(canvas); this.bus = new EventBus(); this.assets = new AssetLoader(); this.settings = new SettingsStore(GameConfig.defaults); this.highScores = new HighScoreStore(GameConfig.highScores.limit); this.audio = new AudioManager({ musicVolume: this.settings.get("musicVolume"), sfxVolume: this.settings.get("sfxVolume"), }).register(AUDIO_MANIFEST); this.scenes = new SceneManager(this); this.input = new Input(canvas, this.renderer, this.scenes); /** Seconds since the game started — useful for ambient animation. */ this.time = 0; this.running = false; this.paused = false; this.#bindWindow(); } /** Register scenes up front so any scene can navigate to any other by name. */ registerScenes(map) { for (const [name, SceneClass] of Object.entries(map)) this.scenes.register(name, SceneClass); return this; } /** * Toggle real browser fullscreen. * * The canvas already fills its window edge-to-edge; this removes the browser * chrome as well. Must be called from a user gesture or the browser refuses. */ toggleFullscreen() { if (document.fullscreenElement) { document.exitFullscreen?.().catch(() => {}); } else { document.documentElement.requestFullscreen?.().catch(() => {}); } } get isFullscreen() { return Boolean(document.fullscreenElement); } /** * Hide or show the OS cursor over the canvas. * Scenes that draw their own cursor (the hammer) hide it; menus restore it. */ setPointerVisible(visible) { this.canvas.classList.toggle("hide-cursor", !visible); } start(firstScene, params) { this.scenes.switchNow(firstScene, params); this.running = true; this.lastTime = performance.now(); requestAnimationFrame(this.#frame); } stop() { this.running = false; } #frame = (now) => { if (!this.running) return; requestAnimationFrame(this.#frame); const dt = clamp((now - this.lastTime) / 1000, 0, GameConfig.loop.maxDelta); this.lastTime = now; if (!this.paused) { this.time += dt; this.scenes.update(dt); } this.renderer.begin(); this.scenes.render(this.renderer); this.renderer.end(); }; #bindWindow() { const resize = () => { // Renderer.resize() reports whether the *virtual* size changed; only then // do scenes need to reposition their widgets. if (this.renderer.resize()) this.scenes.resize(); }; window.addEventListener("resize", resize); window.addEventListener("orientationchange", resize); document.addEventListener("fullscreenchange", resize); // A ResizeObserver on the canvas itself is the reliable signal: it fires // after layout has settled, and catches size changes (entering fullscreen, // a devtools dock) that never raise a window resize event. if (window.ResizeObserver) { new ResizeObserver(resize).observe(this.canvas); } // Unlock audio on the first gesture — required by browser autoplay rules. const unlock = () => this.audio.unlock(); window.addEventListener("pointerdown", unlock, { once: true }); window.addEventListener("keydown", unlock, { once: true }); // F toggles fullscreen from any screen. window.addEventListener("keydown", (event) => { if (event.code === "KeyF" && !event.metaKey && !event.ctrlKey && !event.altKey) { event.preventDefault(); this.toggleFullscreen(); } }); // Auto-pause when the tab is hidden so a run cannot tick away unseen. document.addEventListener("visibilitychange", () => { if (document.hidden) this.bus.emit("window:hidden"); this.lastTime = performance.now(); }); } }
import { IMAGE_MANIFEST } from "../config/assets.js"; import { GameConfig } from "../config/gameConfig.js"; import { Palette } from "../config/palette.js"; import { Scene } from "../core/Scene.js"; import { clamp, damp } from "../core/utils.js"; import { Viewport } from "../core/Viewport.js"; /** * Loading screen. * * Runs before any artwork exists, so it draws with primitives only. It holds the * bar on screen for a beat after loading finishes so the transition never * flashes past on a fast connection. */ export class BootScene extends Scene { enter() { this.progress = 0; this.target = 0; this.ready = false; this.failed = null; this.assets .loadImages(IMAGE_MANIFEST, (value) => { this.target = value; }) .then(() => { this.ready = true; if (this.assets.errors.length) { console.warn("[boot] some sprites failed to load", this.assets.errors); } }) .catch((error) => { this.failed = error; console.error("[boot] asset loading failed", error); }); } update(dt) { this.progress = damp(this.progress, this.target, 0.002, dt); if (this.ready && this.progress > 0.985 && this.age > 0.7) { this.goto("menu", { firstRun: true }); } } render(renderer) { const { width, height } = Viewport; renderer.clear(Palette.lavender); renderer.text("WHACK-A-MOLE", width / 2, height / 2 - 70, { size: 62, color: Palette.violetDark, stroke: Palette.white, strokeWidth: 10, }); const barWidth = 460; const barX = (width - barWidth) / 2; const barY = height / 2 + 10; renderer.panel(barX, barY, barWidth, 30, { fill: Palette.white, stroke: Palette.outline, lineWidth: 4, radius: 15, }); renderer.panel(barX + 4, barY + 4, Math.max(8, (barWidth - 8) * clamp(this.progress, 0, 1)), 22, { fill: Palette.mint, stroke: null, radius: 11, shadow: false, }); const label = this.failed ? "Could not load artwork — check the console" : `Loading assets… ${Math.round(this.progress * 100)}%`; renderer.text(label, width / 2, barY + 68, { size: 22, color: this.failed ? Palette.coralDark : Palette.inkSoft, weight: 700, }); } }
import { Game } from "./core/Game.js"; import { BootScene } from "./scenes/BootScene.js"; import { GameOverScene } from "./scenes/GameOverScene.js"; import { GameScene } from "./scenes/GameScene.js"; import { HighScoresScene } from "./scenes/HighScoresScene.js"; import { MenuScene } from "./scenes/MenuScene.js"; import { RulesScene } from "./scenes/RulesScene.js"; import { SettingsScene } from "./scenes/SettingsScene.js"; /** * Entry point. * * Creates the game, registers every scene under a stable name, and kicks off the * boot sequence. Scene names used here are the ones `scene.goto(name)` expects. */ function main() { const canvas = document.getElementById("game"); if (!(canvas instanceof HTMLCanvasElement)) throw new Error("Canvas #game not found"); const game = new Game(canvas); game.registerScenes({ boot: BootScene, menu: MenuScene, game: GameScene, gameover: GameOverScene, highscores: HighScoresScene, settings: SettingsScene, rules: RulesScene, }); game.start("boot"); // Handy for tinkering from the devtools console during the course. window.game = game; } try { main(); } catch (error) { console.error(error); const fatal = document.getElementById("fatal"); if (fatal) { fatal.hidden = false; fatal.textContent = `Failed to start: ${error.message}`; } }
Get the loading bar on screen. Then in the console run
game.assets.progress and game.scenes.activeName. If both
answer, your engine works.
The board
Nine holes, arranged so the front row looks closer than the back.
Hole.js is one cell: where it sits, how big it is, and whether someone
is currently in it. Board.js is the 3×3 grid of them.
Fake perspective, cheaply
Look at rowSpread and rowScale in gameConfig.js.
Lower rows are spread slightly wider apart and drawn slightly larger. That is the
entire 3D effect — two numbers, no maths.
Draw order gives you depth for free
Board.render goes row by row, back to front: tiles for row 0, then moles
for row 0, then row 1, and so on. A mole in a front row therefore overlaps the row
behind it, with no sorting code anywhere.
standY
Each hole exposes a line where a fully-risen mole's feet rest. Next lesson, the mole uses it as the target it slides up to — and as the bottom edge of the clipping rectangle. Two jobs, one number.
Create and paste these 2 files
Each block is one complete file. Create it at the path shown, paste, save.
import { GameConfig } from "../config/gameConfig.js"; /** * One cell of the board. * * A hole owns its geometry (where the tile is drawn, where the mole's feet sit, * where the clip line runs) and its occupancy. It knows nothing about scoring. */ export class Hole { /** * @param {object} options * @param {number} options.index 0..8, row-major * @param {HTMLImageElement} options.tile */ constructor({ index, column, row, x, y, width, height, tile }) { this.index = index; this.column = column; this.row = row; /** Centre of the tile in virtual pixels. */ this.x = x; this.y = y; this.width = width; this.height = height; this.tile = tile; /** @type {import("./Mole.js").Mole|null} */ this.mole = null; /** Seconds until this hole may be used again. */ this.cooldown = 0; /** Pop animation applied to the tile when something is whacked here. */ this.punch = 0; } /** Y coordinate of the hole's mouth — moles are clipped below this line. */ get rimY() { return this.y - this.height / 2 + this.height * GameConfig.board.rimRatio; } /** Where a fully-raised mole's feet rest — also the line moles are clipped at. */ get standY() { return this.y + this.height * GameConfig.board.standRatio; } get isFree() { return !this.mole && this.cooldown <= 0; } /** Rectangle that hides anything still underground. */ get clipRect() { return { x: this.x - this.width / 2, y: this.y - 520, width: this.width, height: 520 + (this.standY - this.y), }; } update(dt) { if (this.cooldown > 0) this.cooldown -= dt; if (this.punch > 0) this.punch = Math.max(0, this.punch - dt * 3.4); } /** Distance from a point to this hole's mouth — used for hit detection. */ distanceTo(point) { return Math.hypot(point.x - this.x, point.y - this.rimY); } render(renderer) { // A whack squashes the hole horizontally for a frame or two. const squash = 1 + this.punch * 0.06; // No canvas shadow here on purpose: nine blurred shadows per frame is a // measurable cost, and the artwork already has its own dark rim. renderer.sprite(this.tile, this.x, this.y, { anchor: [0.5, 0.5], width: this.width * squash, height: this.height / squash, }); } }
import { GameConfig } from "../config/gameConfig.js"; import { Viewport } from "../core/Viewport.js"; import { Hole } from "./Hole.js"; /** * The 3x3 playfield. * * Builds the hole grid with a light faux-perspective (lower rows are slightly * larger and spread wider) and draws the tiles back-to-front so raised moles * always overlap the row behind them. */ export class Board { /** @param {import("../core/AssetLoader.js").AssetLoader} assets */ constructor(assets) { this.assets = assets; /** @type {Hole[]} */ this.holes = []; this.layout(); } /** * Build (or rebuild) the hole grid for the current viewport. * * Called again on resize: holes keep their identity by index, so a mole that * is mid-rise simply follows its hole to the new position. */ layout() { const assets = this.assets; const { columns, rows, tileWidth, columnStep, rowStep, centerY, rowSpread, rowScale } = GameConfig.board; const centerX = Viewport.centerX; // On narrow viewports the whole grid scales down together so the outer // columns can never run off the edge. const fit = Math.min(1, Viewport.width / GameConfig.board.fitWidth); const existing = this.holes; this.holes = []; for (let row = 0; row < rows; row += 1) { const depth = row - (rows - 1) / 2; const scale = (1 + row * rowScale) * fit; const stepX = (columnStep + row * rowSpread) * fit; const y = centerY + depth * rowStep; for (let column = 0; column < columns; column += 1) { const index = row * columns + column; const tile = assets.image(`tile.${(index % 9) + 1}`); const width = tileWidth * scale; const geometry = { x: centerX + (column - (columns - 1) / 2) * stepX, y, width, // Height follows the artwork so hole sprites are never distorted. height: tile ? (tile.height / tile.width) * width : width * 0.48, }; const previous = existing[index]; if (previous) { // Reposition in place so any mole currently in this hole is unaffected. Object.assign(previous, geometry); this.holes.push(previous); } else { this.holes.push(new Hole({ index, column, row, tile, ...geometry })); } } } } get freeHoles() { return this.holes.filter((hole) => hole.isFree); } get activeMoles() { return this.holes.map((hole) => hole.mole).filter(Boolean); } update(dt) { for (const hole of this.holes) hole.update(dt); } /** Tiles only — moles are drawn per row by {@link renderRow}. */ renderTiles(renderer, row) { for (const hole of this.holes) { if (hole.row === row) hole.render(renderer); } } renderMoles(renderer, row) { for (const hole of this.holes) { if (hole.row === row) hole.mole?.render(renderer); } } /** Back-to-front pass so a mole in row 2 covers the tile in row 1. */ render(renderer) { for (let row = 0; row < GameConfig.board.rows; row += 1) { this.renderTiles(renderer, row); this.renderMoles(renderer, row); } } /** * Closest raised mole within `radius` of a point. * @returns {import("./Mole.js").Mole|null} */ moleAt(point, radius) { let best = null; let bestDistance = radius; for (const hole of this.holes) { const mole = hole.mole; if (!mole?.isTargetable) continue; const distance = Math.hypot(point.x - hole.x, point.y - mole.centerY); if (distance <= bestDistance) { best = mole; bestDistance = distance; } } return best; } /** Every raised mole within `radius` — used by the power hammer's splash. */ molesWithin(point, radius) { return this.holes .map((hole) => hole.mole) .filter( (mole) => mole?.isTargetable && Math.hypot(point.x - mole.hole.x, point.y - mole.centerY) <= radius, ); } clear() { for (const hole of this.holes) { hole.mole = null; hole.cooldown = 0; } } }
In gameConfig.js, change board.rows and
board.columns to 4. Everything still works — nothing in the
code assumes there are nine holes. Set it back to 3 when you have seen it.
Moles
A small state machine. Plus why this game animates far less than you would expect.
Mole.js is a state machine: rising → up → struck or escaped.
Spawner.js decides when, where and which.
Why moles barely animate
Find idleFrame. A mole picks one pose when it spawns and holds it
for its entire visit, instead of cycling through the sprite frames.
That is a deliberate choice. At this size, swapping poses every few hundred
milliseconds reads as flicker, not animation. The life comes from the rise curve, the
gentle bob, and a small squash when it lands. Only the rocket mole opts into real frame
animation, via idleFps.
Your instinct will be to animate everything. Resist it. A busy board is a board the player cannot read, and an unreadable board is an unfair game.
Escapes
A mole that is never hit has to count as a miss. Rather than a timer callback per
mole, the game sweeps the list each frame for finished moles and checks a
struck flag. One place to reason about, and it cannot fire twice.
Weighted spawning
Spawner.js picks a mole by weight, then re-normalises against the current
difficulty — which is how grumpy moles get more common as the round goes on.
Create and paste these 2 files
Each block is one complete file. Create it at the path shown, paste, save.
import { GameConfig } from "../config/gameConfig.js"; import { Ease, clamp, pick } from "../core/utils.js"; /** * Mole state machine. * * rising ──▶ up ──▶ escaping ──▶ done (player was too slow) * │ │ * └────────┴────▶ struck ──▶ done (player connected) * * The mole draws itself clipped to its hole so it genuinely looks like it comes * out of the ground; all timing comes from GameConfig + the mole type table. */ export const MoleState = { RISING: "rising", UP: "up", STRUCK: "struck", ESCAPING: "escaping", DONE: "done", }; export class Mole { /** * @param {object} options * @param {import("./Hole.js").Hole} options.hole * @param {object} options.type entry from moleTypes.js * @param {number} options.upTime seconds the mole stays reachable * @param {import("../core/AssetLoader.js").AssetLoader} options.assets */ constructor({ hole, type, upTime, assets }) { this.hole = hole; this.type = type; this.assets = assets; this.upTime = upTime; this.state = MoleState.RISING; this.stateTime = 0; this.age = 0; this.riseFrames = assets.images(type.riseFrames); this.idleFrames = assets.images(type.idleFrames); this.hitFrame = assets.image(pick(type.hitFrames)); /** The pose this mole holds for its whole visit — chosen once, never flickers. */ this.idleFrame = pick(this.idleFrames.length ? this.idleFrames : this.riseFrames); /** Optional half-emerged pose used only while breaking the surface. */ this.emergeFrame = this.riseFrames[0] ?? null; /** 0..1 how far out of the hole the mole is. */ this.emergence = 0; this.wobble = Math.random() * Math.PI * 2; /** Set once the player connects — lets the scene tell hits from escapes. */ this.struck = false; hole.mole = this; } /** True while the player can still score on this mole. */ get isTargetable() { return this.state === MoleState.RISING || this.state === MoleState.UP; } get isFinished() { return this.state === MoleState.DONE; } /** Approximate centre of the visible body — the thing the hammer aims at. */ get centerY() { return this.hole.standY - this.spriteHeight * 0.45 * this.emergence; } get spriteHeight() { const frame = this.#currentFrame(); return (frame?.height ?? 150) * GameConfig.mole.scale * (this.type.scale ?? 1); } /** Register a whack. @returns {boolean} whether it counted */ strike() { if (!this.isTargetable) return false; this.state = MoleState.STRUCK; this.stateTime = 0; this.struck = true; this.hole.punch = 1; return true; } update(dt) { this.age += dt; this.stateTime += dt; const { riseTime, retreatTime, hitTime } = GameConfig.mole; switch (this.state) { case MoleState.RISING: { this.emergence = Ease.outBack(clamp(this.stateTime / riseTime, 0, 1)); if (this.stateTime >= riseTime) this.#enter(MoleState.UP); break; } case MoleState.UP: { this.emergence = 1; if (this.stateTime >= this.upTime) this.#enter(MoleState.ESCAPING); break; } case MoleState.ESCAPING: { this.emergence = 1 - Ease.inCubic(clamp(this.stateTime / retreatTime, 0, 1)); if (this.stateTime >= retreatTime) this.#finish(); break; } case MoleState.STRUCK: { // Squashed into the ground rather than retracted. this.emergence = 1 - Ease.inCubic(clamp(this.stateTime / hitTime, 0, 1)) * 0.85; if (this.stateTime >= hitTime) this.#finish(); break; } default: break; } } render(renderer) { if (this.state === MoleState.DONE || this.emergence <= 0.001) return; const frame = this.#currentFrame(); if (!frame) return; const hole = this.hole; const scale = GameConfig.mole.scale * (this.type.scale ?? 1); const height = frame.height * scale; // Vertical travel: fully hidden means the whole sprite sits below the rim. const hidden = height * 1.02; const bob = this.state === MoleState.UP ? Math.sin(this.age * GameConfig.mole.bobSpeed + this.wobble) * GameConfig.mole.bobAmplitude * (this.type.bobScale ?? 1) : 0; const y = hole.standY + hidden * (1 - this.emergence) + bob; // Struck moles squash and tilt for impact feedback; freshly-risen moles get a // smaller settle squash so arriving at the top has some weight to it. const struck = this.state === MoleState.STRUCK; const { settleTime, settleAmount } = GameConfig.mole; const settling = this.state === MoleState.UP && this.stateTime < settleTime ? Ease.pulse(this.stateTime / settleTime) * settleAmount : 0; const squash = struck ? 1 + Ease.pulse(this.stateTime / GameConfig.mole.hitTime) * 0.14 : 1 + settling; const tilt = struck ? Math.sin(this.stateTime * 26) * 0.06 * (1 - this.stateTime * 2) : 0; // Special moles are flagged twice over — a ring on the ground and a pulsing // aura — so a bomb is never mistaken for a friendly mole in the half-second // the player has to react. if (this.type.glow) { const pulse = 0.72 + 0.28 * Math.sin(this.age * 7); renderer.glow(hole.x, y - height * 0.42, height * 0.86, this.type.glow, this.emergence * pulse); renderer.ellipseRing( hole.x, hole.y + hole.height * 0.12, hole.width * 0.46 * (0.94 + 0.06 * pulse), hole.height * 0.34 * (0.94 + 0.06 * pulse), this.type.accent, { lineWidth: 6, alpha: this.emergence * pulse, dashed: this.type.id === "bomb" }, ); } renderer.spriteClipped(frame, hole.x, y, hole.clipRect, { anchor: [0.5, 1], width: frame.width * scale * squash, height: height / squash, rotate: tilt, }); } /** * Which sprite to draw right now. * * Deliberately conservative: a mole holds **one** pose for its whole visit * rather than cycling through the sheet's frames. Flipping between poses every * few hundred milliseconds reads as flicker at this size — the motion comes * from the rise, the bob and the settle squash instead. Types that genuinely * want animation opt in with `idleFps`. */ #currentFrame() { if (this.state === MoleState.STRUCK) return this.hitFrame; // A brief "just breaking the surface" pose, then straight to the held pose. if ( (this.state === MoleState.RISING || this.state === MoleState.ESCAPING) && this.emergeFrame && this.emergence < 0.45 ) { return this.emergeFrame; } const fps = this.type.idleFps ?? 0; if (fps > 0 && this.idleFrames.length > 1) { return this.idleFrames[Math.floor(this.age * fps) % this.idleFrames.length]; } return this.idleFrame; } #enter(state) { this.state = state; this.stateTime = 0; } #finish() { this.state = MoleState.DONE; this.emergence = 0; if (this.hole.mole === this) { this.hole.mole = null; this.hole.cooldown = GameConfig.mole.holeCooldown; } } }
import { difficultyAt } from "../config/difficulty.js"; import { MOLE_TYPE_LIST, MoleTypes } from "../config/moleTypes.js"; import { pick, randRange, weightedPick } from "../core/utils.js"; import { Mole } from "../entities/Mole.js"; /** * Decides *when* a mole appears, *where*, and *which kind*. * * Keeping this separate from GameScene means the pacing curve can be tuned or * swapped (e.g. a scripted tutorial spawner) without touching rendering or * scoring code. */ export class Spawner { /** * @param {object} options * @param {import("../entities/Board.js").Board} options.board * @param {import("../core/AssetLoader.js").AssetLoader} options.assets * @param {object} options.preset difficulty preset */ constructor({ board, assets, preset, bus }) { this.board = board; this.assets = assets; this.preset = preset; this.bus = bus; this.reset(); } reset() { this.timer = 0.65; // small grace period before the first mole this.lastHoleIndex = -1; this.spawned = 0; } /** * @param {number} dt * @param {number} progress 0..1 through the run */ update(dt, progress) { const tuning = difficultyAt(this.preset, progress); this.timer -= dt; if (this.timer > 0) return; const active = this.board.activeMoles.length; if (active >= tuning.maxActive) { // Board is full: check again shortly rather than banking up spawns. this.timer = 0.12; return; } const hole = this.#chooseHole(); if (!hole) { this.timer = 0.12; return; } const type = this.#chooseType(tuning, progress); const upTime = tuning.upTime * (type.upTimeScale ?? 1) * randRange(0.9, 1.12); const mole = new Mole({ hole, type, upTime, assets: this.assets }); this.spawned += 1; this.lastHoleIndex = hole.index; this.bus?.emit("mole:spawned", mole); // Jitter keeps the rhythm from feeling metronomic. this.timer = tuning.spawnInterval * randRange(0.82, 1.2); } #chooseHole() { const free = this.board.freeHoles; if (!free.length) return null; // Prefer not to reuse the hole we just used — it plays as unfair otherwise. const preferred = free.filter((hole) => hole.index !== this.lastHoleIndex); return pick(preferred.length ? preferred : free); } /** * Re-weights the type table so bombs take exactly `bombShare` of the pool and * the friendly moles split the rest in their original proportions. */ #chooseType(tuning, progress) { // Ease players in: no bombs in the opening moments of a run. if (progress < 0.08 || this.spawned < 3) return MoleTypes.normal; const friendly = MOLE_TYPE_LIST.filter((type) => type.id !== "bomb"); const friendlyTotal = friendly.reduce((sum, type) => sum + type.weight, 0); const share = tuning.bombShare; const pool = friendly.map((type) => ({ ...type, weight: (type.weight / friendlyTotal) * (1 - share) * 100, })); pool.push({ ...MoleTypes.bomb, weight: share * 100 }); const chosen = weightedPick(pool); return MoleTypes[chosen.id]; } }
Refresh and use the console: game.scenes.switchTo("game").
You should see moles rising out of holes, though you cannot hit them yet. If you
commented out ctx.clip() in lesson 4, this is where you see whole moles
floating in front of the holes. Put it back.
Hammer, score, particles
Now you can hit things, and hitting things means something.
One swing, end to end
The most important sequence in the game:
- The hammer animates, and the swing is counted — accuracy counts every swing, hit or miss.
- The board is asked what is under the pointer:
moleAt()normally, ormolesWithin()while the power hammer is active. That single swap is the entire implementation of splash damage. mole.strike()runs. It returnsfalseif that mole was already hit.- Points and multiplier are applied, or a life is lost.
- Feedback fires: particles, a floating score, a sound, screen shake for a grumpy mole.
That guard is what makes double-clicking harmless. Without it, a fast player could hit one mole three times and score it three times. One line; one whole class of bug gone.
The hammer eases toward your cursor rather than snapping to it, so it always trails a few pixels behind. Hit detection deliberately uses the pointer position. If it used the drawn hammer, that visible lag would read as the game ignoring your clicks — and players would blame the game, correctly.
Combos
Every four consecutive hits raises the multiplier, up to ×5. A miss resets it.
Ten in a row unlocks the power hammer for seven seconds. All of it is in
ScoreKeeper.js, and all of it is tunable from
gameConfig.scoring.
Create and paste these 3 files
Each block is one complete file. Create it at the path shown, paste, save.
import { GameConfig } from "../config/gameConfig.js"; import { Ease, approach, clamp } from "../core/utils.js"; import { Viewport } from "../core/Viewport.js"; /** * Where the hammer's *head* sits inside each sprite, as a 0..1 fraction. * * The head is what the player aims with, so every frame is anchored on it — * otherwise the cursor appears to drift as the swing cycles through poses. */ const HEAD_ANCHORS = { "hammer.standard.1": [0.52, 0.24], "hammer.standard.2": [0.33, 0.27], "hammer.standard.3": [0.7, 0.3], "hammer.standard.4": [0.74, 0.36], "hammer.power.1": [0.5, 0.3], "hammer.power.2": [0.5, 0.3], "hammer.power.3": [0.63, 0.38], "hammer.power.4": [0.66, 0.6], }; /** * The player's cursor. * * Follows the pointer with a little lag (so it feels weighty), and plays the * sheet's four-pose swing on every click: idle → up → side → impact. Swapping to * the power hammer is just a different frame set. */ export class Hammer { constructor(assets) { this.assets = assets; this.x = Viewport.centerX; this.y = Viewport.centerY; this.swing = 0; // 0 = idle, otherwise 0..1 through the swing this.swinging = false; this.skin = "standard"; this.powered = false; this.visible = true; } setSkin(skin) { this.skin = skin === "power" ? "power" : "standard"; } /** Power mode overrides the chosen skin for its duration. */ setPowered(powered) { this.powered = powered; } get frameSet() { return this.powered || this.skin === "power" ? "hammer.power" : "hammer.standard"; } get hitRadius() { return this.powered ? GameConfig.hammer.powerHitRadius : GameConfig.hammer.hitRadius; } strike() { this.swinging = true; this.swing = 0; } update(dt, pointer) { // Track the pointer almost 1:1. Hits are tested against the pointer, so any // visible lag here reads as the game ignoring your clicks. const rate = GameConfig.hammer.followRate; this.x = approach(this.x, pointer.x, rate, dt); this.y = approach(this.y, pointer.y, rate, dt); if (this.swinging) { this.swing += dt / GameConfig.hammer.swingTime; if (this.swing >= 1) { this.swing = 0; this.swinging = false; } } } render(renderer) { if (!this.visible) return; const t = this.swing; const set = this.frameSet; // Pose per phase of the swing: the head lifts away on the wind-up and lands // exactly on the pointer at impact. let frame = 1; let rotate = -0.1; let lift = -6; if (this.swinging) { if (t < 0.26) { const k = Ease.outCubic(t / 0.26); frame = 2; // up rotate = -0.1 - 0.16 * k; lift = -6 - 28 * k; } else if (t < 0.44) { frame = 3; // side rotate = 0.1; lift = -18; } else if (t < 0.72) { frame = 4; // impact rotate = 0.06; lift = 2; } else { const k = Ease.outCubic((t - 0.72) / 0.28); frame = 3; // recover rotate = 0.1 - 0.2 * k; lift = 2 - 8 * k; } } const key = `${set}.${frame}`; const image = this.assets.image(key); if (!image) return; const scale = GameConfig.hammer.scale * (this.powered ? 1.12 : 1); const { offsetX, offsetY } = GameConfig.hammer; if (this.powered) { renderer.glow(this.x, this.y, 90, "rgba(162, 235, 205, 0.55)", 0.8); } renderer.sprite(image, this.x + offsetX, this.y + offsetY + lift, { anchor: HEAD_ANCHORS[key] ?? [0.5, 0.3], scale, rotate, shadow: { color: "rgba(74,67,88,0.28)", blur: 12, y: 6 }, }); } /** Impact point of the head — where particles should spawn. */ get impactPoint() { return { x: this.x, y: this.y + clamp(this.swing, 0, 1) * 6 }; } }
import { GameConfig } from "../config/gameConfig.js"; import { clamp } from "../core/utils.js"; /** * All the numbers a run produces: score, combo, multiplier, lives, accuracy and * the power-hammer window. * * It owns no rendering and no input — it reacts to `registerX` calls and emits * events on the bus so the HUD, audio and particles can respond. */ export class ScoreKeeper { constructor({ bus, lives }) { this.bus = bus; this.startingLives = lives; this.reset(); } reset() { this.score = 0; this.combo = 0; this.bestCombo = 0; this.hits = 0; this.swings = 0; this.escapes = 0; this.lives = this.startingLives; this.powerTimer = 0; this.timeBonus = 0; } get multiplier() { const { comboStep, maxMultiplier, powerMultiplier } = GameConfig.scoring; const fromCombo = 1 + Math.floor(this.combo / comboStep); const base = clamp(fromCombo, 1, maxMultiplier); return this.powered ? base * powerMultiplier : base; } get powered() { return this.powerTimer > 0; } get accuracy() { return this.swings === 0 ? 0 : clamp(this.hits / this.swings, 0, 1); } get isOut() { return this.lives <= 0; } update(dt) { if (this.powerTimer > 0) { this.powerTimer -= dt; if (this.powerTimer <= 0) { this.powerTimer = 0; this.bus.emit("power:end"); } } } /** A swing was thrown — counted for accuracy whether or not it lands. */ registerSwing() { this.swings += 1; } /** * A mole was struck. * @param {object} type entry from moleTypes.js * @returns {{points:number, multiplier:number, combo:number, lifeLost:boolean}} */ registerHit(type) { const multiplier = this.multiplier; let points = 0; let lifeLost = false; if (type.livesDelta) { // Bombs: no multiplier mercy, and the combo dies with the life. this.lives = Math.max(0, this.lives + type.livesDelta); points = type.points; this.score = Math.max(0, this.score + points); this.combo = 0; lifeLost = true; this.bus.emit("lives:changed", this.lives); this.bus.emit("combo:broken"); } else { this.hits += 1; points = type.points * multiplier; this.score += points; this.combo += 1; this.bestCombo = Math.max(this.bestCombo, this.combo); if (this.combo > 0 && this.combo % GameConfig.scoring.comboStep === 0) { this.bus.emit("combo:milestone", { combo: this.combo, multiplier: this.multiplier }); } if (!this.powered && this.combo >= GameConfig.scoring.powerCombo) { this.#startPower(); } } return { points, multiplier, combo: this.combo, lifeLost }; } /** A mole retreated untouched. */ registerEscape(type) { this.escapes += 1; if (type.id === "bomb") return; // letting a bomb go is the correct play if (this.combo > 0) this.bus.emit("combo:broken"); this.combo = 0; } /** A swing that connected with nothing. */ registerMiss() { if (this.combo > 0) this.bus.emit("combo:broken"); this.combo = 0; } addTimeBonus(seconds) { this.timeBonus += seconds; return seconds; } #startPower() { this.powerTimer = GameConfig.scoring.powerDuration; this.bus.emit("power:start", { duration: this.powerTimer }); } /** Snapshot used by the results screen and the leaderboard. */ summary(difficultyId) { return { score: Math.round(this.score), combo: this.bestCombo, accuracy: this.accuracy, hits: this.hits, swings: this.swings, escapes: this.escapes, lives: this.lives, difficulty: difficultyId, }; } }
import { Palette } from "../config/palette.js"; import { Ease, clamp, randRange } from "../core/utils.js"; /** * Sprite particles and floating score text. * * Everything visual that is "juice" rather than state lives here: dirt puffs on * impact, sparkles for golden moles, the BOOM sheet for grumpy ones, and the * "+120" numbers that float up out of a whack. */ /** Per-effect emitter recipes, tuned against the sheet's artwork. */ const RECIPES = { dirt: { key: "fx.dirt", count: [4, 6], speed: [140, 300], angle: [-2.6, -0.5], scale: [0.32, 0.6], life: [0.42, 0.7], gravity: 900, spin: 2.4, }, stars: { key: "fx.stars", count: [1, 2], speed: [40, 110], angle: [-2.4, -0.7], scale: [0.55, 0.85], life: [0.5, 0.75], gravity: 120, spin: 1.2, }, sparkle: { key: "fx.sparkle", count: [3, 5], speed: [90, 240], angle: [-2.9, -0.25], scale: [0.4, 0.8], life: [0.45, 0.8], gravity: 60, spin: 2.8, }, particles: { key: "fx.particles", count: [2, 4], speed: [120, 260], angle: [-2.7, -0.4], scale: [0.5, 0.95], life: [0.35, 0.6], gravity: 500, spin: 1.6, }, boom: { key: "fx.boom", count: [1, 1], speed: [0, 20], angle: [-1.7, -1.4], scale: [0.9, 1.1], life: [0.5, 0.6], gravity: -40, spin: 0.4, pop: true, }, }; export class ParticleSystem { /** @param {import("../core/AssetLoader.js").AssetLoader} assets */ constructor(assets) { this.assets = assets; /** @type {object[]} */ this.particles = []; /** @type {object[]} */ this.texts = []; } /** * Emit one effect. * @param {keyof RECIPES} kind */ burst(kind, x, y, options = {}) { const recipe = RECIPES[kind]; if (!recipe) return; const image = this.assets.image(recipe.key); if (!image) return; const count = Math.round(randRange(recipe.count[0], recipe.count[1] + 1)) * (options.scale ?? 1); for (let i = 0; i < count; i += 1) { const angle = randRange(recipe.angle[0], recipe.angle[1]); const speed = randRange(recipe.speed[0], recipe.speed[1]); const life = randRange(recipe.life[0], recipe.life[1]); this.particles.push({ image, x: x + randRange(-14, 14), y: y + randRange(-10, 10), vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, gravity: recipe.gravity, rotation: randRange(0, Math.PI * 2), spin: randRange(-recipe.spin, recipe.spin), scale: randRange(recipe.scale[0], recipe.scale[1]), life, maxLife: life, pop: recipe.pop ?? false, }); } } /** Floating "+250" / "-1 LIFE" text. */ popText(value, x, y, options = {}) { this.texts.push({ value, x, y, vy: options.vy ?? -110, life: options.life ?? 0.9, maxLife: options.life ?? 0.9, color: options.color ?? Palette.ink, size: options.size ?? 34, stroke: options.stroke ?? "#ffffff", }); } update(dt) { for (let i = this.particles.length - 1; i >= 0; i -= 1) { const p = this.particles[i]; p.life -= dt; if (p.life <= 0) { this.particles.splice(i, 1); continue; } p.vy += p.gravity * dt; p.x += p.vx * dt; p.y += p.vy * dt; p.rotation += p.spin * dt; } for (let i = this.texts.length - 1; i >= 0; i -= 1) { const t = this.texts[i]; t.life -= dt; if (t.life <= 0) { this.texts.splice(i, 1); continue; } t.y += t.vy * dt; t.vy *= 1 - 1.6 * dt; } } render(renderer) { for (const p of this.particles) { const t = 1 - p.life / p.maxLife; const alpha = t > 0.65 ? 1 - (t - 0.65) / 0.35 : 1; // "pop" effects punch in with a scale curve instead of flying apart. const scale = p.pop ? p.scale * (0.6 + Ease.outBack(clamp(t * 2.4, 0, 1)) * 0.5) : p.scale; renderer.sprite(p.image, p.x, p.y, { anchor: [0.5, 0.5], scale, rotate: p.rotation, alpha: clamp(alpha, 0, 1), }); } for (const t of this.texts) { const k = 1 - t.life / t.maxLife; const alpha = k > 0.6 ? 1 - (k - 0.6) / 0.4 : 1; const scale = 1 + Ease.pulse(clamp(k * 3, 0, 1)) * 0.18; renderer.text(t.value, t.x, t.y, { size: t.size * scale, color: t.color, stroke: t.stroke, strokeWidth: 6, alpha: clamp(alpha, 0, 1), }); } } clear() { this.particles.length = 0; this.texts.length = 0; } }
You still have no HUD, so use the console while playing:
game.scenes.active.score.score. Hit a few moles and watch it climb. Then
try game.scenes.active.score.combo.
Widgets and the HUD
Buttons, sliders, the score display, and the layout pattern that prevents most UI bugs.
Widget.js is the base class: a rectangle that knows if it is hovered,
pressed or keyboard-focused. UiLayer holds a set of them and routes pointer
and keyboard events, which is how every screen gets arrow-key navigation for free.
The layout pattern
Because the window can be any size, nothing can be positioned once and forgotten.
Every screen has a layout() called from both enter() and
onResize().
The pattern that works is three passes — measure, size, place:
// 1. measure: how tall is the content? let content = 0; for (const w of this.rows) content += w.rowHeight + ROW_GAP; // 2. size the panel around it this.panelHeight = content + ROW_INSET * 2; // 3. place, using each widget's REAL height let cursor = this.panelY + ROW_INSET; for (const w of this.rows) { w.y = cursor + w.height / 2; cursor += w.rowHeight + ROW_GAP; }
Hard-coded positions. An earlier version of the settings screen used
const tops = [352, 444, 528, 612]. The buttons were about 100px tall, the
gaps were not, and two of them visually overlapped. Measuring instead of guessing makes
that impossible.
Centring on the screen instead of on each other. The results screen has a panel on the left and artwork on the right. Centre the buttons on the screen and they sit visibly off, because the panel is not centred — the group is. Treat panel plus artwork as one group, centre that, then centre the buttons on the panel.
Backdrop.js
The sky, hills and drifting clouds are drawn with plain shapes, not an image. It costs 91 lines and never needs loading.
Create and paste these 6 files
Each block is one complete file. Create it at the path shown, paste, save.
import { pointInRect } from "../core/utils.js"; /** * Base class for interactive UI pieces. * * Widgets are positioned by their centre, know how to hit-test themselves, and * expose hover/press state for the renderer. They never touch the DOM. */ export class Widget { constructor({ x, y, width, height, enabled = true, onPress = null, data = null }) { this.x = x; this.y = y; this.width = width; this.height = height; this.enabled = enabled; this.onPress = onPress; this.data = data; this.hovered = false; this.pressed = false; this.focused = false; /** Eased 0..1 used for hover/press animation. */ this.hoverAmount = 0; this.pressAmount = 0; } get left() { return this.x - this.width / 2; } get top() { return this.y - this.height / 2; } contains(point) { return pointInRect(point.x, point.y, this.left, this.top, this.width, this.height); } update(dt) { const target = this.hovered || this.focused ? 1 : 0; const pressTarget = this.pressed ? 1 : 0; const speed = 14 * dt; this.hoverAmount += (target - this.hoverAmount) * Math.min(1, speed); this.pressAmount += (pressTarget - this.pressAmount) * Math.min(1, speed * 1.6); } activate() { if (!this.enabled) return false; this.onPress?.(this); return true; } render(_renderer) {} } /** * A stack of widgets belonging to one screen. * * Handles pointer routing plus keyboard focus (arrows/tab to move, Enter/Space * to activate) so menus are usable without a mouse. */ export class UiLayer { constructor(widgets = []) { this.widgets = widgets; this.focusIndex = -1; /** Set by a scene to play a click sound on activation. */ this.onActivate = null; } add(widget) { this.widgets.push(widget); return widget; } clear() { this.widgets.length = 0; this.focusIndex = -1; } get focusable() { return this.widgets.filter((w) => w.enabled && w.onPress); } pointerMove(point) { for (const widget of this.widgets) { widget.hovered = widget.enabled && widget.contains(point); if (!widget.hovered) widget.pressed = false; } } pointerDown(point) { let consumed = false; for (const widget of this.widgets) { if (widget.enabled && widget.contains(point)) { widget.pressed = true; consumed = true; } } return consumed; } /** @returns {boolean} true when a widget fired, so scenes can ignore stray clicks */ pointerUp(point) { let activated = false; for (const widget of this.widgets) { const hit = widget.pressed && widget.enabled && widget.contains(point); widget.pressed = false; if (hit) { // A widget may swallow the press itself (sliders, cyclers). const fired = widget.handlePointerUp ? widget.handlePointerUp(point) : widget.activate(); if (fired !== false) { activated = true; this.onActivate?.(widget); } } } return activated; } /** Arrow / tab navigation. @returns {boolean} handled */ handleKey(event) { const items = this.focusable; if (!items.length) return false; const step = (delta) => { this.focusIndex = (this.focusIndex + delta + items.length) % items.length; items.forEach((w, i) => { w.focused = i === this.focusIndex; }); }; switch (event.code) { case "ArrowDown": case "Tab": step(1); return true; case "ArrowUp": step(-1); return true; case "ArrowLeft": case "ArrowRight": { const current = items[this.focusIndex]; if (current?.nudge) { current.nudge(event.code === "ArrowRight" ? 1 : -1); return true; } return false; } case "Enter": case "Space": { const current = items[this.focusIndex]; if (!current) return false; if (current.activate()) { this.onActivate?.(current); return true; } return false; } default: return false; } } update(dt) { for (const widget of this.widgets) widget.update(dt); } render(renderer) { for (const widget of this.widgets) widget.render(renderer); } }
import { Palette } from "../config/palette.js"; import { lerp } from "../core/utils.js"; import { Widget } from "./Widget.js"; /** * A button backed by one of the sheet's button sprites. * * The artwork already carries its label ("PLAY", "SETTINGS", ...), so this * widget only adds motion: a lift on hover and a squash on press. */ export class SpriteButton extends Widget { /** * @param {object} options * @param {HTMLImageElement} options.image * @param {string} [options.label] optional text drawn over the sprite */ constructor({ image, label = null, labelSize = 30, ...rest }) { const width = rest.width ?? image?.width ?? 240; const height = rest.height ?? (image ? (image.height / image.width) * width : 80); super({ ...rest, width, height }); this.image = image; this.label = label; this.labelSize = labelSize; } render(renderer) { const lift = lerp(0, -5, this.hoverAmount) + lerp(0, 6, this.pressAmount); const scale = 1 + this.hoverAmount * 0.04 - this.pressAmount * 0.06; const alpha = this.enabled ? 1 : 0.45; renderer.sprite(this.image, this.x, this.y + lift, { anchor: [0.5, 0.5], width: this.width * scale, alpha, filter: this.hovered ? "brightness(1.06) saturate(1.05)" : undefined, shadow: { color: Palette.shadow, blur: 14, y: 5 }, }); if (this.label) { renderer.text(this.label, this.x, this.y + lift + 2, { size: this.labelSize, color: Palette.ink, stroke: "rgba(255,255,255,0.85)", strokeWidth: 5, alpha, }); } } } /** * A button drawn from scratch, for labels the sheet does not provide * ("RESUME", "PLAY AGAIN", difficulty names, ...). Styled to match the sheet: * pastel fill, dark rounded outline, soft shadow. */ export class PanelButton extends Widget { constructor({ label, fill = Palette.mint, textColor = Palette.ink, size = 30, radius = 24, icon = null, ...rest }) { super({ width: 260, height: 84, ...rest }); this.label = label; this.fill = fill; this.textColor = textColor; this.size = size; this.radius = radius; this.icon = icon; } render(renderer) { const lift = lerp(0, -4, this.hoverAmount) + lerp(0, 5, this.pressAmount); const grow = this.hoverAmount * 6 - this.pressAmount * 8; const w = this.width + grow; const h = this.height + grow * 0.4; const x = this.x - w / 2; const y = this.y - h / 2 + lift; const alpha = this.enabled ? 1 : 0.45; renderer.panel(x, y, w, h, { fill: this.fill, stroke: Palette.outline, lineWidth: 4, radius: this.radius, alpha, }); const textX = this.icon ? this.x + 16 : this.x; if (this.icon) { renderer.sprite(this.icon, x + 34, this.y + lift, { anchor: [0.5, 0.5], height: h * 0.52, alpha, }); } renderer.text(this.label, textX, this.y + lift + 2, { size: this.size, color: this.textColor, stroke: "rgba(255,255,255,0.7)", strokeWidth: 4, alpha, letterSpacing: 0.5, }); } }
import { Palette } from "../config/palette.js"; import { clamp, lerp, pointInRect } from "../core/utils.js"; import { Widget } from "./Widget.js"; /** * Left/right option picker: `‹ Classic ›`. * Used on the settings screen for difficulty and hammer skin. */ export class OptionCycler extends Widget { /** * @param {object} options * @param {string} options.label caption above the control * @param {Array<{id:string,label:string}>} options.options * @param {string} options.value current option id * @param {(id:string)=>void} options.onChange */ constructor({ label, options, value, onChange, labelSize = 19, labelGap = 12, ...rest }) { super({ width: 460, height: 64, ...rest, onPress: () => {} }); this.label = label; this.labelSize = labelSize; this.labelGap = labelGap; this.options = options; this.index = Math.max(0, options.findIndex((o) => o.id === value)); this.onChange = onChange; this.arrowFlash = 0; } get value() { return this.options[this.index]; } nudge(direction) { this.index = (this.index + direction + this.options.length) % this.options.length; this.arrowFlash = 1; this.onChange?.(this.value.id, this.value); } /** Height of the caption block sitting above the control. */ get labelBlock() { return this.labelSize + this.labelGap; } /** Total vertical space this control needs, caption included. */ get rowHeight() { return this.height + this.labelBlock; } #arrowRects() { const size = this.height - 12; const y = this.y - size / 2; return { left: [this.left + 6, y, size, size], right: [this.left + this.width - size - 6, y, size, size], }; } /** Clicking an arrow steps; clicking the middle advances forward. */ handlePointerUp(point) { const { left, right } = this.#arrowRects(); if (pointInRect(point.x, point.y, ...left)) this.nudge(-1); else if (pointInRect(point.x, point.y, ...right)) this.nudge(1); else this.nudge(1); return true; } update(dt) { super.update(dt); this.arrowFlash = Math.max(0, this.arrowFlash - dt * 3); } render(renderer) { const { left, right } = this.#arrowRects(); renderer.text(this.label, this.left, this.top - this.labelGap, { size: this.labelSize, align: "left", baseline: "bottom", color: Palette.inkSoft, weight: 700, letterSpacing: 1.2, }); renderer.panel(this.left, this.top, this.width, this.height, { fill: this.hovered || this.focused ? Palette.white : Palette.cream, stroke: Palette.outline, lineWidth: 4, radius: 22, }); renderer.text(this.value?.label ?? "—", this.x, this.y + 2, { size: 27, color: Palette.ink, }); const arrow = (rect, glyph) => { const [ax, ay, aw, ah] = rect; renderer.circle(ax + aw / 2, ay + ah / 2, aw / 2 - 4, { fill: Palette.violet, stroke: Palette.outline, lineWidth: 3, alpha: 0.9 + this.arrowFlash * 0.1, }); renderer.text(glyph, ax + aw / 2, ay + ah / 2, { size: 26, color: Palette.ink }); }; arrow(left, "‹"); arrow(right, "›"); } } /** Horizontal volume slider with a draggable knob. */ export class Slider extends Widget { constructor({ label, value = 0.5, onChange, format = null, labelSize = 19, labelGap = 10, ...rest }) { super({ width: 460, height: 52, ...rest, onPress: () => {} }); this.label = label; this.labelSize = labelSize; this.labelGap = labelGap; this.value = clamp(value, 0, 1); this.onChange = onChange; this.format = format ?? ((v) => `${Math.round(v * 100)}%`); this.dragging = false; } get labelBlock() { return this.labelSize + this.labelGap; } get rowHeight() { return this.height + this.labelBlock; } get trackRect() { const inset = 20; return { x: this.left + inset, y: this.y - 8, width: this.width - inset * 2, height: 16, }; } #setFromPoint(point) { const track = this.trackRect; this.value = clamp((point.x - track.x) / track.width, 0, 1); this.onChange?.(this.value); } nudge(direction) { this.value = clamp(this.value + direction * 0.05, 0, 1); this.onChange?.(this.value); } onPointerDrag(point) { if (this.pressed) this.#setFromPoint(point); } handlePointerUp(point) { this.#setFromPoint(point); return true; } render(renderer) { const track = this.trackRect; renderer.text(this.label, this.left, this.top - this.labelGap, { size: this.labelSize, align: "left", baseline: "bottom", color: Palette.inkSoft, weight: 700, letterSpacing: 1.2, }); renderer.text(this.format(this.value), this.left + this.width, this.top - this.labelGap, { size: this.labelSize + 2, align: "right", baseline: "bottom", color: Palette.ink, }); renderer.panel(track.x, track.y, track.width, track.height, { fill: Palette.lavenderDeep, stroke: Palette.outline, lineWidth: 3, radius: 8, shadow: false, }); const fillWidth = Math.max(track.height, track.width * this.value); renderer.panel(track.x, track.y, fillWidth, track.height, { fill: Palette.mint, stroke: Palette.outline, lineWidth: 3, radius: 8, shadow: false, }); const knobX = track.x + track.width * this.value; const knobR = 18 + this.hoverAmount * 2 - this.pressAmount * 2; renderer.circle(knobX, track.y + track.height / 2, knobR, { fill: Palette.white, stroke: Palette.outline, lineWidth: 4, }); } } /** A read-only stat row used on the results and high-score screens. */ export function drawStatRow(renderer, x, y, width, label, value, options = {}) { const { color = Palette.ink, size = 26 } = options; renderer.text(label, x, y, { size, align: "left", color: Palette.inkSoft, weight: 700, }); renderer.text(value, x + width, y, { size: size + 2, align: "right", color, }); } /** Small helper for the pulsing "press any key" style hints. */ export const breathe = (time, speed = 2, min = 0.55, max = 1) => lerp(min, max, 0.5 + 0.5 * Math.sin(time * speed));
import { Palette } from "../config/palette.js"; /** The pastel sequence the asset sheet uses for its own headline. */ const TITLE_COLORS = [ Palette.violetDark, Palette.violet, Palette.mintDark, Palette.mint, Palette.peachDark, Palette.peach, Palette.coral, Palette.pink, ]; /** * Draw a headline letter-by-letter in the sheet's pastel rainbow, with a thick * white outline and an optional wave. * * @param {import("../core/Renderer.js").Renderer} renderer * @param {string} value * @param {number} centerX * @param {number} baselineY * @param {object} [options] * @param {number} [options.size] * @param {number} [options.wave] amplitude of the per-letter bob * @param {number} [options.time] seconds, drives the wave */ export function drawTitle(renderer, value, centerX, baselineY, options = {}) { const { size = 78, wave = 0, time = 0, spacing = 2, colors = TITLE_COLORS } = options; const letters = [...value]; const widths = letters.map((ch) => renderer.measureText(ch, { size }) + spacing); const total = widths.reduce((sum, w) => sum + w, 0); let x = centerX - total / 2; letters.forEach((ch, i) => { const bob = wave ? Math.sin(time * 3 + i * 0.4) * wave : 0; renderer.text(ch, x + widths[i] / 2, baselineY + bob, { size, color: colors[i % colors.length], stroke: Palette.white, strokeWidth: Math.max(6, size * 0.13), shadow: { color: "rgba(74,67,88,0.25)", blur: 10, y: 5 }, }); x += widths[i]; }); return total; } /** * A small pill caption ("HIGH SCORES", "PAUSED", ...) used above panels. */ export function drawCaption(renderer, value, centerX, y, options = {}) { const { fill = Palette.violet, size = 26, paddingX = 28, height = 50 } = options; const width = renderer.measureText(value, { size }) + paddingX * 2; renderer.panel(centerX - width / 2, y, width, height, { fill, stroke: Palette.outline, lineWidth: 4, radius: height / 2, }); renderer.text(value, centerX, y + height / 2 + 1, { size, color: Palette.ink, letterSpacing: 1.4, }); return width; }
import { GameConfig } from "../config/gameConfig.js"; import { Palette } from "../config/palette.js"; import { Viewport } from "../core/Viewport.js"; /** * The shared sky + rolling-hills backdrop. * * Both background plates from the asset sheet are used: the menu plate supplies * the sky and clouds, the in-game plate supplies the hills. A few drifting * cloud puffs are drawn on top so static screens still feel alive. */ export class Backdrop { constructor(assets, { horizon = 0.42 } = {}) { this.sky = assets.image("bg.menu"); this.hills = assets.image("bg.game"); this.horizon = horizon; this.time = 0; // Deterministic cloud field: same layout every load, no per-frame allocation. this.clouds = [ { x: 0.15, y: 0.14, scale: 1.0, speed: 0.012 }, { x: 0.52, y: 0.09, scale: 0.72, speed: 0.018 }, { x: 0.82, y: 0.19, scale: 1.15, speed: 0.009 }, ]; } update(dt) { this.time += dt; for (const cloud of this.clouds) { cloud.x += cloud.speed * dt; if (cloud.x > 1.2) cloud.x = -0.2; } } render(renderer) { const { width, height } = Viewport; const horizonY = height * this.horizon; renderer.clear(Palette.sky); if (this.sky) { const skyHeight = (this.sky.height / this.sky.width) * width; renderer.sprite(this.sky, 0, 0, { anchor: [0, 0], width, height: Math.max(skyHeight, horizonY + 40), }); } for (const cloud of this.clouds) this.#cloud(renderer, cloud, width, height); if (this.hills) { const hillsTop = horizonY - 30; renderer.sprite(this.hills, 0, hillsTop, { anchor: [0, 0], width, height: height - hillsTop, }); // The hills plate has a hard top edge. Fade the sky into it so the horizon // reads as distance rather than as a wall. const ctx = renderer.ctx; const blend = 46; const gradient = ctx.createLinearGradient(0, hillsTop - blend, 0, hillsTop + blend * 0.5); gradient.addColorStop(0, "rgba(214, 238, 250, 0)"); gradient.addColorStop(0.55, "rgba(222, 242, 232, 0.75)"); gradient.addColorStop(1, "rgba(222, 242, 232, 0)"); ctx.save(); ctx.fillStyle = gradient; ctx.fillRect(0, hillsTop - blend, width, blend * 1.5); ctx.restore(); } } #cloud(renderer, cloud, width, height) { const x = cloud.x * width; const y = cloud.y * height + Math.sin(this.time * 0.6 + cloud.x * 10) * 4; const r = 34 * cloud.scale; const alpha = 0.5; renderer.circle(x, y, r, { fill: Palette.white, alpha }); renderer.circle(x + r * 0.9, y + r * 0.18, r * 0.78, { fill: Palette.white, alpha }); renderer.circle(x - r * 0.85, y + r * 0.22, r * 0.66, { fill: Palette.white, alpha }); renderer.panel(x - r * 1.5, y + r * 0.2, r * 3, r * 0.7, { fill: Palette.white, stroke: null, radius: r * 0.35, shadow: false, alpha, }); } }
import { GameConfig } from "../config/gameConfig.js"; import { Palette } from "../config/palette.js"; import { Ease, clamp, damp, formatClock, formatNumber } from "../core/utils.js"; import { Viewport } from "../core/Viewport.js"; /** * In-run heads-up display: score, clock, lives, combo and the power meter. * * The HUD is a pure view over {@link import("../systems/ScoreKeeper.js").ScoreKeeper} * plus the session clock — it never mutates game state. */ export class HUD { constructor(assets) { this.assets = assets; this.displayScore = 0; this.scorePunch = 0; this.comboPunch = 0; this.livesPunch = 0; this.lastLives = null; } /** * @param {number} dt * @param {object} state { score, combo, multiplier, lives, timeLeft, powered, powerRatio } */ update(dt, state) { if (this.lastLives === null) this.lastLives = state.lives; if (state.lives < this.lastLives) this.livesPunch = 1; this.lastLives = state.lives; if (state.score > this.displayScore + 0.5) this.scorePunch = Math.min(1, this.scorePunch + 0.5); this.displayScore = damp(this.displayScore, state.score, 0.00001, dt); this.scorePunch = Math.max(0, this.scorePunch - dt * 2.6); this.comboPunch = Math.max(0, this.comboPunch - dt * 2.2); this.livesPunch = Math.max(0, this.livesPunch - dt * 1.6); } /** Called by the scene when a combo step is reached, for a little pop. */ punchCombo() { this.comboPunch = 1; } render(renderer, state) { this.#renderScore(renderer); this.#renderClock(renderer, state); this.#renderLives(renderer, state); this.#renderCombo(renderer, state); if (state.powered) this.#renderPower(renderer, state); } #renderScore(renderer) { const x = 34; const y = 26; const w = 288; const h = 76; const punch = Ease.pulse(this.scorePunch) * 0.04; renderer.panel(x, y, w * (1 + punch), h, { fill: Palette.cream, stroke: Palette.outline, lineWidth: 4, radius: 26, }); // Coin badge, echoing the sheet's score pill. renderer.circle(x + 40, y + h / 2, 27, { fill: Palette.gold, stroke: Palette.outline, lineWidth: 4, }); renderer.circle(x + 40, y + h / 2, 16, { fill: Palette.goldDark, alpha: 0.55 }); renderer.text("SCORE", x + 78, y + 24, { size: 17, align: "left", color: Palette.inkSoft, weight: 700, letterSpacing: 1.5, }); renderer.text(formatNumber(this.displayScore), x + 78, y + 51, { size: 34 + punch * 120, align: "left", color: Palette.ink, }); } #renderClock(renderer, state) { const w = 232; const h = 76; const x = (Viewport.width - w) / 2; const y = 26; const urgent = state.timeLeft <= GameConfig.session.endWarningAt; const pulse = urgent ? 0.5 + 0.5 * Math.sin(state.elapsed * 8) : 0; renderer.panel(x, y, w, h, { fill: urgent ? Palette.pink : Palette.cream, stroke: Palette.outline, lineWidth: 4, radius: 26, alpha: 1, }); renderer.circle(x + 42, y + h / 2, 25, { fill: Palette.white, stroke: Palette.outline, lineWidth: 4, }); // Clock hands. const cx = x + 42; const cy = y + h / 2; const ctx = renderer.ctx; ctx.save(); ctx.strokeStyle = Palette.outline; ctx.lineWidth = 3.5; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx, cy - 13); ctx.moveTo(cx, cy); ctx.lineTo(cx + 10, cy + 5); ctx.stroke(); ctx.restore(); renderer.text(formatClock(state.timeLeft), x + 78, y + h / 2 + 2, { size: 40 + pulse * 3, align: "left", color: urgent ? Palette.coralDark : Palette.ink, }); } #renderLives(renderer, state) { const heart = this.assets.image("ui.heart"); const right = Viewport.width - 34; const y = 64; const size = 46; const gap = 12; const total = state.maxLives; renderer.text("LIVES", right, 26, { size: 17, align: "right", baseline: "top", color: Palette.inkSoft, weight: 700, letterSpacing: 1.5, }); for (let i = 0; i < total; i += 1) { const filled = i < state.lives; const x = right - (total - 1 - i) * (size + gap) - size / 2; const lost = !filled && i === state.lives; const punch = lost ? Ease.pulse(this.livesPunch) : 0; renderer.sprite(heart, x, y + 12, { anchor: [0.5, 0.5], height: size * (1 + punch * 0.35), alpha: filled ? 1 : 0.22, filter: filled ? undefined : "grayscale(0.8)", }); } } #renderCombo(renderer, state) { if (state.combo < 2) return; const punch = Ease.pulse(this.comboPunch); const x = 44; const y = 136; renderer.text(`COMBO x${state.multiplier}`, x, y, { size: 30 + punch * 8, align: "left", color: state.multiplier >= 4 ? Palette.goldDark : Palette.ink, stroke: "#ffffff", strokeWidth: 6, }); renderer.text(`${state.combo} in a row`, x, y + 30, { size: 19, align: "left", color: Palette.inkSoft, weight: 700, }); } #renderPower(renderer, state) { const w = 232; const x = (Viewport.width - w) / 2; const y = 112; renderer.panel(x, y, w, 26, { fill: Palette.white, stroke: Palette.outline, lineWidth: 3, radius: 13, shadow: false, }); renderer.panel(x + 3, y + 3, Math.max(6, (w - 6) * clamp(state.powerRatio, 0, 1)), 20, { fill: Palette.mint, stroke: null, radius: 10, shadow: false, }); renderer.text("POWER HAMMER", x + w / 2, y + 13, { size: 15, color: Palette.ink, weight: 800, letterSpacing: 1.2, }); } }
In HUD.js, find where the hearts are drawn and make it show
five instead of three. Then set lives: 5 in a difficulty preset so it is
actually true.
The screens
Six screens. Paste them, refresh, and you have the whole game.
This is the longest lesson and the least surprising one — by now you have met
every idea these files use. Paste them in order, then update
src/main.js to register them (the last snippet below).
Read GameScene.js most carefully. It is the biggest file, but notice how
little it actually does: it owns almost no logic and mostly routes between the
systems you already wrote.
A player must know which mole not to hit before the first one appears. The game says so in three places: the How to Play cards, the legend during the countdown, and the red ring around a grumpy mole on the board.
All three read from the same MoleTypes table. Add a mole and all
three update. They cannot drift, because there is only one list.
SceneManager constructs a new instance every time you navigate. That is
why "play again" is one line. If scenes were reused you would need a
reset() on each one, and every bug would be something you forgot to clear
in it.
You are done
Refresh. Menu, How to Play, a full round, results, high scores, settings. All of it, running on code you pasted and can read.
Create and paste these 6 files
Each block is one complete file. Create it at the path shown, paste, save.
import { getDifficulty } from "../config/difficulty.js"; import { GameConfig } from "../config/gameConfig.js"; import { Palette } from "../config/palette.js"; import { Scene } from "../core/Scene.js"; import { Ease, formatNumber } from "../core/utils.js"; import { Backdrop } from "../ui/Backdrop.js"; import { PanelButton, SpriteButton } from "../ui/Button.js"; import { breathe } from "../ui/Controls.js"; import { drawTitle } from "../ui/Title.js"; import { UiLayer } from "../ui/Widget.js"; import { Viewport } from "../core/Viewport.js"; /** Left column width, shared by the stat panel and every button. */ const COLUMN_W = 264; /** Height for the one drawn button, matched to the sprite buttons' height. */ const BUTTON_H = 88; const BUTTON_GAP = 12; const STACK_TOP = 296; /** Space between the button column and the hero art. */ const GUTTER = 90; const HERO_W = 520; /** Vertical centre of the hero art, matched to the left column's centre. */ const HERO_Y = 442; /** * Main menu: the game's front door. * * A button column on the left and the sheet's isometric board art on the right, * laid out as one centred group. */ export class MenuScene extends Scene { enter() { this.backdrop = new Backdrop(this.assets, { horizon: 0.46 }); this.ui = new UiLayer(); this.ui.onActivate = () => this.audio.play("sfx.click"); // PLAY routes through the rules card the first time, so nobody starts a run // without knowing which moles are safe to hit. const play = () => this.settings.get("seenRules") ? this.goto("game") : this.goto("rules", { then: "game" }); const sprite = (key, onPress) => this.ui.add( new SpriteButton({ image: this.assets.image(key), width: COLUMN_W, x: 0, y: 0, onPress }), ); this.buttons = [ sprite("ui.btn.play", play), // The sheet has no "how to play" button art, so this one is drawn — sized // to match the sprite buttons exactly so the column stays even. this.ui.add( new PanelButton({ label: "HOW TO PLAY", fill: Palette.violet, size: 25, width: COLUMN_W, height: BUTTON_H, x: 0, y: 0, onPress: () => this.goto("rules"), }), ), sprite("ui.btn.highscores", () => this.goto("highscores")), sprite("ui.btn.settings", () => this.goto("settings")), ]; this.layout(); this.audio.playMusic("music.menu"); } /** * Two columns — the button stack and the hero art — centred as one group. * * The stack is built from each button's **real height** rather than a table of * hard-coded tops, so buttons of differing heights can never overlap and the * gaps stay even. */ layout() { const heroW = Math.min(HERO_W, Viewport.width * 0.42); const groupW = COLUMN_W + GUTTER + heroW; const groupX = Viewport.centerX - groupW / 2; const columnX = groupX + COLUMN_W / 2; this.heroWidth = heroW; this.heroX = groupX + COLUMN_W + GUTTER + heroW / 2; this.bestPanel = { x: columnX - COLUMN_W / 2, y: 198, width: COLUMN_W, height: 78 }; let cursor = STACK_TOP; for (const button of this.buttons) { button.x = columnX; button.y = cursor + button.height / 2; cursor += button.height + BUTTON_GAP; } this.stackBottom = cursor - BUTTON_GAP; } onResize() { this.layout(); } update(dt) { this.backdrop.update(dt); this.ui.update(dt); } onPointerMove(point) { this.ui.pointerMove(point); } onPointerDown(point) { this.ui.pointerDown(point); } onPointerUp(point) { this.ui.pointerUp(point); } onKeyDown(event) { if (this.ui.handleKey(event)) return; if (event.code === "Enter" || event.code === "Space") this.buttons[0].activate(); } render(renderer) { const { width } = Viewport; this.backdrop.render(renderer); // Hero art. const hero = this.assets.image("ui.board.iso"); const float = Math.sin(this.age * 1.4) * 8; renderer.sprite(hero, this.heroX, HERO_Y + float, { anchor: [0.5, 0.5], width: this.heroWidth, shadow: { color: "rgba(74,67,88,0.28)", blur: 26, y: 12 }, }); drawTitle(renderer, "WHACK-A-MOLE", width / 2, 110, { size: 78, wave: 5, time: this.age, }); renderer.text("Three rows. Sixty seconds. One very determined hammer.", width / 2, 172, { size: 24, color: Palette.ink, stroke: "rgba(255,255,255,0.9)", strokeWidth: 5, weight: 700, }); this.#renderBestScore(renderer); this.ui.render(renderer); renderer.text("Enter to play · Arrow keys to navigate · F for fullscreen", width / 2, 702, { size: 19, color: Palette.ink, alpha: breathe(this.age, 2.2, 0.45, 0.85), weight: 700, stroke: "rgba(255,255,255,0.8)", strokeWidth: 4, }); } #renderBestScore(renderer) { const difficulty = getDifficulty(this.settings.get("difficulty")); const best = this.game.highScores.best(difficulty.id); const { x, y, width: w, height: h } = this.bestPanel; const pop = 1 + Ease.pulse(Math.max(0, 1 - this.age)) * 0.05; renderer.panel(x, y, w * pop, h, { fill: Palette.cream, stroke: Palette.outline, lineWidth: 4, radius: 24, }); renderer.text(`BEST · ${difficulty.label.toUpperCase()}`, x + 20, y + 24, { size: 17, align: "left", color: Palette.inkSoft, weight: 700, letterSpacing: 1.4, }); renderer.text(best ? formatNumber(best) : "—", x + 20, y + 53, { size: 30, align: "left", color: Palette.ink, }); } }
import { Palette } from "../config/palette.js"; import { MOLE_TYPE_LIST } from "../config/moleTypes.js"; import { Scene } from "../core/Scene.js"; import { Viewport } from "../core/Viewport.js"; import { Ease, clamp } from "../core/utils.js"; import { Backdrop } from "../ui/Backdrop.js"; import { PanelButton } from "../ui/Button.js"; import { drawCaption } from "../ui/Title.js"; import { UiLayer } from "../ui/Widget.js"; /** Panel padding, and the gaps between its three bands. */ const PANEL_MAX_W = 980; const PANEL_PAD = 40; const SUBTITLE_SIZE = 24; const SUBTITLE_GAP = 30; const FOOTER_SIZE = 19; const FOOTER_GAP = 34; const FOOTER_LINE_H = 28; /** * How far the HOW TO PLAY caption rises above the panel's top edge. * * `drawCaption` treats its `y` as the pill's *top*, so this one number is both * the draw offset and the headroom the layout reserves — used in both places so * the two cannot drift apart. */ const CAPTION_OVERHANG = 26; /** Gap between the foot of the panel and the primary button. */ const BUTTON_GAP = 22; /** * Card geometry, in coordinates relative to the card's own top-left corner. * * The badge is placed from the card's **foot** while everything above it is * placed from the **head**, so the two are guaranteed to meet with a gap * between them. Deriving both from the same edge is what let the points line * creep under the badge. */ const CARD = { height: 290, gap: 18, /** Panel edge to the first card. */ inset: 36, portraitHeight: 118, /** Where the portrait's feet rest. */ portraitBase: 150, nameY: 182, pointsY: 210, badgeHeight: 40, /** Badge to the card's bottom edge. */ badgeInset: 18, }; const SUBTITLE = "Moles pop out of the holes. Whack them before they duck back down."; const FOOTER_LINES = [ "Hitting the Grumpy Mole costs a life — it is ringed in red, so leave it alone.", "4 hits in a row raises your multiplier · 10 in a row unlocks the Power Hammer.", ]; /** * "How to play" — the one screen that answers *which moles do I hit?* * * Shown automatically before a player's first ever run (see `seenRules` in * settings) and reachable any time from the menu. The same data drives the * in-run legend, so the rules can never drift from the actual mole table. */ export class RulesScene extends Scene { enter(params = {}) { /** Scene to continue to when the player presses the primary button. */ this.then = params.then ?? "menu"; this.backdrop = new Backdrop(this.assets, { horizon: 0.5 }); this.ui = new UiLayer(); this.ui.onActivate = () => this.audio.play("sfx.click"); this.primary = this.ui.add( new PanelButton({ label: this.then === "game" ? "LET'S GO!" : "BACK", fill: this.then === "game" ? Palette.mint : Palette.coral, x: 0, y: 0, onPress: () => this.#continue(), }), ); this.layout(); } /** * Measure the three bands, size the panel around them, then centre the whole * group — caption, panel and button — on the viewport. * * The panel is exactly as tall as its contents, so changing the card height * or adding a footer line re-flows the screen instead of leaving a pool of * dead space above the button. */ layout() { const panelWidth = Math.min(PANEL_MAX_W, Viewport.width - 80); const footerHeight = FOOTER_LINE_H * (FOOTER_LINES.length - 1) + FOOTER_SIZE; const content = SUBTITLE_SIZE + SUBTITLE_GAP + CARD.height + FOOTER_GAP + footerHeight; const panelHeight = content + PANEL_PAD * 2; const groupHeight = CAPTION_OVERHANG + panelHeight + BUTTON_GAP + this.primary.height; const panelY = Math.round((Viewport.height - groupHeight) / 2) + CAPTION_OVERHANG; this.panel = { x: Viewport.centerX - panelWidth / 2, y: panelY, width: panelWidth, height: panelHeight, }; let cursor = panelY + PANEL_PAD; this.subtitleY = cursor + SUBTITLE_SIZE / 2; cursor += SUBTITLE_SIZE + SUBTITLE_GAP; this.cardsTop = cursor; cursor += CARD.height + FOOTER_GAP; this.footerY = cursor + FOOTER_SIZE / 2; this.cardWidth = (panelWidth - CARD.inset * 2 - CARD.gap * (MOLE_TYPE_LIST.length - 1)) / MOLE_TYPE_LIST.length; this.primary.x = Viewport.centerX; this.primary.y = panelY + panelHeight + BUTTON_GAP + this.primary.height / 2; } onResize() { this.layout(); } #continue() { // Seeing this screen once is enough; later runs go straight into play. this.settings.set("seenRules", true); this.goto(this.then); } onPointerMove(point) { this.ui.pointerMove(point); } onPointerDown(point) { this.ui.pointerDown(point); } onPointerUp(point) { this.ui.pointerUp(point); } onKeyDown(event) { if (this.ui.handleKey(event)) return; if (["Enter", "Space", "Escape"].includes(event.code)) this.#continue(); } render(renderer) { this.backdrop.render(renderer); renderer.veil(0.26, "#3a2f57"); const { x, y, width, height } = this.panel; const centerX = Viewport.centerX; renderer.panel(x, y, width, height, { fill: Palette.lavender, stroke: Palette.outline, lineWidth: 5, radius: 32, }); drawCaption(renderer, "HOW TO PLAY", centerX, y - CAPTION_OVERHANG, { fill: Palette.peach, size: 30, }); renderer.text(SUBTITLE, centerX, this.subtitleY, { size: SUBTITLE_SIZE, color: Palette.ink, weight: 700, }); this.#renderMoleCards(renderer); FOOTER_LINES.forEach((line, i) => { renderer.text(line, centerX, this.footerY + i * FOOTER_LINE_H, { size: FOOTER_SIZE, color: i === 0 ? Palette.coralDark : Palette.inkSoft, weight: 700, }); }); this.ui.render(renderer); } /** One card per mole type: portrait, name, points, and HIT / DON'T HIT. */ #renderMoleCards(renderer) { const top = this.cardsTop; const cardW = this.cardWidth; const startX = this.panel.x + CARD.inset; const badgeTop = top + CARD.height - CARD.badgeInset - CARD.badgeHeight; MOLE_TYPE_LIST.forEach((type, index) => { const x = startX + index * (cardW + CARD.gap); const avoid = Boolean(type.livesDelta); // Cards stagger in so the eye is walked across them left to right. const pop = Ease.outBack(clamp((this.age - index * 0.09) * 3, 0, 1)); if (pop <= 0) return; const cardCenterX = x + cardW / 2; const cy = top + CARD.height / 2; renderer.ctx.save(); renderer.ctx.translate(cardCenterX, cy); renderer.ctx.scale(pop, pop); renderer.ctx.translate(-cardCenterX, -cy); renderer.panel(x, top, cardW, CARD.height, { fill: avoid ? "#ffe3de" : Palette.white, stroke: avoid ? Palette.coralDark : Palette.outline, lineWidth: avoid ? 5 : 4, radius: 24, }); // Portrait — the very sprite the player will see on the board. const portrait = this.assets.image(type.idleFrames[0]); const bob = Math.sin(this.age * 2.2 + index) * 3; renderer.sprite(portrait, cardCenterX, top + CARD.portraitBase + bob, { anchor: [0.5, 1], height: CARD.portraitHeight, }); renderer.text(type.label, cardCenterX, top + CARD.nameY, { size: 21, color: Palette.ink, }); renderer.text(scoreLine(type), cardCenterX, top + CARD.pointsY, { size: FOOTER_SIZE, color: avoid ? Palette.coralDark : Palette.inkSoft, weight: 700, }); // The verdict badge — the single most important thing on this screen. renderer.panel(x + 18, badgeTop, cardW - 36, CARD.badgeHeight, { fill: avoid ? Palette.coral : Palette.mint, stroke: Palette.outline, lineWidth: 3, radius: 20, shadow: false, }); renderer.text( avoid ? "✕ DON'T HIT" : "✓ HIT IT", cardCenterX, badgeTop + CARD.badgeHeight / 2, { size: FOOTER_SIZE, color: Palette.ink, letterSpacing: 0.6 }, ); renderer.ctx.restore(); }); } } /** * "+50 pts", or "−25 pts · −1 life" for a mole that punishes you. * * The life cost is read from the type rather than written out, so a mole that * costs two lives says so without anyone remembering to update this screen. * Both signs use a typographic minus, so `−25` and `−1` match. */ function scoreLine(type) { if (type.points > 0) return `+${type.points} pts`; const points = `−${Math.abs(type.points)} pts`; if (!type.livesDelta) return points; const lives = Math.abs(type.livesDelta); return `${points} · −${lives} ${lives === 1 ? "life" : "lives"}`; }
import { getDifficulty } from "../config/difficulty.js"; import { GameConfig } from "../config/gameConfig.js"; import { MOLE_TYPE_LIST } from "../config/moleTypes.js"; import { Palette } from "../config/palette.js"; import { Scene } from "../core/Scene.js"; import { Ease, clamp, formatNumber, randRange } from "../core/utils.js"; import { Board } from "../entities/Board.js"; import { Hammer } from "../entities/Hammer.js"; import { ParticleSystem } from "../systems/ParticleSystem.js"; import { ScoreKeeper } from "../systems/ScoreKeeper.js"; import { Spawner } from "../systems/Spawner.js"; import { Backdrop } from "../ui/Backdrop.js"; import { PanelButton } from "../ui/Button.js"; import { HUD } from "../ui/HUD.js"; import { drawCaption } from "../ui/Title.js"; import { UiLayer } from "../ui/Widget.js"; import { Viewport } from "../core/Viewport.js"; /** Phases of a single run. */ const Phase = { COUNTDOWN: "countdown", PLAYING: "playing", ENDING: "ending", }; /** * The run itself. * * Composition over inheritance: this scene owns a Board, a Spawner, a * ScoreKeeper, a ParticleSystem, a HUD and a Hammer, and its job is to route * input and events between them. Gameplay rules live in the systems. */ export class GameScene extends Scene { enter() { this.preset = getDifficulty(this.settings.get("difficulty")); this.reducedMotion = Boolean(this.settings.get("reducedMotion")); /** Particle volume multiplier — halved when the player asks for less motion. */ this.fxScale = this.reducedMotion ? 0.5 : 1; this.backdrop = new Backdrop(this.assets, { horizon: 0.24 }); this.board = new Board(this.assets); this.particles = new ParticleSystem(this.assets); this.hud = new HUD(this.assets); this.hammer = new Hammer(this.assets); this.hammer.setSkin(this.settings.get("hammerSkin")); this.score = new ScoreKeeper({ bus: this.bus, lives: this.preset.lives }); this.spawner = new Spawner({ board: this.board, assets: this.assets, preset: this.preset, bus: this.bus, }); /** @type {import("../entities/Mole.js").Mole[]} */ this.moles = []; this.phase = Phase.COUNTDOWN; this.countdown = GameConfig.session.countdownFrom; this.timeLeft = this.preset.duration; this.elapsed = 0; this.endTimer = 0; this.shake = 0; this.paused = false; this.lastCountdownTick = null; this.#buildPauseUi(); this.#subscribe(); // The hammer is the cursor for the duration of the run. this.game.setPointerVisible(false); this.audio.playMusic("music.game"); } exit() { this.unsubscribe?.forEach((off) => off()); this.game.setPointerVisible(true); this.audio.stopMusic(); } // ------------------------------------------------------------------ wiring #subscribe() { this.unsubscribe = [ this.bus.on("mole:spawned", (mole) => { this.moles.push(mole); this.audio.play("sfx.pop", { rate: randRange(0.92, 1.08) }); }), this.bus.on("combo:milestone", ({ multiplier }) => { this.hud.punchCombo(); this.particles.popText(`x${multiplier}`, 210, 220, { color: Palette.goldDark, size: 46, }); }), this.bus.on("power:start", () => { this.audio.play("sfx.power"); this.particles.popText("POWER HAMMER!", Viewport.centerX, 240, { color: Palette.mintDark, size: 44, life: 1.2, }); }), this.bus.on("window:hidden", () => { if (this.phase === Phase.PLAYING) this.#setPaused(true); }), ]; } #buildPauseUi() { this.pauseUi = new UiLayer(); this.pauseUi.onActivate = () => this.audio.play("sfx.click"); const cx = 0; this.pauseUi.add( new PanelButton({ label: "RESUME", fill: Palette.mint, x: cx, y: 348, width: 300, onPress: () => this.#setPaused(false), }), ); this.pauseUi.add( new PanelButton({ label: "RESTART", fill: Palette.peach, x: cx, y: 444, width: 300, onPress: () => this.goto("game"), }), ); this.pauseUi.add( new PanelButton({ label: "QUIT TO MENU", fill: Palette.coral, x: cx, y: 540, width: 300, onPress: () => this.goto("menu"), }), ); this.layout(); } /** Re-centre everything that depends on the viewport width. */ layout() { for (const widget of this.pauseUi.widgets) widget.x = Viewport.centerX; } onResize() { this.board.layout(); this.layout(); } // ------------------------------------------------------------------- input onPointerMove(point) { if (this.paused) this.pauseUi.pointerMove(point); } onPointerDown(point) { if (this.paused) { this.pauseUi.pointerDown(point); return; } if (this.phase === Phase.PLAYING) this.#swing(point); } onPointerUp(point) { if (this.paused) this.pauseUi.pointerUp(point); } onKeyDown(event) { if (event.code === "Escape" || event.code === "KeyP") { if (this.phase !== Phase.ENDING) this.#setPaused(!this.paused); return; } if (this.paused) { this.pauseUi.handleKey(event); return; } if (event.code === "Space" && this.phase === Phase.PLAYING) { this.#swing(this.input.pointer); } } onBlur() { if (this.phase === Phase.PLAYING) this.#setPaused(true); } // ------------------------------------------------------------------- logic #setPaused(paused) { if (this.paused === paused) return; this.paused = paused; // The pause menu needs a real pointer; the hammer steps aside. this.hammer.visible = !paused; this.game.setPointerVisible(paused); this.bus.emit(paused ? "game:paused" : "game:resumed"); } /** Resolve one hammer swing at `point`. */ #swing(point) { this.hammer.strike(); this.score.registerSwing(); const radius = this.hammer.hitRadius; const targets = this.score.powered ? this.board.molesWithin(point, radius) : [this.board.moleAt(point, radius)].filter(Boolean); if (!targets.length) { this.score.registerMiss(); this.audio.play("sfx.miss"); this.particles.burst("dirt", point.x, point.y + 18, { scale: 0.6 * this.fxScale }); return; } for (const mole of targets) this.#resolveHit(mole); } #resolveHit(mole) { if (!mole.strike()) return; const type = mole.type; const x = mole.hole.x; const y = mole.centerY; const result = this.score.registerHit(type); this.particles.burst("dirt", x, mole.hole.rimY + 10, { scale: this.fxScale }); this.particles.burst(type.hitFx, x, y - 10, { scale: this.fxScale }); this.audio.play(type.sfx ?? "sfx.whack", { rate: randRange(0.95, 1.06) }); if (result.lifeLost) { this.particles.popText("-1 LIFE", x, y - 40, { color: Palette.coralDark, size: 38 }); this.shake = this.reducedMotion ? 0 : 1; } else { const label = result.multiplier > 1 ? `+${formatNumber(result.points)} x${result.multiplier}` : `+${formatNumber(result.points)}`; this.particles.popText(label, x, y - 40, { color: type.id === "gold" ? Palette.goldDark : Palette.ink, size: type.id === "gold" ? 42 : 34, }); } if (type.timeBonus) { const bonus = this.score.addTimeBonus(GameConfig.session.goldenTimeBonus); this.timeLeft += bonus; this.particles.popText(`+${bonus.toFixed(1)}s`, x, y - 82, { color: Palette.mintDark, size: 30, }); } } /** Sweep finished moles, crediting escapes. */ #reapMoles() { for (let i = this.moles.length - 1; i >= 0; i -= 1) { const mole = this.moles[i]; if (!mole.isFinished) continue; this.moles.splice(i, 1); if (mole.struck) continue; this.score.registerEscape(mole.type); if (mole.type.id !== "bomb") { this.particles.burst("dirt", mole.hole.x, mole.hole.rimY + 8, { scale: 0.5 * this.fxScale }); } } } #endRun(reason) { if (this.phase === Phase.ENDING) return; this.phase = Phase.ENDING; this.endReason = reason; this.endTimer = 0; this.audio.play("sfx.gameover"); this.audio.stopMusic(); } update(dt) { this.backdrop.update(dt); this.hud.update(dt, this.#hudState()); if (this.paused) { this.pauseUi.update(dt); this.hammer.update(dt, this.input.pointer); return; } // Derived, not event-driven: the hammer can never desync from the run state. this.hammer.setPowered(this.score.powered); this.hammer.update(dt, this.input.pointer); this.particles.update(dt); this.board.update(dt); for (const mole of this.moles) mole.update(dt); this.#reapMoles(); if (this.shake > 0) this.shake = Math.max(0, this.shake - dt * 2.6); switch (this.phase) { case Phase.COUNTDOWN: { this.countdown -= dt; const tick = Math.ceil(this.countdown); if (tick !== this.lastCountdownTick && tick > 0) { this.lastCountdownTick = tick; this.audio.play("sfx.countdown"); } if (this.countdown <= 0) { this.phase = Phase.PLAYING; // A distinct sound rather than the tick pitched up: the source // recording ends on its own flourish, so GO gets the real thing. this.audio.play("sfx.go"); } break; } case Phase.PLAYING: { this.elapsed += dt; this.timeLeft -= dt; this.score.update(dt); this.spawner.update(dt, this.progress); if (this.score.isOut) this.#endRun("lives"); else if (this.timeLeft <= 0) { this.timeLeft = 0; this.#endRun("time"); } break; } case Phase.ENDING: { this.endTimer += dt; if (this.endTimer > 1.15) { this.goto("gameover", { summary: this.score.summary(this.preset.id), reason: this.endReason, }); } break; } default: break; } } /** 0..1 through the run — drives the difficulty curve. */ get progress() { return clamp(1 - this.timeLeft / this.preset.duration, 0, 1); } // ------------------------------------------------------------------ render render(renderer) { this.backdrop.render(renderer); const shake = this.shake > 0 ? Ease.pulse(this.shake) * 14 : 0; const dx = shake ? randRange(-shake, shake) : 0; const dy = shake ? randRange(-shake, shake) : 0; renderer.withOffset(dx, dy, () => { this.board.render(renderer); this.particles.render(renderer); }); this.hud.render(renderer, this.#hudState()); // The legend belongs to the countdown only. It used to linger into the first // seconds of play as a strip along the foot of the screen, which sat on top // of the back row of holes and clipped against the bottom edge — instructions // covering the very thing they describe. if (this.phase === Phase.COUNTDOWN) this.#renderCountdown(renderer); if (this.phase === Phase.ENDING) this.#renderTimeUp(renderer); this.hammer.render(renderer); if (this.paused) this.#renderPause(renderer); } #hudState() { return { score: this.score.score, combo: this.score.combo, multiplier: this.score.multiplier, lives: this.score.lives, maxLives: this.preset.lives, timeLeft: this.timeLeft, elapsed: this.elapsed, powered: this.score.powered, powerRatio: this.score.powerTimer / GameConfig.scoring.powerDuration, }; } #renderCountdown(renderer) { const { width, height } = Viewport; const value = Math.ceil(this.countdown); const fraction = 1 - (this.countdown - Math.floor(this.countdown)); const scale = 1 + Ease.outBack(clamp(fraction * 2, 0, 1)) * 0.25; renderer.veil(0.34); renderer.text(value > 0 ? String(value) : "GO!", width / 2, height / 2 - 130, { size: 150 * scale, color: Palette.white, stroke: Palette.violetDark, strokeWidth: 16, }); renderer.text("Whack every mole you can", width / 2, height / 2 - 30, { size: 26, color: Palette.white, weight: 700, }); this.#renderLegend(renderer); } /** * The rule, restated on every run: which moles to hit and which to leave. * * Countdown only. The board is empty and dimmed at that point, so the legend * can sit large and centred without hiding anything the player needs. Once * play begins the rule is carried by the coloured ring and aura drawn around * a special mole on the board itself. */ #renderLegend(renderer) { const cards = MOLE_TYPE_LIST; const cardW = 176; const cardH = 78; const gap = 14; const totalW = cards.length * cardW + (cards.length - 1) * gap; const x0 = Viewport.centerX - totalW / 2; const y = 556; renderer.text("HIT THESE · NOT THE RED ONE", Viewport.centerX, y - 28, { size: 20, color: Palette.white, weight: 800, letterSpacing: 1.8, shadow: { color: "rgba(43,36,64,0.65)", blur: 8, y: 2 }, }); cards.forEach((type, index) => { const avoid = Boolean(type.livesDelta); const x = x0 + index * (cardW + gap); renderer.panel(x, y, cardW, cardH, { fill: avoid ? "#ffdcd6" : Palette.white, stroke: avoid ? Palette.coralDark : Palette.outline, lineWidth: avoid ? 4 : 3, radius: 18, alpha: 0.96, }); // Portrait sized by width so it always fits its column, whatever the // sprite's aspect ratio. const portraitW = 64; renderer.sprite(this.assets.image(type.idleFrames[0]), x + 14 + portraitW / 2, y + cardH - 7, { anchor: [0.5, 1], width: portraitW, }); const textX = x + 22 + portraitW; renderer.text(avoid ? "DON'T HIT" : "HIT", textX, y + 28, { size: avoid ? 17 : 20, color: avoid ? Palette.coralDark : Palette.mintDark, align: "left", }); // Typographic minus, matching the How to Play cards. renderer.text(avoid ? "−1 life" : `+${type.points}`, textX, y + 54, { size: 16, color: avoid ? Palette.coralDark : Palette.inkSoft, align: "left", weight: 700, }); }); } #renderTimeUp(renderer) { const { width, height } = Viewport; const pop = Ease.outBack(clamp(this.endTimer * 3, 0, 1)); renderer.veil(0.3 * clamp(this.endTimer * 2, 0, 1)); const label = this.endReason === "lives" ? "OUT OF LIVES!" : "TIME'S UP!"; renderer.text(label, width / 2, height / 2, { size: 86 * pop, color: Palette.white, stroke: Palette.coralDark, strokeWidth: 14, }); } #renderPause(renderer) { const { width } = Viewport; renderer.veil(0.45); renderer.panel(width / 2 - 246, 236, 492, 384, { fill: Palette.lavender, stroke: Palette.outline, lineWidth: 5, radius: 30, }); drawCaption(renderer, "PAUSED", width / 2, 258, { fill: Palette.violet, size: 30 }); this.pauseUi.render(renderer); renderer.text("Esc to resume", width / 2, 598, { size: 18, color: Palette.inkSoft, weight: 700, }); } }
import { getDifficulty } from "../config/difficulty.js"; import { GameConfig } from "../config/gameConfig.js"; import { Palette } from "../config/palette.js"; import { Scene } from "../core/Scene.js"; import { Ease, clamp, formatNumber, randRange } from "../core/utils.js"; import { ParticleSystem } from "../systems/ParticleSystem.js"; import { Backdrop } from "../ui/Backdrop.js"; import { PanelButton } from "../ui/Button.js"; import { drawStatRow } from "../ui/Controls.js"; import { UiLayer } from "../ui/Widget.js"; import { Viewport } from "../core/Viewport.js"; /** * Results screen. * * Submits the run to the local leaderboard, then reports it back: final score, * best combo, accuracy, and whether the run took the top slot. */ export class GameOverScene extends Scene { enter(params = {}) { this.summary = params.summary ?? { score: 0, combo: 0, accuracy: 0, hits: 0, swings: 0, escapes: 0, difficulty: this.settings.get("difficulty"), }; this.reason = params.reason ?? "time"; this.difficulty = getDifficulty(this.summary.difficulty); this.rank = this.game.highScores.submit(this.summary); this.isNewBest = this.rank === 0; this.backdrop = new Backdrop(this.assets, { horizon: 0.5 }); this.particles = new ParticleSystem(this.assets); this.confettiTimer = 0; this.ui = new UiLayer(); this.ui.onActivate = () => this.audio.play("sfx.click"); this.playButton = this.ui.add( new PanelButton({ label: "PLAY AGAIN", fill: Palette.mint, x: 0, y: 638, width: 280, onPress: () => this.goto("game"), }), ); this.menuButton = this.ui.add( new PanelButton({ label: "MENU", fill: Palette.violet, x: 0, y: 638, width: 240, onPress: () => this.goto("menu"), }), ); this.layout(); this.audio.playMusic("music.menu"); } /** * Lay out the results panel, the decorative board art and the buttons as one * balanced group. * * The panel and the art are treated as a single unit that is centred on the * viewport; the buttons then centre on the **panel**, not on the screen — that * mismatch is what made the earlier layout look off. On narrow viewports the * art is dropped and the panel centres on its own. */ layout() { const panelW = 560; const artW = 330; const gap = 44; this.showArt = Viewport.width >= 1160; const groupW = this.showArt ? panelW + gap + artW : panelW; const groupX = Viewport.centerX - groupW / 2; this.panelRect = { x: groupX, y: 168, width: panelW, height: 404 }; this.art = this.showArt ? { x: groupX + panelW + gap + artW / 2, y: this.panelRect.y + this.panelRect.height / 2, width: artW, } : null; // Buttons: one row, centred under the panel. const panelCenterX = groupX + panelW / 2; const rowGap = 28; const rowW = this.playButton.width + rowGap + this.menuButton.width; const rowX = panelCenterX - rowW / 2; this.playButton.x = rowX + this.playButton.width / 2; this.menuButton.x = rowX + this.playButton.width + rowGap + this.menuButton.width / 2; const buttonY = this.panelRect.y + this.panelRect.height + 66; this.playButton.y = buttonY; this.menuButton.y = buttonY; } onResize() { this.layout(); } update(dt) { this.backdrop.update(dt); this.ui.update(dt); this.particles.update(dt); // Celebrate a new personal best with a steady sparkle shower. if (this.isNewBest && this.age < 2.6) { this.confettiTimer -= dt; if (this.confettiTimer <= 0) { this.confettiTimer = 0.14; // Spawn in the outer thirds only: the middle is where the GAME OVER tag // sits, and burying it in sparkles reads as a glitch. const { x, y, width } = this.panelRect; const left = Math.random() < 0.5; const from = left ? x - 20 : x + width * 0.72; this.particles.burst("sparkle", randRange(from, from + width * 0.28), randRange(y - 70, y - 10)); } } } onPointerMove(point) { this.ui.pointerMove(point); } onPointerDown(point) { this.ui.pointerDown(point); } onPointerUp(point) { this.ui.pointerUp(point); } onKeyDown(event) { if (this.ui.handleKey(event)) return; if (event.code === "Enter" || event.code === "Space") this.goto("game"); if (event.code === "Escape") this.goto("menu"); } render(renderer) { this.backdrop.render(renderer); renderer.veil(0.24, "#3a2f57"); const pop = Ease.outBack(clamp(this.age * 2.4, 0, 1)); const { x: panelX, y: panelY, width: panelW, height: panelH } = this.panelRect; // Decorative board illustration, sitting beside the panel in the group. if (this.art) { renderer.sprite(this.assets.image("ui.board.result"), this.art.x, this.art.y, { anchor: [0.5, 0.5], width: this.art.width, alpha: 0.92, }); } renderer.panel(panelX, panelY, panelW, panelH * pop, { fill: Palette.lavender, stroke: Palette.outline, lineWidth: 5, radius: 32, }); if (pop < 0.98) return; renderer.sprite(this.assets.image("ui.tag.gameover"), panelX + panelW / 2, panelY - 6, { anchor: [0.5, 0.5], width: 300, shadow: { color: Palette.shadow, blur: 14, y: 6 }, }); const centerX = panelX + panelW / 2; renderer.text(this.reason === "lives" ? "You ran out of lives" : "Time's up!", centerX, panelY + 62, { size: 22, color: Palette.inkSoft, weight: 700, }); renderer.text(formatNumber(this.summary.score), centerX, panelY + 126, { size: 78, color: Palette.ink, stroke: Palette.white, strokeWidth: 9, }); renderer.text("FINAL SCORE", centerX, panelY + 176, { size: 18, color: Palette.inkSoft, weight: 700, letterSpacing: 2, }); if (this.isNewBest) { // Pinned to the panel's top-right corner as a sticker, deliberately clear // of the score line underneath it. const wobble = Math.sin(this.age * 5) * 0.06; renderer.ctx.save(); renderer.ctx.translate(panelX + panelW - 24, panelY + 46); renderer.ctx.rotate(-0.18 + wobble); renderer.panel(-74, -24, 148, 48, { fill: Palette.gold, stroke: Palette.outline, lineWidth: 4, radius: 24, }); renderer.text("NEW BEST!", 0, 2, { size: 24, color: Palette.ink }); renderer.ctx.restore(); } const rowX = panelX + 46; const rowW = panelW - 92; let rowY = panelY + 224; const step = 42; drawStatRow(renderer, rowX, rowY, rowW, "Difficulty", this.difficulty.label); rowY += step; drawStatRow(renderer, rowX, rowY, rowW, "Best combo", `${this.summary.combo}`, { color: this.summary.combo >= 10 ? Palette.goldDark : Palette.ink, }); rowY += step; drawStatRow( renderer, rowX, rowY, rowW, "Accuracy", `${Math.round(this.summary.accuracy * 100)}%`, ); rowY += step; drawStatRow( renderer, rowX, rowY, rowW, "Moles whacked", `${this.summary.hits} / ${this.summary.swings} swings`, ); if (this.rank >= 0 && !this.isNewBest) { renderer.text(`Leaderboard rank #${this.rank + 1}`, centerX, panelY + panelH - 24, { size: 20, color: Palette.violetDark, weight: 700, }); } this.particles.render(renderer); this.ui.render(renderer); renderer.text("Enter to play again · Esc for the menu", panelX + panelW / 2, 700, { size: 18, color: Palette.white, weight: 700, alpha: 0.85, }); } }
import { getDifficulty } from "../config/difficulty.js"; import { GameConfig } from "../config/gameConfig.js"; import { Palette } from "../config/palette.js"; import { Scene } from "../core/Scene.js"; import { formatNumber } from "../core/utils.js"; import { Backdrop } from "../ui/Backdrop.js"; import { PanelButton, SpriteButton } from "../ui/Button.js"; import { drawCaption } from "../ui/Title.js"; import { UiLayer } from "../ui/Widget.js"; import { Viewport } from "../core/Viewport.js"; /** Local leaderboard, stored in localStorage by {@link HighScoreStore}. */ export class HighScoresScene extends Scene { enter() { this.backdrop = new Backdrop(this.assets, { horizon: 0.52 }); this.ui = new UiLayer(); this.ui.onActivate = () => this.audio.play("sfx.click"); this.backButton = this.ui.add( new SpriteButton({ image: this.assets.image("ui.btn.back"), x: 0, y: 648, width: 260, onPress: () => this.goto("menu"), }), ); this.clearButton = this.ui.add( new PanelButton({ label: "CLEAR", fill: Palette.plum, x: 0, y: 648, width: 220, height: 82, onPress: () => { this.game.highScores.clear(); this.entries = []; }, }), ); this.entries = this.game.highScores.entries; this.layout(); } /** Centre the two buttons as one row, not individually against the screen. */ layout() { const gap = 28; const rowW = this.backButton.width + gap + this.clearButton.width; const rowX = Viewport.centerX - rowW / 2; this.backButton.x = rowX + this.backButton.width / 2; this.clearButton.x = rowX + this.backButton.width + gap + this.clearButton.width / 2; } onResize() { this.layout(); } update(dt) { this.backdrop.update(dt); this.ui.update(dt); } onPointerMove(point) { this.ui.pointerMove(point); } onPointerDown(point) { this.ui.pointerDown(point); } onPointerUp(point) { this.ui.pointerUp(point); } onKeyDown(event) { if (this.ui.handleKey(event)) return; if (event.code === "Escape" || event.code === "Backspace") this.goto("menu"); } render(renderer) { const { width } = Viewport; this.backdrop.render(renderer); renderer.veil(0.22, "#3a2f57"); const panelW = 760; const panelX = width / 2 - panelW / 2; const panelY = 118; const panelH = 468; renderer.panel(panelX, panelY, panelW, panelH, { fill: Palette.lavender, stroke: Palette.outline, lineWidth: 5, radius: 32, }); drawCaption(renderer, "HIGH SCORES", width / 2, panelY - 26, { fill: Palette.peach, size: 30, }); if (!this.entries.length) { renderer.sprite(this.assets.image("mole.pop.4"), width / 2, panelY + 300, { anchor: [0.5, 1], scale: 1.1, }); renderer.text("No runs yet — go make some history.", width / 2, panelY + 340, { size: 24, color: Palette.inkSoft, weight: 700, }); this.ui.render(renderer); return; } const headerY = panelY + 54; const left = panelX + 40; const right = panelX + panelW - 40; renderer.text("#", left, headerY, { size: 17, align: "left", color: Palette.inkSoft, weight: 700 }); renderer.text("SCORE", left + 54, headerY, { size: 17, align: "left", color: Palette.inkSoft, weight: 700, }); renderer.text("MODE", left + 250, headerY, { size: 17, align: "left", color: Palette.inkSoft, weight: 700, }); renderer.text("COMBO", left + 420, headerY, { size: 17, align: "left", color: Palette.inkSoft, weight: 700, }); renderer.text("ACC", right, headerY, { size: 17, align: "right", color: Palette.inkSoft, weight: 700, }); // Draw the full table height, including empty slots, so a short list still // reads as a leaderboard rather than a stray row. for (let index = this.entries.length; index < GameConfig.highScores.limit; index += 1) { const y = panelY + 96 + index * 46; renderer.panel(left - 14, y - 20, panelW - 52, 40, { fill: Palette.white, stroke: null, radius: 14, shadow: false, alpha: 0.22, }); renderer.text(`${index + 1}`, left, y, { size: 22, align: "left", color: Palette.inkSoft, alpha: 0.5, }); renderer.text("—", left + 54, y, { size: 22, align: "left", color: Palette.inkSoft, alpha: 0.5, }); } this.entries.slice(0, GameConfig.highScores.limit).forEach((entry, index) => { const y = panelY + 96 + index * 46; const top = index === 0; renderer.panel(left - 14, y - 20, panelW - 52, 40, { fill: top ? Palette.gold : index % 2 ? Palette.white : Palette.cream, stroke: null, radius: 14, shadow: false, alpha: top ? 0.85 : 0.6, }); renderer.text(`${index + 1}`, left, y, { size: 22, align: "left", color: top ? Palette.goldDark : Palette.inkSoft, }); renderer.text(formatNumber(entry.score), left + 54, y, { size: 26, align: "left", color: Palette.ink, }); renderer.text(getDifficulty(entry.difficulty).label, left + 250, y, { size: 22, align: "left", color: Palette.inkSoft, weight: 700, }); renderer.text(`x${entry.combo ?? 0}`, left + 420, y, { size: 22, align: "left", color: Palette.ink, }); renderer.text(`${Math.round((entry.accuracy ?? 0) * 100)}%`, right, y, { size: 22, align: "right", color: Palette.ink, }); }); this.ui.render(renderer); } }
import { DIFFICULTY_IDS, Difficulties, getDifficulty } from "../config/difficulty.js"; import { Palette } from "../config/palette.js"; import { Scene } from "../core/Scene.js"; import { Backdrop } from "../ui/Backdrop.js"; import { SpriteButton } from "../ui/Button.js"; import { OptionCycler, Slider } from "../ui/Controls.js"; import { drawCaption } from "../ui/Title.js"; import { UiLayer } from "../ui/Widget.js"; import { Viewport } from "../core/Viewport.js"; /** * Panel geometry. Only the width is fixed — `x`, `y` and `height` are all * derived in `layout()`, so the panel is always exactly as tall as the controls * inside it. A hard-coded height drifts out of step the moment a row is added. */ const PANEL = { width: 640 }; const panelX = () => Viewport.centerX - PANEL.width / 2; const ROW_GAP = 8; const ROW_INSET = 20; /** Space reserved under a control that carries an explanatory line of its own. */ const NOTE_BLOCK = 30; /** Gap between the foot of the panel and the BACK button. */ const BUTTON_GAP = 22; /** * How far the SETTINGS caption pill rises above the panel's top edge. * * `drawCaption` treats its `y` as the pill's *top*, so this single number is * both the draw offset and the headroom the layout has to reserve — used in * both places precisely so the two cannot drift apart. */ const CAPTION_OVERHANG = 26; /** * Settings. * * Every control writes straight through to the persisted SettingsStore, so * changes survive a reload and apply to the next run without an explicit save. */ export class SettingsScene extends Scene { enter() { this.backdrop = new Backdrop(this.assets, { horizon: 0.52 }); this.ui = new UiLayer(); this.ui.onActivate = () => this.audio.play("sfx.click"); const centerX = Viewport.centerX; // Controls are stacked from their own reported row heights (control plus its // caption), so adding or resizing a control cannot silently overlap another. const rows = []; const stack = (widget) => { rows.push(widget); return this.ui.add(widget); }; this.difficultyCycler = stack( new OptionCycler({ label: "DIFFICULTY", x: centerX, y: 0, options: DIFFICULTY_IDS.map((id) => ({ id, label: Difficulties[id].label })), value: this.settings.get("difficulty"), onChange: (id) => this.settings.set("difficulty", id), }), ); // A note is drawn under the control it belongs to, and the stack reserves a // line for it. The difficulty blurb describes *this* control, so anchoring // it here is what stops it drifting to the panel foot and reading as a // caption for whatever row happens to be last. this.difficultyCycler.note = () => getDifficulty(this.difficultyCycler.value.id).blurb; stack( new OptionCycler({ label: "HAMMER", x: centerX, y: 0, options: [ { id: "standard", label: "Standard Hammer" }, { id: "power", label: "Power Hammer" }, ], value: this.settings.get("hammerSkin"), onChange: (id) => this.settings.set("hammerSkin", id), }), ); stack( new Slider({ label: "MUSIC", x: centerX, y: 0, value: this.settings.get("musicVolume"), onChange: (value) => { this.settings.set("musicVolume", value); this.audio.setMusicVolume(value); }, }), ); stack( new Slider({ label: "SOUND EFFECTS", x: centerX, y: 0, value: this.settings.get("sfxVolume"), onChange: (value) => { this.settings.set("sfxVolume", value); this.audio.setSfxVolume(value); }, }), ); stack( new OptionCycler({ label: "MOTION", x: centerX, y: 0, options: [ { id: "full", label: "Full effects" }, { id: "reduced", label: "Reduced motion" }, ], value: this.settings.get("reducedMotion") ? "reduced" : "full", onChange: (id) => this.settings.set("reducedMotion", id === "reduced"), }), ); this.rows = rows; this.backButton = this.ui.add( new SpriteButton({ image: this.assets.image("ui.btn.back"), x: centerX, y: 0, width: 240, onPress: () => this.goto("menu"), }), ); this.layout(); } /** * Measure the control stack, size the panel around it, then centre the whole * group — caption, panel and BACK button — on the viewport. * * Measuring before placing is what lets the panel be exactly as tall as its * contents. Every widget reports its own `rowHeight` (control plus caption), * so adding a row or resizing a control re-flows the panel instead of * silently overflowing it. */ layout() { // Pass 1 — how tall is the content? let content = 0; for (const widget of this.rows) { content += widget.rowHeight + (widget.note ? NOTE_BLOCK : 0) + ROW_GAP; } content -= ROW_GAP; // Pass 2 — size the panel and centre the group vertically. The caption // overhangs the panel's top edge, so it is counted in the group height; // without it the group centres too high and the caption clips off-screen. this.panelHeight = content + ROW_INSET * 2; const backHeight = this.backButton.height; const groupHeight = CAPTION_OVERHANG + this.panelHeight + BUTTON_GAP + backHeight; this.panelY = Math.round((Viewport.height - groupHeight) / 2) + CAPTION_OVERHANG; // Pass 3 — place everything. let cursor = this.panelY + ROW_INSET; for (const widget of this.rows) { widget.x = Viewport.centerX; widget.y = cursor + widget.labelBlock + widget.height / 2; cursor += widget.rowHeight; if (widget.note) { widget.noteY = cursor + NOTE_BLOCK / 2; cursor += NOTE_BLOCK; } cursor += ROW_GAP; } this.contentBottom = cursor - ROW_GAP; this.backButton.x = Viewport.centerX; this.backButton.y = this.panelY + this.panelHeight + BUTTON_GAP + backHeight / 2; } onResize() { this.layout(); } update(dt) { this.backdrop.update(dt); this.ui.update(dt); } onPointerMove(point) { this.ui.pointerMove(point); // Live-drag support for the sliders. for (const widget of this.ui.widgets) widget.onPointerDrag?.(point); } onPointerDown(point) { this.ui.pointerDown(point); } onPointerUp(point) { this.ui.pointerUp(point); } onKeyDown(event) { if (this.ui.handleKey(event)) return; if (event.code === "Escape" || event.code === "Backspace") this.goto("menu"); } render(renderer) { const { width } = Viewport; this.backdrop.render(renderer); renderer.veil(0.22, "#3a2f57"); renderer.panel(panelX(), this.panelY, PANEL.width, this.panelHeight, { fill: Palette.lavender, stroke: Palette.outline, lineWidth: 5, radius: 32, }); drawCaption(renderer, "SETTINGS", width / 2, this.panelY - CAPTION_OVERHANG, { fill: Palette.violet, size: 30, }); // What the selected difficulty actually changes, on the DIFFICULTY label row. const preset = getDifficulty(this.difficultyCycler.value.id); renderer.text( `${preset.duration}s · ${preset.lives} lives`, this.difficultyCycler.left + this.difficultyCycler.width, this.difficultyCycler.top - this.difficultyCycler.labelGap, { size: 20, align: "right", baseline: "bottom", color: Palette.ink }, ); this.ui.render(renderer); // Notes sit in the space their own control reserved for them. for (const widget of this.rows) { if (!widget.note) continue; renderer.text(widget.note(), width / 2, widget.noteY, { size: 19, color: Palette.inkSoft, weight: 700, }); } } }
Play a full round. Then find one thing you dislike — the timer is too short, the grumpy mole is too common, the hammer is too slow — and change it. You know where every number lives now.
Ship it
Put it on the internet with a URL you can send people.
No build step means deploying is genuinely a two-minute job.
Option A — drag and drop
- Go to vercel.com/new and sign up (free).
- Drag your project folder onto the page.
- About twenty seconds later you have a live URL.
Option B — from GitHub
git init
git add .
git commit -m "my whack-a-mole"
git branch -M main
git remote add origin <your-repo-url>
git push -u origin main
Then on Vercel choose Import Git Repository, leave every setting at its default, and deploy. There is no build command and no output directory to set.
Most tutorials have you fight with build settings because their project needs compiling first. Yours does not. The browser runs your files exactly as you wrote them, so deploying is just copying them to a server.
Add analytics
In your Vercel dashboard: your project → Analytics → Enable,
then redeploy. Add this to the bottom of index.html:
<script> window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); }; if (!["localhost", "127.0.0.1", ""].includes(location.hostname)) { const s = document.createElement("script"); s.defer = true; s.src = "/_vercel/insights/script.js"; document.head.appendChild(s); } </script>
That script only exists on a Vercel deployment. Load it unconditionally and you get a 404 in your console every single time you run the game locally, forever. Four lines to avoid permanently confusing yourself.
Where to go next
- Add a mole. One new entry in
moleTypes.jsand it spawns, gets its own aura, and appears on the How to Play screen automatically. - Add a screen. A pause menu showing your run stats. New file in
scenes/, register it inmain.js. - Break something on purpose. Delete a random line and read the error. Learning to read stack traces is worth more than this whole course.
You built a game.
Deploy it, then send the URL to one person and watch them play without explaining anything first. Where they hesitate is your next thing to fix.