Back to Frontend
Evergreen··30 min read

Vanilla JS Game & Widget Tutorials for Frontend Interviews

Build Tic-Tac-Toe, Connect Four, a Hierarchical Checkbox tree, and Snake from scratch using only HTML, CSS, and vanilla JavaScript. Complete, interview-ready implementations with thorough explanations.

javascriptvanilla-jsinterviewsgameshtmlcss
Share

Vanilla JS Game & Widget Tutorials for Frontend Interviews

Frontend interviews at top companies routinely ask you to build interactive applications from scratch — no frameworks, no libraries. These four projects cover the core patterns interviewers care about: state management, DOM manipulation, event handling, recursive data structures, and game loops.

Each tutorial provides a complete, working implementation broken into HTML, CSS, and JavaScript with detailed commentary on every design decision.


1. Tic-Tac-Toe

Tic-Tac-Toe is arguably the single most common frontend interview question. It tests your ability to manage game state, detect win conditions, and handle turn-based interaction cleanly.

What Interviewers Look For

  • Clean board state representation (flat array vs 2D)
  • Efficient win-condition checking (not brute-forcing every cell)
  • Clear separation between state and rendering
  • Handling draw detection
  • Reset functionality without page reload

Key Concepts

ConceptWhy It Matters
State-driven renderingThe board array is the single source of truth; the DOM is derived from it
Win detectionPre-defined winning lines checked against current state — O(1) lines to check
Event delegationOne click handler on the board container instead of nine on cells
Immutable turnscurrentPlayer flips only after a valid move

HTML

<div class="ttt-game">
  <h2 class="ttt-status">Player X's turn</h2>
  <div class="ttt-board" role="grid" aria-label="Tic-Tac-Toe board">
    <button class="ttt-cell" data-index="0" aria-label="Row 1, Column 1"></button>
    <button class="ttt-cell" data-index="1" aria-label="Row 1, Column 2"></button>
    <button class="ttt-cell" data-index="2" aria-label="Row 1, Column 3"></button>
    <button class="ttt-cell" data-index="3" aria-label="Row 2, Column 1"></button>
    <button class="ttt-cell" data-index="4" aria-label="Row 2, Column 2"></button>
    <button class="ttt-cell" data-index="5" aria-label="Row 2, Column 3"></button>
    <button class="ttt-cell" data-index="6" aria-label="Row 3, Column 1"></button>
    <button class="ttt-cell" data-index="7" aria-label="Row 3, Column 2"></button>
    <button class="ttt-cell" data-index="8" aria-label="Row 3, Column 3"></button>
  </div>
  <button class="ttt-reset">New Game</button>
</div>

CSS

.ttt-game {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 20px;
  font-family: system-ui, sans-serif;
}
 
.ttt-status {
  font-size: 1.25rem;
  min-height: 2rem;
}
 
.ttt-board {
  display: grid;
  grid-template-columns: repeat(3, 100px);
  grid-template-rows: repeat(3, 100px);
  gap: 4px;
  background: #334155;
  padding: 4px;
  border-radius: 8px;
}
 
.ttt-cell {
  width: 100px;
  height: 100px;
  background: #1e293b;
  border: none;
  border-radius: 4px;
  font-size: 2.5rem;
  font-weight: 700;
  color: #e2e8f0;
  cursor: pointer;
  transition: background 0.15s;
  display: flex;
  align-items: center;
  justify-content: center;
}
 
.ttt-cell:hover:not(:disabled) {
  background: #293548;
}
 
.ttt-cell:disabled {
  cursor: not-allowed;
}
 
