Master

Testing a Game with Node

A game you can't verify is luck, not skill. Learn to test pure logic, math helpers, difficulty, collision, scoring, with a tiny Node harness that runs off the browser.

What you will see

buggy score=20 total=2 FAIL: two coins = 200 points -- expected 200, got 20

About 6 min of reading

Try It

A game you can't verify is luck, not skill. I build a tiny assertion harness, test the pure math helpers clamp and aabb, check all four collision edges, encode the difficulty curve's fairness caps as assertions, and watch a deliberately broken test stop the run.

1

Every test is one assertion: it throws if the actual value isn't the expected one, otherwise it prints 'ok:'. I write two helpers, assertEqual and assertTrue, then run five checks against clamp and aabb so the first failing test stops the run.

function assertEqual(actual, expected, message) { if (actual !== expected) { throw new Error('FAIL: ' + message + ' -- expected ' + expected + ', got ' + actual); } console.log('ok: ' + message); } function assertTrue(value, message) { if (!value) throw new Error('FAIL: ' + message); console.log('ok: ' + message); } const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); const aabb = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; assertEqual(clamp(15, 0, 10), 10, 'clamp caps at the max'); assertEqual(clamp(-3, 0, 10), 0, 'below-range clamps to min'); assertEqual(clamp(20, 0, 10), 10, 'above-range clamps to max'); assertTrue(aabb({ x: 100, y: 100, w: 40, h: 56 }, { x: 120, y: 130, w: 22, h: 22 }), 'overlapping rects collide'); assertTrue(!aabb({ x: 100, y: 100, w: 40, h: 56 }, { x: 500, y: 110, w: 20, h: 20 }), 'far-apart rects do not collide');

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

What it prints
ok: clamp caps at the max ok: below-range clamps to min ok: above-range clamps to max ok: overlapping rects collide ok: far-apart rects do not collide
A throw stops the whole run and prints the failure, so the tests must run in a fixed order. clamp and aabb have no state and no browser, so they are the fastest, most-depended-upon things to verify.
2

The purest functions are the ones with no dependencies. I test clamp inside range, below range, and above range, plus the rand-bound inequality the game ships, and print the collision boolean for an overlapping pair and a far-apart pair so the result is visible in the output.

const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); const aabb = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; console.log(clamp(5, 0, 10)); console.log(clamp(-3, 0, 10)); console.log(clamp(20, 0, 10)); console.log(clamp(5, 0, 10) < 2, clamp(5, 0, 10) < 15); console.log(aabb({ x: 100, y: 100, w: 40, h: 56 }, { x: 120, y: 130, w: 22, h: 22 })); console.log(!aabb({ x: 100, y: 100, w: 40, h: 56 }, { x: 500, y: 110, w: 20, h: 20 }));

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

What it prints
5 0 10 false true true true
clamp(5, 0, 10) stays 5 because 5 is between the bounds. aabb returns a single boolean, the rects overlap only when each axis overlaps, which is why four comparisons are ANDed together.
3

Now I test behaviour: the Score class must always add exactly 100 per coin and count coins with total. I also encode the difficulty curve's fairness caps as assertions, speed never decreases with distance and never exceeds its cap, spacing never drops below its floor, and the hazard rate is capped, so an unfair change would break a test immediately.

class Score { constructor() { this.total = 0; this.score = 0; } addCoin() { this.total += 1; this.score += 100; } } const s = new Score(); s.addCoin(); s.addCoin(); console.log('score=' + s.score, 'total=' + s.total); const DIFFICULTY = { speedAt(dist) { return Math.min(720, 340 + dist * 0.0045); }, spacingAt(dist) { return Math.max(210, 460 - dist * 0.02); }, hazardRateAt(dist) { return Math.min(0.85, 0.10 + dist * 0.00018); }, coinRateAt(dist) { return Math.min(0.7, 0.25 + dist * 0.00008); }, }; console.log('speed1000=' + DIFFICULTY.speedAt(1000)); console.log('speed5000=' + DIFFICULTY.speedAt(5000)); console.log('speed1e9=' + DIFFICULTY.speedAt(1e9), '(<=720?)'); console.log('spacing1e9=' + DIFFICULTY.spacingAt(1e9), '(>=210?)'); console.log('hazard1e9=' + DIFFICULTY.hazardRateAt(1e9), '(<=0.85?)'); console.log('monotonic=' + (DIFFICULTY.speedAt(1000) <= DIFFICULTY.speedAt(5000))); console.log('coin1000=' + DIFFICULTY.coinRateAt(1000), 'coin5000=' + DIFFICULTY.coinRateAt(5000));

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

