UNPKG

snake-ai-game

Version:

A terminal-based snake game powered by AI.

300 lines (257 loc) • 11.5 kB
#!/usr/bin/env node 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;