A game without a high score is a game without motivation. In Part 1, we built a working Snake game with movement, food, and collision detection. Now we add score tracking — a running score that updates as you eat food, a persistent high score saved in localStorage, floating +10 animations, and a game over screen that compares your run against your best.
Start with a simple score variable that increments each time the snake eats food. We draw it on the canvas so it is always visible during gameplay.
let score = 0;
function drawScore() {
ctx.fillStyle = '#fff';
ctx.font = '14px "Segoe UI", sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Score: ' + score, 8, 18);
}
Call drawScore() at the end of your draw() function so it renders on top of everything else. Each time the snake eats food, increment the score:
function checkFood() {
const head = snake[0];
if (head.x === food.x && head.y === food.y) {
snake.push({ ...snake[snake.length - 1] });
food = spawnFood();
score += 10;
}
}
The Web Storage API gives us localStorage — a simple key-value store that persists across browser sessions. We use it to save the high score so it survives page refreshes.
const HI_KEY = 'snake_hi';
function loadHighScore() {
const saved = localStorage.getItem(HI_KEY);
return saved ? parseInt(saved, 10) : 0;
}
function saveHighScore(score) {
const current = loadHighScore();
if (score > current) {
localStorage.setItem(HI_KEY, score.toString());
return true; // new record
}
return false;
}
let highScore = loadHighScore();
We load the high score once when the game starts. After each game over, we call saveHighScore() to check if the player beat their record. The function returns true if it is a new best — useful for showing a "New Record!" message.
parseInt(saved, 10) with the radix parameter. localStorage stores everything as strings, so you need to parse it back to a number explicitly.
Show both the current score and the best score during gameplay. This gives the player a target to beat and adds tension as they approach their record.
function drawScore() {
ctx.fillStyle = '#fff';
ctx.font = '14px "Segoe UI", sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Score: ' + score, 8, 18);
ctx.textAlign = 'right';
ctx.fillStyle = '#ffd700';
ctx.fillText('Best: ' + highScore, WIDTH - 8, 18);
}
The current score sits in the top-left corner, the best score in the top-right. We use gold (#ffd700) for the best score to make it visually distinct. As the player gets closer to their record, the numbers converge — that is where the excitement comes from.
When the snake eats food, a "+10" text floats upward and fades out. This gives satisfying visual feedback for each food eaten.
let floatingTexts = [];
function addFloatingText(x, y, text) {
floatingTexts.push({
x: x * CELL_SIZE + CELL_SIZE / 2,
y: y * CELL_SIZE,
text: text,
opacity: 1.0,
life: 30 // frames
});
}
function updateFloatingTexts() {
for (let i = floatingTexts.length - 1; i >= 0; i--) {
const ft = floatingTexts[i];
ft.y -= 1; // float upward
ft.life--;
ft.opacity = ft.life / 30;
if (ft.life <= 0) {
floatingTexts.splice(i, 1);
}
}
}
function drawFloatingTexts() {
floatingTexts.forEach(ft => {
ctx.save();
ctx.globalAlpha = ft.opacity;
ctx.fillStyle = '#ffd700';
ctx.font = 'bold 14px "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.fillText(ft.text, ft.x, ft.y);
ctx.restore();
});
}
When the snake eats food, call addFloatingText(food.x, food.y, '+10'). In your game loop, call updateFloatingTexts() to animate them and drawFloatingTexts() to render them. The text rises and fades over 30 frames.
// In checkFood(), after score += 10:
addFloatingText(food.x, food.y, '+10');
ctx.save() and ctx.restore() around globalAlpha changes. This prevents the transparency from leaking into other draw calls.
The game over screen shows the final score, the best score, and whether the player set a new record. This is the payoff moment — the player sees how they did.
function drawGameOver() {
draw(); // draw final state underneath
// Dark overlay
ctx.fillStyle = 'rgba(0,0,0,0.75)';
ctx.fillRect(0, 0, WIDTH, HEIGHT);
// Title
ctx.fillStyle = '#ff4040';
ctx.font = 'bold 24px "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.fillText('GAME OVER', WIDTH / 2, HEIGHT / 2 - 40);
// Current score
ctx.fillStyle = '#fff';
ctx.font = '16px "Segoe UI", sans-serif';
ctx.fillText('Score: ' + score, WIDTH / 2, HEIGHT / 2 - 10);
// Best score
const isNewRecord = saveHighScore(score);
if (isNewRecord) {
highScore = score;
ctx.fillStyle = '#ffd700';
ctx.font = 'bold 16px "Segoe UI", sans-serif';
ctx.fillText('★ New Record! ★', WIDTH / 2, HEIGHT / 2 + 15);
} else {
ctx.fillStyle = '#aaa';
ctx.font = '14px "Segoe UI", sans-serif';
ctx.fillText('Best: ' + highScore, WIDTH / 2, HEIGHT / 2 + 15);
}
// Restart hint
ctx.fillStyle = '#888';
ctx.font = '12px "Segoe UI", sans-serif';
ctx.fillText('Press R to restart', WIDTH / 2, HEIGHT / 2 + 50);
}
The saveHighScore() call returns true if this run is a new record. We use that to show either a celebratory "New Record!" message in gold or the existing best score in gray. The high score variable is updated immediately so the next game shows the correct best.
When the player presses R to restart, we reset the current score to zero but keep the high score intact. The high score is already saved in localStorage, so it persists even if the player closes the browser.
document.addEventListener('keydown', (e) => {
if ((e.key === 'r' || e.key === 'R') && gameOver) {
// Reset game state
snake = [
{ x: 10, y: 10 },
{ x: 9, y: 10 },
{ x: 8, y: 10 },
];
direction = { x: 1, y: 0 };
nextDirection = { x: 1, y: 0 };
food = spawnFood();
// Reset score but keep high score
score = 0;
floatingTexts = [];
gameOver = false;
// Reload high score (in case another tab updated it)
highScore = loadHighScore();
}
});
Here is the updated game loop with all the score features integrated:
function gameLoop() {
if (gameOver) return;
moveSnake();
checkFood();
updateFloatingTexts();
if (checkCollision()) {
gameOver = true;
drawGameOver();
return;
}
draw();
drawFloatingTexts();
drawScore();
}
setInterval(gameLoop, SPEED);
The order matters: update floating texts before collision check (so they animate even on the death frame), draw them after the main draw call, and draw the score last so it is always on top.
With high scores in place, the player has a reason to keep playing. In the next part, we make the game progressively harder by increasing the speed as the snake grows.
Continue to Part 3: Speed Increase →