snake-ai-game
Version:
A terminal-based snake game powered by AI.
300 lines (257 loc) ⢠11.5 kB
text/typescript
import { generateText } from 'ai';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { snakeTools } from './tools.js';
import { renderGameFrame, updateTokenUsage } from './display.js';
import { gameState } from './gameState.js';
import { formatTime } from './utils.js';
import { GAME_CONSTANTS } from './constants/index.js';
import chalk from 'chalk';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
async function validateApiKey(apiKey: string): Promise<boolean> {
const google = createGoogleGenerativeAI({ apiKey });
try {
console.log(chalk.cyan('š Validating API key...'));
await generateText({
model: google('gemini-2.5-flash-lite-preview-06-17'),
temperature: 0,
maxOutputTokens: 1,
messages: [{
role: 'user',
content: 'test'
}]
});
console.log(chalk.green('ā
API key validated successfully\n'));
return true;
} catch (error: any) {
if (error?.data?.error?.code === 400 && error?.data?.error?.message?.includes('API key')) {
console.error(chalk.red('ā Invalid API Key'));
console.error(chalk.yellow('The provided Google Generative AI API key is not valid.'));
console.error(chalk.cyan('Please check your API key and try again.'));
console.error(chalk.gray('Get your API key from: https://makersuite.google.com/app/apikey'));
return false;
}
console.log(chalk.yellow('ā ļø Could not validate API key (network issue?), proceeding anyway...'));
return true;
}
}
async function runSnakeGame(apiKey: string, timeLimit: number = GAME_CONSTANTS.DEFAULT_TIME_LIMIT) {
const google = createGoogleGenerativeAI({ apiKey });
let timerId: NodeJS.Timeout | null = null;
try {
let moveNumber = 0;
let stepCount = 0;
gameState.setState({ timeLimit });
const initialState = gameState.getState();
const startTime = Date.now();
const endTime = startTime + (timeLimit * 1000);
let timeRemaining = timeLimit;
await renderGameFrame(initialState, moveNumber, {}, false, timeRemaining);
timerId = setInterval(() => {
timeRemaining = Math.max(0, Math.floor((endTime - Date.now()) / 1000));
if (timeRemaining <= 0) {
if (timerId) clearInterval(timerId);
if (!gameState.getState().isGameOver) gameState.setState({ isGameOver: true });
}
}, 1000);
let scanCount = 0;
await executeScanTool();
scanCount++;
while (!gameState.getState().isGameOver && timeRemaining > 0) {
await executeAIMove();
timeRemaining = Math.max(0, Math.floor((endTime - Date.now()) / 1000));
if (timeRemaining <= 0) break;
}
async function executeScanTool() {
stepCount++;
try {
const { usage, toolResults, response } = await generateText({
model: google('gemini-2.5-flash-lite-preview-06-17'),
tools: { scan: snakeTools.scan, giveUp: snakeTools.giveUp },
toolChoice: { type: 'tool', toolName: 'scan' },
temperature: 0.1,
maxOutputTokens: 200,
system: "Analyze the current snake game state",
messages: [{
role: 'user',
content: `Scan the game state and provide optimal path.`
}]
});
if (usage) {
updateTokenUsage({
promptTokens: usage.inputTokens ?? 0,
completionTokens: usage.outputTokens ?? 0,
totalTokens: usage.totalTokens ?? 0,
}, response);
}
await renderGameFrame(
gameState.getState(),
moveNumber,
{ scanning: true, scanCount },
true,
timeRemaining
);
return toolResults?.[0]?.output;
} catch (error: any) {
if (error?.data?.error?.code === 400 && error?.data?.error?.message?.includes('API key')) {
throw error;
}
console.log(chalk.yellow('ā ļø Scan failed, using fallback strategy'));
return { suggestion: 'RIGHT', confidence: 0.1 };
}
}
async function executeAIMove() {
stepCount++;
const scanResult = await executeScanTool();
scanCount++;
const suggestedPath = (scanResult && 'path' in scanResult) ? scanResult.path : [(scanResult && 'suggestion' in scanResult) ? scanResult.suggestion : 'RIGHT'];
try {
const { usage, toolResults, response } = await generateText({
model: google('gemini-2.5-flash-lite-preview-06-17'),
tools: { move: snakeTools.move, giveUp: snakeTools.giveUp },
temperature: 0.1,
maxOutputTokens: 200,
system: "You are an expert snake game AI. Your goal is to maximize the score by eating food and staying alive. You can execute a sequence of moves in a single turn. If you detect that you're completely stuck with no valid moves or trapped in a dead end, use the giveUp tool to end the game gracefully.",
messages: [{
role: 'user',
content: `Make the next sequence of moves for the snake. You can make between 1 and 5 moves. The suggested optimal path is: ${suggestedPath.slice(0, 5).join(', ')}.
Scan results: ${JSON.stringify(scanResult)}
Use this suggestion to decide on your next moves. IMPORTANT: If the scan shows isStuck=true (no valid moves) or isTrapped=true (trapped with no path to food), you MUST use the giveUp tool instead of trying to move.`
}]
});
if (usage) {
updateTokenUsage({
promptTokens: usage.inputTokens ?? 0,
completionTokens: usage.outputTokens ?? 0,
totalTokens: usage.totalTokens ?? 0,
}, response);
}
timeRemaining = Math.max(0, Math.floor((endTime - Date.now()) / 1000));
if (timeRemaining <= 0 || gameState.getState().isGameOver) return;
if (toolResults && toolResults.length > 0) {
const result = toolResults[0].output;
const toolName = toolResults[0].toolName;
if (toolName === 'giveUp') {
// AI decided to give up
console.log(chalk.yellow('\nš¤ AI decided to give up - no viable moves detected'));
gameState.setState({ isGameOver: true });
return;
} else if (result.success && 'movesExecuted' in result && result.movesExecuted.length > 0) {
for (const direction of result.movesExecuted) {
moveNumber++;
const decisionMetrics = {
direction: direction,
confidence: 0.8,
isAIMove: true
};
await renderGameFrame(gameState.getState(), moveNumber, decisionMetrics, false, timeRemaining);
}
}
}
} catch (error: any) {
// Handle API errors during move execution
if (error?.data?.error?.code === 400 && error?.data?.error?.message?.includes('API key')) {
throw error; // Re-throw API key errors to be handled by main catch
}
console.log(chalk.yellow('ā ļø Move execution failed, ending game'));
gameState.setState({ isGameOver: true });
}
}
if (timerId) clearInterval(timerId);
const finalState = gameState.getState();
if (!finalState.isGameOver) {
gameState.setState({ isGameOver: true });
}
timeRemaining = Math.max(0, Math.floor((endTime - Date.now()) / 1000));
await renderGameFrame(gameState.getState(), moveNumber, {}, false, timeRemaining);
console.log(chalk.green('\n=== Game Complete ==='));
console.log(`Time Elapsed: ${formatTime(timeLimit - timeRemaining)}`);
console.log(`Final Score: ${finalState.score}`);
console.log(`Scan Count: ${scanCount}`);
console.log(`Step Count: ${stepCount}`);
const scanEfficiency = finalState.score / (scanCount || 1);
const moveEfficiency = finalState.score / (moveNumber || 1);
const scanColor = scanEfficiency > 0.2 ? chalk.green : scanEfficiency > 0.1 ? chalk.yellow : chalk.red;
const moveColor = moveEfficiency > 0.15 ? chalk.green : moveEfficiency > 0.08 ? chalk.yellow : chalk.red;
console.log(`Scan Efficiency: ${scanColor(scanEfficiency.toFixed(2))} points per scan`);
console.log(`Move Efficiency: ${moveColor(moveEfficiency.toFixed(2))} points per move`);
return {
score: finalState.score,
moves: moveNumber,
scanCount,
timeElapsed: timeLimit - timeRemaining,
scanEfficiency: finalState.score / (scanCount || 1),
moveEfficiency: finalState.score / (moveNumber || 1)
};
} catch (error: any) {
if (timerId) clearInterval(timerId);
// Handle API key errors specifically
if (error?.data?.error?.code === 400 && error?.data?.error?.message?.includes('API key')) {
console.error(chalk.red('\nā Invalid API Key Error'));
console.error(chalk.yellow('The provided Google Generative AI API key is not valid.'));
console.error(chalk.cyan('Please check your API key and try again.'));
console.error(chalk.gray('Get your API key from: https://makersuite.google.com/app/apikey'));
process.exit(1);
}
// Handle other API errors
if (error?.data?.error) {
console.error(chalk.red(`\nā API Error: ${error.data.error.message}`));
process.exit(1);
}
// Handle network/connection errors
if (error?.cause || error?.message?.includes('fetch')) {
console.error(chalk.red('\nā Network Error'));
console.error(chalk.yellow('Unable to connect to Google Generative AI service.'));
console.error(chalk.cyan('Please check your internet connection and try again.'));
process.exit(1);
}
// Generic error handling
console.error(chalk.red('\nā Unexpected Error:'), error?.message || error);
return null;
}
}
yargs(hideBin(process.argv))
.command(
'$0',
'Play the snake game',
(yargs) => {
return yargs
.option('apiKey', {
alias: 'k',
type: 'string',
description: 'Google Generative AI API key',
demandOption: false
})
.option('time', {
alias: 't',
type: 'number',
description: `Game time limit in seconds (${GAME_CONSTANTS.MIN_TIME_LIMIT}-${GAME_CONSTANTS.MAX_TIME_LIMIT})`,
default: GAME_CONSTANTS.DEFAULT_TIME_LIMIT
});
},
(argv) => {
const apiKey = argv.apiKey || process.env.GOOGLE_GENERATIVE_AI_API_KEY;
let timeLimit = argv.time;
if (!apiKey) {
console.error(chalk.red('Error: API key not provided. Please use the --apiKey option or set the GOOGLE_GENERATIVE_AI_API_KEY environment variable.'));
process.exit(1);
}
// Validate time limit
if (timeLimit < GAME_CONSTANTS.MIN_TIME_LIMIT || timeLimit > GAME_CONSTANTS.MAX_TIME_LIMIT) {
console.error(chalk.red(`Error: Time limit must be between ${GAME_CONSTANTS.MIN_TIME_LIMIT} and ${GAME_CONSTANTS.MAX_TIME_LIMIT} seconds.`));
process.exit(1);
}
// Validate API key before starting the game
validateApiKey(apiKey).then(isValid => {
if (isValid) {
console.log(chalk.cyan('š® Starting Snake Game...\n'));
runSnakeGame(apiKey, timeLimit).catch(console.error);
} else {
process.exit(1);
}
}).catch(console.error);
}
)
.help()
.argv;