Event-Driven Architecture

In the previous section, we started dispatching custom events from the update function. But the game logic, rendering, and input handling still live in a single file. Let’s split the code into separate modules that communicate entirely through events.

The Architecture

We will reorganize into three files:

src/
├── engine.js   ← Game state and logic (no DOM access)
├── ui.js       ← Board creation, rendering, UI listeners
└── main.js     ← Wires everything together

The engine knows nothing about the DOM. It manages the snake, food, collisions, and score, and emits events when something happens. The UI knows nothing about the game logic. It listens for events and renders the state that arrives with each event. The main module imports both and connects keyboard input to the engine.

This is event-driven architecture. Components communicate through events rather than direct function calls.

The Engine Module

Create src/engine.js with all the game logic:

export const GRID_SIZE = 20;
const TICK_RATE = 150;

let snake = [
  { x: 10, y: 10 },
  { x: 9, y: 10 },
  { x: 8, y: 10 },
];

let direction = { x: 1, y: 0 };
let food = placeFood();
let score = 0;
let gameOver = false;
let intervalId = null;

function placeFood() {
  let position;
  do {
    position = {
      x: Math.floor(Math.random() * GRID_SIZE),
      y: Math.floor(Math.random() * GRID_SIZE),
    };
  } while (snake.some((seg) => seg.x === position.x && seg.y === position.y));
  return position;
}

function checkCollision(position) {
  if (
    position.x < 0 ||
    position.x >= GRID_SIZE ||
    position.y < 0 ||
    position.y >= GRID_SIZE
  ) {
    return true;
  }

  for (let i = 0; i < snake.length - 1; i++) {
    if (snake[i].x === position.x && snake[i].y === position.y) {
      return true;
    }
  }

  return false;
}

function emit(name, detail = {}) {
  document.dispatchEvent(new CustomEvent(name, { detail }));
}

function update() {
  const head = snake[0];
  const newHead = {
    x: head.x + direction.x,
    y: head.y + direction.y,
  };

  if (checkCollision(newHead)) {
    gameOver = true;
    clearInterval(intervalId);
    emit("snake:die", { score });
    return;
  }

  snake.unshift(newHead);

  if (newHead.x === food.x && newHead.y === food.y) {
    score += 10;
    food = placeFood();
    emit("snake:eat", { score });
  } else {
    snake.pop();
  }
}

function tick() {
  update();
  emit("snake:tick", { snake: [...snake], food: { ...food }, gameOver });
}

export function setDirection(newDirection) {
  // Prevent reversing
  if (newDirection.x !== 0 && direction.x !== 0) return;
  if (newDirection.y !== 0 && direction.y !== 0) return;
  direction = newDirection;
}

export function start() {
  // Emit an initial tick so the UI renders the starting state
  emit("snake:tick", { snake: [...snake], food: { ...food }, gameOver });
  intervalId = setInterval(tick, TICK_RATE);
}

A few things to note:

  • The emit helper keeps event dispatching concise.
  • The tick function calls update then emits snake:tick with a snapshot of the current state. The spread operators ([...snake], { ...food }) create copies so the UI cannot accidentally mutate the engine’s data.
  • The setDirection function replaces the inline direction logic from the keyboard listener, with the same reverse-prevention guard.
  • The module exports only GRID_SIZE, setDirection, and start — the rest is private.

The UI Module

Create src/ui.js with all DOM-related code:

import { GRID_SIZE } from "./engine.js";

const board = document.getElementById("board");
const scoreDisplay = document.getElementById("score");

// Create the grid cells
const cells = [];
for (let i = 0; i < GRID_SIZE * GRID_SIZE; i++) {
  const cell = document.createElement("div");
  cell.classList.add("cell");
  board.appendChild(cell);
  cells.push(cell);
}

function render(snake, food) {
  // Clear all cells
  cells.forEach((cell) =>
    cell.classList.remove("snake", "snake-head", "food"),
  );

  // Draw the food
  const foodIndex = food.y * GRID_SIZE + food.x;
  cells[foodIndex].classList.add("food");

  // Draw the snake
  snake.forEach((segment, index) => {
    const cellIndex = segment.y * GRID_SIZE + segment.x;
    if (cellIndex >= 0 && cellIndex < cells.length) {
      cells[cellIndex].classList.add("snake");
      if (index === 0) {
        cells[cellIndex].classList.add("snake-head");
      }
    }
  });
}

// --- Event Listeners ---

document.addEventListener("snake:tick", (e) => {
  const { snake, food } = e.detail;
  render(snake, food);
});

document.addEventListener("snake:eat", (e) => {
  scoreDisplay.textContent = `Score: ${e.detail.score}`;
});

document.addEventListener("snake:die", () => {
  board.classList.add("game-over");
});

The UI imports GRID_SIZE from the engine (it needs to know the grid dimensions to create cells and compute indices). Everything else comes through events. The UI never reads the engine’s state directly.

The Main Module

Replace src/main.js with a much shorter entry point:

import "./style.css";
import "./ui.js";
import { start, setDirection } from "./engine.js";

document.addEventListener("keydown", (e) => {
  const directions = {
    ArrowUp: { x: 0, y: -1 },
    ArrowDown: { x: 0, y: 1 },
    ArrowLeft: { x: -1, y: 0 },
    ArrowRight: { x: 1, y: 0 },
  };

  if (directions[e.key]) {
    setDirection(directions[e.key]);
  }
});

start();

The main module:

  1. Imports the CSS.
  2. Imports the UI (which sets up the board and registers its listeners).
  3. Imports the engine’s public API.
  4. Connects keyboard input to the engine.
  5. Starts the game.

The Event Flow

Here is how the modules communicate:

Keyboard ──→ main.js ──→ engine.setDirection()
                              │
                         engine.tick()
                              │
                    ┌─────────┼─────────┐
                    ▼         ▼         ▼
              snake:tick  snake:eat  snake:die
                    │         │         │
                    ▼         ▼         ▼
                 ui.js     ui.js     ui.js
                render()  update    add class
                          score     game-over

The engine emits events and the UI reacts to them. Neither module references the other directly. That makes it easier to add features later. If you want sound effects, you add another listener for snake:eat. If you want to log analytics, you listen for snake:die. In both cases you do not have to change any of the existing code.

Checkpoint: Commit your progress.

git add .
git commit -m "snake-09: Split into engine, UI, and main modules"
git push