Test store. Prices are the planned list prices stored as test data, license terms are drafts, and payments go to a Stripe test-mode account. No real money moves.

Word Search and Word Games in HTML5: Code and Tutorial

This word search game is one HTML file of about 280 lines: an editable word list, random placement in eight directions, drag-to-select with snapping, a list of found words and a timer. It plays with touch, mouse or keyboard. Play it above, copy the code and save it as word-game.html. It's MIT-licensed, and the same parts make hangman or a typing game.

Runs in your browser in a sandbox · 281 lines · no downloads

A word search hides a list of words in a grid of letters, forwards, backwards and diagonally, and the player drags across each one to find it. This HTML5 word search game source code builds a new puzzle every round from a word list you can edit, then handles the part most tutorials skip: turning a wobbly finger drag into a clean straight line. Below, we walk through placing the words, filling the grid, snapping the selection and checking it, and then reuse the same parts for hangman and a typing game.

How to run it

  1. Press Copy code above (or Download .html).
  2. Paste it into a new file called word-game.html in any text editor.
  3. Double-click the file to play it in your browser.

There's nothing to install. To try your own words first, paste the code into the HTML game code runner, change the WORDS list and press Run.

How the code works

The game is one <canvas> and one script. The script builds a puzzle, draws it every frame, and turns pointer and keyboard input into a selection. Each part is short; the interesting logic is in four functions: findSpot, makePuzzle, snap and finish.

1. Settings and the word list

The settings and the word list sit together at the top. SIZE sets the grid, WORDS_PER_GAME how many words hide in each puzzle, and ALLOW_BACKWARDS whether words may run right to left and upwards. Words longer than the grid are skipped automatically.

word-game.html
// ----- Settings -----
const SIZE = 10;                     // the grid is SIZE × SIZE letters
const WORDS_PER_GAME = 8;            // words hidden in each puzzle
const ALLOW_BACKWARDS = true;        // words may also read right to left and upwards
// The word list: edit freely (capital letters A-Z). Each puzzle picks words at random.
const WORDS = ["CANVAS", "PIXEL", "SPRITE", "SCRIPT", "BROWSER", "KEYBOARD", "SCORE", "LEVEL", "PUZZLE", "LETTER",
  "PLAYER", "TIMER", "BONUS", "ARCADE", "VECTOR", "ROCKET", "PLANET", "GARDEN", "DRAGON", "CASTLE"];

2. Placing the words

findSpot() picks a random cell and a random direction, checks that the word would end inside the grid, then walks along it. Every cell must be empty or already hold the same letter, which is how words cross each other. After 300 failed tries it gives up, and makePuzzle() moves on to the next word in the shuffled list.

js
function findSpot(word) {
  const dirs = [[1, 0], [0, 1], [1, 1], [1, -1]];
  if (ALLOW_BACKWARDS) dirs.push(...dirs.map(([dx, dy]) => [-dx, -dy]));
  for (let tries = 0; tries < 300; tries++) {
    const [dx, dy] = dirs[Math.floor(Math.random() * dirs.length)];
    const x = Math.floor(Math.random() * SIZE), y = Math.floor(Math.random() * SIZE);
    const ex = x + dx * (word.length - 1), ey = y + dy * (word.length - 1);
    if (ex < 0 || ey < 0 || ex >= SIZE || ey >= SIZE) continue;
    let fits = true;
    for (let i = 0; i < word.length && fits; i++) {
      const c = grid[y + dy * i][x + dx * i];
      fits = !c || c === word[i];
    }
    if (fits) return { x, y, dx, dy };
  }
  return null;
}

Directions are stored as steps: [1, 0] moves one cell right, [1, -1] moves right and up. Reversing a direction is just negating both numbers, so the four forward directions become eight with one line.

Random placement is simple and works well for a 10×10 grid with eight words. If you push the grid to its limits with long words, some won't fit; the game then hides fewer words and the counter shows the real number.

3. Filling the gaps

