diff --git a/package-lock.json b/package-lock.json
index 04bebc6..a25c086 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -63,7 +63,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
@@ -78,8 +77,7 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC",
- "optional": true,
- "peer": true
+ "optional": true
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
@@ -112,6 +110,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -434,7 +433,6 @@
],
"license": "MIT-0",
"optional": true,
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -456,7 +454,6 @@
],
"license": "MIT",
"optional": true,
- "peer": true,
"engines": {
"node": ">=18"
},
@@ -482,7 +479,6 @@
],
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
@@ -511,6 +507,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -534,6 +531,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -2058,6 +2056,7 @@
"integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.14.0"
}
@@ -2068,6 +2067,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -2128,6 +2128,7 @@
"integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.1",
"@typescript-eslint/types": "8.46.1",
@@ -2495,6 +2496,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2519,7 +2521,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"engines": {
"node": ">= 14"
}
@@ -2836,6 +2837,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@@ -3174,7 +3176,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
@@ -3197,7 +3198,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
@@ -3284,8 +3284,7 @@
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true
+ "optional": true
},
"node_modules/deep-eql": {
"version": "5.0.2",
@@ -3426,7 +3425,6 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
- "peer": true,
"engines": {
"node": ">=0.12"
},
@@ -3709,6 +3707,7 @@
"integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3770,6 +3769,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -4649,7 +4649,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
@@ -4677,7 +4676,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
@@ -4693,7 +4691,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
@@ -4724,7 +4721,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
@@ -5078,8 +5074,7 @@
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true
+ "optional": true
},
"node_modules/is-regex": {
"version": "1.2.1",
@@ -5288,48 +5283,6 @@
"js-yaml": "bin/js-yaml.js"
}
},
- "node_modules/jsdom": {
- "version": "26.1.0",
- "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
- "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "cssstyle": "^4.2.1",
- "data-urls": "^5.0.0",
- "decimal.js": "^10.5.0",
- "html-encoding-sniffer": "^4.0.0",
- "http-proxy-agent": "^7.0.2",
- "https-proxy-agent": "^7.0.6",
- "is-potential-custom-element-name": "^1.0.1",
- "nwsapi": "^2.2.16",
- "parse5": "^7.2.1",
- "rrweb-cssom": "^0.8.0",
- "saxes": "^6.0.0",
- "symbol-tree": "^3.2.4",
- "tough-cookie": "^5.1.1",
- "w3c-xmlserializer": "^5.0.0",
- "webidl-conversions": "^7.0.0",
- "whatwg-encoding": "^3.1.1",
- "whatwg-mimetype": "^4.0.0",
- "whatwg-url": "^14.1.1",
- "ws": "^8.18.0",
- "xml-name-validator": "^5.0.0"
- },
- "engines": {
- "node": ">=18"
- },
- "peerDependencies": {
- "canvas": "^3.0.0"
- },
- "peerDependenciesMeta": {
- "canvas": {
- "optional": true
- }
- }
- },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -5927,8 +5880,7 @@
"integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true
+ "optional": true
},
"node_modules/object-assign": {
"version": "4.1.1",
@@ -6155,7 +6107,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"entities": "^6.0.0"
},
@@ -6347,6 +6298,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -6396,6 +6348,7 @@
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -6427,6 +6380,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -6608,6 +6562,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6781,8 +6736,7 @@
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true
+ "optional": true
},
"node_modules/run-parallel": {
"version": "1.2.0",
@@ -6869,8 +6823,7 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true
+ "optional": true
},
"node_modules/saxes": {
"version": "6.0.0",
@@ -6879,7 +6832,6 @@
"dev": true,
"license": "ISC",
"optional": true,
- "peer": true,
"dependencies": {
"xmlchars": "^2.2.0"
},
@@ -7542,8 +7494,7 @@
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true
+ "optional": true
},
"node_modules/synckit": {
"version": "0.11.11",
@@ -7740,6 +7691,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -7784,7 +7736,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"tldts-core": "^6.1.86"
},
@@ -7798,8 +7749,7 @@
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true
+ "optional": true
},
"node_modules/to-regex-range": {
"version": "5.0.1",
@@ -7821,7 +7771,6 @@
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
- "peer": true,
"dependencies": {
"tldts": "^6.1.32"
},
@@ -7836,7 +7785,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"punycode": "^2.3.1"
},
@@ -7977,6 +7925,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -8109,6 +8058,7 @@
"integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -8225,6 +8175,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -8325,7 +8276,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"xml-name-validator": "^5.0.0"
},
@@ -8340,7 +8290,6 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
- "peer": true,
"engines": {
"node": ">=12"
}
@@ -8352,7 +8301,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"iconv-lite": "0.6.3"
},
@@ -8367,7 +8315,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -8379,7 +8326,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
@@ -8541,7 +8487,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"engines": {
"node": ">=10.0.0"
},
@@ -8565,7 +8510,6 @@
"dev": true,
"license": "Apache-2.0",
"optional": true,
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -8576,8 +8520,7 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true
+ "optional": true
},
"node_modules/yallist": {
"version": "3.1.1",
diff --git a/src/app/components/HUD.tsx b/src/app/components/HUD.tsx
index daca225..0b0c45d 100644
--- a/src/app/components/HUD.tsx
+++ b/src/app/components/HUD.tsx
@@ -3,6 +3,7 @@ type Props = {
best: number;
bump: boolean;
alive: boolean;
+ paused?: boolean;
onRestart: () => void;
};
diff --git a/src/app/components/Leaderboard.tsx b/src/app/components/Leaderboard.tsx
new file mode 100644
index 0000000..37ad13f
--- /dev/null
+++ b/src/app/components/Leaderboard.tsx
@@ -0,0 +1,43 @@
+import type { LeaderboardEntry } from '@/hooks/useLeaderboard';
+import { DIFFICULTY_PRESETS } from '@/constants/game';
+import type { GameDifficulty } from '@/constants/game';
+
+type Props = {
+ entries: LeaderboardEntry[];
+};
+
+function formatDate(ts: number) {
+ const d = new Date(ts);
+ return d.toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ });
+}
+
+export default function Leaderboard({ entries }: Props) {
+ if (entries.length === 0) return null;
+
+ return (
+
+
+ Top scores
+
+
+ {entries.slice(0, 5).map((e, i) => (
+ -
+ #{i + 1}
+ {e.score}
+
+ {DIFFICULTY_PRESETS[e.difficulty as GameDifficulty]?.label ??
+ e.difficulty}
+
+ {formatDate(e.date)}
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/components/SnakeCanvas.tsx b/src/app/components/SnakeCanvas.tsx
index 656f819..826f916 100644
--- a/src/app/components/SnakeCanvas.tsx
+++ b/src/app/components/SnakeCanvas.tsx
@@ -1,8 +1,7 @@
-// src/app/components/SnakeCanvas.tsx
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DIFFICULTY_PRESETS, type GameDifficulty } from '@/constants/game';
import type { XY, Dir } from '@/types';
-import { randomFreeCell, inferDirFromSnake } from '@/utils/logic';
+import { inferDirFromSnake } from '@/utils/logic';
import { drawFrame } from '@/utils/canvas';
import { useTicker } from '@/hooks/useTicker';
import { useSnakeGame } from '@/hooks/useSnakeGame';
@@ -10,21 +9,57 @@ import { useInput } from '@/hooks/useInput';
import { useCanvas2D } from '@/hooks/useCanvas2D';
import { useBestScore } from '@/hooks/useBestScore';
import { usePauseHotkey } from '@/hooks/usePauseHotkey';
+import { useSwipe } from '@/hooks/useSwipe';
+import { useLeaderboard } from '@/hooks/useLeaderboard';
import HUD from '@/components/HUD';
+import Leaderboard from '@/components/Leaderboard';
+import { isOpposite } from '@/utils/logic';
type Phase = 'menu' | 'playing' | 'gameover';
+const DIFFICULTIES: GameDifficulty[] = [
+ 'relaxed',
+ 'classic',
+ 'expert',
+ 'blitz',
+ 'endless',
+];
+
+function computeDelayMs(
+ T: (typeof DIFFICULTY_PRESETS)[GameDifficulty],
+ score: number
+): number {
+ const linear = T.TICK_START_MS - score * T.TICK_STEP_MS;
+ const curve = T.speedCurve;
+ let delay = linear;
+ if (curve === 'ease-in') {
+ delay = T.TICK_START_MS - score * score * 0.08;
+ } else if (curve === 'ease-out') {
+ delay = T.TICK_START_MS - score * T.TICK_STEP_MS * 0.85;
+ }
+ return Math.max(T.TICK_MIN_MS, delay);
+}
+
export default function SnakeCanvas() {
const { canvasRef, ctxRef } = useCanvas2D();
const [bump, setBump] = useState(false);
const [paused, setPaused] = useState(false);
const [phase, setPhase] = useState('menu');
- const [difficulty, setDifficulty] = useState('medium');
+ const [difficulty, setDifficulty] = useState('classic');
const T = DIFFICULTY_PRESETS[difficulty];
+ const gameConfig = useMemo(
+ () => ({
+ cols: T.COLS,
+ rows: T.ROWS,
+ wrap: T.wrap,
+ obstacleCount: T.obstacleCount,
+ powerChance: T.powerChance,
+ }),
+ [T]
+ );
- // π preload sounds
const eatSnd = useMemo(() => new Audio('/sounds/food.mp3'), []);
const dieSnd = useMemo(() => new Audio('/sounds/gameover.mp3'), []);
const keySnd = useMemo(() => new Audio('/sounds/move.mp3'), []);
@@ -39,79 +74,86 @@ export default function SnakeCanvas() {
try {
a.currentTime = 0;
void a.play();
- } catch {
+ } catch (err) {
console.error('Audio play exception:', err);
}
}, []);
- // random free cell based on current tuning
- const pickCell = useCallback(
- (snake: XY[]) => randomFreeCell(snake, T.COLS, T.ROWS),
- [T]
- );
-
- const { alive, score, snakeRef, foodRef, reset, turn, tick } = useSnakeGame(
- pickCell,
- {
+ const { alive, score, snakeRef, foodRef, obstaclesRef, reset, turn, tick } =
+ useSnakeGame(gameConfig, {
onEat: () => play(eatSnd),
onDie: () => play(dieSnd),
- isOutOfBounds: (p) =>
- p.x < 0 || p.x >= T.COLS || p.y < 0 || p.y >= T.ROWS,
- }
- );
+ });
- // phase: playing β gameover when you die
useEffect(() => {
if (!alive && phase === 'playing') setPhase('gameover');
}, [alive, phase]);
- // derive current dir for opposite-turn guard
const getCurrentDir = useCallback<() => Dir>(
() => inferDirFromSnake(snakeRef.current),
[snakeRef]
);
- // drawing (thread tuning into canvas utils)
const draw = useCallback(() => {
const ctx = ctxRef.current;
if (!ctx) return;
- drawFrame(ctx, alive, foodRef.current, snakeRef.current, T);
- }, [alive, ctxRef, foodRef, snakeRef, T]);
+ drawFrame(
+ ctx,
+ alive,
+ foodRef.current,
+ snakeRef.current,
+ obstaclesRef.current,
+ T
+ );
+ }, [alive, ctxRef, foodRef, snakeRef, obstaclesRef, T]);
- // score bump anim
useEffect(() => {
setBump(true);
const id = setTimeout(() => setBump(false), 200);
return () => clearTimeout(id);
}, [score]);
- // best score persistence
const best = useBestScore(score);
+ const { entries, submitScore } = useLeaderboard();
+ const submittedRef = useRef(false);
+
+ useEffect(() => {
+ if (!alive && phase === 'gameover' && score > 0 && !submittedRef.current) {
+ submittedRef.current = true;
+ submitScore(score, difficulty);
+ }
+ }, [alive, phase, score, difficulty, submitScore]);
+
+ useEffect(() => {
+ if (phase === 'playing') submittedRef.current = false;
+ }, [phase]);
+
+ useEffect(() => {
+ if (phase === 'menu') {
+ reset();
+ draw();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [difficulty]);
- // first paint (menu): draw an empty frame for current tuning
useEffect(() => {
draw();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- // dynamic speed: faster as you eat
- const delayMs = Math.max(
- T.TICK_MIN_MS,
- T.TICK_START_MS - score * T.TICK_STEP_MS
- );
+ const delayMs = computeDelayMs(T, score);
useTicker(
delayMs,
() => {
- if (phase === 'playing' && alive && !paused) {
+ if (alive && !paused) {
tick();
draw();
}
},
- true
+ phase === 'playing'
);
- // start/restart helper
const startGame = useCallback(() => {
setPaused(false);
reset();
@@ -119,7 +161,6 @@ export default function SnakeCanvas() {
setPhase('playing');
}, [reset, draw]);
- // Space to start when not playing
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (phase !== 'playing' && e.code === 'Space') {
@@ -131,7 +172,6 @@ export default function SnakeCanvas() {
return () => window.removeEventListener('keydown', onKey);
}, [phase, startGame]);
- // keyboard for turns + HUD restart
useInput({
alive,
getCurrentDir,
@@ -140,37 +180,60 @@ export default function SnakeCanvas() {
onMoveKey: () => play(keySnd),
});
+ const handleSwipe = useCallback(
+ (d: Dir) => {
+ const cur = getCurrentDir();
+ if (!isOpposite(cur, d)) {
+ turn(d);
+ play(keySnd);
+ }
+ },
+ [getCurrentDir, turn, keySnd, play]
+ );
+
+ useSwipe({
+ enabled: phase === 'playing' && alive && !paused,
+ onSwipe: handleSwipe,
+ });
+
usePauseHotkey(alive && phase === 'playing', () => setPaused((p) => !p));
return (
-
- {/* Toolbar - only visible in menu or game over */}
+
{phase !== 'playing' && (
-
-
-
-
+
+
+ Snake
+
+
+
+
+
+
+ {T.description}
+
+
)}
- {/* Game canvas */}
+ {paused && phase === 'playing' && (
+
+ Paused (P)
+
+ )}
- {/* HUD + stats */}
-
- {(1000 / delayMs).toFixed(1)} moves/s
+
+ {(1000 / delayMs).toFixed(1)} moves/s Β· Arrows / WASD / Swipe Β· P pause
);
diff --git a/src/app/constants/game.ts b/src/app/constants/game.ts
index 46723ed..1ccbfdc 100644
--- a/src/app/constants/game.ts
+++ b/src/app/constants/game.ts
@@ -1,6 +1,11 @@
// Game-wide configuration and presets
-export type GameDifficulty = 'easy' | 'medium' | 'hard';
+export type GameDifficulty =
+ | 'relaxed'
+ | 'classic'
+ | 'expert'
+ | 'blitz'
+ | 'endless';
export type GameTuning = {
CELL: number;
@@ -9,35 +14,101 @@ export type GameTuning = {
TICK_START_MS: number;
TICK_MIN_MS: number;
TICK_STEP_MS: number;
+ wrap: boolean;
+ obstacleCount: number;
+ powerChance: number;
+ label: string;
+ description: string;
+ speedCurve?: 'linear' | 'ease-in' | 'ease-out';
};
// Difficulty presets (adjust these to taste)
export const DIFFICULTY_PRESETS: Record
= {
- easy: {
- CELL: 28,
+ relaxed: {
+ CELL: 30,
COLS: 16,
ROWS: 16,
- TICK_START_MS: 200,
- TICK_MIN_MS: 90,
- TICK_STEP_MS: 6,
+ TICK_START_MS: 240,
+ TICK_MIN_MS: 120,
+ TICK_STEP_MS: 5,
+ wrap: true,
+ obstacleCount: 0,
+ powerChance: 0.1,
+ label: 'Relaxed',
+ description: 'Chill speed, wrap-around edges, great for warming up.',
+ speedCurve: 'ease-out',
},
- medium: {
- CELL: 24,
+ classic: {
+ CELL: 26,
COLS: 20,
ROWS: 20,
- TICK_START_MS: 160,
+ TICK_START_MS: 170,
TICK_MIN_MS: 70,
TICK_STEP_MS: 6,
+ wrap: false,
+ obstacleCount: 2,
+ powerChance: 0.12,
+ label: 'Classic',
+ description: 'Balanced pace with light obstacles and occasional power-ups.',
+ speedCurve: 'linear',
},
- hard: {
- CELL: 20,
+ expert: {
+ CELL: 22,
COLS: 24,
ROWS: 24,
+ TICK_START_MS: 150,
+ TICK_MIN_MS: 55,
+ TICK_STEP_MS: 6,
+ wrap: false,
+ obstacleCount: 8,
+ powerChance: 0.16,
+ label: 'Expert',
+ description: 'Faster ramp, tighter space, dodging lots of barriers.',
+ speedCurve: 'ease-in',
+ },
+ blitz: {
+ CELL: 20,
+ COLS: 22,
+ ROWS: 22,
TICK_START_MS: 120,
- TICK_MIN_MS: 60,
+ TICK_MIN_MS: 45,
+ TICK_STEP_MS: 8,
+ wrap: false,
+ obstacleCount: 4,
+ powerChance: 0.2,
+ label: 'Blitz',
+ description: 'Starts fast, gets wildβperfect for short intense runs.',
+ speedCurve: 'ease-in',
+ },
+ endless: {
+ CELL: 18,
+ COLS: 28,
+ ROWS: 28,
+ TICK_START_MS: 190,
+ TICK_MIN_MS: 80,
TICK_STEP_MS: 5,
+ wrap: true,
+ obstacleCount: 3,
+ powerChance: 0.14,
+ label: 'Endless',
+ description: 'Massive board with wrap edgesβhow long can you last?',
+ speedCurve: 'linear',
},
};
-// Emoji pool for food
+// Emoji pool for normal food
export const FOOD_EMOJIS = ['π', 'π', 'π', 'π§', 'π', 'π', 'π₯', 'π½'];
+
+// Power-up food config
+import type { FoodKind } from '@/types';
+
+export const POWER_UP_CONFIG: Record<
+ FoodKind,
+ { emoji: string; value: number; label: string }
+> = {
+ normal: { emoji: 'π', value: 1, label: '+1' },
+ golden: { emoji: 'β', value: 5, label: '+5' },
+ freeze: { emoji: 'βοΈ', value: 1, label: 'Freeze' },
+ ghost: { emoji: 'π»', value: 2, label: 'Ghost' },
+ multiplier: { emoji: 'π₯', value: 3, label: '+3' },
+};
diff --git a/src/app/hooks/useLeaderboard.ts b/src/app/hooks/useLeaderboard.ts
new file mode 100644
index 0000000..23ffe8a
--- /dev/null
+++ b/src/app/hooks/useLeaderboard.ts
@@ -0,0 +1,50 @@
+import { useCallback, useState } from 'react';
+import type { GameDifficulty } from '@/constants/game';
+
+export type LeaderboardEntry = {
+ score: number;
+ difficulty: GameDifficulty;
+ date: number;
+};
+
+const STORAGE_KEY = 'snake-leaderboard';
+const MAX_ENTRIES = 10;
+
+function loadLeaderboard(): LeaderboardEntry[] {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw) as LeaderboardEntry[];
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+function saveLeaderboard(entries: LeaderboardEntry[]) {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
+ } catch {
+ // ignore
+ }
+}
+
+export function useLeaderboard() {
+ const [entries, setEntries] = useState(loadLeaderboard);
+
+ const submitScore = useCallback(
+ (score: number, difficulty: GameDifficulty) => {
+ const next: LeaderboardEntry[] = [
+ ...entries,
+ { score, difficulty, date: Date.now() },
+ ]
+ .sort((a, b) => b.score - a.score)
+ .slice(0, MAX_ENTRIES);
+ setEntries(next);
+ saveLeaderboard(next);
+ },
+ [entries]
+ );
+
+ return { entries, submitScore };
+}
diff --git a/src/app/hooks/useSnakeGame.ts b/src/app/hooks/useSnakeGame.ts
index bc120a9..c30a488 100644
--- a/src/app/hooks/useSnakeGame.ts
+++ b/src/app/hooks/useSnakeGame.ts
@@ -1,58 +1,88 @@
import { useCallback, useRef, useState } from 'react';
-import type { XY, Dir, Food } from '@/types';
+import type { XY, Dir, Food, FoodKind } from '@/types';
import {
eq,
nextHead,
- outOfBounds as oobDefault,
initSnake,
+ wrapPoint,
+ randomFreeCell,
+ generateObstacles,
} from '@/utils/logic';
-import { FOOD_EMOJIS } from '@/constants/game';
+import { FOOD_EMOJIS, POWER_UP_CONFIG } from '@/constants/game';
+
+export type GameConfig = {
+ cols: number;
+ rows: number;
+ wrap: boolean;
+ obstacleCount: number;
+ powerChance: number;
+};
/**
* Core game state + rules (no rendering).
- * Accepts callbacks for events like eating or dying, and an optional bounds override.
+ * Supports obstacles, wrap mode, and power-up food.
*/
export function useSnakeGame(
- pickFreeCell: (snake: XY[]) => XY,
+ config: GameConfig,
opts?: {
- onEat?: () => void;
+ onEat?: (value: number, kind: FoodKind) => void;
onDie?: () => void;
- isOutOfBounds?: (p: XY) => boolean; // overrides default bounds if provided
}
) {
+ const { cols, rows, wrap, obstacleCount, powerChance } = config;
+
const dirRef = useRef('right');
const nextDirRef = useRef(null);
- const snakeRef = useRef(initSnake());
+ const snakeRef = useRef(initSnake(cols, rows));
const foodRef = useRef(null);
+ const obstaclesRef = useRef([]);
const [alive, setAlive] = useState(true);
const [score, setScore] = useState(0);
const spawnFood = useCallback(
(snake: XY[]): Food => {
- const coords = pickFreeCell(snake);
- const emoji = FOOD_EMOJIS[Math.floor(Math.random() * FOOD_EMOJIS.length)];
- return { ...coords, emoji };
+ const blocked = [...obstaclesRef.current];
+ const coords = randomFreeCell(snake, cols, rows, blocked);
+
+ const isPowerUp = Math.random() < powerChance;
+ const kinds: FoodKind[] = ['golden', 'freeze', 'ghost', 'multiplier'];
+ const kind: FoodKind = isPowerUp
+ ? kinds[Math.floor(Math.random() * kinds.length)]
+ : 'normal';
+
+ const cfg = POWER_UP_CONFIG[kind];
+ const emoji =
+ kind === 'normal'
+ ? FOOD_EMOJIS[Math.floor(Math.random() * FOOD_EMOJIS.length)]
+ : cfg.emoji;
+
+ return {
+ ...coords,
+ kind,
+ emoji,
+ value: cfg.value,
+ };
},
- [pickFreeCell]
+ [cols, rows, powerChance]
);
const reset = useCallback(() => {
- snakeRef.current = initSnake();
+ const snake = initSnake(cols, rows);
+ snakeRef.current = snake;
dirRef.current = 'right';
nextDirRef.current = null;
- foodRef.current = spawnFood(snakeRef.current);
+ obstaclesRef.current = generateObstacles(obstacleCount, cols, rows, snake);
+ foodRef.current = spawnFood(snake);
setAlive(true);
setScore(0);
- }, [spawnFood]);
+ }, [cols, rows, obstacleCount, spawnFood]);
- /** Queue a direction; consumed once per tick */
const turn = useCallback((d: Dir) => {
nextDirRef.current = d;
}, []);
const tick = useCallback(() => {
- // apply queued turn
if (nextDirRef.current) {
dirRef.current = nextDirRef.current;
nextDirRef.current = null;
@@ -62,13 +92,27 @@ export function useSnakeGame(
const head = snake[0];
if (!head) return;
- const nh = nextHead(head, dirRef.current);
+ let nh = nextHead(head, dirRef.current);
+
+ if (wrap) {
+ nh = wrapPoint(nh, cols, rows);
+ } else {
+ const oob = nh.x < 0 || nh.x >= cols || nh.y < 0 || nh.y >= rows;
+ if (oob) {
+ setAlive(false);
+ opts?.onDie?.();
+ return;
+ }
+ }
+
const food = foodRef.current;
const willEat = !!food && eq(nh, food);
const bodyToCheck = willEat ? snake : snake.slice(0, -1);
- const isOOB = opts?.isOutOfBounds ?? oobDefault;
- if (isOOB(nh) || bodyToCheck.some((s) => eq(s, nh))) {
+ const hitBody = bodyToCheck.some((s) => eq(s, nh));
+ const hitObstacle = obstaclesRef.current.some((o) => eq(o, nh));
+
+ if (hitBody || hitObstacle) {
setAlive(false);
opts?.onDie?.();
return;
@@ -76,14 +120,24 @@ export function useSnakeGame(
if (willEat) {
snake.unshift(nh);
- setScore((s) => s + 1);
+ const value = food.value ?? 1;
+ setScore((s) => s + value);
foodRef.current = spawnFood(snake);
- opts?.onEat?.();
+ opts?.onEat?.(value, food.kind);
} else {
snake.unshift(nh);
snake.pop();
}
- }, [opts, spawnFood]);
+ }, [cols, rows, wrap, opts, spawnFood]);
- return { alive, score, snakeRef, foodRef, reset, turn, tick };
+ return {
+ alive,
+ score,
+ snakeRef,
+ foodRef,
+ obstaclesRef,
+ reset,
+ turn,
+ tick,
+ };
}
diff --git a/src/app/hooks/useSwipe.ts b/src/app/hooks/useSwipe.ts
new file mode 100644
index 0000000..545f3bb
--- /dev/null
+++ b/src/app/hooks/useSwipe.ts
@@ -0,0 +1,54 @@
+import { useCallback, useEffect, useRef } from 'react';
+import type { Dir } from '@/types';
+
+const MIN_SWIPE_DIST = 30;
+
+export function useSwipe(opts: {
+ enabled: boolean;
+ onSwipe: (dir: Dir) => void;
+}) {
+ const { enabled, onSwipe } = opts;
+ const startRef = useRef<{ x: number; y: number } | null>(null);
+
+ const handleStart = useCallback(
+ (e: TouchEvent) => {
+ if (!enabled) return;
+ const t = e.touches[0];
+ if (t) startRef.current = { x: t.clientX, y: t.clientY };
+ },
+ [enabled]
+ );
+
+ const handleEnd = useCallback(
+ (e: TouchEvent) => {
+ if (!enabled || !startRef.current) return;
+ const t = e.changedTouches[0];
+ if (!t) return;
+
+ const dx = t.clientX - startRef.current.x;
+ const dy = t.clientY - startRef.current.y;
+ startRef.current = null;
+
+ const adx = Math.abs(dx);
+ const ady = Math.abs(dy);
+ if (adx < MIN_SWIPE_DIST && ady < MIN_SWIPE_DIST) return;
+
+ if (adx > ady) {
+ onSwipe(dx > 0 ? 'right' : 'left');
+ } else {
+ onSwipe(dy > 0 ? 'down' : 'up');
+ }
+ },
+ [enabled, onSwipe]
+ );
+
+ useEffect(() => {
+ const target = document;
+ target.addEventListener('touchstart', handleStart, { passive: true });
+ target.addEventListener('touchend', handleEnd, { passive: true });
+ return () => {
+ target.removeEventListener('touchstart', handleStart);
+ target.removeEventListener('touchend', handleEnd);
+ };
+ }, [handleStart, handleEnd]);
+}
diff --git a/src/app/types/index.ts b/src/app/types/index.ts
index a9a3be0..b02929b 100644
--- a/src/app/types/index.ts
+++ b/src/app/types/index.ts
@@ -9,18 +9,29 @@ export * from './ui';
// Basic coordinate point
export type XY = { x: number; y: number };
-// Food extends XY so it can hold an emoji
-export type Food = XY & { emoji?: string };
+export type FoodKind = 'normal' | 'golden' | 'freeze' | 'ghost' | 'multiplier';
+
+// Food extends XY so it can hold rendering + gameplay metadata
+export type Food = XY & {
+ kind: FoodKind;
+ emoji?: string;
+ value?: number;
+ expiresAt?: number;
+};
// Snake movement direction
export type Dir = 'up' | 'down' | 'left' | 'right';
-// Keyboard direction mapping
+// Keyboard direction mapping (arrows + WASD)
export const keyToDir: Record = {
ArrowUp: 'up',
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right',
+ KeyW: 'up',
+ KeyS: 'down',
+ KeyA: 'left',
+ KeyD: 'right',
};
// Equality helper for comparing coordinates
diff --git a/src/app/utils/canvas.ts b/src/app/utils/canvas.ts
index 03ed4ed..40c3504 100644
--- a/src/app/utils/canvas.ts
+++ b/src/app/utils/canvas.ts
@@ -2,19 +2,19 @@ import type { GameTuning } from '@/constants/game';
import type { XY, Food } from '@/types';
export function drawGrid(ctx: CanvasRenderingContext2D, T: GameTuning) {
- const W = T.COLS * T.CELL,
- H = T.ROWS * T.CELL;
+ const W = T.COLS * T.CELL;
+ const H = T.ROWS * T.CELL;
- ctx.fillStyle = '#111';
+ ctx.fillStyle = '#0f172a';
ctx.fillRect(0, 0, W, H);
- ctx.strokeStyle = '#666';
- ctx.lineWidth = 1.5;
+ ctx.strokeStyle = '#334155';
+ ctx.lineWidth = 2;
ctx.strokeRect(0, 0, W, H);
ctx.save();
- ctx.globalAlpha = 0.8;
- ctx.strokeStyle = '#000';
+ ctx.globalAlpha = 0.4;
+ ctx.strokeStyle = '#1e293b';
ctx.lineWidth = 1;
for (let i = 1; i < T.COLS; i++) {
@@ -34,6 +34,22 @@ export function drawGrid(ctx: CanvasRenderingContext2D, T: GameTuning) {
ctx.restore();
}
+export function drawObstacles(
+ ctx: CanvasRenderingContext2D,
+ obstacles: XY[],
+ T: GameTuning
+) {
+ ctx.fillStyle = '#374151';
+ ctx.strokeStyle = '#4b5563';
+ ctx.lineWidth = 1;
+ for (const { x, y } of obstacles) {
+ const px = x * T.CELL;
+ const py = y * T.CELL;
+ ctx.fillRect(px + 1, py + 1, T.CELL - 2, T.CELL - 2);
+ ctx.strokeRect(px + 1, py + 1, T.CELL - 2, T.CELL - 2);
+ }
+}
+
export function drawFood(
ctx: CanvasRenderingContext2D,
f: Food | null,
@@ -41,17 +57,31 @@ export function drawFood(
) {
if (!f) return;
+ const cx = f.x * T.CELL + T.CELL / 2;
+ const cy = f.y * T.CELL + T.CELL / 2;
+ const isPowerUp = f.kind && f.kind !== 'normal';
+
+ if (isPowerUp) {
+ ctx.save();
+ ctx.shadowColor = '#fbbf24';
+ ctx.shadowBlur = 8;
+ ctx.beginPath();
+ ctx.arc(cx, cy, T.CELL * 0.35, 0, Math.PI * 2);
+ ctx.fillStyle = 'rgba(251, 191, 36, 0.3)';
+ ctx.fill();
+ ctx.restore();
+ }
+
if (f.emoji) {
ctx.save();
- ctx.font = `${Math.floor(T.CELL * 0.8)}px system-ui, -apple-system, Segoe UI, Roboto, Emoji, sans-serif`;
+ ctx.font = `${Math.floor(T.CELL * 0.85)}px system-ui, -apple-system, Segoe UI, Roboto, Emoji, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
- ctx.fillText(f.emoji, f.x * T.CELL + T.CELL / 2, f.y * T.CELL + T.CELL / 2);
+ ctx.fillText(f.emoji, cx, cy);
ctx.restore();
return;
}
- // fallback: red square
ctx.fillStyle = '#ef4444';
ctx.fillRect(f.x * T.CELL, f.y * T.CELL, T.CELL, T.CELL);
}
@@ -61,20 +91,37 @@ export function drawSnake(
snake: XY[],
T: GameTuning
) {
- ctx.fillStyle = '#22c55e';
- for (const { x, y } of snake) {
- ctx.fillRect(x * T.CELL, y * T.CELL, T.CELL, T.CELL);
- }
+ const pad = 1;
+ snake.forEach(({ x, y }, i) => {
+ const px = x * T.CELL + pad;
+ const py = y * T.CELL + pad;
+ const size = T.CELL - pad * 2;
+ const isHead = i === 0;
+ ctx.fillStyle = isHead ? '#10b981' : '#22c55e';
+ ctx.fillRect(px, py, size, size);
+ if (isHead) {
+ ctx.strokeStyle = '#059669';
+ ctx.lineWidth = 1;
+ ctx.strokeRect(px, py, size, size);
+ }
+ });
}
export function drawGameOver(ctx: CanvasRenderingContext2D, T: GameTuning) {
+ const W = T.COLS * T.CELL;
+ const H = T.ROWS * T.CELL;
ctx.save();
- ctx.globalAlpha = 0.3;
- ctx.fillRect(0, 0, T.COLS * T.CELL, T.ROWS * T.CELL);
+ ctx.fillStyle = 'rgba(0,0,0,0.6)';
+ ctx.fillRect(0, 0, W, H);
ctx.globalAlpha = 1;
- ctx.fillStyle = '#fff';
- ctx.font = '16px monospace';
- ctx.fillText('Game Over β press Space', 12, 28);
+ ctx.fillStyle = '#f8fafc';
+ ctx.font = 'bold 20px monospace';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('Game Over', W / 2, H / 2 - 12);
+ ctx.font = '14px monospace';
+ ctx.fillStyle = '#94a3b8';
+ ctx.fillText('Press Space to restart', W / 2, H / 2 + 12);
ctx.restore();
}
@@ -83,9 +130,11 @@ export function drawFrame(
alive: boolean,
food: Food | null,
snake: XY[],
+ obstacles: XY[],
T: GameTuning
) {
drawGrid(ctx, T);
+ drawObstacles(ctx, obstacles, T);
drawFood(ctx, food, T);
drawSnake(ctx, snake, T);
if (!alive) drawGameOver(ctx, T);
diff --git a/src/app/utils/logic.ts b/src/app/utils/logic.ts
index c688158..14fd4b8 100644
--- a/src/app/utils/logic.ts
+++ b/src/app/utils/logic.ts
@@ -29,20 +29,30 @@ export const outOfBounds = (p: XY, cols = 20, rows = 20) =>
* Initial snake centered on the given number of rows.
* Defaults to 20 so initSnake() keeps working if caller doesn't pass rows.
*/
-export const initSnake = (rows = 20): XY[] => {
+export const initSnake = (cols = 20, rows = 20): XY[] => {
const row = Math.floor(rows / 2);
+ const headX = Math.floor(cols / 2);
return [
- { x: 2, y: row },
- { x: 1, y: row },
- { x: 0, y: row },
+ { x: headX, y: row },
+ { x: headX - 1, y: row },
+ { x: headX - 2, y: row },
];
};
-export function randomFreeCell(snake: XY[], cols: number, rows: number): XY {
+export function randomFreeCell(
+ snake: XY[],
+ cols: number,
+ rows: number,
+ blocked: XY[] = []
+): XY {
+ const isBlocked = (x: number, y: number) =>
+ snake.some((c) => c.x === x && c.y === y) ||
+ blocked.some((c) => c.x === x && c.y === y);
+
while (true) {
const x = Math.floor(Math.random() * cols);
const y = Math.floor(Math.random() * rows);
- if (!snake.some((c) => c.x === x && c.y === y)) return { x, y };
+ if (!isBlocked(x, y)) return { x, y };
}
}
@@ -53,3 +63,47 @@ export function inferDirFromSnake(snake: XY[]): Dir {
if (h.x === s.x) return h.y < s.y ? 'up' : 'down';
return h.x < s.x ? 'left' : 'right';
}
+
+export function wrapPoint(p: XY, cols: number, rows: number): XY {
+ let x = p.x;
+ let y = p.y;
+ if (x < 0) x = cols - 1;
+ if (x >= cols) x = 0;
+ if (y < 0) y = rows - 1;
+ if (y >= rows) y = 0;
+ return { x, y };
+}
+
+export function manhattan(a: XY, b: XY) {
+ return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
+}
+
+export function generateObstacles(
+ count: number,
+ cols: number,
+ rows: number,
+ snake: XY[],
+ padding = 2
+): XY[] {
+ if (count <= 0) return [];
+ const taken = new Set();
+ const key = (x: number, y: number) => `${x}:${y}`;
+ snake.forEach((p) => taken.add(key(p.x, p.y)));
+
+ const head = snake[0] ?? { x: 0, y: 0 };
+ const result: XY[] = [];
+ let attempts = 0;
+
+ while (result.length < count && attempts < count * 50) {
+ attempts += 1;
+ const x = Math.floor(Math.random() * cols);
+ const y = Math.floor(Math.random() * rows);
+ const candidate = key(x, y);
+ if (taken.has(candidate)) continue;
+ if (manhattan({ x, y }, head) <= padding) continue;
+ taken.add(candidate);
+ result.push({ x, y });
+ }
+
+ return result;
+}