FrontendAtlas
Interview Questions
Home>Angular interview questions> Angular coding challenges >Build a Snake Game in Angular

Build a Snake Game in Angular

hard
By FrontendAtlas Editorial · Updated Jul 15, 2026
hard level
Angular component state for grid game data Template rendering for grid board cells, the snake body, and food Keyboard event handling with reverse-direction guards Timer/game loop cleanup and change detection considerations Predictable snake body updates and collision detection fakeAsync/tick tests for timer-driven movement
Build a Snake game in Angular on a fixed grid board with starter code, tests, edge cases, and interview follow-ups. Move the snake on a timer, handle keyboard direction changes, spawn food, grow score and snake body, and end on wall or body collisions while explaining game-loop and component-state decisions.
Angular component state for grid game dataTemplate rendering for grid board cells, the snake body, and foodKeyboard event handling with reverse-direction guardsTimer/game loop cleanup and change detection considerationsPredictable snake body updates and collision detectionfakeAsync/tick tests for timer-driven movement

Requirements

  • Render a 14x14 grid board with snake body segments and food.
  • Move one cell per game loop tick using keyboard controls.
  • Support Arrow key movement and WASD controls without changing the base grid board rules.
  • Buffer at most one valid perpendicular turn per tick. Validate it against the committed movement direction; same/opposite inputs do not consume the buffer.
  • Grow the snake body and increment the score when the next head cell reaches food.
  • Respawn food only in a free cell; when no free cell remains, store no food and end with a distinct win state.
  • End the game on wall collision when the next head position leaves the grid board.
  • End the game on self collision when the next head position is inside the snake body.
  • Stop movement after game over so additional timer ticks cannot keep changing component state.
  • Provide a restart/reset action that restores the initial snake, score, direction, food, and paused status.
  • Clean up timers and keyboard-driven state when the Angular component is destroyed.
  • Keep template rendering predictable by tracking each @for row and column with its stable numeric coordinate.

Expected behavior

Example gameplay timeline:

  • t=0: the head starts at zero-based coordinate (x=6, y=7), moving RIGHT; food is at (x=8, y=7).
  • t=140ms: the head moves to (x=7, y=7) and the tail drops.
  • t=280ms: the head reaches food at (x=8, y=7), score increases by 1, and the snake grows.
  • The first valid perpendicular key between ticks is buffered; later keys are ignored until movement commits it.
  • RIGHT → UP → LEFT during one tick commits UP, because LEFT is not allowed to replace the occupied buffer.
  • Eating the final free cell sets food to null, stops the timer, and shows a win instead of spawning food on the snake.

Implementation notes

  • Keep the snake body as an ordered list of grid cells with the head at index 0.
  • Keep a nullable one-turn buffer separate from the committed direction; clear it immediately when the next tick commits.
  • Compute the next head on each game loop tick before deciding whether to move, grow, or end the game.
  • Use Angular component state for score, food, gameOver, and running status, then render the grid through the template.
  • Use setInterval or an equivalent timer loop and clear it on pause, reset, wall collision, self collision, and ngOnDestroy.
  • Use deterministic fakeAsync/tick tests for timer behavior instead of relying on real elapsed time.

Solution

Overview

Interview answer: the base prompt is a grid-state problem plus a timer-driven game loop. Keep the snake body as an ordered list of cells, track the current direction, compute the next head on each tick, then decide whether to move, grow, or end the game. Keyboard input should update direction while accepting at most one valid perpendicular turn per tick. Food placement, restart behavior, speed changes, pause/resume, mobile controls, and deterministic tests are follow-ups. In Angular, keep the board, snake body, food, score, running state, and gameOver flag in component state, render cells through the template, and clean up the timer when the component stops or is destroyed. Treat a full board as a win: food becomes null, the loop stops, and no food cell renders.

1

Single Angular component with predictable grid-state updates

