Skip to content

Snake

<cinq-snake> is a canvas Snake host. Markup supplies the canvas. The component runs the game loop, draws squares, and handles collisions. Score, overlay, and replay stay in your page. Read $host.score, listen for snake:eat, snake:over, and snake:replay, then call $host.replay().

The loop uses requestAnimationFrame with a fixed timestep. It does not use setInterval.

Terminal window
pnpm add @agencecinq/snake
import "@agencecinq/snake"
<cinq-snake cols="21" rows="15">
<p>Score <span data-score aria-live="polite">0</span></p>
<div class="snake-stage">
<canvas tabindex="0" aria-label="Snake. Arrow keys to move."></canvas>
<div data-overlay hidden>
<p data-status>Game over</p>
<button type="button" data-replay>Play again</button>
</div>
</div>
</cinq-snake>

The [data-score] span, overlay, and replay button are yours. The host does not query them or write to them.

Layout is your responsibility: size the host and the canvas. Use width: 100% and aspect-ratio: 21 / 15 (or cols / rows) for square tiles. After the canvas box changes, call sync() (a ResizeObserver on the canvas is enough). To change density, set cols and rows yourself. Changing them resets the board.

Focus lives on the canvas. Give it tabindex="0" so arrow keys reach it. The component will not invent that attribute. Style :focus-visible yourself. Never rely on a JS .focus class.

cinq-snake {
display: block;
}
.snake-stage {
position: relative;
}
cinq-snake canvas {
display: block;
width: 100%;
height: auto;
aspect-ratio: 21 / 15;
touch-action: none;
image-rendering: pixelated;
background-color: #c5d0b8;
color: #2a2a2a;
border: 2px dotted currentColor;
}
cinq-snake [data-overlay]:not([hidden]) {
position: absolute;
inset: 0;
}

Click the board, then steer with the arrow keys. Eat food to grow. Hit a wall or your own tail and the game ends.

Score0

cols and rows stay under your control. The fill demo maps a target cell size onto the canvas box, writes cols / rows, and sets aspect-ratio: var(--cols) / var(--rows) so tiles stay square while the board fills the parent width.

const CELL = 24
const cols = Math.max(8, Math.floor($canvas.clientWidth / CELL))
const rows = Math.max(8, Math.round((cols * 15) / 21))
$host.setAttribute("cols", String(cols))
$host.setAttribute("rows", String(rows))
$host.style.setProperty("--cols", String(cols))
$host.style.setProperty("--rows", String(rows))
cinq-snake canvas {
width: 100%;
height: auto;
aspect-ratio: var(--cols, 21) / var(--rows, 15);
}

The host is 100% of this parent. The demo writes `cols` and `rows` from the canvas size so cells stay about 24px and square.

Grid 21 x 150

color on the canvas (or an ancestor, it inherits) tints the snake and the food. background-color and any frame (border, outline, etc.) are yours. The component clears the bitmap each frame so that fill shows through.

cinq-snake canvas {
background-color: #c5d0b8;
color: #2a2a2a;
aspect-ratio: 21 / 15;
border: 2px dotted currentColor;
}

If CSS color is missing, the package falls back to #2a2a2a.

Same game, different ink. Set `background-color` and `color` on the canvas.

Score0

The component draws into the <canvas> 2D context.

  1. JS. sync() maps the current cols by rows grid onto the whole canvas. Cells stretch with the box. Snake and food are filled rects.
  2. CSS. Size the canvas with width: 100% and aspect-ratio: 21 / 15 (or cols / rows) so cells stay square while the board fills the parent width. image-rendering: pixelated keeps the upscale blocky.

The animation loop is requestAnimationFrame. Simulation steps accumulate time and advance one cell when the timestep elapses. Drawing runs once per frame. The component does not observe size. Wire that yourself:

const $host = document.querySelector("cinq-snake")
const $canvas = $host.querySelector("canvas")
new ResizeObserver(() => {
$host.sync()
}).observe($canvas)
Attribute Default Description
cols 21 Horizontal cell count. Observed. Resets the board when it changes.
rows 15 Vertical cell count. Observed. Resets the board when it changes.
Key Function
Arrow keys Turn. 180-degree reverses are ignored. The first arrow starts the game.
Enter or Space After a collision, start again (when the canvas still holds focus).

The replay button is yours. Listen for snake:over, show your overlay, and call $host.replay() from the button. On a touch screen, swipe on the board to turn.

const $host = document.querySelector("cinq-snake")
$host.init()
$host.sync()
$host.replay()
$host.destroy()
$host.score
$host.cols
$host.rows
$host.$canvas

Call destroy() before init() if the host is already bound and you mutated the light DOM.

Prefer constants from @agencecinq/utils.

Event Constant Detail
snake:eat SNAKE_EAT { score } after food
snake:over SNAKE_OVER { score } after a wall or self hit
snake:replay SNAKE_REPLAY { score } after replay(), or after cols / rows change
import { EVENTS } from "@agencecinq/utils"
const $host = document.querySelector("cinq-snake")
const $score = $host.querySelector("[data-score]")
const $overlay = $host.querySelector("[data-overlay]")
const $replay = $host.querySelector("[data-replay]")
$host.addEventListener(EVENTS.SNAKE_EAT, (event) => {
$score.textContent = String(event.detail.score)
})
$host.addEventListener(EVENTS.SNAKE_OVER, () => {
$overlay.hidden = false
$replay.focus()
})
$host.addEventListener(EVENTS.SNAKE_REPLAY, (event) => {
$overlay.hidden = true
$score.textContent = String(event.detail.score)
})
$replay.addEventListener("click", () => {
$host.replay()
})