Saturday, October 31, 2026
HomeJavaScriptConstruct Snake in Aurelia 2

Construct Snake in Aurelia 2


Snake is an efficient weekend venture. The principles match on a serviette, and a playable model nonetheless wants a sport loop, keyboard enter, collision checks and a few saved state. It additionally occurs to be a pleasant technique to see how far a plain Aurelia 2 part will get you.

By the top of this put up you’ll have a full sport in a single part: a 20 by 20 board, a snake that grows when it eats, arrow key and WASD controls, an on-screen pad for telephones, and a excessive rating saved in localStorage. There are three recordsdata, underneath 300 traces in whole, and no dependencies past Aurelia.

Establishing

Begin with a brand new venture:

Select TypeScript and Vite when it asks. Then create three recordsdata in src: snake-game.ts, snake-game.html and snake-game.css. Aurelia’s conventions pair them up for you. The category and the template share a reputation, so that they turn out to be one part, and a CSS file with the identical title is imported mechanically.

To make the sport the entire app, level fundamental.ts at it:

import Aurelia from 'aurelia';
import { SnakeGame } from './snake-game';

Aurelia.app(SnakeGame).begin();

The board

The board doesn’t want 400 cells. It’s a CSS grid with 20 columns and 20 rows, and every snake phase locations itself with grid-column and grid-row. Solely the snake and the meals are precise parts.

<div class="snake-game">
  <div class="snake-scores">
    <span>Rating: ${rating}</span>
    <span>Finest: ${highScore}</span>
  </div>

  <div class="snake-board" model="--size: ${dimension}">
    <div
      class="snake-food"
      model="grid-column: ${meals.x + 1}; grid-row: ${meals.y + 1}"></div>

    <div
      repeat.for="a part of snake"
      class="snake-part ${$first ? 'snake-head' : ''}"
      model="grid-column: ${half.x + 1}; grid-row: ${half.y + 1}"></div>

    <div class="snake-overlay" if.bind="state !== 'taking part in'">
      <p if.bind="state === 'over'">Sport over. You scored ${rating}.</p>
      <button click on.set off="begin()">
        ${state === 'over' ? 'Play once more' : 'Begin'}
      </button>
      <p class="snake-hint">Arrow keys or WASD to steer. Area to start out.</p>
    </div>
  </div>

  <div class="snake-pad">
    <button pointerdown.set off="flip('up')" aria-label="Up">â–²</button>
    <button pointerdown.set off="flip('left')" aria-label="Left">â—€</button>
    <button pointerdown.set off="flip('down')" aria-label="Down">â–¼</button>
    <button pointerdown.set off="flip('proper')" aria-label="Proper">â–¶</button>
  </div>
</div>

A couple of issues are value mentioning:

  • Interpolation works inside model. model="grid-column: ${half.x + 1}" updates at any time when half.x modifications. Grid traces begin at 1, so the + 1 turns zero-based coordinates into grid positions.
  • $first marks the top. repeat.for provides each row $index, $first, $final and some others, so the top will get its personal color with none additional state.
  • The overlay sits on the board. It covers the entire grid (see the CSS under) and solely exists whereas the sport isn’t working. It exhibits the beginning button earlier than the primary sport and your rating after every one.
  • The pad makes use of pointerdown. On a telephone, click on waits for the finger to elevate. pointerdown fires the second you contact the button, which issues whenever you’re two cells from a wall.

The CSS units up the grid and colors:

.snake-game {
  max-width: 420px;
  margin: 0 auto;
  font-family: system-ui, sans-serif;
}

.snake-scores {
  show: flex;
  justify-content: space-between;
  margin-bottom: 8px;
  font-weight: 600;
}

.snake-board {
  show: grid;
  grid-template-columns: repeat(var(--dimension), 1fr);
  grid-template-rows: repeat(var(--dimension), 1fr);
  aspect-ratio: 1;
  background: #1d2b1f;
  border-radius: 6px;
}

.snake-part {
  background: #8bd17c;
  border-radius: 3px;
  margin: 1px;
}

.snake-head {
  background: #c5f2b6;
}

.snake-food {
  background: #f25c54;
  border-radius: 50%;
  margin: 2px;
}

.snake-overlay {
  grid-area: 1 / 1 / -1 / -1;
  show: flex;
  flex-direction: column;
  align-items: heart;
  justify-content: heart;
  hole: 8px;
  background: rgb(0 0 0 / 0.6);
  colour: white;
  border-radius: 6px;
  text-align: heart;
}

