diff --git a/game.js b/game.js
index c56f655..e75e599 100644
--- a/game.js
+++ b/game.js
@@ -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;
@@ -130,6 +147,7 @@
var api = {
DIRECTIONS: DIRECTIONS,
createState: createState,
+ tickInterval: tickInterval,
directionFor: directionFor,
setDirection: setDirection,
placeFood: placeFood,
diff --git a/index.html b/index.html
index a4fc27e..a09c068 100644
--- a/index.html
+++ b/index.html
@@ -97,7 +97,6 @@
Game over
var COLS = 20;
var ROWS = 20;
- var TICK_MS = 110;
var canvas = document.getElementById('board');
var ctx = canvas.getContext('2d');
@@ -112,6 +111,7 @@ Game over
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',
@@ -171,6 +171,15 @@ Game over
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);
}
}
@@ -187,7 +196,9 @@ Game over
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() {
diff --git a/tests/browser.test.js b/tests/browser.test.js
index b5b189c..2ee1461 100644
--- a/tests/browser.test.js
+++ b/tests/browser.test.js
@@ -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');
@@ -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
@@ -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();
@@ -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
diff --git a/tests/game.test.js b/tests/game.test.js
index 6c3b0a5..668e43d 100644
--- a/tests/game.test.js
+++ b/tests/game.test.js
@@ -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.