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.
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
| Concept | Why It Matters |
|---|---|
| State-driven rendering | The board array is the single source of truth; the DOM is derived from it |
| Win detection | Pre-defined winning lines checked against current state — O(1) lines to check |
| Event delegation | One click handler on the board container instead of nine on cells |
| Immutable turns | currentPlayer 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
- 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'. - Moves: On click, we write the current player's mark into the array, then check for a win or draw before flipping the turn.
- 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.
- 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. - 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_LINESwith dynamically generated lines based onN. The flat array approach scales cleanly. - "How would you add undo?" — Maintain a history stack of board snapshots. Push a copy of
boardbefore 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
| Concept | Why It Matters |
|---|---|
| Column-based input | Players choose a column, not a cell — the row is computed by gravity |
| Direction vectors | Win checking uses [dx, dy] pairs to scan in all four directions |
| 2D array state | A rows × cols matrix is more natural than a flat array for a grid with gravity |
| Hover preview | Shows 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
- Board state: A 2D array of 6 rows × 7 columns, where
board[0]is the top row. This makes gravity intuitive — we scan fromboard[ROWS-1]upward to find the first empty slot. - Column-based input: Players click a column button, not individual cells. The
findLowestRowfunction determines where the piece lands. - 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. - Drop animation: A CSS
@keyframesanimation slides the piece down from the top. Thedroppingclass is added after render. - 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
setTimeoutorrequestAnimationFrameto 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
indeterminateproperty on checkboxes (it's not an HTML attribute!) - Clean recursive tree traversal
- Accessible tree structure with proper ARIA roles
Key Concepts
| Concept | Why It Matters |
|---|---|
el.indeterminate | The indeterminate state can only be set via JavaScript, not HTML — interviewers test whether you know this |
| Tree recursion | Both downward and upward propagation require recursive or iterative tree walks |
| Parent references | Each node needs a reference to its parent for upward propagation |
| Data-driven rendering | Build 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
- Data augmentation: We walk the input tree and add
checked,parent, andelproperties to each node. Theparentreference is essential for upward propagation. - Rendering: We recursively build DOM elements for each node. Each node gets a checkbox, a label, and optionally a children container. The checkbox's
changeevent triggers the propagation logic. - Downward propagation:
setDescendantsrecursively sets all descendants to match the parent's new state. Both the data (child.checked) and DOM (child.el.checked) are updated, andindeterminateis cleared. - Upward propagation:
updateAncestorswalks 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. - The indeterminate gotcha:
checkbox.indeterminateis 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-windowdo 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
updateAncestorson every leaf that starts checked. This propagates the initial state upward correctly. - "How would you add keyboard navigation?" — Implement
ArrowUp/ArrowDownto move focus between nodes,ArrowRightto expand,ArrowLeftto collapse, andSpace/Enterto 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 (
setIntervalorrequestAnimationFrame) - 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
| Concept | Why It Matters |
|---|---|
| Game loop | The snake moves on a timer regardless of input — you must manage the interval lifecycle |
| Direction buffering | If the player presses two keys within one tick, both should register in order — a queue prevents swallowed inputs |
| Linked-list-style body | The snake is an array of {x, y} segments; movement adds a new head and removes the tail |
| Collision = set lookup | Checking 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
- Grid-based world: The game area is a 20×20 grid. Each cell is
CELLpixels wide. Positions are stored as grid coordinates{x, y}, not pixel positions. - Game loop:
setInterval(tick, 120)callstick()every 120ms. Each tick processes input, moves the snake, checks collisions, and redraws. This is simpler thanrequestAnimationFramefor a game that runs at a fixed speed. - 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. - Snake movement: The snake is an array of
{x, y}segments wheresnake[0]is the head. Moving meansunshifta new head andpopthe tail. When food is eaten, we skip thepop, making the snake grow by one. - 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.
- 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).
- 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_MSas 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
divelements 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
touchstartandtouchendcoordinates. Calculate the swipe direction from the delta and push it ontodirQueue.
Summary: Patterns Across All Four Projects
These four projects share common architectural principles that interviewers value:
| Pattern | Tic-Tac-Toe | Connect Four | Hierarchical Checkbox | Snake |
|---|---|---|---|---|
| State representation | Flat array | 2D array | Tree with parent refs | Array of coordinates |
| State → DOM | render() resyncs all cells | render() resyncs grid | Direct DOM updates on change | Canvas redraw each frame |
| User input | Click → place mark | Click → drop piece | Click → toggle checkbox | Keyboard → direction queue |
| Win/end detection | 8 pre-defined lines | Direction vector scan | N/A | Wall & self collision |
| Key algorithm | Line matching | 4-direction counting | Recursive tree propagation | Game loop + collision |
Master these and you will be ready for any vanilla JS frontend interview question they throw at you.
You might also like
Build Connect Four from Scratch with Vanilla JS
A complete guide to building Connect Four using only HTML, CSS, and vanilla JavaScript. Covers gravity-based piece dropping, win detection across rows/columns/diagonals, and AI opponent. Interview-ready implementation.
FrontendBuild a Hierarchical Checkbox from Scratch with Vanilla JS
A complete guide to building a hierarchical (tri-state) checkbox tree using only HTML, CSS, and vanilla JavaScript. Covers parent-child propagation, indeterminate state, dynamic trees, and accessibility. Interview-ready implementation.
FrontendBuild Snake from Scratch with Vanilla JS
A complete guide to building the classic Snake game using only HTML, CSS, and vanilla JavaScript. Covers game loops, collision detection, growing mechanics, and responsive canvas rendering. Interview-ready implementation.