.snake-hint {
  font-size: 0.85rem;
  opacity: 0.8;
}

.snake-pad {
  show: grid;
  grid-template-columns: repeat(3, 56px);
  grid-template-areas:
    ". up ."
    "left down proper";
  hole: 6px;
  justify-content: heart;
  margin-top: 12px;
}

.snake-pad button {
  top: 56px;
  font-size: 1.25rem;
  touch-action: manipulation;
}

.snake-pad [aria-label="Up"] { grid-area: up; }
.snake-pad [aria-label="Left"] { grid-area: left; }
.snake-pad [aria-label="Down"] { grid-area: down; }
.snake-pad [aria-label="Right"] { grid-area: proper; }

aspect-ratio: 1 retains the board sq. at any width, and grid-area: 1 / 1 / -1 / -1 stretches the overlay throughout each row and column. The board dimension comes from the view mannequin by means of the --size customized property, so altering SIZE in a single place resizes every thing. The part’s CSS is international, which is why each class begins with snake-.

The view mannequin

The remainder of the sport lives in snake-game.ts. It’s a plain class with no decorators and no base class. The snippets under make up the entire file, so as.

Constants

sort Level = { x: quantity; y: quantity };
sort Path = 'up' | 'down' | 'left' | 'proper';

const SIZE = 20;
const STEP_MS = 120;
const HIGH_SCORE_KEY = 'snake-high-score';

const MOVES: Document<Path, Level> = {
  up: { x: 0, y: -1 },
  down: { x: 0, y: 1 },
  left: { x: -1, y: 0 },
  proper: { x: 1, y: 0 },
};

const OPPOSITE: Document<Path, Path> = {
  up: 'down',
  down: 'up',
  left: 'proper',
  proper: 'left',
};

const KEYS: Document<string, Path> = {
  ArrowUp: 'up',
  ArrowDown: 'down',
  ArrowLeft: 'left',
  ArrowRight: 'proper',
  w: 'up',
  s: 'down',
  a: 'left',
  d: 'proper',
};

const similar = (a: Level, b: Level) => a.x === b.x && a.y === b.y;

STEP_MS is the sport pace: the snake strikes one cell each 120 milliseconds. Decrease it for a more durable sport.

State

export class SnakeGame {
  dimension = SIZE;
  snake: Level[] = [];
  meals: Level = { x: 0, y: 0 };
  rating = 0;
  highScore = Quantity(localStorage.getItem(HIGH_SCORE_KEY) ?? 0);
  state: 'prepared' | 'taking part in' | 'over' = 'prepared';

  personal course: Path = 'proper';
  personal turns: Path[] = [];
  personal body = 0;
  personal lastStep = 0;

  constructor() {
    this.reset();
  }

The general public fields are what the template binds to. The personal ones solely matter to the sport loop. The snake is an array of factors with the top first. highScore reads from localStorage when the part is created, so your finest rating survives a refresh.

Calling reset() from the constructor places the snake and meals on the board earlier than the primary sport, so the beginning display screen exhibits the snake behind the overlay as an alternative of an empty board.

Beginning and the sport loop

  begin() {
    this.reset();
    this.state = 'taking part in';
    this.lastStep = efficiency.now();
    this.body = requestAnimationFrame(this.loop);
  }

  personal reset() {
    this.snake = [
      { x: 6, y: 10 },
      { x: 5, y: 10 },
      { x: 4, y: 10 },
    ];
    this.course = 'proper';
    this.turns = [];
    this.rating = 0;
    this.placeFood();
  }

  personal loop = (time: quantity) => {
    if (time - this.lastStep >= STEP_MS) {
      this.lastStep = time;
      this.step();
    }
    if (this.state === 'taking part in') {
      this.body = requestAnimationFrame(this.loop);
    }
  };

The loop runs on requestAnimationFrame, which fires as soon as per show body, often 60 occasions a second. Snake doesn’t wish to transfer 60 cells a second, so the loop solely calls step() as soon as a minimum of STEP_MS has handed because the final transfer. On each different body it does nothing.

There are two causes to choose this over setInterval. Strikes line up with the browser’s repaints, so the snake doesn’t stutter. And the browser pauses requestAnimationFrame in background tabs, so the sport stops whenever you change away as an alternative of working your snake right into a wall the place you possibly can’t see it. Once you come again, it carries on from the identical spot.

loop is an arrow operate saved in a area, so this nonetheless factors on the part when the browser calls it.

Shifting, consuming and dying