Render the grid board from component state, keep the snake body as ordered coordinates, and run movement through one timer/game loop. On each tick, derive the next head from the one buffered turn or the committed direction, clear the buffer, check collisions, then either grow on food or drop the tail. Keyboard handling accepts only the first valid perpendicular turn per tick, and cleanup clears the timer on pause, reset, game over, win, and destroy.

Files

{
  "name": "angular-snake-game-solution",
  "private": true
}
{
  "compilerOptions": {
    "target": "es2022",
    "strict": true,
    "noEmitOnError": false
  }
}
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Angular SDK • Snake Game (solution)</title>
  </head>
  <body>
    <app-root></app-root>
  </body>
</html>
import 'zone.js';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent).catch(console.error);
:root {
  color-scheme: dark;
}

html, body {
  margin: 0;
  height: 100%;
  background: #0a0a0a;
}

body {
  font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
}
.page {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
  background: #0a0a0a;
  color: #e5e7eb;
  font-family: system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
}

.card {
  width: min(700px,100%);
  background: #0b1020;
  border: 1px solid #1f2937;
  border-radius: 14px;
  padding: 18px;
  display: grid;
  gap: 14px;
}

.title {
  margin: 0;
  font-size: 22px;
  font-weight: 800;
}

.subtitle {
  margin: 0;
  color: #9ca3af;
  font-size: 13px;
}

.status-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.pill {
  border: 1px solid #334155;
  border-radius: 999px;
  padding: 6px 10px;
  font-size: 12px;
  color: #d1d5db;
  background: #0a0f1e;
}

.pill--success {
  border-color: #166534;
  color: #bbf7d0;
  background: rgba(22,101,52,.25);
}

.pill--danger {
  border-color: #7f1d1d;
  color: #fecaca;
  background: rgba(127,29,29,.25);
}

.actions {
  display: flex;
  gap: 10px;
}

.ghost {
  background: transparent;
  color: #e5e7eb;
  border: 1px solid #374151;
  border-radius: 12px;
  padding: 10px 14px;
  font-weight: 700;
  cursor: pointer;
}

.ghost:hover {
  border-color: #60a5fa;
}

.board {
  width: min(560px,100%);
  display: grid;
  gap: 4px;
  border-radius: 12px;
  background: #0a0f1e;
  border: 1px solid #334155;
  padding: 8px;
}

.cell {
  aspect-ratio: 1/1;
  border-radius: 4px;
  background: #111827;
}

.cell--snake {
  background: #2563eb;
}

.cell--head {
  background: #93c5fd;
}

.cell--food {
  background: #f43f5e;
}

.hint {
  margin: 0;
  color: #94a3b8;
  font-size: 12px;
}
<main class="page">
  <section class="card">
    <h1 class="title">Angular Snake Game</h1>
    <p class="subtitle">
      Build a Snake game on a fixed grid board with keyboard controls, food, score updates, and wall/self collision handling.
    </p>

    <div class="status-row">
      <span class="pill">Score: {{ score }}</span>
      <span class="pill">Length: {{ length }}</span>
      <span class="pill" [class.pill--danger]="isGameOver" [class.pill--success]="isWon">{{ statusLabel }}</span>
    </div>

    <div class="actions">
      <button type="button" class="ghost" (click)="toggleRunning()">
        {{ isRunning ? 'Pause' : 'Start' }}
      </button>
      <button type="button" class="ghost" (click)="reset()">Reset</button>
    </div>

    <div
      class="board"
      role="grid"
      aria-label="Snake game grid board"
      [style.gridTemplateColumns]="'repeat(' + boardSize + ', minmax(0, 1fr))'"
    >
      @for (row of boardIndices; track row; let rowIndex = $index) {
        @for (column of boardIndices; track column; let colIndex = $index) {
          <div
          class="cell"
          [class.cell--snake]="isSnake(colIndex, rowIndex)"
          [class.cell--head]="isHead(colIndex, rowIndex)"
          [class.cell--food]="isFood(colIndex, rowIndex)"
          [attr.aria-label]="isHead(colIndex, rowIndex) ? 'snake head' : (isFood(colIndex, rowIndex) ? 'food' : 'empty cell')"
          ></div>
        }
      }
    </div>

    <p class="hint">Use Arrow keys or WASD to move. Space pauses, Enter resets after game over or a win.</p>
  </section>
