Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions game.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@
};
}

// Progressive difficulty (M2-R2). The loop interval starts at TICK_BASE_MS
// and shaves TICK_STEP_MS off per point of score, clamped to TICK_MIN_MS so
// the game stays controllable. Score 0 equals the old fixed interval, so an
// untouched run feels exactly as it did before.
var TICK_BASE_MS = 110; // interval at score 0 (matches the former fixed tick)
var TICK_STEP_MS = 6; // ms shaved per point of score
var TICK_MIN_MS = 60; // fastest the game gets (reached at score 9)

// Pure: map a score to the tick interval in ms. Defends against bad input
// (non-number / negative / non-finite → treated as 0) and floors the score,
// so the browser can call it with live state without extra guarding.
function tickInterval(score) {
if (typeof score !== 'number' || !isFinite(score) || score < 0) score = 0;
var ms = TICK_BASE_MS - Math.floor(score) * TICK_STEP_MS;
return ms < TICK_MIN_MS ? TICK_MIN_MS : ms;
}

// Resolve a direction name to a vector, or null if unknown.
function directionFor(name) {
return DIRECTIONS[name] || null;
Expand Down Expand Up @@ -130,6 +147,7 @@
var api = {
DIRECTIONS: DIRECTIONS,
createState: createState,
tickInterval: tickInterval,
directionFor: directionFor,
setDirection: setDirection,
placeFood: placeFood,
Expand Down
15 changes: 13 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ <h2 id="overlay-title">Game over</h2>

var COLS = 20;
var ROWS = 20;
var TICK_MS = 110;

var canvas = document.getElementById('board');
var ctx = canvas.getContext('2d');
Expand All @@ -112,6 +111,7 @@ <h2 id="overlay-title">Game over</h2>
var state = SnakeGame.createState(COLS, ROWS);
var best = BestScore.loadBest(); // M2-R1: restore the persisted best.
var timer = null;
var tickMs = null; // current loop interval; tracks SnakeGame.tickInterval(score).

var KEY_TO_DIR = {
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
Expand Down Expand Up @@ -171,6 +171,15 @@ <h2 id="overlay-title">Game over</h2>
if (state.over) {
stop();
showGameOver();
return;
}
// M2-R2: eating raises the score, which shortens the interval. Reschedule
// the loop whenever the score-driven interval changes (i.e. after a bite).
var want = SnakeGame.tickInterval(state.score);
if (want !== tickMs) {
stop();
tickMs = want;
timer = setInterval(tick, tickMs);
}
}

Expand All @@ -187,7 +196,9 @@ <h2 id="overlay-title">Game over</h2>

function start() {
if (timer !== null) return;
timer = setInterval(tick, TICK_MS);
// M2-R2: seed the interval from the current score (base speed at 0).
tickMs = SnakeGame.tickInterval(state.score);
timer = setInterval(tick, tickMs);
}

function stop() {
Expand Down
50 changes: 49 additions & 1 deletion tests/browser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const http = require('node:http');
const path = require('node:path');
const vm = require('node:vm');

const game = require('../game.js'); // shared source of the M2-R2 interval curve

const ROOT = path.join(__dirname, '..');
const HTML = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8');
const GAME_SRC = fs.readFileSync(path.join(ROOT, 'game.js'), 'utf8');
Expand Down Expand Up @@ -108,13 +110,14 @@ function makeHarness(opts) {
};

let captured = null; // the setInterval tick callback
let capturedMs = null; // and the delay it was scheduled at (M2-R2 reschedules it)
const sandbox = {
module: { exports: {} },
console,
document,
getComputedStyle: () => ({ getPropertyValue: () => '#000000' }),
localStorage: storage, // M2-R1: persistence surface for bestscore.js.
setInterval: (fn) => { captured = fn; return 1; },
setInterval: (fn, ms) => { captured = fn; capturedMs = ms; return 1; },
clearInterval: noop,
// Deterministic food placement. SnakeGame.placeFood falls back to this
// Math.random when the controller steps without an rng, so a fixed 0 puts
Expand All @@ -137,6 +140,9 @@ function makeHarness(opts) {
document,
storage,
headCell,
// The delay the loop is currently scheduled at. M2-R2 reschedules the
// interval as the score rises, so this shrinks over a run.
intervalMs: () => capturedMs,
tick: () => {
assert.ok(captured, 'controller should have started a tick loop via setInterval');
captured();
Expand Down Expand Up @@ -296,6 +302,48 @@ test('M2-R1: a weaker run does not clobber a higher stored best', () => {
assert.strictEqual(h.elements.best.textContent, 5, 'the DOM keeps the higher best');
});

// --- M2-R2: progressive difficulty (the loop speeds up with the score) ------

test('M2-R2: the loop boots at the base interval and speeds up after eating', () => {
const h = makeHarness();
// At boot the controller schedules the loop at the score-0 (base) interval.
assert.strictEqual(h.intervalMs(), game.tickInterval(0), 'boots at the base interval');
assert.strictEqual(h.elements.score.textContent, 0);

// Fresh state: head (10,10) moving right, food 3 cells ahead at (13,10).
// Three ticks reach and eat it; the score goes 0 -> 1 and the loop must be
// rescheduled to the shorter score-1 interval.
h.tick(); h.tick(); h.tick();
assert.strictEqual(h.elements.score.textContent, 1, 'ate one food');
assert.strictEqual(h.intervalMs(), game.tickInterval(1),
'loop rescheduled to the score-1 interval after eating');
assert.ok(h.intervalMs() < game.tickInterval(0),
'the game got faster, not slower');
});

test('M2-R2: the interval never drops below the floor across a whole run', () => {
const h = makeHarness();
const floor = game.tickInterval(1000); // clamped floor
let guard = 0;
while (h.elements.overlay.hidden && guard++ < 500) {
h.tick();
assert.ok(h.intervalMs() >= floor,
'the loop is never scheduled faster than the floor');
}
assert.strictEqual(h.elements.overlay.hidden, false, 'run reached game over');
});

test('M2-R2: restarting resets the loop back to the base speed', () => {
const h = makeHarness();
h.tick(); h.tick(); h.tick(); // eat one -> faster than base
assert.ok(h.intervalMs() < game.tickInterval(0), 'sped up during the run');

h.elements.restart.dispatch('click');
assert.strictEqual(h.elements.score.textContent, 0, 'score reset');
assert.strictEqual(h.intervalMs(), game.tickInterval(0),
'a fresh run starts back at the base speed');
});

test('M2-R1: a weaker run keeps the displayed best when storage goes unusable mid-session (regression)', () => {
// Storage reads once at boot (restoring 6) then starts throwing — a tab whose
// storage access flips to blocked mid-session. A subsequent run scores the
Expand Down
33 changes: 33 additions & 0 deletions tests/game.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,39 @@ test('stepping a finished game is a no-op', () => {
assert.strictEqual(JSON.stringify(s.snake), snapshot);
});

// --- M2-R2: progressive difficulty (tickInterval curve) ---------------------

test('tickInterval starts at the base interval for a fresh (score 0) game', () => {
const s = game.createState(20, 20);
assert.strictEqual(s.score, 0);
assert.strictEqual(game.tickInterval(0), 110, 'score 0 keeps the original feel');
assert.strictEqual(game.tickInterval(s.score), 110);
});

test('tickInterval shortens the loop as the score grows, then clamps to a floor', () => {
// Strictly decreasing while above the floor: each point shaves a fixed amount.
let prev = game.tickInterval(0);
for (let score = 1; score <= 8; score++) {
const cur = game.tickInterval(score);
assert.ok(cur < prev, `score ${score} must be faster than ${score - 1}`);
assert.ok(cur >= 60, 'never faster than the floor');
prev = cur;
}
// Floor reached at 9 and held for every higher score — the game does not run
// away to an unplayable speed.
assert.strictEqual(game.tickInterval(9), 60, 'floor reached at score 9');
assert.strictEqual(game.tickInterval(50), 60, 'clamped at the floor beyond');
assert.strictEqual(game.tickInterval(9), game.tickInterval(1000));
});

test('tickInterval floors a fractional score and guards bad input', () => {
assert.strictEqual(game.tickInterval(1.9), game.tickInterval(1), 'fractional score floored');
// Bad input degrades to the base interval rather than throwing or going NaN.
for (const bad of [-3, NaN, Infinity, undefined, null, 'nope']) {
assert.strictEqual(game.tickInterval(bad), 110, `bad input ${String(bad)} -> base`);
}
});

test('moving into the vacating tail cell is allowed (not self-collision)', () => {
// A body-length move that lands where the tail currently sits must survive,
// because the tail vacates on the same tick when not eating.
Expand Down
Loading