From 7c0ba2bee7c6d4b0fb7d4dc1e5c2f04252f8f6d0 Mon Sep 17 00:00:00 2001 From: "radiusred-cody[bot]" <270330637+radiusred-cody[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:18:32 +0000 Subject: [PATCH] feat: speed up the loop as the score grows (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M2-R2: a session should get harder the longer it runs. Add a pure, unit-testable `SnakeGame.tickInterval(score)` in game.js — base 110ms at score 0 (unchanged feel), shaving 6ms per point down to a 60ms floor (reached at score 9). The index.html controller drives its setInterval from the score and reschedules the loop whenever a bite shortens the interval; restart resets to base for free. Tests ship with the change: unit coverage of the interval curve (base, strictly-decreasing, floor clamp, bad-input guard) and browser-integration coverage that the loop reschedules to the shorter interval after eating, never drops below the floor across a run, and resets on restart. --- game.js | 18 ++++++++++++++++ index.html | 15 +++++++++++-- tests/browser.test.js | 50 ++++++++++++++++++++++++++++++++++++++++++- tests/game.test.js | 33 ++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 3 deletions(-) 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.