</main>
import { TestBed, discardPeriodicTasks, fakeAsync, tick } from '@angular/core/testing';
import { AppComponent } from './app.component';

const STEP_MS = 140;
type Direction = 'UP' | 'DOWN' | 'LEFT' | 'RIGHT';
type Point = { x: number; y: number };
type TestAppComponent = AppComponent & {
  snake: Point[];
  direction: Direction;
  bufferedDirection: Direction | null;
  food: Point | null;
};

function createComponent() {
  TestBed.configureTestingModule({ imports: [AppComponent] });
  const fixture = TestBed.createComponent(AppComponent);
  fixture.detectChanges();
  return { fixture, component: fixture.componentInstance as TestAppComponent };
}

function press(component: AppComponent, key: string): void {
  component.onKeydown(new KeyboardEvent('keydown', { key }));
}

describe('Angular Snake Game', () => {
  it('renders the initial 14x14 board, snake, and deterministic food', () => {
    const { fixture, component } = createComponent();
    expect(fixture.nativeElement.querySelectorAll('.cell').length).toBe(196);
    expect(component.snake).toEqual([
      { x: 6, y: 7 }, { x: 5, y: 7 }, { x: 4, y: 7 }
    ]);
    expect(component.food).toEqual({ x: 8, y: 7 });
    fixture.destroy();
  });

  it('does not move before the first 140 ms tick', fakeAsync(() => {
    const { fixture, component } = createComponent();
    component.start();
    tick(STEP_MS - 1);
    expect(component.snake[0]).toEqual({ x: 6, y: 7 });
    fixture.destroy();
    discardPeriodicTasks();
  }));

  it('buffers only the first valid turn in RIGHT -> UP -> LEFT during one tick', fakeAsync(() => {
    const { fixture, component } = createComponent();
    component.start();
    press(component, 'ArrowUp');
    press(component, 'ArrowLeft');
    tick(STEP_MS);
    expect(component.snake[0]).toEqual({ x: 6, y: 6 });
    expect(component.direction).toBe('UP');
    expect(component.bufferedDirection).toBeNull();
    expect(component.isGameOver).toBeFalse();
    fixture.destroy();
    discardPeriodicTasks();
  }));

  it('does not let an invalid key consume the one-turn buffer', fakeAsync(() => {
    const { fixture, component } = createComponent();
    component.start();
    press(component, 'ArrowLeft');
    press(component, 'ArrowUp');
    tick(STEP_MS);
    expect(component.snake[0]).toEqual({ x: 6, y: 6 });
    expect(component.direction).toBe('UP');
    fixture.destroy();
    discardPeriodicTasks();
  }));

  it('grows at 280 ms, increments score, and places food off the snake', fakeAsync(() => {
    const { fixture, component } = createComponent();
    component.start();
    tick(STEP_MS * 2);
    expect(component.score).toBe(1);
    expect(component.snake.length).toBe(4);
    expect(component.snake[0]).toEqual({ x: 8, y: 7 });
    expect(component.food).not.toBeNull();
    expect(component.snake.some((part) => part.x === component.food?.x && part.y === component.food?.y)).toBeFalse();
    fixture.destroy();
    discardPeriodicTasks();
  }));

  it('wins and stops with no food when the final free cell is eaten', fakeAsync(() => {
    const { fixture, component } = createComponent();
    const remaining = { x: 13, y: 13 };
    const allOccupied = Array.from({ length: 196 }, (_, index) => ({
      x: index % 14,
      y: Math.floor(index / 14)
    })).filter((point) => point.x !== remaining.x || point.y !== remaining.y);
    const head = { x: 12, y: 13 };
    component.snake = [head, ...allOccupied.filter((point) => point.x !== head.x || point.y !== head.y)];
    component.direction = 'RIGHT';
    component.bufferedDirection = null;
    component.food = remaining;
    component.start();
    tick(STEP_MS);
    fixture.detectChanges();
    expect(component.snake.length).toBe(196);
    expect(component.food).toBeNull();
    expect(component.isWon).toBeTrue();
    expect(component.isGameOver).toBeFalse();
    expect(component.isRunning).toBeFalse();
    expect(fixture.nativeElement.textContent).toContain('You win');
    fixture.destroy();
    discardPeriodicTasks();
  }));

  it('ends on a wall collision and does not schedule more movement', fakeAsync(() => {
    const { fixture, component } = createComponent();
    component.snake = [{ x: 13, y: 7 }, { x: 12, y: 7 }, { x: 11, y: 7 }];
    component.direction = 'RIGHT';
    component.bufferedDirection = null;
    component.start();
    tick(STEP_MS * 3);
    expect(component.isGameOver).toBeTrue();
    expect(component.snake[0]).toEqual({ x: 13, y: 7 });
    expect(component.isRunning).toBeFalse();
    fixture.destroy();
    discardPeriodicTasks();
  }));

  it('reset restores the buffer, terminal state, timer state, and food', fakeAsync(() => {
    const { fixture, component } = createComponent();
    component.start();
    press(component, 'ArrowUp');
    tick(STEP_MS);
    component.reset();
    expect(component.snake[0]).toEqual({ x: 6, y: 7 });
    expect(component.direction).toBe('RIGHT');
    expect(component.bufferedDirection).toBeNull();
    expect(component.food).toEqual({ x: 8, y: 7 });
    expect(component.score).toBe(0);
    expect(component.isRunning).toBeFalse();
    expect(component.isGameOver).toBeFalse();
    expect(component.isWon).toBeFalse();
    fixture.destroy();
    discardPeriodicTasks();
  }));

  it('cleans up its interval on destroy', fakeAsync(() => {
    const clearIntervalSpy = spyOn(window, 'clearInterval').and.callThrough();
    const { fixture, component } = createComponent();
    component.start();
    fixture.destroy();
    expect(clearIntervalSpy).toHaveBeenCalled();
    discardPeriodicTasks();
  }));
});
import { Component, HostListener, OnDestroy } from '@angular/core';
import {
  bufferTurn,
  stepSnake,
  type Direction,
  type Point
} from './snake-engine';

