A constant-speed Snake game gets boring fast. In Part 2, we added score tracking and high scores. Now the real challenge comes from the game speeding up as you grow — each food eaten makes the next one harder to reach. In this part, we replace the fixed interval with dynamic speed that scales with the snake's length.
Instead of a fixed setInterval, we calculate the interval dynamically based on how many food the snake has eaten. The formula starts slow and gets faster with each food.
const BASE_SPEED = 150; // starting interval (ms)
const MIN_SPEED = 60; // fastest possible (ms)
const SPEED_STEP = 5; // ms reduction per food
function getSpeed() {
const foodEaten = snake.length - 3; // started with 3 segments
const speed = BASE_SPEED - (foodEaten * SPEED_STEP);
return Math.max(speed, MIN_SPEED);
}
The snake starts with 3 segments, so foodEaten is the current length minus 3. Each food reduces the interval by 5ms. At 150ms base and 5ms per food, the snake reaches max speed after 18 food items: 150 - (18 × 5) = 60ms.
Math.max() call is critical. Without it, the interval could go to zero or negative, which would freeze or crash the game. Always cap your speed.
A fixed setInterval cannot change its interval after creation. We switch to requestAnimationFrame with timestamp tracking, which lets us change the tick rate every frame.
let lastTick = 0;
function gameLoop(timestamp) {
if (gameOver) return;
const elapsed = timestamp - lastTick;
const currentSpeed = getSpeed();
if (elapsed >= currentSpeed) {
lastTick = timestamp;
moveSnake();
checkFood();
updateFloatingTexts();
if (checkCollision()) {
gameOver = true;
drawGameOver();
return;
}
draw();
drawFloatingTexts();
drawScore();
}
requestAnimationFrame(gameLoop);
}
// Start the loop
requestAnimationFrame(gameLoop);
The browser calls gameLoop roughly 60 times per second. We only process a game tick when enough time has passed (based on getSpeed()). This gives us smooth, variable-speed gameplay without creating and destroying intervals.
requestAnimationFrame passes a high-resolution timestamp as the first argument. We compare it against lastTick to decide when to process the next game step. This is more reliable than setInterval for variable timing.
Instead of a smooth linear increase, you can use discrete speed tiers. Every 5 food items, the speed jumps to the next tier. This creates noticeable "level up" moments that feel more rewarding.
const SPEED_TIERS = [
{ threshold: 0, speed: 150 }, // Tier 1: start
{ threshold: 5, speed: 130 }, // Tier 2: getting warm
{ threshold: 10, speed: 110 }, // Tier 3: picking up
{ threshold: 15, speed: 90 }, // Tier 4: fast
{ threshold: 20, speed: 75 }, // Tier 5: very fast
{ threshold: 25, speed: 60 }, // Tier 6: max speed
];
function getSpeedTier() {
const foodEaten = snake.length - 3;
let currentTier = SPEED_TIERS[0];
for (const tier of SPEED_TIERS) {
if (foodEaten >= tier.threshold) {
currentTier = tier;
}
}
return currentTier;
}
function getSpeed() {
return getSpeedTier().speed;
}
The tier system is easier to tune than a formula. You can adjust each tier independently — maybe the jump from Tier 3 to Tier 4 is too harsh, so you change it from 90 to 100. With a formula, changing one point affects the entire curve.
Show the player which speed tier they are on. A small bar or label in the corner gives feedback that the game is getting harder.
function drawSpeedIndicator() {
const tier = getSpeedTier();
const tierIndex = SPEED_TIERS.indexOf(tier);
const tierCount = SPEED_TIERS.length;
// Background bar
const barX = 8;
const barY = HEIGHT - 16;
const barW = 80;
const barH = 6;
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fillRect(barX, barY, barW, barH);
// Filled portion
const fill = ((tierIndex + 1) / tierCount) * barW;
const colors = ['#4cff72','#a0ff40','#ffd700','#ff8c00','#ff4040','#ff0040'];
ctx.fillStyle = colors[tierIndex] || '#ff0040';
ctx.fillRect(barX, barY, fill, barH);
// Label
ctx.fillStyle = '#aaa';
ctx.font = '10px "Segoe UI", sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Speed ' + (tierIndex + 1), barX, barY - 4);
}
The bar fills up as the player advances through tiers, changing color from green to red. It is a subtle but effective way to communicate difficulty progression without cluttering the screen.
The minimum interval should never go below about 60ms. At 60ms per tick, the snake moves roughly 16 cells per second — fast enough to be challenging but still controllable. Going below 50ms makes the game feel unfair on most displays.
// Already handled in our tier system (last tier = 60ms)
// But if using the formula approach, always enforce:
function getSpeed() {
const foodEaten = snake.length - 3;
const speed = BASE_SPEED - (foodEaten * SPEED_STEP);
return Math.max(speed, MIN_SPEED); // MIN_SPEED = 60
}
The difficulty curve is the relationship between progress and challenge. A good curve starts gentle and ramps up gradually. Here are three common approaches:
// Linear: constant increase per food
function linearSpeed(foodEaten) {
return Math.max(BASE_SPEED - foodEaten * 5, MIN_SPEED);
}
// Logarithmic: fast early gains, then plateaus
function logSpeed(foodEaten) {
const factor = Math.log2(foodEaten + 1) * 15;
return Math.max(BASE_SPEED - factor, MIN_SPEED);
}
// Stepped: discrete jumps (our tier system)
function steppedSpeed(foodEaten) {
return getSpeedTier().speed;
}
Linear is predictable — each food makes the same difference. Logarithmic feels fast at first but slows down, giving experienced players a longer challenge at high speed. Stepped creates clear milestones that feel like leveling up.
For most Snake games, stepped or linear works best. Logarithmic is better for games where you want a long endgame at near-max speed.
Speed increase gives the game a natural difficulty curve. In the next part, we add wall wrapping — instead of dying when hitting a wall, the snake appears on the opposite side. This creates a friendlier "kids mode" experience.
Continue to Part 4: Wall Wrapping →