In Part 4, we added scoring, lives, a countdown timer, and a game over screen. The game is fully playable, but every round feels the same — traffic never speeds up and the timer never gets shorter. Now we add level progression so the game gets harder as the player advances. Fill all goal slots to complete a level, then face faster traffic, more obstacles, and less time.
We define a level config table that maps each level to its settings: speed multiplier, extra obstacles per lane, and time limit. The table makes it easy to tune difficulty without changing game logic.
let level = 1;
const LEVEL_CONFIG = [
null, // index 0 unused (levels start at 1)
{ speedMult: 1.0, extraObs: 0, time: 30 }, // Lv 1
{ speedMult: 1.2, extraObs: 0, time: 28 }, // Lv 2
{ speedMult: 1.4, extraObs: 1, time: 26 }, // Lv 3
{ speedMult: 1.6, extraObs: 1, time: 24 }, // Lv 4
{ speedMult: 1.8, extraObs: 2, time: 22 }, // Lv 5
{ speedMult: 2.0, extraObs: 2, time: 20 }, // Lv 6
{ speedMult: 2.2, extraObs: 3, time: 18 }, // Lv 7
{ speedMult: 2.5, extraObs: 3, time: 16 }, // Lv 8
];
function getLevelConfig() {
const maxIdx = LEVEL_CONFIG.length - 1;
const idx = Math.min(level, maxIdx);
return LEVEL_CONFIG[idx];
}
getLevelConfig() function caps at the last entry so levels beyond 8 use the hardest settings.
Each obstacle has a base speed defined when it is created. We multiply that base speed by the level's speed multiplier. This means all traffic gets proportionally faster — slow trucks stay relatively slow, fast cars stay relatively fast.
function createObstaclesForLevel() {
const config = getLevelConfig();
obstacles.length = 0; // clear existing
// Base obstacles (same as before)
const baseObs = [
{ x: 0, row: 7, baseSpd: 1.5, w: 1, dir: 1,
color: '#e74c3c' },
{ x: 200, row: 7, baseSpd: 1.5, w: 1, dir: 1,
color: '#e74c3c' },
{ x: 400, row: 7, baseSpd: 1.5, w: 1, dir: 1,
color: '#e74c3c' },
{ x: 80, row: 8, baseSpd: 1.0, w: 2, dir: -1,
color: '#8e44ad' },
{ x: 320, row: 8, baseSpd: 1.0, w: 2, dir: -1,
color: '#8e44ad' },
{ x: 0, row: 9, baseSpd: 2.5, w: 1, dir: 1,
color: '#f39c12' },
{ x: 260, row: 9, baseSpd: 2.5, w: 1, dir: 1,
color: '#f39c12' },
{ x: 40, row: 10, baseSpd: 1.2, w: 2, dir: -1,
color: '#2c3e50' },
{ x: 280, row: 10, baseSpd: 1.2, w: 2, dir: -1,
color: '#2c3e50' },
{ x: 100, row: 11, baseSpd: 1.8, w: 1, dir: 1,
color: '#e67e22' },
{ x: 300, row: 11, baseSpd: 1.8, w: 1, dir: 1,
color: '#e67e22' },
];
baseObs.forEach(ob => {
obstacles.push({
...ob,
speed: ob.baseSpd * config.speedMult
});
});
}
We store a baseSpd on each obstacle template and compute the actual speed by multiplying with the level's speed multiplier. This keeps the speed ratios between lanes consistent across levels.
Higher levels add extra obstacles to each road lane. More obstacles means smaller gaps, which means tighter timing. We distribute extra obstacles evenly across the lane width.
function addExtraObstacles(config) {
if (config.extraObs <= 0) return;
const roadRows = [7, 8, 9, 10, 11];
const colors = [
'#e74c3c', '#8e44ad', '#f39c12',
'#2c3e50', '#e67e22'
];
roadRows.forEach((row, i) => {
const existing = obstacles.filter(
o => o.row === row
);
if (existing.length === 0) return;
const template = existing[0];
const spacing = WIDTH / (config.extraObs + 1);
for (let j = 0; j < config.extraObs; j++) {
obstacles.push({
x: spacing * (j + 1) +
Math.random() * 40 - 20,
row: row,
speed: template.speed,
w: template.w,
dir: template.dir,
color: colors[i]
});
}
});
}
Math.random() * 40 - 20) to extra obstacle positions prevents them from lining up perfectly with existing ones. This creates more natural-looking traffic patterns.
Each level reduces the time limit. This is the simplest difficulty knob — less time means less room for hesitation. We read the time limit from the level config when resetting the timer.
function resetTimerForLevel() {
const config = getLevelConfig();
timeLeft = config.time;
}
// Called when:
// - A new level starts
// - The frog reaches a goal (reset for next frog)
// - The frog dies (reset for next attempt)
function awardGoal() {
const config = getLevelConfig();
const timeBonus = timeLeft * 2;
score += SCORE_GOAL + timeBonus;
resetTimerForLevel();
highestRow = ROWS - 1;
// Check if all goals are filled
if (filledGoals.length >= goalSlots.length) {
startNextLevel();
}
}
The time bonus still rewards fast play, but now the maximum possible bonus decreases each level because the starting time is lower. This naturally reduces scoring potential at higher levels, keeping the difficulty curve honest.
When the player fills all goal slots, we show a brief "Level N" overlay before starting the next level. This gives the player a moment to breathe and builds anticipation.
let levelTransition = false;
let transitionTimer = 0;
const TRANSITION_DURATION = 90; // ~1.5s at 60fps
function startNextLevel() {
level++;
levelTransition = true;
transitionTimer = 0;
}
function updateTransition() {
if (!levelTransition) return false;
transitionTimer++;
if (transitionTimer >= TRANSITION_DURATION) {
levelTransition = false;
initLevel(); // set up new level
return false;
}
return true; // still showing
}
function drawTransition() {
// Semi-transparent overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.75)';
ctx.fillRect(0, 0, WIDTH, HEIGHT);
// Level number
ctx.fillStyle = '#ffd700';
ctx.font = 'bold 32px "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Level ' + level,
WIDTH / 2, HEIGHT / 2 - 10);
// Subtitle
ctx.fillStyle = '#fff';
ctx.font = '14px "Segoe UI", sans-serif';
ctx.fillText('Get ready...', WIDTH / 2,
HEIGHT / 2 + 20);
}
function initLevel() {
filledGoals = [];
createObstaclesForLevel();
addExtraObstacles(getLevelConfig());
createLogs();
resetTimerForLevel();
resetFrog();
highestRow = ROWS - 1;
}
A good difficulty curve starts easy and ramps up gradually. The first few levels should feel comfortable so new players learn the mechanics. Later levels should challenge experienced players without feeling impossible.
// Difficulty curve visualization:
//
// Level | Speed | Extra Obs | Timer | Feel
// ------+-------+-----------+-------+--------
// 1 | 1.0x | 0 | 30s | Easy
// 2 | 1.2x | 0 | 28s | Gentle
// 3 | 1.4x | +1 | 26s | Medium
// 4 | 1.6x | +1 | 24s | Tricky
// 5 | 1.8x | +2 | 22s | Hard
// 6 | 2.0x | +2 | 20s | Expert
// 7 | 2.2x | +3 | 18s | Brutal
// 8+ | 2.5x | +3 | 16s | Max
// Three knobs working together:
// 1. Speed — makes timing windows smaller
// 2. Obstacle count — makes gaps rarer
// 3. Timer — makes hesitation costly
//
// Each knob increases independently so the
// player feels multiple pressures at once.
The key insight is that three difficulty knobs compound. Level 3 is not just 1.4x harder than level 1 — it is 1.4x speed × more obstacles × less time. This exponential feel is what makes arcade games addictive. The player always feels like "one more try" could get them to the next level.
Here is the game loop with level transitions integrated:
function gameLoop(timestamp) {
// Game over screen
if (gameOver) {
drawGameOver();
requestAnimationFrame(gameLoop);
return;
}
// Level transition overlay
if (updateTransition()) {
ctx.clearRect(0, 0, WIDTH, HEIGHT);
drawLanes();
drawLaneDetails();
drawLogs();
drawObstacles();
drawFilledGoals();
drawHUD();
drawTransition();
requestAnimationFrame(gameLoop);
return;
}
// Death animation
if (updateDeathAnim()) {
ctx.clearRect(0, 0, WIDTH, HEIGHT);
drawLanes();
drawLaneDetails();
drawLogs();
drawObstacles();
drawFilledGoals();
drawDeathAnim();
drawHUD();
requestAnimationFrame(gameLoop);
return;
}
// Normal gameplay
updateTimer(timestamp);
update();
awardForwardStep();
const result = checkAllCollisions();
if (result === 'hit' || result === 'drown') {
onFrogDeath(result);
} else if (result === 'goal') {
if (checkGoal()) {
awardGoal();
resetFrog();
} else {
onFrogDeath('drown');
}
}
ctx.clearRect(0, 0, WIDTH, HEIGHT);
drawLanes();
drawLaneDetails();
drawLogs();
drawObstacles();
drawFilledGoals();
if (!deathAnim) drawFrog();
drawHUD();
requestAnimationFrame(gameLoop);
}
The game now has full level progression — faster traffic, more obstacles, and shorter timers as the player advances. The last piece missing is audio feedback. Sound effects make every hop, splash, and level-up feel satisfying.
Continue to Part 6: Sound Effects where we add hop, splash, death, and level-up sounds using the Web Audio API.