Most word search code fills empty cells with random letters from A to Z. That has a flaw: a Z or an X then appears about as often as an E, so a single Z in a corner points straight at PUZZLE. This game fills the gaps with letters taken from the hidden words themselves, so the grid has the same mix of letters as the answers and is full of near misses.

js
words.sort((a, b) => a.word.localeCompare(b.word));
// Fill the gaps with letters taken from the hidden words, so a rare letter like Z
// doesn't give its word away and the grid is full of near misses.
const pool = words.map((w) => w.word).join("");
for (const row of grid) for (let x = 0; x < SIZE; x++) {
  if (!row[x]) row[x] = pool[Math.floor(Math.random() * pool.length)];
}

4. Snapping a drag to a straight line

A finger never moves in a perfect line. snap() takes the start cell and the cell under the pointer, measures the angle with Math.atan2, rounds it to the nearest 45 degrees and rebuilds the line along that direction. The length is the larger of the two distances, shortened if needed so the line stays inside the grid.

js
function snap(x1, y1, x, y) {
  const len = Math.max(Math.abs(x - x1), Math.abs(y - y1));
  if (len === 0) return { x2: x1, y2: y1 };
  const a = Math.round(Math.atan2(y - y1, x - x1) / (Math.PI / 4)) * (Math.PI / 4);
  const sx = Math.round(Math.cos(a)), sy = Math.round(Math.sin(a));
  let n = len;                       // shorten the line until it fits inside the grid
  while (n > 0 && (x1 + sx * n < 0 || x1 + sx * n >= SIZE || y1 + sy * n < 0 || y1 + sy * n >= SIZE)) n--;
  return { x2: x1 + sx * n, y2: y1 + sy * n };
}

This is the difference between a word search that feels fair on a phone and one that doesn't. Without snapping, a drag that drifts half a cell selects nothing useful.

5. Checking a word

When the player lets go, finish() reads the letters along the line and compares them, forwards and reversed, with every word not yet found. A match gets a coloured highlight; a miss flashes red for half a second. Single taps are ignored, so touching the grid to look closer never counts as a wrong guess.

js
const dx = Math.sign(s.x2 - s.x1), dy = Math.sign(s.y2 - s.y1);
const n = Math.max(Math.abs(s.x2 - s.x1), Math.abs(s.y2 - s.y1)) + 1;
if (n < 2) return;                 // a single tap is not a guess
let text = "";
for (let i = 0; i < n; i++) text += grid[s.y1 + dy * i][s.x1 + dx * i];
const back = [...text].reverse().join("");
const hit = words.find((w) => !w.found && (w.word === text || w.word === back));
if (!hit) { miss = { ...s, t: 0.5 }; return; }

Checking the letters rather than the stored position has a side effect worth knowing: if the filler letters happen to spell a hidden word a second time, either copy counts. That's the behaviour players expect.

6. Drawing the highlights

Each found word is drawn as one thick line with rounded ends, from the centre of its first cell to the centre of its last, before the letters go on top. That single lineCap = "round" stroke gives the familiar capsule shape in all eight directions, with no special cases for diagonals.

js
function line(s, color) {
  ctx.strokeStyle = color;
  ctx.lineWidth = cell * 0.78;
  ctx.lineCap = "round";
  ctx.beginPath();
  ctx.moveTo(boardX + (s.x1 + 0.5) * cell, boardY + (s.y1 + 0.5) * cell);
  ctx.lineTo(boardX + (s.x2 + 0.5) * cell, boardY + (s.y2 + 0.5) * cell);
  ctx.stroke();
}

The colours are semi-transparent, so where two found words cross, both highlights stay visible.

7. Input: drag, or arrows and Space

A drag starts on pointerdown, follows pointermove and ends on pointerup. The call to setPointerCapture keeps the moves coming even when a finger slides off the grid, and the cell lookup clamps positions to the board edge, so overshooting the last letter still selects it.

js
canvas.addEventListener("pointerdown", (e) => {
  showCursor = false;
  if (state !== "playing") { start(); return; }
  const c = cellAt(e);
  if (!c.inside) return;
  canvas.setPointerCapture(e.pointerId); // keep receiving moves even outside the canvas
  sel = { x1: c.x, y1: c.y, x2: c.x, y2: c.y };
});

