import { useState, useRef, useEffect, useCallback } from 'react'; import AppContainer from '../../components/AppContainer'; import { createArenaViewer, getArenaScene } from '../../lib/neatArena/arenaScene'; import { createSimulation, stepSimulation } from '../../lib/neatArena/simulation'; import { spinnerBotAction } from '../../lib/neatArena/baselineBots'; import { createPopulation, getPopulationStats, DEFAULT_EVOLUTION_CONFIG, type Population } from '../../lib/neatArena/evolution'; import { createNetwork } from '../../lib/neatArena/network'; import { generateObservation, observationToInputs } from '../../lib/neatArena/sensors'; import { exportGenome, downloadGenomeAsFile, uploadGenomeFromFile } from '../../lib/neatArena/exportImport'; import type { SimulationState, AgentAction, Genome } from '../../lib/neatArena/types'; import type { TrainingWorkerMessage, TrainingWorkerResponse } from '../../lib/neatArena/training.worker'; import FitnessGraph from './FitnessGraph'; import './NeatArena.css'; /** * NEAT Arena Miniapp * * Trains AI agents using NEAT (NeuroEvolution of Augmenting Topologies) to play * a 2D top-down shooter arena via self-play. */ export default function NeatArena() { // Training state const [population, setPopulation] = useState(() => createPopulation(DEFAULT_EVOLUTION_CONFIG)); const [isTraining, setIsTraining] = useState(false); const [showRays, setShowRays] = useState(false); const [mapSeed] = useState(12345); const [importedGenome, setImportedGenome] = useState(null); const [fitnessHistory, setFitnessHistory] = useState<{ generation: number; best: number; avg: number }[]>([]); // Stats const stats = getPopulationStats(population); // Phaser game instance const phaserGameRef = useRef(null); const phaserContainerRef = useRef(null); // Exhibition match state (visualizing champion) const simulationRef = useRef(null); // Web Worker const workerRef = useRef(null); // Initialize Web Worker useEffect(() => { const worker = new Worker(new URL('../../lib/neatArena/training.worker.ts', import.meta.url), { type: 'module' }); worker.onmessage = (e: MessageEvent) => { const response = e.data; switch (response.type) { case 'update': if (response.population) { setPopulation(response.population); console.log('[UI] Stats?', response.stats ? 'YES' : 'NO', response.stats); // Track fitness history for graph if (response.stats) { setFitnessHistory(prev => [...prev, { generation: response.stats!.generation, best: response.stats!.maxFitness, avg: response.stats!.avgFitness, }]); } } break; case 'error': console.error('Worker error:', response.error); setIsTraining(false); alert('Training error: ' + response.error); break; case 'ready': console.log('Worker ready'); break; } }; // Initialize worker with config worker.postMessage({ type: 'init', config: DEFAULT_EVOLUTION_CONFIG, } as TrainingWorkerMessage); workerRef.current = worker; return () => { worker.terminate(); workerRef.current = null; }; }, []); // Control worker based on training state useEffect(() => { if (!workerRef.current) return; if (isTraining) { workerRef.current.postMessage({ type: 'start', } as TrainingWorkerMessage); } else { workerRef.current.postMessage({ type: 'pause', } as TrainingWorkerMessage); } }, [isTraining]); // Initialize Phaser useEffect(() => { if (!phaserContainerRef.current) return; phaserContainerRef.current.innerHTML = ''; const game = createArenaViewer(phaserContainerRef.current); phaserGameRef.current = game; simulationRef.current = createSimulation(mapSeed, 0); return () => { game.destroy(true); phaserGameRef.current = null; }; }, [mapSeed]); // Exhibition match loop (visualizing best vs second-best AI) useEffect(() => { if (!phaserGameRef.current) return; const interval = setInterval(() => { if (!simulationRef.current) return; const sim = simulationRef.current; if (sim.isOver) { simulationRef.current = createSimulation(mapSeed, 0); return; } // Get best and second-best genomes const sortedGenomes = [...population.genomes].sort((a, b) => b.fitness - a.fitness); const genome0 = importedGenome || sortedGenomes[0] || null; const genome1 = sortedGenomes.length > 1 ? sortedGenomes[1] : null; // Agent 0: Best AI let action0: AgentAction; if (genome0) { const network = createNetwork(genome0); const obs = generateObservation(0, sim); const inputs = observationToInputs(obs); const outputs = network.activate(inputs); action0 = { moveX: outputs[0], moveY: outputs[1], turn: outputs[2], shoot: outputs[3], }; } else { action0 = spinnerBotAction(); } // Agent 1: Second-best AI (or spinner if not enough genomes) let action1: AgentAction; if (genome1) { const network = createNetwork(genome1); const obs = generateObservation(1, sim); const inputs = observationToInputs(obs); const outputs = network.activate(inputs); action1 = { moveX: outputs[0], moveY: outputs[1], turn: outputs[2], shoot: outputs[3], }; } else { action1 = spinnerBotAction(); } simulationRef.current = stepSimulation(sim, [action0, action1]); if (phaserGameRef.current) { const scene = getArenaScene(phaserGameRef.current); scene.updateSimulation(simulationRef.current); scene.setShowRays(showRays); } }, 1000 / 30); return () => clearInterval(interval); }, [showRays, mapSeed, population.genomes, importedGenome]); const handleReset = useCallback(() => { setIsTraining(false); setImportedGenome(null); setFitnessHistory([]); if (workerRef.current) { workerRef.current.postMessage({ type: 'reset', } as TrainingWorkerMessage); } simulationRef.current = createSimulation(mapSeed, 0); if (phaserGameRef.current) { const scene = getArenaScene(phaserGameRef.current); scene.updateSimulation(simulationRef.current); } }, [mapSeed]); const handleStepGeneration = useCallback(() => { if (workerRef.current) { workerRef.current.postMessage({ type: 'step', } as TrainingWorkerMessage); } }, []); const handleExport = useCallback(() => { if (!population.bestGenomeEver) { alert('No champion to export yet!'); return; } const exported = exportGenome( population.bestGenomeEver, DEFAULT_EVOLUTION_CONFIG, { generation: stats.generation, fitness: stats.bestFitnessEver, speciesCount: stats.speciesCount, } ); downloadGenomeAsFile(exported, `neat-champion-gen${stats.generation}.json`); }, [population.bestGenomeEver, stats]); const handleImport = useCallback(async () => { try { const exported = await uploadGenomeFromFile(); setImportedGenome(exported.genome); alert(`Imported champion from generation ${exported.metadata?.generation || '?'} with fitness ${exported.metadata?.fitness?.toFixed(1) || '?'}`); } catch (err) { alert('Failed to import genome: ' + (err as Error).message); } }, []); return (
{/* Left Panel: Controls */}

Training Controls

{isTraining ? '🟢 Training in background worker...' : importedGenome ? '🎮 Watching imported champion vs Gen best' : population.genomes.length > 1 ? `🎮 Watching Gen ${stats.generation}: Best vs 2nd-Best AI` : '⚪ Need at least 2 genomes for exhibition'}

Evolution Stats

Generation {stats.generation}
Species {stats.speciesCount}
Best Fitness {stats.maxFitness.toFixed(1)}
Avg Fitness {stats.avgFitness.toFixed(1)}
Champion {stats.bestFitnessEver.toFixed(1)}
Innovations {stats.totalInnovations}
{fitnessHistory.length > 0 && (

Fitness Progress

)}

Debug Options

Export / Import

{importedGenome && (

✅ Imported genome loaded

)}

NEAT Arena Status

  • ✅ Deterministic 30Hz simulation
  • ✅ Symmetric procedural maps
  • ✅ Agent physics & bullets
  • ✅ 360° ray sensors (53 inputs)
  • ✅ NEAT evolution with speciation
  • ✅ Self-play training (K=4 matches)
  • ✅ Export/import genomes
  • ✅ Web worker (no UI lag!)
{/* Right Panel: Phaser Viewer */}
); }