What it prints
score=200 total=2 speed1000=344.5 speed5000=362.5 speed1e9=720 (<=720?) spacing1e9=210 (>=210?) hazard1e9=0.85 (<=0.85?) monotonic=true coin1000=0.33 coin5000=0.65
A regression (say, bumping the speed cap away from 720) breaks 'speed1e9=720 (<=720?)' instantly, which is the whole point of the test. Every Math.min/Math.max cap in config.js has a corresponding assertion that locks in the fairness rule.
4

Two rects overlap when every edge of one is inside the other. I test that directly: a fully overlapping pair, then the tricky boundary where one right edge just touches, is 1px away, and the left edge creeps in, proving aabb is a strict four-edge test, not a guess about center points.

const aabb = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; const a = { x: 100, y: 100, w: 40, h: 56 }; const b = { x: 120, y: 130, w: 22, h: 22 }; console.log('overlapping: ' + aabb(a, b)); const right = { x: 141, y: 130, w: 22, h: 22 }; console.log('right edge 1px away: ' + aabb(a, right)); const left = { x: 99, y: 130, w: 22, h: 22 }; console.log('left edge just inside: ' + aabb(a, left)); const far = { x: 500, y: 110, w: 20, h: 20 }; console.log('far apart: ' + aabb(a, far));

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

What it prints
overlapping: true right edge 1px away: false left edge just inside: true far apart: false
Each comparison uses strict < and >, so touching edges (a.x + a.w exactly equal to b.x) do not count as overlapping. That matches how the game treats a shared edge as not-a-hit.
5

A test is only useful if it can fail. I write a Score that adds the wrong amount (10 instead of 100) on purpose, run the same assertions, and watch the suite stop on the first mismatch with the exact expected and got values printed. That failing message is the whole point, it tells you which rule broke.

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

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

What it prints
buggy score=20 total=2 FAIL: two coins = 200 points -- expected 200, got 20
The throw stops the run before the second assertion, so the first failing rule is the one reported. A test that never fails is useless, this deliberately-failing case proves the harness reports exactly where the code broke.

Put it in order

Before you write anything, put these lines back in the right order. Click a line to move it up one step. This support step is easier than writing code from scratch, and it is here to make the last example stick.

Data Flow

Tests import the pure modules, run assertions, and fail the whole suite on the first mismatch, locking in fairness rules.
1 Import the pure modules (math helpers, config, Score)
↓
2 Run each function and assertEqual against the expected value
↓
3 A throw stops the run and prints the failure
↓
4 No throw prints ok: and the next test runs
↓
5 A passing run means every fairness rule still holds
Why this works
What to test, in priority order

Not everything needs a test. Test the things that matter most and are hardest to eyeball:

  1. Pure helpers (clamp, aabb, lerp), fast, no dependencies, relied upon everywhere.
  2. **Difficulty curve*: encode the fairness caps as assertions so they can't regress.
  3. **Scoring*: addCoin always adds 100; total counts coins; high only grows.
  4. **Collision*: the four-edge test for overlap and non-overlap.
  5. **Generation*: a spawned chunk never places a hazard in the safe zone; coins avoid hazards by the clearance.

Skip testing anything that just moves pixels around visually (like draw) unless you add expensive screenshot tests. Focus on the rules, not the rendering.

flowchart TD
  A[Test strategy] --> B[Pure helpers]
  A --> B2[Difficulty / fairness]
  A --> B3[Scoring]
  A --> B4[Collision]
  A --> B5[Generation rules]
  B --> C[Most bugs covered]
  B2 --> C
  B3 --> C
  B4 --> C
  B5 --> C

These five layers catch the bugs that actually matter: unfair difficulty, wrong scores, missed collisions, and impossible spawns.

Why games are worth testing

