Master

Scoring, Persistence, and the HUD

A score is a number that changes. Learn the Score class, localStorage to save high scores between sessions, and how a tiny UI layer draws them on screen.

What you will see

ok: two coins = 200 points ok: two coins counted score test passed FAIL: two coins = 200 points -- expected 200, got 20

About 6 min of reading

Quick check

Before this new lesson, one small question about the lesson you just finished. Recall it, do not look it up.

Try It

A score is a number with a couple of operations. I build the Score class, watch it grow by coins and by time, save the high score to a tiny browser database, read it back in a new instance, then chase a record and learn what happens when a run does not beat it.

1

The Score class holds two numbers and changes them through methods: addCoin() bumps the coin count and the running score, and update() adds a small trickle every second. I create one Score, print its two fields, collect a coin, then run one half-second frame so the time-trickle shows up too.

class Score { constructor() { this.total = 0; this.score = 0; } addCoin() { this.total += 1; this.score += 100; } update(dt) { this.score += 10 * dt; } } const score = new Score(); console.log(score.score, score.total); // start score.addCoin(); console.log(score.score, score.total); // one coin score.update(0.5); console.log(score.score); // after half a second

Before you run it, say out loud what it will print. Then open the answer.

What it prints
0 0 100 1 105
addCoin adds exactly 100 points and counts one coin. update multiplies dt (in seconds) by the trickle rate, so update(0.5) adds only 5, time alone earns slowly, so standing still never wins the score race.
2

localStorage stores everything as a string and survives a reload. I back it with a plain object so the class can load the saved high score on construction and persist the record the instant the score passes it. I collect a couple of coins and watch the string the database writes.

const store = {}; const localStorage = { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: (k) => { delete store[k]; }, }; class Score { constructor() { this.total = 0; this.score = 0; this.high = 0; this._load(); } _load() { const saved = localStorage.getItem('high_score'); this.high = saved ? parseInt(saved, 10) : 0; } _save() { localStorage.setItem('high_score', String(this.score)); } addCoin() { this.total += 1; this.score += 100; if (this.score > this.high) { this.high = this.score; this._save(); } } } const a = new Score(); a.addCoin(); a.addCoin(); console.log('stored: "' + localStorage.getItem('high_score') + '" typeof ' + typeof localStorage.getItem('high_score'));

Before you run it, say out loud what it will print. Then open the answer.

What it prints
stored: "100" typeof string stored: "200" typeof string
Everything in localStorage is a string, so _save() calls String(this.score) and _load() calls parseInt(saved, 10) to turn it back into a number before comparing. The record is written only when the score strictly passes the old high.
3

A brand-new Score instance must read the saved record back so a fresh start already knows your best. I build a second Score, watch it load high=200, then push past the record with more coins and confirm the high advances and the database updates.

const store = { 'high_score': '200' }; const localStorage = { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: (k) => { delete store[k]; }, }; class Score { constructor() { this.total = 0; this.score = 0; this.high = 0; this._load(); } _load() { const saved = localStorage.getItem('high_score'); this.high = saved ? parseInt(saved, 10) : 0; } _save() { localStorage.setItem('high_score', String(this.score)); } addCoin() { this.total += 1; this.score += 100; if (this.score > this.high) { this.high = this.score; this._save(); } } } const b = new Score(); console.log('reads high=' + b.high); b.addCoin(); b.addCoin(); console.log('score=' + b.score + ' high=' + b.high); b.addCoin(); console.log('score=' + b.score + ' high=' + b.high); console.log('stored = "' + localStorage.getItem('high_score') + '"');

Before you run it, say out loud what it will print. Then open the answer.

What it prints
reads high=200 score=200 high=200 score=300 high=300 stored = "300"
The second instance starts empty but _load() fills high from the stored string, so a new run already begins at your best. Once the score strictly exceeds the stored high, high advances and the database is rewritten with the new value.
4