Keyboard players move an orange cursor with the arrow keys or WASD, press Space or Enter on the first letter, move to the last letter and press it again. Escape cancels. The same snap() function shapes keyboard selections, so they follow the same rules as a drag.

Make it work on phones

The layout changes with the screen's shape. On a wide window the word list sits to the right of the grid; on a tall phone screen it moves below the grid in three columns. On a 390-pixel phone each cell is 36 pixels, big enough to start a drag on the right letter. touch-action: none stops the browser from scrolling or zooming while the player drags.

For small screens, keep SIZE at 10 or below; a 12×12 grid on a 390-pixel phone shrinks cells to 30 pixels, and players start missing their first letter. Our guide to HTML5 mobile game development covers touch input and screen sizes in more depth.

Hangman and typing games from the same parts

A word list, a check against it and a drawn result are the core of most word games. Two common ones need only small changes.

HTML5 hangman game code

Hangman keeps one secret word and a set of guessed letters. The display shows each letter if it's been guessed and an underscore if not; each wrong guess costs a life. This sketch is the whole rule set:

js
let word = WORDS[Math.floor(Math.random() * WORDS.length)];
let guessed = new Set(), lives = 6;

function guess(letter) {
  if (guessed.has(letter) || lives === 0) return;
  guessed.add(letter);
  if (!word.includes(letter)) lives--;
}
const shown = () => [...word].map((c) => (guessed.has(c) ? c : "_")).join(" ");
const won = () => [...word].every((c) => guessed.has(c));

On a computer, call guess(e.key.toUpperCase()) from a keydown listener when the key is a single letter. Phones need on-screen letter buttons, 26 real <button> elements in a grid, which also makes the game usable with a screen reader. Draw the figure in six steps on a canvas, one per lost life, or swap it for something friendlier, like a balloon losing air.

A typing game

In a typing game, words fall from the top and the player types them before they land. Keep each falling word as { word, x, y }, move y down in the loop, and compare what the player has typed with the words on screen:

js
let typed = "";
window.addEventListener("keydown", (e) => {
  if (e.key === "Backspace") typed = typed.slice(0, -1);
  else if (/^[a-z]$/i.test(e.key)) typed += e.key.toUpperCase();
  const hit = falling.findIndex((f) => f.word === typed);
  if (hit >= 0) { score += typed.length; falling.splice(hit, 1); typed = ""; }
});

Typing games are the one word game that phones make awkward. The on-screen keyboard only opens for a focused text field, and many Android keyboards report key presses as "Unidentified" in keydown. Use a real <input> and read its value in the input event instead.