A common myth is that games can't be tested because they're "visual." But the interesting parts of a game are logic, not pixels:

  • Is the score right? → test addCoin.
  • Is the difficulty fair? → test DIFFICULTY caps.
  • Did the player touch the hazard? → test aabb.
  • Is the spawn safe? → test generation rules.

Testing these means you can change the difficulty curve or the coin value **without fear*: a test suite tells you in seconds if you broke a rule. That's the real payoff: confidence.

The game ships with this exact suite in test/: files like score.test.js, physics.test.js, group13-solvability.test.js. Run them with a Node test runner; they need no browser. They turn "does this feel right?" into "the tests say yes."

Every cap in config.js has a matching test. That's the contract: the tests lock in fairness so nobody can accidentally make the game unfair again.

Examples

A tiny assertion harness

Testing is the same for any project: assert that your code produces the expected result, and fail loudly if not. The game's tests use a tiny harness:

function assertEqual(actual, expected, message) {
  if (actual !== expected) {
    throw new Error(`FAIL: ${message}, expected ${expected}, got ${actual}`);
  }
  console.log(`ok: ${message}`);
}

function assertTrue(value, message) {
  if (!value) throw new Error(`FAIL: ${message}`);
  console.log(`ok: ${message}`);
}

Every test calls one of these. If it throws, the run stops and the failure prints. If not, ok: is logged. The whole suite is just a list of these calls:

assertEqual(clamp(15, 0, 10), 10, 'clamp caps at the max');
assertTrue(aabb(rectA, rectB), 'overlapping rects collide');
assertEqual(new Score().score, 0, 'score starts at 0');

This is all the framework you need. Real test libraries (Jest, Vitest) only add describe/it and mock functions on top of assertEqual. Under the hood, every test is this same actual !== expected check.

Testing the math helpers

The purest, most important things to test are the helper functions, no browser, no state. Test the ones the game ships:

import { clamp, lerp, rand, randInt, aabb } from './utils/math.js';

assertEqual(clamp(5, 0, 10), 5, 'value inside range stays');
assertEqual(clamp(-3, 0, 10), 0, 'below-range clamps to min');
assertEqual(clamp(20, 0, 10), 10, 'above-range clamps to max');

assertEqual(clamp(5) < 2, clamp(5) < 15, 'rand is within [min, max)');

assert(aabb({ x: 100, y: 100, w: 40, h: 56 }, { x: 120, y: 130, w: 22, h: 22 }), 'overlapping rects collide');
assert(!aabb({ x: 100, y: 100, w: 40, h: 56 }, { x: 500, y: 110, w: 20, h: 20 }), 'far-apart rects do not collide');

clamp is used everywhere (movement, difficulty) so getting it right protects the whole game. aabb is the collision backbone. If these two are tested, a huge fraction of bugs can't exist.

The key insight: test the pure functions first. They have no dependencies, so they're the fastest to verify and the most relied-upon.

Testing scoring and fairness

Now test behaviour, that the classes do what they should. The Score class:

const s = new Score();
assertEqual(s.score, 0, 'score starts at 0');
assertEqual(s.total, 0, 'coin count starts at 0');
s.addCoin();
s.addCoin();
assertEqual(s.score, 200, 'two coins = 200 points');
assertEqual(s.total, 2, 'two coins counted');

And the fairness rules that the game cares about, the same guards we tuned by hand:

import { DIFFICULTY } from './app1/js/config.js';

// Difficulty is monotonic and capped.
assert(DIFFICULTY.speedAt(1000) <= DIFFICULTY.speedAt(5000), 'speed never decreases with distance');
assert(DIFFICULTY.speedAt(1e9) <= 720, 'speed is capped at MAX_SPEED');
assert(DIFFICULTY.spacingAt(1e9) >= 210, 'spacing never below the jumpable floor');
assert(DIFFICULTY.hazardRateAt(1e9) <= 0.85, 'hazard rate capped at 0.85');

These tests aren't just checking numbers, they encode the design promise: "the game gets harder, but never unfair." A regression (say, someone bumping the speed cap to infinity) would break these immediately. That's the point of tests: they lock in the rules you decided.

The tests mirror the "fairness" comments in config.js. Every cap (Math.min/Math.max) has a corresponding test, so the fairness can't silently break.

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.

← Juice: Making It Feel Good
Previous Lesson
Juice: Making It Feel Good