The HUD draws the current score, coin count, and saved best with a few fillText calls; the score float is floored to a clean integer. When the player beats the record the game over screen prints a 'NEW BEST' line; otherwise it just shows the old best. I run a winning run and a losing run and see which line appears.

const width = 960; const score = { score: 2347.8, total: 5 }; const high = 2347; console.log('Score: ' + Math.floor(score.score)); console.log('Coins: ' + score.total); console.log('Best: ' + Math.floor(high)); function gameOver(finalScore, finalHigh) { const line1 = 'GAME OVER'; if (finalScore >= finalHigh) { return line1 + '\n' + 'NEW BEST: ' + Math.floor(finalScore); } else { return line1 + '\n' + 'Best: ' + Math.floor(finalHigh); } } console.log(gameOver(2347, 2347)); console.log(gameOver(1000, 2347));

Before you run it, say out loud what it will print. Then open the answer.

What it prints
Score: 2347 Coins: 5 Best: 2347 GAME OVER NEW BEST: 2347 GAME OVER Best: 2347
Math.floor(2347.8) is 2347, the score is a float, so floor it for display. The '>=' test flashes 'NEW BEST' only when the final score equals or beats the stored best; otherwise it shows 'Best:' with the old record.
5

Every cap in config.js has a matching test so fairness cannot silently break. I encode the score rule (two coins = 200) and the coin-count rule as assertions. Then I intentionally write a buggy Score that adds only 10 per coin and watch the suite stop on the first failing test with the exact mismatch printed.

function assertEqual(actual, expected, message) { if (actual !== expected) { throw new Error('FAIL: ' + message + ' -- expected ' + expected + ', got ' + actual); } console.log('ok: ' + message); } class Score { constructor() { this.total = 0; this.score = 0; } addCoin() { this.total += 1; this.score += 100; } } class ScoreBug { constructor() { this.total = 0; this.score = 0; } addCoin() { this.total += 1; this.score += 10; } } const s = new Score(); s.addCoin(); s.addCoin(); assertEqual(s.score, 200, 'two coins = 200 points'); assertEqual(s.total, 2, 'two coins counted'); console.log('score test passed'); const bug = new ScoreBug(); bug.addCoin(); bug.addCoin(); try { assertEqual(bug.score, 200, 'two coins = 200 points'); console.log('bug test passed'); } catch (err) { console.log(err.message); }

Before you run it, say out loud what it will print. Then open the answer.

What it prints
ok: two coins = 200 points ok: two coins counted score test passed FAIL: two coins = 200 points -- expected 200, got 20
A throw stops the whole run and prints the mismatch, so tests must run in a fixed order. The passing suite proves addCoin adds 100 every time; the deliberately buggy class shows the suite failing exactly where the rule broke, that is the point.

Data Flow

Coins and time feed the Score class, which compares against a saved high score and persists the record to localStorage.
1 addCoin adds 100 to the running score
↓
2 update adds a small trickle per second
↓
3 If the score exceeds the stored high, bump high
↓
4 _save writes the new record to localStorage
↓
5 The HUD reads score.score and score.high to draw text each frame
Why this works
Why localStorage and not just a variable

A plain variable like let high = 0; dies when the page reloads. localStorage is **persistent*: it writes to the browser's disk and comes back next time.

localStorage.setItem('high_score', '5200');
// ... tab closes, reopens, machine restarts ...
localStorage.getItem('high_score');   // still "5200"

The only gotcha: everything stored is a string. Read it back, then parseInt or parseFloat. Also note localStorage is shared across all tabs of the same origin, so writing high_score from one tab is visible in the game tab.

For this game the data is tiny (one number), so localStorage is perfect. For anything sensitive, passwords, API keys, you never store it client-side. That's a web-app concern, not a game one.
Keeping the score fair