const BOARD_SIZE = 14;
const STEP_MS = 140;
const INITIAL_SNAKE: Point[] = [
  { x: 6, y: 7 },
  { x: 5, y: 7 },
  { x: 4, y: 7 }
];
const INITIAL_FOOD: Point = { x: 8, y: 7 };

@Component({
  selector: 'app-root',
  standalone: true,
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnDestroy {
  readonly boardSize = BOARD_SIZE;
  readonly boardIndices = Array.from({ length: BOARD_SIZE }, (_, index) => index);

  snake: Point[] = INITIAL_SNAKE.map((point) => ({ ...point }));
  direction: Direction = 'RIGHT';
  bufferedDirection: Direction | null = null;
  food: Point | null = { ...INITIAL_FOOD };
  isRunning = false;
  isGameOver = false;
  isWon = false;
  score = 0;

  private timerId: ReturnType<typeof setInterval> | null = null;

  get length(): number {
    return this.snake.length;
  }

  get statusLabel(): string {
    if (this.isWon) return 'You win';
    if (this.isGameOver) return 'Game over';
    return this.isRunning ? 'Running' : 'Paused';
  }

  @HostListener('window:keydown', ['$event'])
  onKeydown(event: KeyboardEvent): void {
    const key = String(event.key || '').toLowerCase();

    if (key === ' ') {
      event.preventDefault();
      if (!this.isGameOver && !this.isWon) this.toggleRunning();
      return;
    }

    if (key === 'enter' && (this.isGameOver || this.isWon)) {
      event.preventDefault();
      this.reset();
      return;
    }

    const requested = this.directionFromKey(key);
    if (!requested) return;

    event.preventDefault();
    this.bufferedDirection = bufferTurn(this.direction, this.bufferedDirection, requested);
  }

  toggleRunning(): void {
    if (this.isGameOver || this.isWon) return;
    this.isRunning ? this.pause() : this.start();
  }

  start(): void {
    if (this.isRunning || this.isGameOver || this.isWon) return;
    this.isRunning = true;
    this.clearTimer();
    this.timerId = setInterval(() => this.tick(), STEP_MS);
  }

  pause(): void {
    this.isRunning = false;
    this.clearTimer();
  }

  reset(): void {
    this.clearTimer();
    this.snake = INITIAL_SNAKE.map((point) => ({ ...point }));
    this.direction = 'RIGHT';
    this.bufferedDirection = null;
    this.food = { ...INITIAL_FOOD };
    this.isRunning = false;
    this.isGameOver = false;
    this.isWon = false;
    this.score = 0;
  }

  isSnake(x: number, y: number): boolean {
    return this.snake.some((point) => point.x === x && point.y === y);
  }

  isHead(x: number, y: number): boolean {
    const head = this.snake[0];
    return head.x === x && head.y === y;
  }

  isFood(x: number, y: number): boolean {
    return this.food !== null && this.food.x === x && this.food.y === y;
  }


  ngOnDestroy(): void {
    this.clearTimer();
  }

  private tick(): void {
    if (!this.isRunning || this.isGameOver || this.isWon) return;

    const result = stepSnake({
      snake: this.snake,
      direction: this.direction,
      bufferedDirection: this.bufferedDirection,
      food: this.food,
      score: this.score
    }, this.boardSize);

    this.snake = result.snake;
    this.direction = result.direction;
    this.bufferedDirection = result.bufferedDirection;
    this.food = result.food;
    this.score = result.score;

    if (result.outcome === 'lost') {
      this.isGameOver = true;
      this.pause();
    } else if (result.outcome === 'won') {
      this.isWon = true;
      this.pause();
    }
  }

  private directionFromKey(key: string): Direction | null {
    if (key === 'arrowup' || key === 'w') return 'UP';
    if (key === 'arrowdown' || key === 's') return 'DOWN';
    if (key === 'arrowleft' || key === 'a') return 'LEFT';
    if (key === 'arrowright' || key === 'd') return 'RIGHT';
    return null;
  }

  private clearTimer(): void {
    if (this.timerId !== null) {
      clearInterval(this.timerId);
      this.timerId = null;
    }
  }
}
export type Direction = 'UP' | 'DOWN' | 'LEFT' | 'RIGHT';
export type Point = { x: number; y: number };

export interface SnakeSnapshot {
  snake: Point[];
  direction: Direction;
  bufferedDirection: Direction | null;
  food: Point | null;
  score: number;
}

export interface SnakeStep extends SnakeSnapshot {
  outcome: 'moved' | 'lost' | 'won';
}

const DELTA: Record<Direction, Point> = {
  UP: { x: 0, y: -1 },
  DOWN: { x: 0, y: 1 },
  LEFT: { x: -1, y: 0 },
  RIGHT: { x: 1, y: 0 }
};

const OPPOSITE: Record<Direction, Direction> = {
  UP: 'DOWN',
  DOWN: 'UP',
  LEFT: 'RIGHT',
  RIGHT: 'LEFT'
};

function key(point: Point): string {
  return String(point.x) + ',' + String(point.y);
}

export function bufferTurn(
  committed: Direction,
  buffered: Direction | null,
  requested: Direction
): Direction | null {
  if (buffered !== null) return buffered;
  if (requested === committed || OPPOSITE[committed] === requested) return null;
  return requested;
}

export function placeFood(
  snake: Point[],
  boardSize: number,
  random: () => number = Math.random
): Point | null {
  const occupied = new Set(snake.map(key));
  const free: Point[] = [];

  for (let y = 0; y < boardSize; y += 1) {
    for (let x = 0; x < boardSize; x += 1) {
      const point = { x, y };
      if (!occupied.has(key(point))) free.push(point);
    }
  }

  if (free.length === 0) return null;
  const index = Math.min(free.length - 1, Math.floor(random() * free.length));
  return free[index];
}

export function stepSnake(
  state: SnakeSnapshot,
  boardSize: number,
  random: () => number = Math.random
): SnakeStep {
  const direction = state.bufferedDirection ?? state.direction;
  const delta = DELTA[direction];
  const head = state.snake[0];
  const nextHead = { x: head.x + delta.x, y: head.y + delta.y };
  const base = { ...state, direction, bufferedDirection: null };

  if (
    nextHead.x < 0 ||
    nextHead.y < 0 ||
    nextHead.x >= boardSize ||
    nextHead.y >= boardSize
  ) {
    return { ...base, outcome: 'lost' };
  }

  const ateFood = state.food !== null && key(nextHead) === key(state.food);
  const collisionBody = ateFood ? state.snake : state.snake.slice(0, -1);
  if (collisionBody.some((part) => key(part) === key(nextHead))) {
    return { ...base, outcome: 'lost' };
  }

  const snake = [nextHead, ...state.snake];
  if (!ateFood) {
    snake.pop();
    return { ...base, snake, outcome: 'moved' };
  }

  const food = placeFood(snake, boardSize, random);
  return {
    ...base,
    snake,
    food,
    score: state.score + 1,
    outcome: food === null ? 'won' : 'moved'
  };
}

Notes & Pitfalls

Pitfalls
  • Validating each key against the previous key allows RIGHT → UP → LEFT to reverse before the tick commits; keep only the first valid turn.
  • Starting multiple timers creates duplicate game loops and inconsistent snake speed.
  • Spawning food without excluding the snake—or inventing a fallback cell on a full board—can overlap the snake.
  • Checking self collision against the wrong body slice can incorrectly block moves into the tail cell that is about to drop.
Techniques
  • Use occupancy keys like `x,y` for constant-time snake body and food checks.
  • Buffer at most one valid perpendicular turn separately from the committed direction, then clear it when the tick commits.
  • Use fakeAsync/tick tests for timer-driven movement, growth, collision, restart, and cleanup behavior.
  • Use stable row and column coordinates in @for track expressions when rendering the fixed grid board.

Related links

Angular Tic-Tac-Toe coding challengeAngular coding challengeAngular Todo List coding challengeAngular coding challengeAngular Progress Bar coding challengeAngular coding challengeBuild Snake Game in ReactReact coding challengeBuild Snake Game in VueVue coding challenge

Guides

Frontend interview preparation guideGuideFrontend coding interview questions and prep guideBlueprintJavaScript Problems That Actually Show UpBlueprintBuild Great UI in 60 MinutesBlueprint

Preparing for interviews? Use Frontend Coding Challenges first, then move into a concrete Study Plan before targeted Company Prep.

Open frontend interview questionsBrowse Angular interview questionsOpen Essential 60Open Machine Coding HubOpen Frontend Coding ChallengesOpen System DesignOpen Interview Prep GuideOpen System Design BlueprintOpen Framework Prep PathsOpen Study PlansOpen 30-day foundations study planOpen Company PrepOpen Angular Framework Prep Guide
↗Incidents hub
13 / 26