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
emithelper keeps event dispatching concise. - The
tickfunction callsupdatethen emitssnake:tickwith a snapshot of the current state. The spread operators ([...snake],{ ...food }) create copies so the UI cannot accidentally mutate the engine’s data. - The
setDirectionfunction replaces the inline direction logic from the keyboard listener, with the same reverse-prevention guard. - The module exports only
GRID_SIZE,setDirection, andstart— 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:
- Imports the CSS.
- Imports the UI (which sets up the board and registers its listeners).
- Imports the engine’s public API.
- Connects keyboard input to the engine.
- 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