.ttt-cell.x { color: #38bdf8; }
.ttt-cell.o { color: #f472b6; }
.ttt-cell.winner { background: #164e63; }
 
.ttt-reset {
  padding: 8px 24px;
  background: #334155;
  color: #e2e8f0;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 0.95rem;
}
 
.ttt-reset:hover { background: #475569; }

JavaScript

function TicTacToe(container) {
  // --- State ---
  let board = Array(9).fill(null);  // flat array: null | 'X' | 'O'
  let currentPlayer = 'X';
  let gameOver = false;
 
  // Pre-computed winning combinations (indices into the flat array).
  // This is the standard approach interviewers expect — three rows,
  // three columns, two diagonals = 8 lines total.
  const WIN_LINES = [
    [0, 1, 2], [3, 4, 5], [6, 7, 8], // rows
    [0, 3, 6], [1, 4, 7], [2, 5, 8], // columns
    [0, 4, 8], [2, 4, 6],            // diagonals
  ];
 
  // --- DOM references ---
  const statusEl = container.querySelector('.ttt-status');
  const cells = container.querySelectorAll('.ttt-cell');
  const resetBtn = container.querySelector('.ttt-reset');
 
  // --- Event delegation on the board ---
  // We attach one listener to the board container and use data-index
  // to identify which cell was clicked. This is more efficient than
  // attaching 9 separate listeners.
  const boardEl = container.querySelector('.ttt-board');
  boardEl.addEventListener('click', (e) => {
    const cell = e.target.closest('.ttt-cell');
    if (!cell) return;
 
    const index = Number(cell.dataset.index);
    handleMove(index);
  });
 
  resetBtn.addEventListener('click', reset);
 
  // --- Core logic ---
 
  function handleMove(index) {
    // Guard: ignore clicks on occupied cells or after game ends
    if (board[index] || gameOver) return;
 
    // 1. Update state
    board[index] = currentPlayer;
 
    // 2. Check for winner
    const winLine = checkWinner();
    if (winLine) {
      gameOver = true;
      render();
      highlightWin(winLine);
      statusEl.textContent = `Player ${currentPlayer} wins!`;
      return;
    }
 
    // 3. Check for draw — every cell filled, no winner
    if (board.every(cell => cell !== null)) {
      gameOver = true;
      render();
      statusEl.textContent = "It's a draw!";
      return;
    }
 
    // 4. Switch turns
    currentPlayer = currentPlayer === 'X' ? 'O' : 'X';
    render();
  }
 
  function checkWinner() {
    // Returns the winning line array if found, otherwise null.
    // We check if all three indices in any line share the same
    // non-null value.
    for (const line of WIN_LINES) {
      const [a, b, c] = line;
      if (board[a] && board[a] === board[b] && board[a] === board[c]) {
        return line;
      }
    }
    return null;
  }
 
  function highlightWin(line) {
    line.forEach(i => cells[i].classList.add('winner'));
  }
 
  function render() {
    // Sync DOM to state. Each cell displays the board value and is
    // disabled when occupied or when the game is over.
    cells.forEach((cell, i) => {
      cell.textContent = board[i] || '';
      cell.className = 'ttt-cell';
      if (board[i]) cell.classList.add(board[i].toLowerCase());
      cell.disabled = !!board[i] || gameOver;
      cell.setAttribute('aria-label',
        board[i]
          ? `Row ${Math.floor(i / 3) + 1}, Column ${(i % 3) + 1}: ${board[i]}`
          : `Row ${Math.floor(i / 3) + 1}, Column ${(i % 3) + 1}: empty`
      );
    });
    if (!gameOver) {
      statusEl.textContent = `Player ${currentPlayer}'s turn`;
    }
  }
 
  function reset() {
    board = Array(9).fill(null);
    currentPlayer = 'X';
    gameOver = false;
    render();
  }
 
  // Initial render
  render();
}
 
// Initialize
TicTacToe(document.querySelector('.ttt-game'));

How It Works — Step by Step

  1. State: A flat 9-element array represents the board. Index 0 is top-left, index 8 is bottom-right. Each cell is null, 'X', or 'O'.
  2. Moves: On click, we write the current player's mark into the array, then check for a win or draw before flipping the turn.
  3. Win detection: We iterate the 8 pre-defined lines. If all three cells in any line match the same non-null value, that player wins. This runs in constant time relative to board size.
  4. Rendering: After every state change, render() synchronises the entire DOM to the board array. This "re-render the world" pattern is simple and bug-free — the same approach React uses internally.
  5. Reset: Restoring the initial state and calling render() gives us a clean board with zero DOM hacking.

Common Follow-Up Questions

  • "How would you add an AI opponent?" — Implement minimax. For Tic-Tac-Toe, the state space is small enough that you can search exhaustively without alpha-beta pruning.
  • "Can you make the board size configurable (NxN)?" — Replace the hardcoded WIN_LINES with dynamically generated lines based on N. The flat array approach scales cleanly.
  • "How would you add undo?" — Maintain a history stack of board snapshots. Push a copy of board before each move; pop on undo.

2. Connect Four

Connect Four is a significant step up from Tic-Tac-Toe. It introduces gravity (pieces fall to the lowest available row) and a larger board that requires a more generalised win-detection algorithm. This is an excellent interview question because it tests whether you can think beyond hardcoded solutions.

What Interviewers Look For

  • Gravity mechanic — pieces drop to the bottom of the column
  • Generalised four-in-a-row detection (horizontal, vertical, both diagonals)
  • Column-full detection (disabling full columns)
  • Clean state representation for a 6×7 grid
  • Visual feedback — hover preview, animated drops

Key Concepts

ConceptWhy It Matters
Column-based inputPlayers choose a column, not a cell — the row is computed by gravity
Direction vectorsWin checking uses [dx, dy] pairs to scan in all four directions
2D array stateA rows × cols matrix is more natural than a flat array for a grid with gravity
Hover previewShows which column the player is targeting — improves UX and demonstrates DOM skill

HTML

<div class="c4-game">
  <h2 class="c4-status">Red's turn</h2>
  <div class="c4-board" role="grid" aria-label="Connect Four board">
    <!-- Column buttons for dropping pieces -->
    <div class="c4-columns">
      <button class="c4-col-btn" data-col="0" aria-label="Drop piece in column 1"></button>
      <button class="c4-col-btn" data-col="1" aria-label="Drop piece in column 2"></button>
      <button class="c4-col-btn" data-col="2" aria-label="Drop piece in column 3"></button>
      <button class="c4-col-btn" data-col="3" aria-label="Drop piece in column 4"></button>
      <button class="c4-col-btn" data-col="4" aria-label="Drop piece in column 5"></button>
      <button class="c4-col-btn" data-col="5" aria-label="Drop piece in column 6"></button>
      <button class="c4-col-btn" data-col="6" aria-label="Drop piece in column 7"></button>
    </div>
    <!-- The grid cells are generated by JavaScript -->
    <div class="c4-grid"></div>
  </div>
  <button class="c4-reset">New Game</button>
</div>

CSS

.c4-game {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
  font-family: system-ui, sans-serif;
}
 
.c4-status {
  font-size: 1.25rem;
  min-height: 2rem;
}
 
.c4-board {
  display: flex;
  flex-direction: column;
  background: #1e40af;
  padding: 12px;
  border-radius: 12px;
  gap: 4px;
}
 
/* Column drop buttons across the top */
.c4-columns {
  display: grid;
  grid-template-columns: repeat(7, 56px);
  gap: 4px;
}
 
.c4-col-btn {
  height: 28px;
  background: transparent;
  border: 2px dashed transparent;
  border-radius: 6px;
  cursor: pointer;
  transition: border-color 0.15s, background 0.15s;
}
 
.c4-col-btn:hover:not(:disabled) {
  border-color: rgba(255, 255, 255, 0.4);
  background: rgba(255, 255, 255, 0.05);
}
 
.c4-col-btn:disabled {
  cursor: not-allowed;
  opacity: 0.3;
}
 
/* The 6×7 cell grid */
.c4-grid {
  display: grid;
  grid-template-columns: repeat(7, 56px);
  grid-template-rows: repeat(6, 56px);
  gap: 4px;
}
 
.c4-cell {
  width: 56px;
  height: 56px;
  background: #1e3a8a;
  border-radius: 50%;
  transition: background 0.2s;
}
 
.c4-cell.red {
  background: #ef4444;
  box-shadow: inset 0 -3px 6px rgba(0, 0, 0, 0.3);
}
 
.c4-cell.yellow {
  background: #facc15;
  box-shadow: inset 0 -3px 6px rgba(0, 0, 0, 0.2);
}
 
.c4-cell.winner {
  outline: 3px solid #fff;
  outline-offset: -3px;
}
 
/* Drop animation */
@keyframes c4-drop {
  from { transform: translateY(-300px); opacity: 0.6; }
  to   { transform: translateY(0); opacity: 1; }
}
 
.c4-cell.dropping {
  animation: c4-drop 0.35s ease-in;
}
 
.c4-reset {
  padding: 8px 24px;
  background: #334155;
  color: #e2e8f0;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 0.95rem;
}
 
.c4-reset:hover { background: #475569; }

JavaScript

function ConnectFour(container) {
  const ROWS = 6;
  const COLS = 7;
  const CONNECT = 4; // number in a row needed to win
 
  // --- State ---
  // 2D array: board[row][col], where row 0 is the TOP row.
  // Values: null | 'red' | 'yellow'
  let board = createEmptyBoard();
  let currentPlayer = 'red';
  let gameOver = false;
 
  function createEmptyBoard() {
    return Array.from({ length: ROWS }, () => Array(COLS).fill(null));
  }
 
  // --- DOM setup ---
  const statusEl = container.querySelector('.c4-status');
  const gridEl = container.querySelector('.c4-grid');
  const colBtns = container.querySelectorAll('.c4-col-btn');
  const resetBtn = container.querySelector('.c4-reset');
 
  // Build grid cells
  const cells = [];
  for (let r = 0; r < ROWS; r++) {
    cells[r] = [];
    for (let c = 0; c < COLS; c++) {
      const cell = document.createElement('div');
      cell.className = 'c4-cell';
      cell.setAttribute('aria-label', `Row ${r + 1}, Column ${c + 1}: empty`);
      gridEl.appendChild(cell);
      cells[r][c] = cell;
    }
  }
 
  // --- Events ---
  // Column buttons handle drops. Event delegation is also fine here,
  // but with only 7 buttons the difference is negligible.
  colBtns.forEach(btn => {
    btn.addEventListener('click', () => {
      const col = Number(btn.dataset.col);
      handleDrop(col);
    });
  });
 
  resetBtn.addEventListener('click', reset);
 
  // --- Core logic ---
 
  function handleDrop(col) {
    if (gameOver) return;
 
    // Find the lowest empty row in this column (gravity).
    // We scan from the bottom (ROWS-1) upward.
    const row = findLowestRow(col);
    if (row === -1) return; // column is full
 
    // 1. Update state
    board[row][col] = currentPlayer;
 
    // 2. Render with drop animation
    render();
    cells[row][col].classList.add('dropping');
 
    // 3. Check win
    const winCells = checkWin(row, col);
    if (winCells) {
      gameOver = true;
      winCells.forEach(([r, c]) => cells[r][c].classList.add('winner'));
      statusEl.textContent = `${capitalize(currentPlayer)} wins!`;
      disableFullColumns();
      return;
    }
 
    // 4. Check draw
    if (board[0].every(cell => cell !== null)) {
      // If the top row is full, the entire board is full
      gameOver = true;
      statusEl.textContent = "It's a draw!";
      return;
    }
 
    // 5. Switch turns
    currentPlayer = currentPlayer === 'red' ? 'yellow' : 'red';
    statusEl.textContent = `${capitalize(currentPlayer)}'s turn`;
    disableFullColumns();
  }
 
  function findLowestRow(col) {
    for (let r = ROWS - 1; r >= 0; r--) {
      if (!board[r][col]) return r;
    }
    return -1; // column full
  }
 
  // --- Win detection using direction vectors ---
  // Instead of hardcoding every possible winning line (impractical on
  // a 6×7 board), we check from the last-placed piece outward in all
  // four axes: horizontal, vertical, and both diagonals.
  //
  // For each axis, we count consecutive same-color pieces in both
  // directions. If the total (including the placed piece) reaches
  // CONNECT, we have a winner.
 
  function checkWin(row, col) {
    const directions = [
      [0, 1],  // horizontal  →
      [1, 0],  // vertical    ↓
      [1, 1],  // diagonal    ↘
      [1, -1], // diagonal    ↙
    ];
 
    const color = board[row][col];
 
    for (const [dr, dc] of directions) {
      const line = [[row, col]]; // start with the placed piece
 
      // Count in the positive direction
      for (let i = 1; i < CONNECT; i++) {
        const r = row + dr * i;
        const c = col + dc * i;
        if (r < 0 || r >= ROWS || c < 0 || c >= COLS) break;
        if (board[r][c] !== color) break;
        line.push([r, c]);
      }
 
      // Count in the negative direction
      for (let i = 1; i < CONNECT; i++) {
        const r = row - dr * i;
        const c = col - dc * i;
        if (r < 0 || r >= ROWS || c < 0 || c >= COLS) break;
        if (board[r][c] !== color) break;
        line.push([r, c]);
      }
 
      if (line.length >= CONNECT) return line;
    }
 
    return null;
  }
 
  function disableFullColumns() {
    colBtns.forEach(btn => {
      const col = Number(btn.dataset.col);
      btn.disabled = gameOver || board[0][col] !== null;
    });
  }
 
  function render() {
    for (let r = 0; r < ROWS; r++) {
      for (let c = 0; c < COLS; c++) {
        const cell = cells[r][c];
        cell.className = 'c4-cell';
        if (board[r][c]) cell.classList.add(board[r][c]);
        cell.setAttribute('aria-label',
          `Row ${r + 1}, Column ${c + 1}: ${board[r][c] || 'empty'}`
        );
      }
    }
    disableFullColumns();
  }
 
  function reset() {
    board = createEmptyBoard();
    currentPlayer = 'red';
    gameOver = false;
    statusEl.textContent = `${capitalize(currentPlayer)}'s turn`;
    render();
  }
 
  function capitalize(s) {
    return s.charAt(0).toUpperCase() + s.slice(1);
  }
 
  // Initial render
  render();
}
 
// Initialize
ConnectFour(document.querySelector('.c4-game'));

How It Works — Step by Step

  1. Board state: A 2D array of 6 rows × 7 columns, where board[0] is the top row. This makes gravity intuitive — we scan from board[ROWS-1] upward to find the first empty slot.
  2. Column-based input: Players click a column button, not individual cells. The findLowestRow function determines where the piece lands.
  3. Direction-vector win detection: This is the critical algorithm. Starting from the last-placed piece, we scan outward along four axes (horizontal, vertical, two diagonals) using [dr, dc] direction pairs. For each axis, we count consecutive matching pieces in both the positive and negative directions. If the total reaches 4, it's a win. This approach works for any board size and any "connect N" variant.
  4. Drop animation: A CSS @keyframes animation slides the piece down from the top. The dropping class is added after render.
  5. Column disabling: After every move, we check if each column's top cell is filled and disable the button accordingly.

Common Follow-Up Questions

  • "How would you add an AI?" — Minimax with alpha-beta pruning. Unlike Tic-Tac-Toe, Connect Four's search space is too large for exhaustive search, so you'd set a depth limit and use a heuristic evaluation function (e.g., count open three-in-a-rows).
  • "How would you animate the drop cell by cell?" — Use setTimeout or requestAnimationFrame to reveal the piece in each row sequentially before settling at the final position.
  • "How would you detect a draw earlier?" — Track whether either player can still reach 4 in a row given the remaining empty cells. This is non-trivial but avoids playing out an obviously drawn game.

3. Hierarchical Checkbox (Tri-State Tree)

This is a favourite at companies like Google and Meta. You are given a tree of checkboxes (like a file explorer) where checking a parent checks all its children, and children's states propagate upward (a parent is "indeterminate" if only some children are checked). This tests your ability to work with recursive data structures and the indeterminate checkbox state.

What Interviewers Look For

  • Correct tri-state logic: unchecked, checked, indeterminate
  • Downward propagation — toggling a parent sets all descendants
  • Upward propagation — child changes update all ancestors
  • Using the native indeterminate property on checkboxes (it's not an HTML attribute!)
  • Clean recursive tree traversal
  • Accessible tree structure with proper ARIA roles

Key Concepts

ConceptWhy It Matters
el.indeterminateThe indeterminate state can only be set via JavaScript, not HTML — interviewers test whether you know this
Tree recursionBoth downward and upward propagation require recursive or iterative tree walks
Parent referencesEach node needs a reference to its parent for upward propagation
Data-driven renderingBuild the DOM from a tree data structure, not the other way around

HTML

<div class="hcb-tree" role="tree" aria-label="File permissions">
  <!-- The tree is rendered by JavaScript from data -->
</div>

CSS

.hcb-tree {
  font-family: system-ui, sans-serif;
  font-size: 0.95rem;
  color: #e2e8f0;
  user-select: none;
}
 
.hcb-node {
  margin-left: 0px;
}
 
.hcb-children {
  margin-left: 24px;
}
 
.hcb-label {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 8px;
  border-radius: 4px;
  cursor: pointer;
  transition: background 0.12s;
}
 
.hcb-label:hover {
  background: rgba(255, 255, 255, 0.05);
}
 
/* Custom checkbox styling */
.hcb-checkbox {
  appearance: none;
  -webkit-appearance: none;
  width: 18px;
  height: 18px;
  border: 2px solid #64748b;
  border-radius: 4px;
  background: transparent;
  cursor: pointer;
  position: relative;
  flex-shrink: 0;
  transition: background 0.12s, border-color 0.12s;
}
 
.hcb-checkbox:checked {
  background: #3b82f6;
  border-color: #3b82f6;
}
 
/* Checkmark for checked state */
.hcb-checkbox:checked::after {
  content: '';
  position: absolute;
  left: 4px;
  top: 1px;
  width: 6px;
  height: 10px;
  border: solid white;
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}
 
/* Dash for indeterminate state */
.hcb-checkbox:indeterminate {
  background: #3b82f6;
  border-color: #3b82f6;
}
 
.hcb-checkbox:indeterminate::after {
  content: '';
  position: absolute;
  left: 3px;
  top: 6px;
  width: 8px;
  height: 2px;
  background: white;
  border-radius: 1px;
}
 
/* Toggle arrow for expandable nodes */
.hcb-toggle {
  background: none;
  border: none;
  color: #94a3b8;
  cursor: pointer;
  font-size: 0.75rem;
  width: 20px;
  height: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 4px;
  flex-shrink: 0;
  transition: transform 0.2s, background 0.12s;
}
 
.hcb-toggle:hover {
  background: rgba(255, 255, 255, 0.1);
}
 
.hcb-toggle.expanded {
  transform: rotate(90deg);
}
 
/* Spacer for leaf nodes (no toggle arrow) */
.hcb-toggle-spacer {
  width: 20px;
  flex-shrink: 0;
}
 
.hcb-text {
  line-height: 1.4;
}

JavaScript

function HierarchicalCheckbox(container, data) {
  // --- Build internal tree with parent references ---
  // The input data is a nested array of objects:
  //   { label: string, children?: [...] }
  //
  // We augment each node with:
  //   - checked: boolean
  //   - parent: reference to parent node (null for roots)
  //   - el: the checkbox DOM element (set during render)
 
  function initNode(node, parent) {
    node.checked = false;
    node.parent = parent;
    node.expanded = true;
    if (node.children) {
      node.children.forEach(child => initNode(child, node));
    }
  }
 
  data.forEach(root => initNode(root, null));
 
  // --- Render tree recursively ---
 
  function renderNode(node) {
    const wrapper = document.createElement('div');
    wrapper.className = 'hcb-node';
    wrapper.setAttribute('role', 'treeitem');
    wrapper.setAttribute('aria-label', node.label);
 
    const label = document.createElement('label');
    label.className = 'hcb-label';
 
    // Toggle arrow (only for nodes with children)
    if (node.children && node.children.length > 0) {
      const toggle = document.createElement('button');
      toggle.className = 'hcb-toggle expanded';
      toggle.textContent = '▶';
      toggle.setAttribute('aria-label', `Toggle ${node.label}`);
      toggle.addEventListener('click', (e) => {
        e.preventDefault();
        e.stopPropagation();
        node.expanded = !node.expanded;
        toggle.classList.toggle('expanded', node.expanded);
        childrenContainer.style.display = node.expanded ? 'block' : 'none';
      });
      label.appendChild(toggle);
    } else {
      const spacer = document.createElement('span');
      spacer.className = 'hcb-toggle-spacer';
      label.appendChild(spacer);
    }
 
    // Checkbox
    const checkbox = document.createElement('input');
    checkbox.type = 'checkbox';
    checkbox.className = 'hcb-checkbox';
    checkbox.checked = node.checked;
    checkbox.setAttribute('aria-label', node.label);
    node.el = checkbox; // store reference for state updates
 
    checkbox.addEventListener('change', () => {
      const isChecked = checkbox.checked;
      // 1. Set this node
      node.checked = isChecked;
      // 2. Propagate DOWN to all descendants
      setDescendants(node, isChecked);
      // 3. Propagate UP through all ancestors
      updateAncestors(node.parent);
    });
 
    label.appendChild(checkbox);
 
    // Label text
    const text = document.createElement('span');
    text.className = 'hcb-text';
    text.textContent = node.label;
    label.appendChild(text);
 
    wrapper.appendChild(label);
 
    // Children container
    let childrenContainer;
    if (node.children && node.children.length > 0) {
      childrenContainer = document.createElement('div');
      childrenContainer.className = 'hcb-children';
      childrenContainer.setAttribute('role', 'group');
      node.children.forEach(child => {
        childrenContainer.appendChild(renderNode(child));
      });
      wrapper.appendChild(childrenContainer);
    }
 
    return wrapper;
  }
 
  // --- Downward propagation ---
  // When a parent is toggled, all descendants must match.
  // This is a simple recursive pre-order traversal.
 
  function setDescendants(node, checked) {
    if (!node.children) return;
    node.children.forEach(child => {
      child.checked = checked;
      child.el.checked = checked;
      child.el.indeterminate = false;
      setDescendants(child, checked);
    });
  }
 
  // --- Upward propagation ---
  // After any change, we walk up the tree recalculating each
  // ancestor's state based on its children:
  //   - All children checked     → parent checked
  //   - No children checked      → parent unchecked
  //   - Mixed                    → parent indeterminate
  //
  // The indeterminate state is a visual-only property on the
  // checkbox element. It can ONLY be set via JavaScript:
  //   checkbox.indeterminate = true;
  // There is no HTML attribute for it — this is a common
  // interview gotcha.
 
  function updateAncestors(node) {
    if (!node) return; // reached the root
 
    const children = node.children || [];
    const allChecked = children.every(c => c.checked && !c.el.indeterminate);
    const noneChecked = children.every(c => !c.checked && !c.el.indeterminate);
 
    if (allChecked) {
      node.checked = true;
      node.el.checked = true;
      node.el.indeterminate = false;
    } else if (noneChecked) {
      node.checked = false;
      node.el.checked = false;
      node.el.indeterminate = false;
    } else {
      // Mixed state: some checked, some not, or some indeterminate
      node.checked = false;
      node.el.checked = false;
      node.el.indeterminate = true;
    }
 
    // Continue up
    updateAncestors(node.parent);
  }
 
  // --- Build the tree ---
  const treeEl = document.createElement('div');
  treeEl.setAttribute('role', 'tree');
  data.forEach(root => {
    treeEl.appendChild(renderNode(root));
  });
  container.appendChild(treeEl);
}
 
// --- Sample data: file permission tree ---
const treeData = [
  {
    label: 'src',
    children: [
      {
        label: 'components',
        children: [
          { label: 'Header.jsx' },
          { label: 'Footer.jsx' },
          { label: 'Sidebar.jsx' },
        ],
      },
      {
        label: 'pages',
        children: [
          { label: 'Home.jsx' },
          { label: 'About.jsx' },
          {
            label: 'Dashboard',
            children: [
              { label: 'Overview.jsx' },
              { label: 'Analytics.jsx' },
              { label: 'Settings.jsx' },
            ],
          },
        ],
      },
      { label: 'index.js' },
      { label: 'App.jsx' },
    ],
  },
  {
    label: 'public',
    children: [
      { label: 'index.html' },
      { label: 'favicon.ico' },
    ],
  },
  { label: 'package.json' },
  { label: 'README.md' },
];
 
// Initialize
HierarchicalCheckbox(document.querySelector('.hcb-tree'), treeData);

How It Works — Step by Step

  1. Data augmentation: We walk the input tree and add checked, parent, and el properties to each node. The parent reference is essential for upward propagation.
  2. Rendering: We recursively build DOM elements for each node. Each node gets a checkbox, a label, and optionally a children container. The checkbox's change event triggers the propagation logic.
  3. Downward propagation: setDescendants recursively sets all descendants to match the parent's new state. Both the data (child.checked) and DOM (child.el.checked) are updated, and indeterminate is cleared.
  4. Upward propagation: updateAncestors walks up from the changed node to the root. At each level, it examines all children to determine whether the parent should be checked, unchecked, or indeterminate. The key insight is that a parent is indeterminate if its children are in a mixed state — either some checked and some not, or any child is itself indeterminate.
  5. The indeterminate gotcha: checkbox.indeterminate is a JavaScript-only property. It cannot be set via HTML (<input indeterminate> does nothing). This is the #1 thing interviewers test for in this question.

Common Follow-Up Questions

  • "How would you handle thousands of nodes?" — Virtualise the tree. Only render nodes that are visible in the viewport. Libraries like react-window do this, but you can implement a basic version by tracking scroll position and rendering only the visible range.
  • "What if the initial data has some nodes pre-checked?" — After rendering, run updateAncestors on every leaf that starts checked. This propagates the initial state upward correctly.
  • "How would you add keyboard navigation?" — Implement ArrowUp/ArrowDown to move focus between nodes, ArrowRight to expand, ArrowLeft to collapse, and Space/Enter to toggle. This follows the WAI-ARIA Treeview pattern.

4. Snake

Snake is the ultimate DOM game interview question. It combines a game loop (running at a fixed interval), keyboard input, collision detection, and queue-based growth. It's more complex than the other projects because the game runs autonomously between user inputs.

What Interviewers Look For

  • Fixed-interval game loop (setInterval or requestAnimationFrame)
  • Direction queue to prevent 180° reversals
  • Efficient snake movement (add head, remove tail)
  • Collision detection: walls and self
  • Random food placement that avoids the snake body
  • Clean start/pause/restart lifecycle

Key Concepts

ConceptWhy It Matters
Game loopThe snake moves on a timer regardless of input — you must manage the interval lifecycle
Direction bufferingIf the player presses two keys within one tick, both should register in order — a queue prevents swallowed inputs
Linked-list-style bodyThe snake is an array of {x, y} segments; movement adds a new head and removes the tail
Collision = set lookupChecking self-collision with a Set of position keys is O(1) vs O(n) array scan

HTML

<div class="snake-game" tabindex="0" aria-label="Snake game">
  <div class="snake-header">
    <h2 class="snake-score">Score: 0</h2>
    <span class="snake-high">Best: 0</span>
  </div>
  <canvas class="snake-canvas" width="400" height="400"></canvas>
  <div class="snake-overlay snake-start">
    <p>Press <kbd>Space</kbd> to start</p>
    <p class="snake-controls-hint">Arrow keys or WASD to move</p>
  </div>
  <div class="snake-overlay snake-over" hidden>
    <p>Game Over</p>
    <p class="snake-final-score"></p>
    <p>Press <kbd>Space</kbd> to restart</p>
  </div>
</div>

CSS

.snake-game {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  font-family: system-ui, sans-serif;
  position: relative;
  outline: none;
}
 
.snake-header {
  display: flex;
  gap: 24px;
  align-items: baseline;
}
 
.snake-score {
  font-size: 1.25rem;
  margin: 0;
}
 
.snake-high {
  font-size: 0.85rem;
  color: #94a3b8;
}
 
.snake-canvas {
  background: #0f172a;
  border: 2px solid #334155;
  border-radius: 8px;
  display: block;
}
 
/* Overlay screens (start / game over) */
.snake-overlay {
  position: absolute;
  top: 44px; /* below header */
  left: 50%;
  transform: translateX(-50%);
  width: 400px;
  height: 400px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background: rgba(15, 23, 42, 0.85);
  border-radius: 8px;
  text-align: center;
  gap: 8px;
  color: #e2e8f0;
}
 
.snake-overlay[hidden] { display: none; }
 
.snake-overlay p {
  margin: 0;
  font-size: 1.1rem;
}
 
.snake-overlay kbd {
  background: #334155;
  padding: 2px 8px;
  border-radius: 4px;
  font-size: 0.9rem;
}
 
.snake-controls-hint {
  font-size: 0.85rem !important;
  color: #94a3b8;
}
 
.snake-final-score {
  font-size: 1.5rem !important;
  font-weight: 700;
  color: #38bdf8;
}

JavaScript

function SnakeGame(container) {
  const canvas = container.querySelector('.snake-canvas');
  const ctx = canvas.getContext('2d');
  const scoreEl = container.querySelector('.snake-score');
  const highEl = container.querySelector('.snake-high');
  const startOverlay = container.querySelector('.snake-start');
  const overOverlay = container.querySelector('.snake-over');
  const finalScoreEl = container.querySelector('.snake-final-score');
 
  // --- Configuration ---
  const GRID = 20;       // number of cells per axis
  const CELL = canvas.width / GRID; // pixel size of each cell
  const TICK_MS = 120;   // milliseconds between ticks (game speed)
 
  // --- State ---
  let snake;       // array of { x, y } segments, head is snake[0]
  let direction;   // current direction: { x: 0|1|-1, y: 0|1|-1 }
  let dirQueue;    // buffered direction inputs
  let food;        // { x, y }
  let score;
  let highScore = 0;
  let intervalId = null;
  let running = false;
 
  // --- Direction mapping ---
  // Maps key names to direction vectors. We support both arrow keys
  // and WASD for accessibility and preference.
  const KEY_MAP = {
    ArrowUp:    { x:  0, y: -1 },
    ArrowDown:  { x:  0, y:  1 },
    ArrowLeft:  { x: -1, y:  0 },
    ArrowRight: { x:  1, y:  0 },
    w:          { x:  0, y: -1 },
    s:          { x:  0, y:  1 },
    a:          { x: -1, y:  0 },
    d:          { x:  1, y:  0 },
  };
 
  // --- Initialisation ---
 
  function init() {
    // Snake starts in the middle, 3 segments long, moving right
    const midX = Math.floor(GRID / 2);
    const midY = Math.floor(GRID / 2);
    snake = [
      { x: midX, y: midY },
      { x: midX - 1, y: midY },
      { x: midX - 2, y: midY },
    ];
    direction = { x: 1, y: 0 };
    dirQueue = [];
    score = 0;
    scoreEl.textContent = `Score: ${score}`;
    food = spawnFood();
    draw();
  }
 
  function start() {
    if (running) return;
    running = true;
    startOverlay.hidden = true;
    overOverlay.hidden = true;
    init();
    // Start the game loop. setInterval is simpler than
    // requestAnimationFrame for a fixed-tick game.
    intervalId = setInterval(tick, TICK_MS);
  }
 
  function stop(reason) {
    running = false;
    clearInterval(intervalId);
    intervalId = null;
 
    if (reason === 'gameover') {
      if (score > highScore) {
        highScore = score;
        highEl.textContent = `Best: ${highScore}`;
      }
      finalScoreEl.textContent = `Score: ${score}`;
      overOverlay.hidden = false;
    }
  }
 
  // --- Game loop ---
  // Each tick: process direction, move snake, check collisions,
  // check food, redraw.
 
  function tick() {
    // 1. Process buffered direction input
    if (dirQueue.length > 0) {
      const next = dirQueue.shift();
      // Prevent 180° reversal: ensure the new direction isn't
      // directly opposite to the current one. Without this check,
      // pressing Left while moving Right would cause the snake to
      // immediately collide with itself.
      if (next.x + direction.x !== 0 || next.y + direction.y !== 0) {
        direction = next;
      }
    }
 
    // 2. Calculate new head position
    const head = snake[0];
    const newHead = {
      x: head.x + direction.x,
      y: head.y + direction.y,
    };
 
    // 3. Collision detection: walls
    if (newHead.x < 0 || newHead.x >= GRID ||
        newHead.y < 0 || newHead.y >= GRID) {
      stop('gameover');
      return;
    }
 
    // 4. Collision detection: self
    // We use a string key for O(1) lookups. For very long snakes,
    // this is much faster than Array.some().
    if (snakeHas(newHead.x, newHead.y)) {
      stop('gameover');
      return;
    }
 
    // 5. Move: add new head
    snake.unshift(newHead);
 
    // 6. Check food collision
    if (newHead.x === food.x && newHead.y === food.y) {
      // Grow: don't remove tail
      score++;
      scoreEl.textContent = `Score: ${score}`;
      food = spawnFood();
    } else {
      // Normal move: remove tail to maintain length
      snake.pop();
    }
 
    // 7. Redraw
    draw();
  }
 
  // --- Helpers ---
 
  // Build a Set of "x,y" keys for O(1) body collision lookups
  function snakeHas(x, y) {
    return snake.some(seg => seg.x === x && seg.y === y);
  }
 
  function spawnFood() {
    // Place food on a random empty cell.
    // Build a set of occupied positions, then pick randomly from
    // the unoccupied ones. This guarantees the food never spawns
    // inside the snake.
    const occupied = new Set(snake.map(s => `${s.x},${s.y}`));
    const empty = [];
 
    for (let x = 0; x < GRID; x++) {
      for (let y = 0; y < GRID; y++) {
        if (!occupied.has(`${x},${y}`)) {
          empty.push({ x, y });
        }
      }
    }
 
    // Edge case: if the snake fills the entire grid, the player wins
    if (empty.length === 0) {
      stop('gameover');
      return { x: -1, y: -1 };
    }
 
    return empty[Math.floor(Math.random() * empty.length)];
  }
 
  // --- Rendering ---
  // Canvas-based rendering. We draw directly to a 2D context which
  // is much more performant than manipulating DOM elements for games.
 
  function draw() {
    // Clear
    ctx.fillStyle = '#0f172a';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
 
    // Draw grid lines (subtle)
    ctx.strokeStyle = '#1e293b';
    ctx.lineWidth = 0.5;
    for (let i = 0; i <= GRID; i++) {
      ctx.beginPath();
      ctx.moveTo(i * CELL, 0);
      ctx.lineTo(i * CELL, canvas.height);
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(0, i * CELL);
      ctx.lineTo(canvas.width, i * CELL);
      ctx.stroke();
    }
 
    // Draw food
    ctx.fillStyle = '#ef4444';
    ctx.beginPath();
    ctx.arc(
      food.x * CELL + CELL / 2,
      food.y * CELL + CELL / 2,
      CELL / 2 - 2,
      0, Math.PI * 2
    );
    ctx.fill();
 
    // Draw snake
    snake.forEach((seg, i) => {
      const isHead = i === 0;
      ctx.fillStyle = isHead ? '#4ade80' : '#22c55e';
      const padding = 1;
      ctx.fillRect(
        seg.x * CELL + padding,
        seg.y * CELL + padding,
        CELL - padding * 2,
        CELL - padding * 2
      );
 
      // Rounded corners for a nicer look
      if (isHead) {
        ctx.fillStyle = '#166534';
        // Eyes based on direction
        const cx = seg.x * CELL + CELL / 2;
        const cy = seg.y * CELL + CELL / 2;
        const eyeOffset = 4;
        const eyeSize = 2.5;
 
        if (direction.x === 1) {
          // Moving right
          drawEye(cx + eyeOffset, cy - eyeOffset, eyeSize);
          drawEye(cx + eyeOffset, cy + eyeOffset, eyeSize);
        } else if (direction.x === -1) {
          // Moving left
          drawEye(cx - eyeOffset, cy - eyeOffset, eyeSize);
          drawEye(cx - eyeOffset, cy + eyeOffset, eyeSize);
        } else if (direction.y === -1) {
          // Moving up
          drawEye(cx - eyeOffset, cy - eyeOffset, eyeSize);
          drawEye(cx + eyeOffset, cy - eyeOffset, eyeSize);
        } else {
          // Moving down
          drawEye(cx - eyeOffset, cy + eyeOffset, eyeSize);
          drawEye(cx + eyeOffset, cy + eyeOffset, eyeSize);
        }
      }
    });
  }
 
  function drawEye(x, y, r) {
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fill();
  }
 
  // --- Input handling ---
  // We listen on the container (which has tabindex="0") so the
  // game captures input when focused.
 
  container.addEventListener('keydown', (e) => {
    // Space to start/restart
    if (e.key === ' ') {
      e.preventDefault();
      if (!running) start();
      return;
    }
 
    const dir = KEY_MAP[e.key];
    if (!dir) return;
    e.preventDefault();
 
    // Buffer the direction. We limit the queue to 2 to prevent
    // input flooding, while still allowing quick two-key combos
    // (e.g., Up then Right within a single tick).
    if (dirQueue.length < 2) {
      dirQueue.push(dir);
    }
  });
 
  // --- Focus management ---
  // Auto-focus the game container so keyboard input works immediately
  container.focus();
 
  // Initial draw
  init();
}
 
// Initialize
SnakeGame(document.querySelector('.snake-game'));

How It Works — Step by Step

  1. Grid-based world: The game area is a 20×20 grid. Each cell is CELL pixels wide. Positions are stored as grid coordinates {x, y}, not pixel positions.
  2. Game loop: setInterval(tick, 120) calls tick() every 120ms. Each tick processes input, moves the snake, checks collisions, and redraws. This is simpler than requestAnimationFrame for a game that runs at a fixed speed.
  3. Direction buffering: Key presses push directions onto dirQueue. Each tick shifts one direction off the queue. This prevents two problems: (a) 180° reversals (checked before applying), and (b) lost inputs when the player presses two keys within a single tick.
  4. Snake movement: The snake is an array of {x, y} segments where snake[0] is the head. Moving means unshift a new head and pop the tail. When food is eaten, we skip the pop, making the snake grow by one.
  5. Food spawning: We collect all empty grid positions into an array and pick one randomly. This guarantees the food never spawns on the snake. For a 20×20 grid, iterating 400 cells each time food spawns is trivially fast.
  6. Canvas rendering: We use Canvas 2D instead of DOM elements for performance. Each frame clears the canvas and redraws everything — grid lines, food (circle), and snake segments (rounded rectangles with eyes on the head).
  7. Collision detection: Wall collision checks if the new head is outside [0, GRID). Self collision checks if the new head matches any existing segment.

Common Follow-Up Questions

  • "How would you add wrap-around walls?" — Replace the wall collision with modular arithmetic: newHead.x = (head.x + direction.x + GRID) % GRID. The snake exits one side and enters the other.
  • "How would you increase difficulty over time?" — Decrease TICK_MS as the score increases. Clear the interval and restart it with a shorter delay, e.g., Math.max(50, 120 - score * 2).
  • "How would you implement this with DOM elements instead of Canvas?" — Use a CSS Grid of div elements and toggle classes (snake, food, empty) each tick. This is slower but works fine for small grids.
  • "What about mobile support?" — Add touch/swipe detection by tracking touchstart and touchend coordinates. Calculate the swipe direction from the delta and push it onto dirQueue.

Summary: Patterns Across All Four Projects

These four projects share common architectural principles that interviewers value:

PatternTic-Tac-ToeConnect FourHierarchical CheckboxSnake
State representationFlat array2D arrayTree with parent refsArray of coordinates
State → DOMrender() resyncs all cellsrender() resyncs gridDirect DOM updates on changeCanvas redraw each frame
User inputClick → place markClick → drop pieceClick → toggle checkboxKeyboard → direction queue
Win/end detection8 pre-defined linesDirection vector scanN/AWall & self collision
Key algorithmLine matching4-direction countingRecursive tree propagationGame loop + collision

Master these and you will be ready for any vanilla JS frontend interview question they throw at you.

You might also like