DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

A word search that builds itself: eight words into a 12 12 grid, crossing only where letters agree, and a drag read as a line

A word search looks like a database problem — a dictionary, a lattice, a solver — but it is really just a 2-D array of characters and a pinch of vector geometry. I built a full one: eight words hidden in a fresh 12×12 grid every round, placed across, down, diagonally, forwards and backwards, selectable by dragging a straight line of cells. It came to about 120 lines of vanilla JavaScript, no libraries, no word database beyond a small array. Here is how it fits together.

The board is a 2-D array, and directions are vectors

There is no canvas and no tile atlas. The whole puzzle is an N×N array where every cell holds one character, and I start it as empty strings so the placement step can tell "free" from "taken." A parallel placements list records each hidden word and the exact cells it occupies — that record, not the letters, is the ground truth I check a player's selection against later.

The eight compass directions reduce to eight (row, col) step vectors. Once a direction is a vector, walking a word across the grid is one loop: add the vector to the current cell to get the next. The four directions with a negative component are exactly what give you backwards and upward words.

const DIRS = [
  [0, 1], [0,-1],   // E,  W
  [1, 0], [-1,0],   // S,  N
  [1, 1], [1,-1],   // SE, SW
  [-1,1], [-1,-1]   // NE, NW
];
// cell i of a word = { r: r0 + dr*i, c: c0 + dc*i }
Enter fullscreen mode Exit fullscreen mode

Placing a word is a gamble with one overlap rule

To hide a word I pick a random start cell and direction, bounds-check the far end, then walk the path. Each cell is legal if it is empty or already holds the exact letter this word wants there — that second case is a shared crossing, exactly like a crossword. Any other occupied cell is a clash and the whole attempt is rejected; I roll again up to 200 times.

function fits(word, r0, c0, dr, dc){
  for (let i = 0; i < word.length; i++){
    const cell = grid[r0+dr*i][c0+dc*i];
    if (cell !== "" && cell !== word[i]) return false;  // clash
  }
  return true;                                          // free or matching
}
Enter fullscreen mode Exit fullscreen mode

That one rule is what makes the board feel dense instead of a few words floating in noise. After eight words stick, I flood every still-empty cell with a random letter — that camouflage turns placed strings into an actual puzzle.

Selection is placement run backwards

The nicest part: reading a drag is the same walk in reverse. Two clicked cells — where you pressed and where the pointer is now — define a line only if it is horizontal, vertical, or a perfect 45° diagonal (dr===0 || dc===0 || |dr|===|dc|). When it qualifies, the step is the sign of each delta and the cells between are the same start + step × i I used to place.

function lineBetween(a, b){
  const dr = b.r - a.r, dc = b.c - a.c;
  if (!(dr===0 || dc===0 || Math.abs(dr)===Math.abs(dc))) return [a];
  const len = Math.max(Math.abs(dr), Math.abs(dc));
  const sr = Math.sign(dr), sc = Math.sign(dc);
  const path = [];
  for (let i=0;i<=len;i++) path.push({ r:a.r+sr*i, c:a.c+sc*i });
  return path;
}
Enter fullscreen mode Exit fullscreen mode

One set of pointer handlers gives mouse-drag and touch for free: pointerdown sets the anchor, pointermove uses elementFromPoint to find the cell under the finger, pointerup commits. A no-move tap routes to a tap-start / tap-end flow instead.

Validating without being fooled

When a line commits I compare its cell path — not the letters it spells — against the placements, forwards or reversed (a word reads the same swept either way). Matching cells rather than a string is deliberate: a lucky run through random filler can spell a real word by accident, and path-matching means that coincidence never falsely counts.

function samePath(a, b){                    // b, forwards or reversed
  if (a.length !== b.length) return false;
  const fwd = a.every((p,i) => p.r===b[i].r            && p.c===b[i].c);
  const rev = a.every((p,i) => p.r===b[b.length-1-i].r && p.c===b[b.length-1-i].c);
  return fwd || rev;
}
Enter fullscreen mode Exit fullscreen mode

The Hint button reuses the classic solver — stand on every cell, try all eight directions, match letter by letter — which is rows × cols × 8 × length, brute force, and on a 12×12 board effectively instant.

Play it (drag a line, or tap start then end):
https://dev48v.infy.uk/game/day42-word-search.html

Top comments (0)