In Part 3, we added collision detection — the frog dies when hit by a car or when it falls in the water, and scores when it reaches a goal slot. But there is no score counter, no lives, and no way to lose. Now we add the systems that turn Frogger into a complete game: points, a countdown timer, lives, high scores, and a game over screen.
Frogger awards points for two things: reaching a goal slot (+100) and moving forward (+10 per row). We track the frog's highest row reached so forward-step points are only awarded once per row per life.
let score = 0;
let highestRow = ROWS - 1; // tracks forward progress
const SCORE_GOAL = 100;
const SCORE_STEP = 10;
function awardForwardStep() {
if (frog.row < highestRow) {
score += SCORE_STEP * (highestRow - frog.row);
highestRow = frog.row;
}
}
function awardGoal() {
score += SCORE_GOAL;
highestRow = ROWS - 1; // reset for next frog
}
highestRow prevents the player from farming points by moving forward and backward repeatedly. Points are only awarded for new forward progress. When the frog dies or scores a goal, highestRow resets.
The player starts with 3 lives. Each death costs one life. When lives reach zero, the game is over. We decrement lives in the death handler and check for game over before resetting the frog.
let lives = 3;
let gameOver = false;
function onFrogDeath(type) {
lives--;
if (lives <= 0) {
gameOver = true;
checkHighScore();
return;
}
// Reset frog position and progress tracker
startDeath(type);
highestRow = ROWS - 1;
}
// In the collision check:
const result = checkAllCollisions();
if (result === 'hit' || result === 'drown') {
onFrogDeath(result);
}
The death animation from Part 3 still plays when the frog dies. The difference now is that we decrement lives first. If lives hit zero, we skip the animation and go straight to the game over screen.
Classic Frogger has a time limit for each frog. If the timer runs out, the frog dies. Reaching the goal quickly awards bonus points based on remaining time.
const TIME_LIMIT = 30; // seconds per frog
let timeLeft = TIME_LIMIT;
let lastTick = 0;
function updateTimer(timestamp) {
if (gameOver || deathAnim) return;
if (timestamp - lastTick >= 1000) {
timeLeft--;
lastTick = timestamp;
if (timeLeft <= 0) {
timeLeft = 0;
onFrogDeath('drown'); // time's up = death
}
}
}
function awardGoal() {
// Time bonus: 1 point per second remaining
const timeBonus = timeLeft * 2;
score += SCORE_GOAL + timeBonus;
// Reset timer for next frog
timeLeft = TIME_LIMIT;
highestRow = ROWS - 1;
}
We save the best score in localStorage so it persists across browser sessions. The pattern is the same as any other game — load on start, save on game over if the score is higher.
const HI_KEY = 'frogger_hi';
function loadHighScore() {
const saved = localStorage.getItem(HI_KEY);
return saved ? parseInt(saved, 10) : 0;
}
function saveHighScore(newScore) {
const current = loadHighScore();
if (newScore > current) {
localStorage.setItem(HI_KEY, newScore.toString());
return true; // new record
}
return false;
}
let highScore = loadHighScore();
let isNewRecord = false;
function checkHighScore() {
isNewRecord = saveHighScore(score);
if (isNewRecord) highScore = score;
}
We call checkHighScore() when the game ends. The isNewRecord flag lets us show a special message on the game over screen.
The HUD (heads-up display) shows score, lives, and timer. We draw it on top of everything else so it is always visible. Lives are shown as small frog icons, the timer as a colored bar that shrinks and changes color.
function drawHUD() {
// Score (top-left)
ctx.fillStyle = '#fff';
ctx.font = '14px "Segoe UI", sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Score: ' + score, 8, 18);
// High score (top-right)
ctx.textAlign = 'right';
ctx.fillStyle = '#ffd700';
ctx.fillText('Best: ' + highScore, WIDTH - 8, 18);
// Lives (bottom-left, frog icons)
ctx.textAlign = 'left';
ctx.font = '14px sans-serif';
for (let i = 0; i < lives; i++) {
ctx.fillText('🐸', 8 + i * 22, HEIGHT - 8);
}
// Timer bar (bottom)
const barW = WIDTH - 80;
const barH = 6;
const barX = 70;
const barY = HEIGHT - 14;
const pct = timeLeft / TIME_LIMIT;
// Background
ctx.fillStyle = '#333';
ctx.fillRect(barX, barY, barW, barH);
// Fill (green → yellow → red)
if (pct > 0.5) ctx.fillStyle = '#2ecc40';
else if (pct > 0.25) ctx.fillStyle = '#f1c40f';
else ctx.fillStyle = '#e74c3c';
ctx.fillRect(barX, barY, barW * pct, barH);
}
When lives reach zero, we show a game over overlay with the final score, high score, and a restart option. The overlay draws on top of the final game state so the player can see where they died.
function drawGameOver() {
// Dark overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.fillRect(0, 0, WIDTH, HEIGHT);
// Title
ctx.fillStyle = '#e74c3c';
ctx.font = 'bold 28px "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.fillText('GAME OVER', WIDTH / 2, HEIGHT / 2 - 50);
// Final score
ctx.fillStyle = '#fff';
ctx.font = '18px "Segoe UI", sans-serif';
ctx.fillText('Score: ' + score, WIDTH / 2,
HEIGHT / 2 - 15);
// High score or new record
if (isNewRecord) {
ctx.fillStyle = '#ffd700';
ctx.font = 'bold 18px "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 + 55);
}
// Restart handler
document.addEventListener('keydown', (e) => {
if ((e.key === 'r' || e.key === 'R') && gameOver) {
score = 0;
lives = 3;
timeLeft = TIME_LIMIT;
highestRow = ROWS - 1;
filledGoals = [];
gameOver = false;
isNewRecord = false;
highScore = loadHighScore();
resetFrog();
createObstacles();
createLogs();
}
});
The restart handler resets everything: score, lives, timer, filled goals, and obstacle positions. We reload the high score from localStorage in case another tab updated it.
Here is the complete game loop with scoring, lives, timer, and game over integrated:
function gameLoop(timestamp) {
if (gameOver) {
drawGameOver();
requestAnimationFrame(gameLoop);
return;
}
// Death animation
if (updateDeathAnim()) {
ctx.clearRect(0, 0, WIDTH, HEIGHT);
drawLanes();
drawLaneDetails();
drawLogs();
drawObstacles();
drawFilledGoals();
drawDeathAnim();
drawHUD();
requestAnimationFrame(gameLoop);
return;
}
// Update
updateTimer(timestamp);
update();
awardForwardStep();
// Collisions
const result = checkAllCollisions();
if (result === 'hit' || result === 'drown') {
onFrogDeath(result);
} else if (result === 'goal') {
if (checkGoal()) {
awardGoal();
resetFrog();
} else {
onFrogDeath('drown');
}
}
// Draw
ctx.clearRect(0, 0, WIDTH, HEIGHT);
drawLanes();
drawLaneDetails();
drawLogs();
drawObstacles();
drawFilledGoals();
if (!deathAnim) drawFrog();
drawHUD();
requestAnimationFrame(gameLoop);
}
The game is now fully playable with scoring, lives, a timer, and a game over screen. But every round feels the same — the traffic never gets harder. In the next part, we add level progression so the game gets faster and more challenging as the player advances.
Continue to Part 5: Levels & Difficulty where we add level progression with faster traffic and shorter timers.