A score that grows from time alone could let a player "win" by standing still. The game keeps the numbers honest:

  1. Coins (addCoin) give the big, instant reward, the reward for skill.
  2. A small per-second trickle keeps a moving player ahead of a stationary one, without letting idle play win.
  3. The high is updated only when the score strictly passes it, and saved immediately, so the record can never be beaten in a single frame by a bug.

if (this.score > this.high) {
  this.high = this.score;
  this._save();
}

This is the same fairness philosophy from the difficulty caps: the reward should track the player's skill, never the machine's speed or a bug. The tests in the Master tier verify addCoin always adds exactly 100 and that high only grows.

Examples

The Score class

A score is just a number with a couple of operations. The game wraps that in a small class:

class Score {
  constructor() {
    this.total = 0;        // coins (countable)
    this.score = 0;        // the running "score" you see on screen
  }

  addCoin() {
    this.total += 1;
    this.score += 100;     // each coin is worth 100 points
  }
}

The reason this is a class, not just a variable, is state plus behaviour (the Classes lesson). Every coin the player touches calls this.score.addCoin(), and the number goes up by exactly 100, in one place, so it's easy to tune.

The running score also grows over time. The game keeps a small timer and adds a little every tenth of a second, so running longer earns more even without collecting coins:

update(dt) {
  this.score += 10 * dt;   // 10 points per second, forever
}

  • total counts coins; score is the big number.
  • addCoin is a method, the behaviour lives with the data.

Saving the high score with localStorage

A game that forgets your best score every time you close the tab feels broken. localStorage is a tiny browser database that stores text and survives tab reloads.

class Score {
  constructor() {
    this.total = 0;
    this.score = 0;
    this.high = 0;
    this._load();
  }

  _load() {
    const saved = localStorage.getItem('high_score');
    this.high = saved ? parseInt(saved, 10) : 0;
  }

  _save() {
    localStorage.setItem('high_score', String(this.score));
  }

  addCoin() {
    this.total += 1;
    this.score += 100;
    if (this.score > this.high) {
      this.high = this.score;
      this._save();     // persist the new record
    }
  }
}

  • localStorage.getItem('high_score'): read a value by name (returns a string, or null if never set).
  • localStorage.setItem('high_score', value): write a value (the browser turns it into a string).
  • parseInt(saved, 10): the stored value came back as a string ("4200"); convert it to a number.

The _ prefix on _load/_save is a convention meaning "this method is private, don't call it from elsewhere." The game only calls them itself.

Drawing score, then game over with a record

The HUD is just a couple of fillText calls. The game draws the current score and the saved best:

// HUD, drawn every frame during play.
ctx.fillStyle = COLORS.text;
ctx.font = '24px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Score: ' + Math.floor(score.score), 20, 34);
ctx.fillText('Coins: ' + score.total, 20, 64);
ctx.textAlign = 'right';
ctx.fillText('Best: ' + Math.floor(score.high), width - 20, 34);

  • Math.floor(score.score): the score is a floating-point float; Math.floor makes it a clean integer to read.
  • ctx.textAlign: 'left' for the score, 'right' for the best, both pinned to the screen edges.

When the player dies, the game over screen compares the final score to the stored best:

function gameOver(ctx, score, high) {
  ctx.fillText('GAME OVER', width / 2, height / 2 - 40);
  if (score.score >= high) {
    ctx.fillStyle = COLORS.accent;
    ctx.fillText('NEW BEST: ' + Math.floor(score.score), width / 2, height / 2 + 10);
  } else {
    ctx.fillText('Best: ' + Math.floor(high), width / 2, height / 2 + 10);
  }
}

The score >= high check, the game only flashes "NEW BEST" when you actually broke the record. this._save() (from the earlier lesson) already wrote the new record the instant the score first passed it.

Words this lesson uses

Each one is defined here on this site. Open the entry for the full definition and the other words it points at.

← Input and the State Machine
Previous Lesson
Input and the State Machine
Next Lesson
Juice: Making It Feel Good
Juice: Making It Feel Good →