פיתוח משחק סנייק עם AI - מה כן עבד
לפני כמה ימים כתבתי כאן על הכישלון שלי בפיתוח משחק סנייק עם קרסר. הפרומפט "תפתח לי משחק סנייק", באופן די צפוי, יצר קוד גרוע ומשחק שרק נראה עובד אבל מכיל באגים וארכיטקטורה גרועה, כך שכל תיקון של אחד שבר 3 אחרים. בפוסט היום אני רוצה להראות מה כן לעשות - איך לגרום ל AI לפתח קוד ממש בסדר, ומה זה אומר על התפקיד של מתכנתים בעולם של Vibe Coding.
1. ניסיון ראשון - תבנה לי סנייק
אז מה בעצם הבעיה? למה ה AI לא יכול לקבל פרומפט כמו "תכתוב משחק סנייק" ופשוט לכתוב משחק נורמלי?
ה AI הוא מכונה שמתוכננת "להמשיך" טקסטים. הוא עובר על הפרומפט והמילים בפרומפט משנות את המצב הפנימי של ה LLM. בסוף הפרומפט המצב הפנימי שלו הוא נקודת ההתחלה, ממנה הוא מוצא את המילה הבאה, שגם היא בתורה מעדכנת את המצב הפנימי ומובילה למילה שאחריה וכך הלאה. זה אומר שמה שנכנס לחלון הקונטקסט לפני ששולחים ל AI את ההודעה חייב לשים אותו בנקודה מאוד ספציפית ממנה הוא יבנה קוד נכון. אפשר לחשוב על זה כאילו הפרומפט מפעיל אזורים שונים באימון של ה AI וגורם לו להמשיך את ההשלמה בצורות אחרות.
אפשר לחשוב על זה כאילו הפרומפט "תבנה לי משחק סנייק" מפעיל את האזורים באימון של ה AI שהיו קרובים למילים בפרומפט, כלומר למילים של בנייה, משחק וסנייק. הבעיה היא שהאינטרנט מלא בקוד גרוע שקשור לאנשים שכתבו משחקי סנייק לא טובים מכל מיני סיבות ומכל מיני סוגים. שלחתי את ה AI להשלים על בסיס פרומפט כללי מדי ולכן אני מקבל השלמה לא רלוונטית ולא טובה.
2. ניסיון שני - בסיס בינוני, תבנה לי סנייק
אז מה כן צריך? אם מדובר במכונת השלמה אז אנחנו צריכים לוודא שאנחנו כותבים בקוד את המבנה שכן נרצה בתוצאה הסופית - כלומר הארכיטקטורה, מבנה הקבצים ואפילו חתימות הפונקציות.
לסיבוב הזה הגעתי יותר מוכן - אפשר למצוא את קוד הסטארטר שכתבתי בריפו כאן:
https://github.com/ynonp/vibe-coding-snake-game/tree/17c67dcee0cc3e6514440f09bb6449478dae0108
זה מתחיל בקובץ חוקים כללי לפרויקט שמתאר את מבנה התיקיות והטכנולוגיות:
---
description:
globs:
alwaysApply: true
---
3. Project Overview
You are an experienced game developer implementing a DOM based snake game using: 1. React 2. Next.JS 15 (App Router) 3. Tailwind CSS 4 4. Vitest
4. Directory Structure
/src
contains source files/tests
contains test files, in a top-level test separation/src/components/dom
are components that render game items to DOM/src/app
is the root for the application pages/src/lib
stores general utilities or logic, including server actions/src/hooks
stores custom hooks
5. Components
- Store DOM rendering logic in components
- Component should be small (less than 200 lines). Their logic is saved in hooks
- Think carefully before using
useEffect
. There's probably a better way. - Do not use
useCallback
oruseMemo
. Be smart about the location of component state. - Create new components as needed
6. Hooks
- Store the client side component logic in custom hooks
- Hooks help us maintain small components
- Create new hooks as needed
7. Tests
- vitest config file is /vitest.config.mts
- Create tests for any feature you implement.
- Use existing mocks where available, for example use fake timers in the tests.
- Do not create new mocks or stubs for the tests.
8. Style
- Prefer using built-in tailwind 4 classes
- All tailwind theme settings is defined in
src/app/globals.css
. - Do not use a dedicated config file. ```
לא תמיד ה AI לוקח ברצינות את הבקשות כאן, אבל הרוב כן נקלט והוא הרבה פחות להוט להתקין ספריות חדשות או להמציא שיטות עבודה.
אחרי זה הלכתי לתיקיית קבצי המקור ויצרתי כמה קבצים. תחילה קובץ game.tsx בשביל הקומפוננטה של המשחק:
'use client';
import useSnake from "@/hooks/use-snake";
import useGameLoop from "@/hooks/use-game-loop";
/**
* Create a Snake game client component
*/
export default function Game() {
const snake = useSnake();
const apple = useApple();
const areColliding = useCollisionDetection(snake.body, apple.body);
const gameLoop = useGameLoop();
if (areColliding) {
}
return (
<div>
<h1>Snake</h1>
<Snake snake={snake} />
<Apple apple={apple} />
</div>
)
}
שלושה קבצים ריקים עבור הלוגיקה שרציתי שתישמר בתור hooks עם השמות use-apple.ts
, use-collision-detection.ts
, use-game-loop.ts
ו use-snake.ts
. יצרתי גם קובץ טיפוסים בשם types/game.ts
עם התוכן הבא:
export type GameObject = Coordinates | Array<Coordinates>;
export type Snake = {
body: Array<Coordinates>;
direction: Direction;
};
export type Coordinates = {
x: number;
y: number;
};
export type Direction = 'up' | 'down' | 'left' | 'right';
export type GameState = {
snake: Snake;
food: Coordinates;
gameOver: boolean;
};
export type GameConfig = {
width: number;
height: number;
gameOver: boolean;
speed: number;
}
יותר מזה, רציתי לוודא שבלולאת המשחק ה UI ישתמש ב requestAnimationFrame אז כתבתי בקובץ use-game-loop.ts
את התוכן הבא:
import { useEffect } from "react";
export default function useGameLoop({
update,
isRunning
}: {
update: () => void;
isRunning: boolean;
}) {
let animationFrame: number;
function tick() {
if (isRunning) {
update();
animationFrame = requestAnimationFrame(tick);
}
}
useEffect(() => {
tick();
return () => cancelAnimationFrame(animationFrame);
}, [isRunning]);
}
למרות שלי היה ברור לגמרי איך להמשיך את המשחק, התוצאה שקיבלתי מה AI לא היתה טובה. כן זה היה יותר טוב מהסיבוב הראשון: הקבצים היו קטנים יותר, היתה התחלה של בדיקות, החלוקה לקבצים היתה לפי מה שאני הגדרתי והוא לא ניסה לממש את המשחק בערבוב מוזר של Canvas ו State. אבל, המשחק עדיין כלל באגים, הבדיקות לא היו טובות, ובאופן כללי המימוש לא איפשר הרחבה של המשחק בלי לשבור דברים.
9. ניסיון שלישי - מבנה בסיסי עובד, AI משלים
אחרי שנתתי למספר מודלים ליצור משחק סנייק לפי ההתחלה של הבסיס שכתבתי בחלק הקודם הבנתי שהבעיה לא במודלים אלא בפרומפט וחזרתי לשולחן השרטוטים. הפעם המשכתי את המשחק בעצמי עד שהגעתי ל Game Loop שמזיזה נחש. הקוד כלל את הקובץ use-game-loop.ts
השלם:
import type { RefObject } from 'react';
import { useRef, useEffect } from "react";
function gameLoop(
nextAnimationFrameRef: RefObject<number>,
lastTime: number,
accumulatorRef: RefObject<number>,
updatesPerSecond: number,
update: () => void,
) {
const timeStep = 1 / updatesPerSecond;
const currentTime = performance.now();
let deltaTime = (currentTime - lastTime) / 1000;
accumulatorRef.current += deltaTime;
while (accumulatorRef.current >= timeStep) {
update();
accumulatorRef.current -= timeStep;
}
nextAnimationFrameRef.current = requestAnimationFrame(() => gameLoop(nextAnimationFrameRef, currentTime, accumulatorRef, updatesPerSecond, update));
}
export default function useGameLoop(
updatesPerSecond: number,
update: () => void,
) {
let accumulatorRef = useRef(0);
const nextAnimationFrameRef = useRef(0);
useEffect(() => {
if (updatesPerSecond > 0) {
gameLoop(nextAnimationFrameRef, performance.now(), accumulatorRef, updatesPerSecond, update)
return () => {
cancelAnimationFrame(nextAnimationFrameRef.current);
}
}
}, [updatesPerSecond, update])
}
קובץ use-snake עם הרבה יותר תוכן:
import { Coordinates, Direction, Snake } from "@/types/game";
import { useState } from "react";
/**
* Creates a new snake object
* Bind keyboard events to move the snake
*/
export default function useSnake(): Snake {
const [body, setBody] = useState([{x: 20, y: 20}]);
const [direction, setDirection] = useState<Direction>('right');
const [grow, setGrow] = useState(2);
return {
body: body,
direction,
update: () => {
setBody(body => moveBody(body, direction, grow))
if (grow > 0) {
setGrow(g => g - 1);
}
},
grow: (addition: number) => {
setGrow(g => g + addition)
}
}
}
function moveBody(body: Array<Coordinates>, direction: Direction, grow: number) {
const head = body[0];
const newHead = {
'right': () => ({x: head.x + 1, y: head.y}),
'left': () => ({x: head.x - 1, y: head.y}),
'up': () => ({x: head.x, y: head.y - 1}),
'down': () => ({x: head.x, y: head.y + 1}),
}[direction]();
if (grow > 0) {
return [newHead, ...body]
} else {
return [newHead, ...body.slice(0, body.length - 1)]
}
}
קובץ קומפוננטה של נחש בשם snake.tsx
:
import type { Snake, Coordinates } from "@/types/game";
import { coordinatesToStyle } from '@/lib/utils';
/**
* Renders the snake using DOM objects
* Each snake part should be a div with a class name of 'snake'
*/
export default function Snake({ snake }: { snake: Snake }) {
return (
<>
{snake.body.map((c, i) => (
<div
key={i}
className='w-5 h-5 bg-amber-900 absolute m-0 p-0'
style={coordinatesToStyle(c)}
/>
))}
</>
)
}
וקובץ משחק game.tsx שמייצר נחש, תפוח ו Game Loop ומתחיל להזיז דברים על המסך:
'use client';
import isColliding from "@/lib/is-colliding";
import useSnake from "@/hooks/use-snake";
import useGameLoop from "@/hooks/use-game-loop";
import useApple from "@/hooks/use-apple";
import Snake from "@/components/dom/snake";
import Apple from "@/components/dom/apple";
import PlayButton from "@/components/dom/play-button";
import { useState } from "react";
/**
* Create a Snake game client component
*/
export default function Game() {
const [isPlaying, setIsPlaying] = useState(false);
const snake = useSnake();
const apple = useApple();
const snakeEatsApple = isColliding(snake.body, apple.body);
const GAME_SPEED = 2; // move the snake 1 step every second
const gameLoop = useGameLoop(isPlaying ? GAME_SPEED : 0, () => {
snake.update();
});
if (snakeEatsApple) {
}
return (
<div>
<h1>Snake</h1>
<PlayButton isPlaying={isPlaying} toggle={() => setIsPlaying(p => !p)} />
<div className="border-purple-600 border w-[800px] h-[600px] relative mx-auto">
<Snake snake={snake} />
<Apple apple={apple} />
</div>
</div>
)
}
פה כבר היתה מספיק מסה של קוד כדי שה AI יבין איך המשחק צריך להיות בנוי ותוך רגע קיבלתי משחק סנייק שעובד. ה AI הוסיף את כל המנגנונים:
נחש יכול לאכול תפוח ולגדול.
שליטה בנחש עם כפתורי החצים או w, s, a, d.
כשנחש אוכל תפוח מקבלים נקודות, ומהירות המשחק לאט לאט עולה.
פסילה כשנחש נוגע בעצמו או בקירות העולם.
אפשר לראות את כל הקוד שקלוד כתב בצורת diff בקישור הזה: https://github.com/ynonp/vibe-coding-snake-game/commit/7476986d0f7cc35ab70aa670c349dfdf5a4c531e
בעבודה עם AI יש נקודה שהחל ממנה ה AI כבר מסתדר להמשיך לבד וכותב קוד שמתאים לסגנון שאתם רוצים, אבל בשביל ששיתוף הפעולה הזה יעבוד טוב אתם צריכים להיות אלה שמבינים איך המערכת בנויה ומה התפקיד של כל קובץ וכל שורת קוד.
אחד הפרמטרים שמעניין להסתכל עליהם בניסיונות מהסוג הזה הוא הפרדיקטביליות של הקוד, כלומר אנחנו נותנים לקרסר ליצור את הקוד ואז שמים אותו ב branch, ואז נותנים לו ליצור שוב עוד גירסה עם אותו פרומפט, וככה ממשיכים 3-4 פעמים. ככל שנראה יותר שינויים בין הגירסאות שנוצרו זו אינדיקציה שהבסיס שלנו עדיין לא מספיק ברור והאלמנט האקראי בעבודה של ה AI משחק תפקיד יותר משמעותי.