  personal step() {
    this.course = this.turns.shift() ?? this.course;

    const head = this.snake[0];
    const transfer = MOVES[this.direction];
    const subsequent = { x: head.x + transfer.x, y: head.y + transfer.y };
    const consuming = similar(subsequent, this.meals);

    // The tail strikes out of the way in which this step, until the snake is rising.
    const physique = consuming ? this.snake : this.snake.slice(0, -1);
    const outdoors = subsequent.x < 0 || subsequent.y < 0 || subsequent.x >= SIZE || subsequent.y >= SIZE;

    if (outdoors || physique.some(half => similar(half, subsequent))) {
      this.gameOver();
      return;
    }

    this.snake.unshift(subsequent);

    if (consuming) {
      this.rating++;
      this.placeFood();
    } else {
      this.snake.pop();
    }
  }

Every step provides a brand new head within the present course. If the snake simply ate, the tail stays and the snake is one cell longer. In any other case the tail comes off, and the size stays the identical.

The collision verify has one refined half. Shifting into the cell your tail is leaving is authorized, as a result of the tail received’t be there by the top of the step. So until the snake is consuming, the verify ignores the final phase. With out that, a snake chasing its personal tail in a decent loop would die for no cause.

The step mutates the array in place with unshift and pop, and the template retains up with out the array ever being reassigned. Aurelia observes these array strategies immediately.
How Aurelia Is aware of One thing Modified
covers how that works.

Meals and sport over

  personal placeFood() {
    let meals: Level;
    do {
      meals = {
        x: Math.flooring(Math.random() * SIZE),
        y: Math.flooring(Math.random() * SIZE),
      };
    } whereas (this.snake.some(half => similar(half, meals)));
    this.meals = meals;
  }

  personal gameOver() {
    this.state = 'over';
    if (this.rating > this.highScore) {
      this.highScore = this.rating;
      localStorage.setItem(HIGH_SCORE_KEY, String(this.rating));
    }
  }

Meals goes on a random cell, and the loop picks once more if that cell is underneath the snake. Setting state to 'over' is sufficient to finish the sport. The loop sees it and stops requesting frames, and the template exhibits the overlay along with your rating.

Turning

  flip(course: Path)  course === OPPOSITE[current]) return;
    if (this.turns.size < 2) this.turns.push(course);
  

That is the best half to get unsuitable. The apparent model units this.course immediately and ignores the wrong way. It breaks when two keys arrive inside one 120 ms step.

Say the snake is heading proper, and also you press up after which left rapidly. With the plain model, up is accepted, and left is accepted too as a result of it’s in contrast in opposition to up. Then the subsequent step strikes left, straight again into the snake’s personal neck.

Queuing the turns fixes it. Every new flip is checked in opposition to the final queued flip, not the present course, and step() takes one flip from the queue every transfer. Up after which left now performs out as up for one step, then left, which is what the participant meant. The queue holds at most two turns, so mashing the keys can’t line up strikes for a second from now.

Keyboard enter

  connected() {
    window.addEventListener('keydown', this.onKeyDown);
  }

  detaching() {
    window.removeEventListener('keydown', this.onKeyDown);
    cancelAnimationFrame(this.body);
  }

  personal onKeyDown = (occasion: KeyboardEvent) => {
    const course = KEYS[event.key];

    if (course) {
      occasion.preventDefault();
      if (this.state === 'taking part in') this.flip(course);
    } else if (occasion.key === ' ' && this.state !== 'taking part in') {
      occasion.preventDefault();
      this.begin();
    }
  };
}

Keyboard occasions go on window so the sport responds no matter has focus. keydown.set off on a component would solely hearth whereas that component, or one thing inside it, has focus. The listener is added in connected and eliminated in detaching, together with any pending body, so nothing retains working if the part is faraway from the web page (behind a route, say).

preventDefault() stops the arrow keys and area bar from scrolling the web page whilst you play.

The place to take it subsequent

That’s the entire sport. If you wish to maintain going, just a few concepts:

  • Pace up because the rating climbs. Make STEP_MS a area and take just a few milliseconds off it every time the snake eats.
  • Wrap across the edges. Exchange the wall verify with (subsequent.x + SIZE) % SIZE and see if it makes the sport simpler or more durable.
  • Pause. Add a 'paused' state and cease requesting frames whereas it’s set. The loop already stops at any time when the state isn’t 'taking part in'.
  • Swipe controls. Pay attention for pointerdown and pointerup on the board and switch within the course of the bigger motion.

In the event you construct one thing with it, share it with us on
Discord
. We’d like to see excessive scores too.

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular

Recent Comments