Ideas to extend it

  • Themed lists: pick a list (animals, space, a lesson's vocabulary) on the start screen.
  • Hide the word list and show only the count, for a harder mode.
  • Add a "hint" button that flashes the first letter of a random unfound word, with a time penalty.
  • Give each puzzle a seed so two players can race on the same grid.
  • Play a soft note for each found word with the Web Audio API, started after the first tap.
  • Make it a daily puzzle: build the grid from the date as the seed.

From prototype to a game you can sell

This HTML5 word game source code is a good base for a classroom, a newsletter puzzle or a jam entry. A commercial word game needs much more around it:

This word search fileA production word game
Content20 words, random gridsCurated lists, levels, daily puzzles, several languages
Art and soundCanvas text, no audioOriginal art, animation, music and effects
SavesBest time lost on reloadProgress, streaks and settings saved per player
PlatformsDesktop and phone browsersPortal SDK hooks, ads, app wrappers
QualityTested by handAutomated tests and cross-browser QA
LicenceMIT, do anythingA commercial licence that covers your use

We don't sell a word game yet. If you'd like one in our catalog, suggest it on the roadmap or vote for an existing idea on the public roadmap. If you need a word game made only for you, send a brief through the exclusive game request: it's free to send, we review briefs within 5 business days, and you pay only if we accept.

If you're after a brain-teaser for players today, these two puzzle games are the closest fit. Both have daily puzzles, hints and undo:

You can also browse all our puzzle games with source code, or play more free games like the memory match game and the quiz game.

The complete code

One file, no libraries. Save it as word-game.html and open it in any browser. Free to use under the MIT license: copy it, change it, ship it.

word-game.html
<!doctype html>
<!-- SPDX-License-Identifier: MIT -->
<!-- Word Search: a complete HTML5 canvas word game in one file. Free to use, change and ship (MIT license). -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Word Search (HTML5 canvas)</title>
<style>
  html, body { margin: 0; height: 100%; background: #0e1726; color: #eef2ff; font: 16px system-ui, sans-serif; overflow: hidden; touch-action: none; }
  canvas { display: block; }
</style>
</head>
<body>
<canvas id="game"></canvas>
<script>
// ----- Settings -----
const SIZE = 10;                     // the grid is SIZE × SIZE letters
const WORDS_PER_GAME = 8;            // words hidden in each puzzle
const ALLOW_BACKWARDS = true;        // words may also read right to left and upwards
// The word list: edit freely (capital letters A-Z). Each puzzle picks words at random.
const WORDS = ["CANVAS", "PIXEL", "SPRITE", "SCRIPT", "BROWSER", "KEYBOARD", "SCORE", "LEVEL", "PUZZLE", "LETTER",
  "PLAYER", "TIMER", "BONUS", "ARCADE", "VECTOR", "ROCKET", "PLANET", "GARDEN", "DRAGON", "CASTLE"];
const COLORS = { bg: "#0e1726", paper: "#f7f3e8", ink: "#1d2433", text: "#eef2ff", dim: "#8d98b8", cursor: "#e8590c", pick: "rgba(232, 89, 12, 0.35)", miss: "rgba(220, 40, 60, 0.35)" };
const MARKS = ["rgba(255, 196, 0, 0.5)", "rgba(64, 192, 255, 0.45)", "rgba(80, 220, 120, 0.45)", "rgba(255, 120, 200, 0.45)", "rgba(170, 130, 255, 0.45)"];

const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const HUD = 48;
let W = 0, H = 0, cell = 30, boardX = 0, boardY = 0, listX = 0, listY = 0, listCols = 1, wide = false;

// ----- Sizing: grid and word list side by side on wide screens, stacked on tall ones -----
function resize() {
  const dpr = window.devicePixelRatio || 1;
  W = window.innerWidth; H = window.innerHeight;
  canvas.style.width = W + "px";
  canvas.style.height = H + "px";
  canvas.width = Math.round(W * dpr);
  canvas.height = Math.round(H * dpr);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  wide = W > H * 1.15;
  const pad = 12;
  if (wide) {
    const listW = Math.min(220, W * 0.3);
    cell = Math.floor(Math.min(H - HUD - pad * 2, W - listW - pad * 3) / SIZE);
    boardX = Math.round((W - cell * SIZE - listW - pad) / 2);
    boardY = Math.round(HUD + (H - HUD - cell * SIZE) / 2);
    listX = boardX + cell * SIZE + pad * 2; listY = boardY + 8; listCols = 1;
  } else {
    listCols = W < 300 ? 2 : 3;
    const listH = Math.ceil(WORDS_PER_GAME / listCols) * 26 + pad + 8;
    cell = Math.floor(Math.min(W - pad * 2, H - HUD - listH - pad * 2) / SIZE);
    boardX = Math.round((W - cell * SIZE) / 2);
    boardY = Math.round(HUD + (H - HUD - listH - cell * SIZE) / 2);
    listX = boardX; listY = boardY + cell * SIZE + pad + 16;
  }
  draw();
}

// ----- Game state -----
let grid = [], words = [], marks = [], sel = null, miss = null;
let state = "ready";                 // ready → playing → over
let time = 0, best = null, last = 0, cursor = { x: 0, y: 0 }, showCursor = false;

function shuffle(a) {
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}

// ----- Building a puzzle: place words, then fill the gaps -----
function makePuzzle() {
  grid = Array.from({ length: SIZE }, () => Array(SIZE).fill(""));
  words = []; marks = []; sel = null; miss = null; time = 0;
  for (const word of shuffle(WORDS.filter((w) => w.length <= SIZE))) {
    if (words.length === WORDS_PER_GAME) break;
    const spot = findSpot(word);
    if (!spot) continue;               // no room left for this word: try the next one
    for (let i = 0; i < word.length; i++) grid[spot.y + spot.dy * i][spot.x + spot.dx * i] = word[i];
    words.push({ word, found: false });
  }
  words.sort((a, b) => a.word.localeCompare(b.word));
  // Fill the gaps with letters taken from the hidden words, so a rare letter like Z
  // doesn't give its word away and the grid is full of near misses.
  const pool = words.map((w) => w.word).join("");
  for (const row of grid) for (let x = 0; x < SIZE; x++) {
    if (!row[x]) row[x] = pool[Math.floor(Math.random() * pool.length)];
  }
}

// Try random positions and directions until the word fits (crossing only on equal letters).
function findSpot(word) {
  const dirs = [[1, 0], [0, 1], [1, 1], [1, -1]];
  if (ALLOW_BACKWARDS) dirs.push(...dirs.map(([dx, dy]) => [-dx, -dy]));
  for (let tries = 0; tries < 300; tries++) {
    const [dx, dy] = dirs[Math.floor(Math.random() * dirs.length)];
    const x = Math.floor(Math.random() * SIZE), y = Math.floor(Math.random() * SIZE);
    const ex = x + dx * (word.length - 1), ey = y + dy * (word.length - 1);
    if (ex < 0 || ey < 0 || ex >= SIZE || ey >= SIZE) continue;
    let fits = true;
    for (let i = 0; i < word.length && fits; i++) {
      const c = grid[y + dy * i][x + dx * i];
      fits = !c || c === word[i];
    }
    if (fits) return { x, y, dx, dy };
  }
  return null;
}

function start() {
  if (state === "over") makePuzzle();
  state = "playing";
}

// ----- Selecting: snap a drag to one of the 8 straight directions -----
function snap(x1, y1, x, y) {
  const len = Math.max(Math.abs(x - x1), Math.abs(y - y1));
  if (len === 0) return { x2: x1, y2: y1 };
  const a = Math.round(Math.atan2(y - y1, x - x1) / (Math.PI / 4)) * (Math.PI / 4);
  const sx = Math.round(Math.cos(a)), sy = Math.round(Math.sin(a));
  let n = len;                       // shorten the line until it fits inside the grid
  while (n > 0 && (x1 + sx * n < 0 || x1 + sx * n >= SIZE || y1 + sy * n < 0 || y1 + sy * n >= SIZE)) n--;
  return { x2: x1 + sx * n, y2: y1 + sy * n };
}

// Read the letters under a selection and compare them with the hidden words.
function finish() {
  const s = sel;
  sel = null;
  const dx = Math.sign(s.x2 - s.x1), dy = Math.sign(s.y2 - s.y1);
  const n = Math.max(Math.abs(s.x2 - s.x1), Math.abs(s.y2 - s.y1)) + 1;
  if (n < 2) return;                 // a single tap is not a guess
  let text = "";
  for (let i = 0; i < n; i++) text += grid[s.y1 + dy * i][s.x1 + dx * i];
  const back = [...text].reverse().join("");
  const hit = words.find((w) => !w.found && (w.word === text || w.word === back));
  if (!hit) { miss = { ...s, t: 0.5 }; return; }
  hit.found = true;
  marks.push({ ...s, color: MARKS[marks.length % MARKS.length] });
  if (words.every((w) => w.found)) {
    state = "over";
    best = best === null ? time : Math.min(best, time);
  }
}

// ----- Main loop -----
function loop(now) {
  const dt = Math.min(0.1, (now - last) / 1000);
  last = now;
  if (state === "playing") time += dt;
  if (miss && (miss.t -= dt) <= 0) miss = null;
  draw();
  requestAnimationFrame(loop);
}

// ----- Drawing -----
function line(s, color) {
  ctx.strokeStyle = color;
  ctx.lineWidth = cell * 0.78;
  ctx.lineCap = "round";
  ctx.beginPath();
  ctx.moveTo(boardX + (s.x1 + 0.5) * cell, boardY + (s.y1 + 0.5) * cell);
  ctx.lineTo(boardX + (s.x2 + 0.5) * cell, boardY + (s.y2 + 0.5) * cell);
  ctx.stroke();
}

const clock = (t) => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`;

function draw() {
  ctx.fillStyle = COLORS.bg;
  ctx.fillRect(0, 0, W, H);
  ctx.fillStyle = COLORS.paper;
  ctx.beginPath();
  ctx.roundRect(boardX - 6, boardY - 6, cell * SIZE + 12, cell * SIZE + 12, 10);
  ctx.fill();
  for (const m of marks) line(m, m.color);
  if (miss) line(miss, COLORS.miss);
  if (sel) line(sel, COLORS.pick);
  if (showCursor && state === "playing") {
    ctx.strokeStyle = COLORS.cursor;
    ctx.lineWidth = 2.5;
    ctx.strokeRect(boardX + cursor.x * cell + 2, boardY + cursor.y * cell + 2, cell - 4, cell - 4);
  }
  ctx.fillStyle = COLORS.ink;
  ctx.font = `600 ${Math.round(cell * 0.56)}px system-ui, sans-serif`;
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  for (let y = 0; y < SIZE; y++) for (let x = 0; x < SIZE; x++) {
    ctx.fillText(grid[y][x], boardX + (x + 0.5) * cell, boardY + (y + 0.5) * cell + 1);
  }

  // Word list: found words are dimmed and crossed out.
  const colW = wide ? 200 : (cell * SIZE) / listCols;
  ctx.font = `600 ${Math.min(17, Math.max(13, cell * 0.45))}px system-ui, sans-serif`;
  ctx.textAlign = "left";
  words.forEach((w, i) => {
    const x = listX + (i % listCols) * colW, y = listY + Math.floor(i / listCols) * 26;
    ctx.fillStyle = w.found ? COLORS.dim : COLORS.text;
    ctx.fillText(w.word, x, y);
    if (w.found) ctx.fillRect(x - 2, y, ctx.measureText(w.word).width + 4, 2);
  });

  const font = Math.round(Math.min(18, Math.max(13, W / 26)));
  ctx.font = `600 ${font}px system-ui, sans-serif`;
  ctx.fillStyle = COLORS.text;
  ctx.textAlign = "left";
  ctx.fillText(`Found ${words.filter((w) => w.found).length}/${words.length}`, 12, HUD / 2);
  ctx.textAlign = "center";
  ctx.fillText(clock(time), W / 2, HUD / 2);
  ctx.textAlign = "right";
  ctx.fillText(best === null ? "" : `Best ${clock(best)}`, W - 12, HUD / 2);

  if (state === "playing") return;
  ctx.fillStyle = "rgba(6, 10, 20, 0.82)";
  ctx.fillRect(0, HUD, W, H - HUD);
  const cy = HUD + (H - HUD) / 2, pw = Math.min(W - 24, 480);
  ctx.fillStyle = "#1a2438";             // solid panel behind the text
  ctx.beginPath();
  ctx.roundRect((W - pw) / 2, cy - font * 4, pw, font * 8, 14);
  ctx.fill();
  ctx.fillStyle = COLORS.text;
  ctx.textAlign = "center";
  ctx.font = `700 ${font * 1.8}px system-ui, sans-serif`;
  ctx.fillText(state === "over" ? "All words found!" : "Word Search", W / 2, cy - font * 2);
  ctx.font = `500 ${font}px system-ui, sans-serif`;
  ctx.fillText(state === "over" ? `Time ${clock(time)}` : `Find the ${words.length} hidden words`, W / 2, cy);
  ctx.fillStyle = COLORS.dim;
  ctx.fillText("Drag across letters, or use arrows and Space", W / 2, cy + font * 1.6);
  ctx.fillText(state === "over" ? "Tap or press Space for a new puzzle" : "Tap or press Space to start", W / 2, cy + font * 3.2);
}

// ----- Input: drag with mouse or finger; arrows/WASD + Space on a keyboard -----
function cellAt(e) {
  const r = canvas.getBoundingClientRect();
  const x = Math.floor((e.clientX - r.left - boardX) / cell), y = Math.floor((e.clientY - r.top - boardY) / cell);
  return { x: Math.max(0, Math.min(SIZE - 1, x)), y: Math.max(0, Math.min(SIZE - 1, y)), inside: x >= 0 && y >= 0 && x < SIZE && y < SIZE };
}

canvas.addEventListener("pointerdown", (e) => {
  showCursor = false;
  if (state !== "playing") { start(); return; }
  const c = cellAt(e);
  if (!c.inside) return;
  canvas.setPointerCapture(e.pointerId); // keep receiving moves even outside the canvas
  sel = { x1: c.x, y1: c.y, x2: c.x, y2: c.y };
});
canvas.addEventListener("pointermove", (e) => {
  if (!sel || sel.keys) return;
  const c = cellAt(e);
  Object.assign(sel, snap(sel.x1, sel.y1, c.x, c.y));
});
canvas.addEventListener("pointerup", () => { if (sel && !sel.keys) finish(); });
canvas.addEventListener("pointercancel", () => { sel = null; });

const MOVES = { ArrowLeft: [-1, 0], KeyA: [-1, 0], ArrowRight: [1, 0], KeyD: [1, 0], ArrowUp: [0, -1], KeyW: [0, -1], ArrowDown: [0, 1], KeyS: [0, 1] };
window.addEventListener("keydown", (e) => {
  const m = MOVES[e.code];
  if (m && state === "playing") {
    e.preventDefault();
    showCursor = true;
    cursor = { x: Math.max(0, Math.min(SIZE - 1, cursor.x + m[0])), y: Math.max(0, Math.min(SIZE - 1, cursor.y + m[1])) };
    if (sel && sel.keys) Object.assign(sel, snap(sel.x1, sel.y1, cursor.x, cursor.y));
  } else if (e.code === "Space" || e.code === "Enter") {
    e.preventDefault();
    if (state !== "playing") { start(); return; }
    showCursor = true;
    if (sel && sel.keys) finish();     // second press: check the word
    else sel = { x1: cursor.x, y1: cursor.y, x2: cursor.x, y2: cursor.y, keys: true };
  } else if (e.code === "Escape") sel = null;
});

window.addEventListener("resize", resize);
makePuzzle();
resize();
requestAnimationFrame((t) => { last = t; loop(t); });
</script>
</body>
</html>

Frequently asked questions

Is this word search game source code free to use?

Yes. It's MIT-licensed: copy it, change it and use it in personal or commercial projects, as long as the licence line at the top of the file stays.

How do I use my own words, or words in another language?

Replace the WORDS array. Write the words in capitals, without spaces. Accented letters like É or Ñ work, because each cell holds one character. Scripts whose letters join, such as Arabic, show isolated letter forms in the grid, which some players find harder to read.

Why does the grid sometimes hide fewer than 8 words?

The placement is random and gives up on a word after 300 tries. With the default list that's rare, but long words in a small grid can run out of room. Raise SIZE, shorten the words or lower WORDS_PER_GAME.

How do I put this word search on my website?

Upload word-game.html to your web host and link to it, or show it inside a page with an <iframe>. Our guide to embedding an HTML5 game in a website covers WordPress, Wix and Google Sites.

How do I keep the best time after a reload?

Save best with localStorage.setItem when the last word is found and read it when the page loads. Wrap both calls in try/catch, because some browsers and sandboxed frames, like the player on this page, block storage.

We make and sell HTML5 game source code; links to our games are marked as ours.