diff --git a/example/src/App.tsx b/example/src/App.tsx
index 82c5bb2..58feba4 100755
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -3,7 +3,7 @@ import "@vimazing/vim-snake/game.css";
import { useKeyBindings } from "./useKeyBindings";
function App() {
- const { containerRef, gameStatus, scoreManager, keyLog } = useGame(30, 20, useKeyBindings);
+ const { containerRef, gameStatus, scoreManager, keyLog, score } = useGame(30, 20, useKeyBindings);
const formatTime = (ms: number) => {
const seconds = Math.floor(ms / 1000);
@@ -20,6 +20,9 @@ function App() {
Time: {formatTime(scoreManager.timeValue)}
+
+ Score: {score}
+
Keys: {keyLog.length}
diff --git a/package.json b/package.json
index 7aa3b4a..1d0ca75 100755
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@vimazing/vim-snake",
- "version": "0.2.0",
+ "version": "0.3.0",
"description": "Classic Snake reimagined with Vim controls — a lightweight, typed React hook library for building snake games with hjkl navigation.",
"author": "André Padez ",
"keywords": [
diff --git a/src/game.css b/src/game.css
index 49ac1d0..5b99c1d 100755
--- a/src/game.css
+++ b/src/game.css
@@ -99,3 +99,24 @@
border-radius: 2px 6px 6px 2px;
}
+/* =========================================================
+ Food Rendering
+ ========================================================= */
+.snake-cell.food {
+ background-color: var(--chart-2, #c3e88d);
+ border-radius: 50%;
+ box-shadow:
+ 0 0 8px color-mix(in srgb, var(--chart-2, #c3e88d) 60%, transparent),
+ inset 0 0 4px color-mix(in srgb, var(--chart-2, #c3e88d) 80%, transparent);
+ position: relative;
+}
+
+.snake-cell.food::after {
+ content: "🍎";
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 12px;
+}
+
diff --git a/src/useGame/useFood.ts b/src/useGame/useFood.ts
index 917566f..da85d09 100755
--- a/src/useGame/useFood.ts
+++ b/src/useGame/useFood.ts
@@ -1,3 +1,96 @@
-export function useFood() {
- return {};
+import { useState, useRef } from "react";
+import type { Position, SnakeBody } from "../types";
+import type { UseRendererType } from "./useRenderer";
+
+export function useFood(
+ cols: number,
+ rows: number,
+ rendererManager: UseRendererType
+) {
+ const { containerRef } = rendererManager;
+ const [foodPositions, setFoodPositions] = useState([]);
+ const foodPositionsRef = useRef([]);
+
+ const spawnFood = (snakeBody: SnakeBody, count: number = 1) => {
+ const occupiedPositions = new Set(
+ snakeBody.map((seg) => `${seg.r},${seg.c}`)
+ );
+
+ const availablePositions: Position[] = [];
+ for (let r = 0; r < rows; r++) {
+ for (let c = 0; c < cols; c++) {
+ const key = `${r},${c}`;
+ if (!occupiedPositions.has(key)) {
+ availablePositions.push({ r, c });
+ }
+ }
+ }
+
+ const newFood: Position[] = [];
+ for (let i = 0; i < count && availablePositions.length > 0; i++) {
+ const randomIndex = Math.floor(Math.random() * availablePositions.length);
+ const food = availablePositions.splice(randomIndex, 1)[0];
+ newFood.push(food);
+ }
+
+ setFoodPositions(newFood);
+ foodPositionsRef.current = newFood;
+ renderFood(newFood);
+ };
+
+ const clearFood = () => {
+ setFoodPositions([]);
+ foodPositionsRef.current = [];
+ const container = containerRef.current;
+ container?.querySelectorAll(".food").forEach((el) => {
+ el.classList.remove("food");
+ });
+ };
+
+ const renderFood = (positions: Position[]) => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ container.querySelectorAll(".food").forEach((el) => {
+ el.classList.remove("food");
+ });
+
+ positions.forEach((pos) => {
+ const cell = container.querySelector(
+ `.snake-cell[data-r="${pos.r}"][data-c="${pos.c}"]`
+ );
+ if (cell) {
+ cell.classList.add("food");
+ }
+ });
+ };
+
+ const checkFoodCollision = (headPos: Position): boolean => {
+ const food = foodPositionsRef.current;
+ const collision = food.some(
+ (f) => f.r === headPos.r && f.c === headPos.c
+ );
+ return collision;
+ };
+
+ const removeFood = (pos: Position) => {
+ const newFood = foodPositionsRef.current.filter(
+ (f) => !(f.r === pos.r && f.c === pos.c)
+ );
+ setFoodPositions(newFood);
+ foodPositionsRef.current = newFood;
+ renderFood(newFood);
+ };
+
+ return {
+ foodPositions,
+ foodPositionsRef,
+ spawnFood,
+ clearFood,
+ checkFoodCollision,
+ removeFood,
+ renderFood,
+ };
}
+
+export type UseFoodType = ReturnType;
diff --git a/src/useGame/useGame.ts b/src/useGame/useGame.ts
index 31dfd8a..883ae2c 100755
--- a/src/useGame/useGame.ts
+++ b/src/useGame/useGame.ts
@@ -1,6 +1,7 @@
import { useEffect } from "react";
import { useRenderer } from "./useRenderer";
import { useSnake } from "./useSnake";
+import { useFood } from "./useFood";
import { useGameStatus } from "./useGameStatus";
import { useKeyBindings, type UseKeyBindingsType } from "./useKeyBindings";
import { useScore } from "../useScore";
@@ -12,8 +13,10 @@ export function useGame(cols: number, rows: number, platformHook?: unknown) {
const snakeManager = useSnake(cols, rows, rendererManager);
const { changeDirection } = snakeManager;
- const gameManager = useGameStatus(rendererManager, snakeManager);
- const { gameStatus, startGame, stopGame } = gameManager;
+ const foodManager = useFood(cols, rows, rendererManager);
+
+ const gameManager = useGameStatus(rendererManager, snakeManager, foodManager);
+ const { gameStatus, startGame, stopGame, score } = gameManager;
useEffect(() => {
renderBoard(cols, rows);
@@ -33,7 +36,9 @@ export function useGame(cols: number, rows: number, platformHook?: unknown) {
gameStatus,
startGame,
stopGame,
+ score,
...snakeManager,
+ ...foodManager,
...keyBindings,
scoreManager,
};
diff --git a/src/useGame/useGameStatus.ts b/src/useGame/useGameStatus.ts
index ee7262f..ee1ac51 100755
--- a/src/useGame/useGameStatus.ts
+++ b/src/useGame/useGameStatus.ts
@@ -2,32 +2,42 @@ import { useState, useEffect, useRef } from "react";
import type { GameStatus } from "../types";
import type { UseRendererType } from "./useRenderer";
import type { UseSnakeType } from "./useSnake";
+import type { UseFoodType } from "./useFood";
-const INITIAL_SPEED = 200;
+const FRAMES_PER_SECOND = 5;
export function useGameStatus(
rendererManager: UseRendererType,
- snakeManager: UseSnakeType
+ snakeManager: UseSnakeType,
+ foodManager: UseFoodType
) {
const { containerRef } = rendererManager;
- const { initSnake, clearSnake } = snakeManager;
+ const { initSnake, clearSnake, snakeBodyRef } = snakeManager;
+ const { spawnFood, clearFood } = foodManager;
const [gameStatus, setGameStatus] = useState("waiting");
- const gameLoopRef = useRef | null>(null);
+ const [score, setScore] = useState(0);
+ const gameLoopRef = useRef(null);
+ const lastUpdateRef = useRef(0);
const snakeManagerRef = useRef(snakeManager);
+ const foodManagerRef = useRef(foodManager);
snakeManagerRef.current = snakeManager;
+ foodManagerRef.current = foodManager;
const startGame = () => {
initSnake();
+ spawnFood(snakeBodyRef.current, 3);
+ setScore(0);
setGameStatus("started");
};
const stopGame = () => {
if (gameLoopRef.current) {
- clearInterval(gameLoopRef.current);
+ cancelAnimationFrame(gameLoopRef.current);
gameLoopRef.current = null;
}
clearSnake();
+ clearFood();
setGameStatus("waiting");
const container = containerRef.current;
container?.querySelectorAll(".snake-head, .snake-body, .snake-tail").forEach((el) => {
@@ -39,20 +49,50 @@ export function useGameStatus(
console.log("useGameStatus effect - gameStatus:", gameStatus);
if (gameStatus === "started") {
console.log("Starting game loop");
- gameLoopRef.current = setInterval(() => {
- console.log("Game loop tick");
- const result = snakeManagerRef.current.moveSnake();
- console.log("Move result:", result);
-
- if (result === "wall-collision") {
- setGameStatus("game-over");
+
+ let shouldGrow = false;
+ const msPerFrame = 1000 / FRAMES_PER_SECOND;
+ lastUpdateRef.current = performance.now();
+
+ const gameLoop = (timestamp: number) => {
+ const elapsed = timestamp - lastUpdateRef.current;
+
+ if (elapsed >= msPerFrame) {
+ console.log("Game loop tick");
+ const result = snakeManagerRef.current.moveSnake(shouldGrow);
+ console.log("Move result:", result);
+
+ shouldGrow = false;
+
+ if (result === "wall-collision") {
+ setGameStatus("game-over");
+ return;
+ }
+
+ const head = snakeManagerRef.current.snakeBodyRef.current[0];
+ if (head && foodManagerRef.current.checkFoodCollision(head)) {
+ console.log("Food eaten!");
+ foodManagerRef.current.removeFood(head);
+ shouldGrow = true;
+ setScore((prev) => prev + 1);
+ foodManagerRef.current.spawnFood(
+ snakeManagerRef.current.snakeBodyRef.current,
+ 1
+ );
+ }
+
+ lastUpdateRef.current = timestamp;
}
- }, INITIAL_SPEED);
+
+ gameLoopRef.current = requestAnimationFrame(gameLoop);
+ };
+
+ gameLoopRef.current = requestAnimationFrame(gameLoop);
return () => {
console.log("Cleaning up game loop");
if (gameLoopRef.current) {
- clearInterval(gameLoopRef.current);
+ cancelAnimationFrame(gameLoopRef.current);
gameLoopRef.current = null;
}
};
@@ -62,7 +102,7 @@ export function useGameStatus(
useEffect(() => {
if (gameStatus === "game-over") {
if (gameLoopRef.current) {
- clearInterval(gameLoopRef.current);
+ cancelAnimationFrame(gameLoopRef.current);
gameLoopRef.current = null;
}
}
@@ -73,6 +113,7 @@ export function useGameStatus(
setGameStatus,
startGame,
stopGame,
+ score,
};
}
diff --git a/src/useGame/useSnake.ts b/src/useGame/useSnake.ts
index f0554c5..6c30688 100755
--- a/src/useGame/useSnake.ts
+++ b/src/useGame/useSnake.ts
@@ -21,9 +21,6 @@ export function useSnake(
{ r: centerR, c: centerC },
{ r: centerR + 1, c: centerC },
{ r: centerR + 2, c: centerC },
- { r: centerR + 3, c: centerC },
- { r: centerR + 4, c: centerC },
- { r: centerR + 5, c: centerC },
];
setSnakeBody(initialBody);
@@ -109,7 +106,7 @@ export function useSnake(
}
};
- const moveSnake = (): "continue" | "wall-collision" | "self-collision" => {
+ const moveSnake = (grow: boolean = false): "continue" | "wall-collision" | "self-collision" => {
const body = snakeBodyRef.current;
if (body.length === 0) return "continue";
@@ -139,7 +136,9 @@ export function useSnake(
}
const newHead = { r: newR, c: newC };
- const newBody = [newHead, ...body.slice(0, -1)];
+ const newBody = grow
+ ? [newHead, ...body]
+ : [newHead, ...body.slice(0, -1)];
setSnakeBody(newBody);
snakeBodyRef.current = newBody;