Add bridge builder (not working) and asteroids

This commit is contained in:
Peter Stockings
2026-01-17 10:59:57 +11:00
parent afada3e8e7
commit 373158fb3d
29 changed files with 4017 additions and 1 deletions

View File

@@ -11,6 +11,7 @@ This project features several mini-apps that showcase evolutionary algorithms an
- **Snake AI**: Watch neural networks learn to play Snake through evolution - **Snake AI**: Watch neural networks learn to play Snake through evolution
- **Self-Driving Car**: AI learns to navigate a race track using genetic algorithms - **Self-Driving Car**: AI learns to navigate a race track using genetic algorithms
- **Lunar Lander**: Evolutionary training for optimal lunar landing with gimballed thrust control - **Lunar Lander**: Evolutionary training for optimal lunar landing with gimballed thrust control
- **Bridge Builder**: Evolve bridge structures with stress visualization and physics
- **Image Approximation**: Genetic algorithm that evolves shapes to approximate a target image - **Image Approximation**: Genetic algorithm that evolves shapes to approximate a target image
- **NEAT Arena**: Neural evolution of augmenting topologies in a competitive environment - **NEAT Arena**: Neural evolution of augmenting topologies in a competitive environment
- **Rogue Gen**: Procedural dungeon generation using evolutionary techniques - **Rogue Gen**: Procedural dungeon generation using evolutionary techniques

View File

@@ -6,6 +6,8 @@ import RogueGenApp from './apps/RogueGen/RogueGenApp';
import NeatArena from './apps/NeatArena/NeatArena'; import NeatArena from './apps/NeatArena/NeatArena';
import LunarLanderApp from './apps/LunarLander/LunarLanderApp'; import LunarLanderApp from './apps/LunarLander/LunarLanderApp';
import { SelfDrivingCarApp } from './apps/SelfDrivingCar/SelfDrivingCarApp'; import { SelfDrivingCarApp } from './apps/SelfDrivingCar/SelfDrivingCarApp';
import BridgeBuilderApp from './apps/BridgeBuilder/BridgeBuilderApp';
import AsteroidsAI from './apps/AsteroidsAI/AsteroidsApp';
import './App.css'; import './App.css';
function App() { function App() {
@@ -21,6 +23,8 @@ function App() {
<Route path="/neat-arena" element={<NeatArena />} /> <Route path="/neat-arena" element={<NeatArena />} />
<Route path="/lunar-lander" element={<LunarLanderApp />} /> <Route path="/lunar-lander" element={<LunarLanderApp />} />
<Route path="/self-driving-car" element={<SelfDrivingCarApp />} /> <Route path="/self-driving-car" element={<SelfDrivingCarApp />} />
<Route path="/bridge-builder" element={<BridgeBuilderApp />} />
<Route path="/asteroids-ai" element={<AsteroidsAI />} />
<Route path="*" element={<div>App not found</div>} /> <Route path="*" element={<div>App not found</div>} />
</Routes> </Routes>
</main> </main>

View File

@@ -0,0 +1,125 @@
.asteroids-app-layout {
display: flex;
flex-direction: column;
height: 100%;
gap: 1rem;
padding: 1rem;
background: linear-gradient(135deg, #0a0a15 0%, #1a1a2e 100%);
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.controls-section {
display: flex;
gap: 0.75rem;
}
.btn-toggle {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #4488ff 0%, #6666ff 100%);
color: white;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(68, 136, 255, 0.3);
}
.btn-toggle:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(68, 136, 255, 0.4);
}
.btn-toggle.active {
background: linear-gradient(135deg, #ff6b6b 0%, #ff8888 100%);
box-shadow: 0 4px 15px rgba(255, 107, 107, 0.3);
}
.btn-reset {
background: linear-gradient(135deg, #888888 0%, #aaaaaa 100%);
box-shadow: 0 4px 15px rgba(136, 136, 136, 0.3);
}
.btn-reset:hover {
box-shadow: 0 6px 20px rgba(136, 136, 136, 0.4);
}
.stats-section {
display: flex;
gap: 1rem;
}
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
min-width: 120px;
}
.stat-label {
font-size: 0.85rem;
color: #aaaaaa;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #ffffff;
font-family: 'Courier New', monospace;
}
.stat-value.highlight {
color: #ffaa00;
text-shadow: 0 0 10px rgba(255, 170, 0, 0.5);
}
.graph-panel {
flex: 0 0 200px;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
.vis-column {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
padding: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.main-view {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.main-view canvas {
display: block;
border-radius: 8px;
}

View File

@@ -0,0 +1,82 @@
import { useRef, useEffect } from 'react';
import AppContainer from '../../components/AppContainer';
import { createAsteroidsViewer, getAsteroidsScene } from './AsteroidsScene';
import FitnessGraph from '../NeatArena/FitnessGraph';
import { useEvolutionWorker } from './useEvolutionWorker';
import ConfigPanel from './ConfigPanel';
import './Asteroids.css';
export default function AsteroidsApp() {
const { isTraining, stats, fitnessHistory, bestGenome, toggleTraining, handleReset } = useEvolutionWorker();
const phaserContainerRef = useRef<HTMLDivElement>(null);
const phaserGameRef = useRef<Phaser.Game | null>(null);
// Phaser Initialization
useEffect(() => {
if (!phaserContainerRef.current) return;
const game = createAsteroidsViewer(phaserContainerRef.current);
phaserGameRef.current = game;
return () => {
game.destroy(true);
phaserGameRef.current = null;
};
}, []);
// Exhibition Loop
useEffect(() => {
const interval = setInterval(() => {
if (!phaserGameRef.current) return;
const scene = getAsteroidsScene(phaserGameRef.current);
if (!scene) return;
// Start new match if game over and we have a genome
const sceneAny = scene as any;
if (bestGenome && (!sceneAny.sim || sceneAny.sim.isGameOver)) {
scene.startMatch(bestGenome, stats.generation);
}
}, 100);
return () => clearInterval(interval);
}, [bestGenome, stats.generation]);
return (
<AppContainer title="Asteroids AI (Dense NN)">
<div className="asteroids-app-layout">
<div className="top-bar">
<div className="controls-section">
<button className={`btn-toggle ${isTraining ? 'active' : ''}`} onClick={toggleTraining}>
{isTraining ? '⏸ Pause' : '▶ Start Evolution'}
</button>
<button className="btn-toggle btn-reset" onClick={handleReset}>
🔄 Reset
</button>
</div>
<div className="stats-section">
<StatCard label="Generation" value={stats.generation} />
<StatCard label="Best Fit" value={stats.maxFitness.toFixed(2)} highlight />
<StatCard label="Avg Fit" value={stats.avgFitness.toFixed(2)} />
</div>
</div>
<ConfigPanel />
<div className="graph-panel">
<FitnessGraph history={fitnessHistory} />
</div>
<div className="vis-column">
<div className="main-view" ref={phaserContainerRef} />
</div>
</div>
</AppContainer>
);
}
function StatCard({ label, value, highlight = false }: { label: string, value: string | number, highlight?: boolean }) {
return (
<div className="stat-card">
<div className="stat-label">{label}</div>
<div className={`stat-value ${highlight ? 'highlight' : ''}`}>{value}</div>
</div>
);
}

View File

@@ -0,0 +1,288 @@
import Phaser from 'phaser';
import { AsteroidsSimulation, WORLD_WIDTH, WORLD_HEIGHT } from './AsteroidsSimulation';
import { DenseNetwork } from './DenseNetwork';
import { CONFIG, getLayerSizes } from './config';
export class AsteroidsScene extends Phaser.Scene {
private sim: AsteroidsSimulation | null = null;
private network: DenseNetwork | null = null;
private generation = 0;
// Graphics
private shipGraphics!: Phaser.GameObjects.Graphics;
private asteroidGraphics!: Phaser.GameObjects.Graphics;
private bulletGraphics!: Phaser.GameObjects.Graphics;
private debugGraphics!: Phaser.GameObjects.Graphics;
// Particle emitters
private thrusterEmitter!: Phaser.GameObjects.Particles.ParticleEmitter;
// HUD
private scoreText!: Phaser.GameObjects.Text;
private generationText!: Phaser.GameObjects.Text;
private showDebug = CONFIG.SHOW_RAYCASTS;
constructor() {
super({ key: 'AsteroidsScene' });
}
preload() {
// Create particle texture using graphics
const graphics = this.make.graphics({});
graphics.fillStyle(0xffffff, 1);
graphics.fillCircle(8, 8, 8);
graphics.generateTexture('particle', 16, 16);
graphics.destroy();
}
create() {
// Background
this.cameras.main.setBackgroundColor('#0a0a15');
// Graphics layers
this.debugGraphics = this.add.graphics();
this.asteroidGraphics = this.add.graphics();
this.bulletGraphics = this.add.graphics();
this.shipGraphics = this.add.graphics();
// Particle system for thrusters
this.thrusterEmitter = this.add.particles(0, 0, 'particle', {
speed: { min: 20, max: 50 },
scale: { start: 0.6, end: 0 },
alpha: { start: 0.8, end: 0 },
lifespan: 300,
blendMode: 'ADD',
tint: [0x4488ff, 0x88ccff, 0xffffff],
frequency: 30,
emitting: false
});
// HUD
this.scoreText = this.add.text(10, 10, 'Score: 0', {
fontSize: '20px',
color: '#ffffff',
fontFamily: 'monospace'
});
this.generationText = this.add.text(10, 40, 'Gen: 0', {
fontSize: '16px',
color: '#aaaaaa',
fontFamily: 'monospace'
});
}
update() {
if (!this.sim || !this.network) return;
// Get AI decision
const inputs = this.sim.getObservation();
const outputs = this.network.predict(inputs);
// Update simulation
const isRunning = this.sim.update(outputs);
// Render
this.render();
// Update HUD
this.scoreText.setText(`Score: ${this.sim.score}`);
this.generationText.setText(`Gen: ${this.generation} | Time: ${this.sim.timeSteps}`);
// Thruster particles
if (outputs[1] > 0.1) { // Thrust active
const angle = this.sim.ship.angle;
const offset = 15;
const pos = {
x: this.sim.ship.position.x - Math.cos(angle) * offset,
y: this.sim.ship.position.y - Math.sin(angle) * offset
};
this.thrusterEmitter.setPosition(pos.x, pos.y);
this.thrusterEmitter.setAngle(Phaser.Math.RadToDeg(angle + Math.PI));
this.thrusterEmitter.emitting = true;
} else {
this.thrusterEmitter.emitting = false;
}
// Game over - create explosion
if (!isRunning && this.sim.isGameOver) {
this.createExplosion(this.sim.ship.position.x, this.sim.ship.position.y, 0xff4444, 30);
}
}
private render() {
if (!this.sim) return;
// Clear graphics
this.shipGraphics.clear();
this.asteroidGraphics.clear();
this.bulletGraphics.clear();
this.debugGraphics.clear();
// Draw ship
this.drawShip();
// Draw asteroids
this.drawAsteroids();
// Draw bullets
this.drawBullets();
// Draw debug info
if (this.showDebug) {
this.drawDebug();
}
}
private drawShip() {
if (!this.sim) return;
const { position, angle } = this.sim.ship;
this.shipGraphics.lineStyle(2, 0xffffff, 1);
this.shipGraphics.fillStyle(0x4488ff, 0.3);
// Triangle pointing in direction of angle
const size = 15;
const points = [
{ x: Math.cos(angle) * size, y: Math.sin(angle) * size },
{ x: Math.cos(angle + 2.5) * size * 0.6, y: Math.sin(angle + 2.5) * size * 0.6 },
{ x: Math.cos(angle - 2.5) * size * 0.6, y: Math.sin(angle - 2.5) * size * 0.6 }
];
this.shipGraphics.beginPath();
this.shipGraphics.moveTo(position.x + points[0].x, position.y + points[0].y);
this.shipGraphics.lineTo(position.x + points[1].x, position.y + points[1].y);
this.shipGraphics.lineTo(position.x + points[2].x, position.y + points[2].y);
this.shipGraphics.closePath();
this.shipGraphics.fillPath();
this.shipGraphics.strokePath();
}
private drawAsteroids() {
if (!this.sim) return;
for (const asteroid of this.sim.asteroids) {
const vertices = asteroid.body.vertices;
// Check if this asteroid is detected by raycasts
const isDetected = this.sim.detectedAsteroids.has(asteroid.body);
// Color based on detection status and size
let color: number;
let fillAlpha: number;
if (isDetected) {
// Detected asteroids are highlighted in orange
color = 0xff8800;
fillAlpha = 0.4;
} else {
// Undetected asteroids are gray
color = asteroid.size === 'large' ? 0x888888 :
asteroid.size === 'medium' ? 0x999999 : 0xaaaaaa;
fillAlpha = 0.2;
}
this.asteroidGraphics.lineStyle(2, color, 1);
this.asteroidGraphics.fillStyle(color, fillAlpha);
this.asteroidGraphics.beginPath();
this.asteroidGraphics.moveTo(vertices[0].x, vertices[0].y);
for (let i = 1; i < vertices.length; i++) {
this.asteroidGraphics.lineTo(vertices[i].x, vertices[i].y);
}
this.asteroidGraphics.closePath();
this.asteroidGraphics.fillPath();
this.asteroidGraphics.strokePath();
}
}
private drawBullets() {
if (!this.sim) return;
this.bulletGraphics.fillStyle(0xffff00, 1);
for (const bullet of this.sim.bullets) {
const { position } = bullet.body;
this.bulletGraphics.fillCircle(position.x, position.y, 3);
}
}
private drawDebug() {
if (!this.sim) return;
// Draw raycasts using actual raycast data from simulation
const { position } = this.sim.ship;
for (const raycast of this.sim.lastRaycasts) {
const endX = position.x + Math.cos(raycast.angle) * raycast.distance * CONFIG.RAYCAST_LENGTH_MULTIPLIER;
const endY = position.y + Math.sin(raycast.angle) * raycast.distance * CONFIG.RAYCAST_LENGTH_MULTIPLIER;
this.debugGraphics.lineStyle(1, CONFIG.RAYCAST_COLOR, CONFIG.RAYCAST_ALPHA);
this.debugGraphics.lineBetween(position.x, position.y, endX, endY);
}
}
private createExplosion(x: number, y: number, tint: number, count: number) {
const emitter = this.add.particles(x, y, 'particle', {
speed: { min: 50, max: 200 },
scale: { start: 1, end: 0 },
alpha: { start: 1, end: 0 },
lifespan: 600,
blendMode: 'ADD',
tint: [tint, 0xff8800, 0xffff00],
quantity: count,
emitting: false
});
emitter.explode(count);
// Clean up after animation
this.time.delayedCall(1000, () => {
emitter.destroy();
});
}
public startMatch(genomeData: { weights: number[] }, generation: number) {
this.generation = generation;
// Create new simulation
this.sim = new AsteroidsSimulation(generation);
// Create network from genome using config
const weights = new Float32Array(genomeData.weights);
this.network = new DenseNetwork(getLayerSizes(), weights);
// Stop thruster particles
this.thrusterEmitter.emitting = false;
}
public toggleDebug() {
this.showDebug = !this.showDebug;
}
}
export function createAsteroidsViewer(parent: HTMLElement): Phaser.Game {
const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
width: WORLD_WIDTH,
height: WORLD_HEIGHT,
parent: parent,
scene: AsteroidsScene,
physics: {
default: 'matter',
matter: {
gravity: { x: 0, y: 0 },
debug: false
}
},
backgroundColor: '#0a0a15'
};
return new Phaser.Game(config);
}
export function getAsteroidsScene(game: Phaser.Game): AsteroidsScene | null {
return game.scene.getScene('AsteroidsScene') as AsteroidsScene;
}

View File

@@ -0,0 +1,420 @@
import Matter from 'matter-js';
import { CONFIG } from './config';
export const WORLD_WIDTH = CONFIG.WORLD_WIDTH;
export const WORLD_HEIGHT = CONFIG.WORLD_HEIGHT;
type AsteroidSize = 'large' | 'medium' | 'small';
interface Bullet {
body: Matter.Body;
lifetime: number;
}
interface Asteroid {
body: Matter.Body;
size: AsteroidSize;
}
export class AsteroidsSimulation {
public engine: Matter.Engine;
public ship!: Matter.Body;
public bullets: Bullet[] = [];
public asteroids: Asteroid[] = [];
public lastRaycasts: { angle: number; distance: number }[] = [];
public detectedAsteroids: Set<Matter.Body> = new Set(); // Track which asteroids are detected
public isGameOver = false;
public score = 0;
public timeSteps = 0;
public readonly maxTimeSteps = CONFIG.MAX_TIME_STEPS;
public shotsFired = 0;
public shotsHit = 0;
public asteroidsDestroyed = 0;
public totalDistanceTraveled = 0; // Track movement
private lastShootTime = 0;
private readonly shootCooldown = CONFIG.SHOOT_COOLDOWN;
private readonly seed: number;
private lastPosition = { x: 0, y: 0 }; // For distance tracking
constructor(seed: number = 0) {
this.seed = seed;
this.engine = Matter.Engine.create({ enableSleeping: false });
this.engine.gravity.y = 0; // Space has no gravity
// Custom PRNG
let s = seed;
const random = () => {
s = (s * 9301 + 49297) % 233280;
return s / 233280;
};
this.setupWorld(random);
Matter.Events.on(this.engine, 'collisionStart', (e) => this.handleCollisions(e));
}
private setupWorld(random: () => number) {
// Create ship at center
const shipVertices = [
{ x: 0, y: -CONFIG.SHIP_SIZE },
{ x: -CONFIG.SHIP_SIZE * 0.6, y: CONFIG.SHIP_SIZE },
{ x: CONFIG.SHIP_SIZE * 0.6, y: CONFIG.SHIP_SIZE }
];
this.ship = Matter.Bodies.fromVertices(
WORLD_WIDTH / 2,
WORLD_HEIGHT / 2,
[shipVertices],
{
friction: 0,
frictionAir: CONFIG.SHIP_FRICTION_AIR,
restitution: 0,
label: 'ship',
angle: -Math.PI / 2
}
);
Matter.Body.setMass(this.ship, CONFIG.SHIP_MASS);
Matter.World.add(this.engine.world, [this.ship]);
// Spawn initial asteroids
this.spawnInitialAsteroids(random);
}
private spawnInitialAsteroids(random: () => number) {
const numAsteroids = CONFIG.ASTEROID_INITIAL_COUNT;
for (let i = 0; i < numAsteroids; i++) {
// Spawn at edges, away from center
const angle = (i / numAsteroids) * Math.PI * 2;
const distance = Math.max(WORLD_WIDTH, WORLD_HEIGHT) / 2 + CONFIG.ASTEROID_SPAWN_DISTANCE;
const x = WORLD_WIDTH / 2 + Math.cos(angle) * distance;
const y = WORLD_HEIGHT / 2 + Math.sin(angle) * distance;
this.spawnAsteroid(x, y, 'large', random);
}
}
private spawnAsteroid(x: number, y: number, size: AsteroidSize, random: () => number) {
const radius = size === 'large' ? CONFIG.ASTEROID_SIZE_LARGE :
size === 'medium' ? CONFIG.ASTEROID_SIZE_MEDIUM :
CONFIG.ASTEROID_SIZE_SMALL;
// Create irregular asteroid shape
const sides = 8 + Math.floor(random() * 4);
const vertices = [];
for (let i = 0; i < sides; i++) {
const angle = (i / sides) * Math.PI * 2;
const r = radius * (0.7 + random() * 0.3);
vertices.push({
x: Math.cos(angle) * r,
y: Math.sin(angle) * r
});
}
const body = Matter.Bodies.fromVertices(x, y, [vertices], {
friction: 0,
frictionAir: 0,
restitution: 1,
label: 'asteroid'
});
// Asteroid speeds from config
const speed = size === 'large' ? CONFIG.ASTEROID_SPEED_LARGE :
size === 'medium' ? CONFIG.ASTEROID_SPEED_MEDIUM :
CONFIG.ASTEROID_SPEED_SMALL;
// ANTI-CAMPING: Make asteroids move AWAY from center
// This forces the AI to chase them instead of camping
const centerX = WORLD_WIDTH / 2;
const centerY = WORLD_HEIGHT / 2;
const angleFromCenter = Math.atan2(y - centerY, x - centerX);
// Add some randomness but bias away from center
const randomOffset = (random() - 0.5) * Math.PI * 0.5; // ±45 degrees
const vAngle = angleFromCenter + randomOffset;
Matter.Body.setVelocity(body, {
x: Math.cos(vAngle) * speed,
y: Math.sin(vAngle) * speed
});
Matter.Body.setAngularVelocity(body, (random() - 0.5) * CONFIG.ASTEROID_ANGULAR_VELOCITY);
this.asteroids.push({ body, size });
Matter.World.add(this.engine.world, [body]);
}
private handleCollisions(event: Matter.IEventCollision<Matter.Engine>) {
if (this.isGameOver) return;
event.pairs.forEach(pair => {
const { bodyA, bodyB } = pair;
// Ship hit asteroid
if ((bodyA === this.ship && bodyB.label === 'asteroid') ||
(bodyB === this.ship && bodyA.label === 'asteroid')) {
this.isGameOver = true;
return;
}
// Bullet hit asteroid
const bullet = this.bullets.find(b => b.body === bodyA || b.body === bodyB);
const asteroidHit = this.asteroids.find(a => a.body === bodyA || a.body === bodyB);
if (bullet && asteroidHit) {
this.handleAsteroidHit(asteroidHit, bullet);
}
});
}
private handleAsteroidHit(asteroid: Asteroid, bullet: Bullet) {
this.shotsHit++;
this.asteroidsDestroyed++;
// Remove bullet
Matter.World.remove(this.engine.world, bullet.body);
this.bullets = this.bullets.filter(b => b !== bullet);
// Score based on size from config
const points = asteroid.size === 'large' ? CONFIG.SCORE_LARGE :
asteroid.size === 'medium' ? CONFIG.SCORE_MEDIUM :
CONFIG.SCORE_SMALL;
this.score += points;
// Split asteroid
const pos = asteroid.body.position;
Matter.World.remove(this.engine.world, asteroid.body);
this.asteroids = this.asteroids.filter(a => a !== asteroid);
// Custom PRNG for splitting
let s = this.seed + this.timeSteps;
const random = () => {
s = (s * 9301 + 49297) % 233280;
return s / 233280;
};
if (asteroid.size === 'large') {
// Split into 3 medium
for (let i = 0; i < 3; i++) {
const angle = (i / 3) * Math.PI * 2;
const offset = 30;
this.spawnAsteroid(
pos.x + Math.cos(angle) * offset,
pos.y + Math.sin(angle) * offset,
'medium',
random
);
}
} else if (asteroid.size === 'medium') {
// Split into 2 small
for (let i = 0; i < 2; i++) {
const angle = (i / 2) * Math.PI * 2;
const offset = 20;
this.spawnAsteroid(
pos.x + Math.cos(angle) * offset,
pos.y + Math.sin(angle) * offset,
'small',
random
);
}
}
// Small asteroids just disappear
}
public update(actions: number[]): boolean {
if (this.isGameOver) return false;
// Track distance traveled
const dx = this.ship.position.x - this.lastPosition.x;
const dy = this.ship.position.y - this.lastPosition.y;
this.totalDistanceTraveled += Math.sqrt(dx * dx + dy * dy);
this.lastPosition = { x: this.ship.position.x, y: this.ship.position.y };
// Apply AI controls.maxTimeSteps) {
if (++this.timeSteps > this.maxTimeSteps) {
this.isGameOver = true;
return false;
}
// Actions: [rotation (-1 to 1), thrust (0 to 1), shoot (0 to 1)]
const rotation = actions[0];
const thrust = Math.max(0, Math.min(1, (actions[1] + 1) / 2));
const shoot = actions[2] > 0;
this.applyControls(rotation, thrust, shoot);
this.updateBullets();
this.wrapBodies();
Matter.Engine.update(this.engine, 1000 / 60);
// Check if all asteroids destroyed (respawn)
if (this.asteroids.length === 0) {
let s = this.seed + this.timeSteps;
const random = () => {
s = (s * 9301 + 49297) % 233280;
return s / 233280;
};
this.spawnInitialAsteroids(random);
}
return !this.isGameOver;
}
private applyControls(rotation: number, thrust: number, shoot: boolean) {
// Rotation from config
Matter.Body.setAngularVelocity(this.ship, rotation * CONFIG.ROTATION_SPEED);
// Thrust from config
if (thrust > 0.1) {
const angle = this.ship.angle;
Matter.Body.applyForce(this.ship, this.ship.position, {
x: Math.cos(angle) * CONFIG.THRUST_FORCE * thrust,
y: Math.sin(angle) * CONFIG.THRUST_FORCE * thrust
});
}
// Shoot
if (shoot && this.timeSteps - this.lastShootTime >= this.shootCooldown) {
this.shootBullet();
this.lastShootTime = this.timeSteps;
}
}
private shootBullet() {
this.shotsFired++;
const angle = this.ship.angle;
const offset = CONFIG.SHIP_SIZE;
const pos = {
x: this.ship.position.x + Math.cos(angle) * offset,
y: this.ship.position.y + Math.sin(angle) * offset
};
const bullet = Matter.Bodies.circle(pos.x, pos.y, CONFIG.BULLET_RADIUS, {
friction: 0,
frictionAir: 0,
restitution: 0,
label: 'bullet',
isSensor: false
});
const velocity = {
x: this.ship.velocity.x + Math.cos(angle) * CONFIG.BULLET_SPEED,
y: this.ship.velocity.y + Math.sin(angle) * CONFIG.BULLET_SPEED
};
Matter.Body.setVelocity(bullet, velocity);
this.bullets.push({ body: bullet, lifetime: CONFIG.BULLET_LIFETIME });
Matter.World.add(this.engine.world, [bullet]);
}
private updateBullets() {
for (let i = this.bullets.length - 1; i >= 0; i--) {
this.bullets[i].lifetime--;
if (this.bullets[i].lifetime <= 0) {
Matter.World.remove(this.engine.world, this.bullets[i].body);
this.bullets.splice(i, 1);
}
}
}
private wrapBodies() {
const wrap = (body: Matter.Body) => {
const pos = body.position;
let wrapped = false;
if (pos.x < 0) {
Matter.Body.setPosition(body, { x: WORLD_WIDTH, y: pos.y });
wrapped = true;
} else if (pos.x > WORLD_WIDTH) {
Matter.Body.setPosition(body, { x: 0, y: pos.y });
wrapped = true;
}
if (pos.y < 0) {
Matter.Body.setPosition(body, { x: pos.x, y: WORLD_HEIGHT });
wrapped = true;
} else if (pos.y > WORLD_HEIGHT) {
Matter.Body.setPosition(body, { x: pos.x, y: 0 });
wrapped = true;
}
return wrapped;
};
wrap(this.ship);
this.asteroids.forEach(a => wrap(a.body));
this.bullets.forEach(b => wrap(b.body));
}
public getObservation(): number[] {
// Raycasts for asteroid detection (configurable count)
const raycasts = this.getRaycasts();
// Ship state
const { velocity, angularVelocity, angle } = this.ship;
return [
...raycasts, // distance to nearest asteroid in each direction
velocity.x / 10,
velocity.y / 10,
angularVelocity / 0.5,
Math.sin(angle), // Encode angle as sin/cos for continuity
Math.cos(angle)
];
}
private getRaycasts(): number[] {
const numRays = CONFIG.NUM_RAYCASTS;
const maxDistance = Math.max(WORLD_WIDTH, WORLD_HEIGHT);
const results: number[] = [];
this.lastRaycasts = []; // Store for visualization
this.detectedAsteroids.clear(); // Clear previous detections
for (let i = 0; i < numRays; i++) {
const rayAngle = this.ship.angle + (i / numRays) * Math.PI * 2;
let minDist = 1.0; // Normalized (1.0 = no asteroid detected)
let closestAsteroid: Matter.Body | null = null;
// Check distance to each asteroid
for (const asteroid of this.asteroids) {
const dx = asteroid.body.position.x - this.ship.position.x;
const dy = asteroid.body.position.y - this.ship.position.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// Check if asteroid is in this ray's direction
const asteroidAngle = Math.atan2(dy, dx);
let angleDiff = asteroidAngle - rayAngle;
// Normalize angle difference to [-PI, PI]
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
// If within ray cone
if (Math.abs(angleDiff) < Math.PI / numRays) {
const normalizedDist = Math.min(1.0, dist / maxDistance);
if (normalizedDist < minDist) {
minDist = normalizedDist;
closestAsteroid = asteroid.body;
}
}
}
// Mark detected asteroid
if (closestAsteroid) {
this.detectedAsteroids.add(closestAsteroid);
}
// Store raycast data for visualization
this.lastRaycasts.push({
angle: rayAngle,
distance: minDist * maxDistance
});
results.push(minDist);
}
return results;
}
}

View File

@@ -0,0 +1,127 @@
.config-panel {
margin-bottom: 20px;
}
.config-toggle {
width: 100%;
padding: 12px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-align: left;
display: flex;
align-items: center;
gap: 8px;
}
.config-toggle:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.config-content {
margin-top: 15px;
padding: 20px;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
max-height: 600px;
overflow-y: auto;
}
.config-section {
margin-bottom: 25px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.config-section:last-of-type {
border-bottom: none;
margin-bottom: 0;
}
.config-section h3 {
margin: 0 0 15px 0;
color: #fff;
font-size: 18px;
font-weight: 600;
}
.config-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
gap: 15px;
}
.config-item label {
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
flex: 1;
min-width: 150px;
}
.config-item input[type="number"] {
width: 100px;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: white;
font-size: 14px;
transition: all 0.2s ease;
}
.config-item input[type="number"]:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
.config-item input[type="number"]:focus {
outline: none;
background: rgba(255, 255, 255, 0.2);
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
}
.config-value {
color: rgba(255, 255, 255, 0.6);
font-family: 'Courier New', monospace;
font-size: 13px;
}
.config-note {
margin-top: 20px;
padding: 12px;
background: rgba(255, 193, 7, 0.1);
border-left: 3px solid #ffc107;
border-radius: 4px;
color: #ffc107;
font-size: 13px;
}
/* Scrollbar styling */
.config-content::-webkit-scrollbar {
width: 8px;
}
.config-content::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.config-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
.config-content::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}

View File

@@ -0,0 +1,265 @@
import { useState } from 'react';
import { CONFIG, updateConfig } from './config';
import './ConfigPanel.css';
interface ConfigPanelProps {
onConfigChange?: () => void;
}
export default function ConfigPanel({ onConfigChange }: ConfigPanelProps) {
const [isOpen, setIsOpen] = useState(false);
const [, forceUpdate] = useState({});
const handleChange = (key: keyof typeof CONFIG, value: number | number[]) => {
updateConfig({ [key]: value });
forceUpdate({}); // Force re-render to show updated values
if (onConfigChange) {
onConfigChange();
}
};
return (
<div className="config-panel">
<button
className="config-toggle"
onClick={() => setIsOpen(!isOpen)}
>
Configuration {isOpen ? '▼' : '▶'}
</button>
{isOpen && (
<div className="config-content">
<div className="config-section">
<h3>🧠 Neural Network</h3>
<div className="config-item">
<label>Raycasts:</label>
<input
type="number"
value={CONFIG.NUM_RAYCASTS}
onChange={(e) => handleChange('NUM_RAYCASTS', parseInt(e.target.value))}
min="4" max="32" step="4"
/>
</div>
<div className="config-item">
<label>Hidden Layers:</label>
<span className="config-value">[{CONFIG.HIDDEN_LAYERS.join(', ')}]</span>
</div>
</div>
<div className="config-section">
<h3>🧬 Genetic Algorithm</h3>
<div className="config-item">
<label>Population Size:</label>
<input
type="number"
value={CONFIG.POPULATION_SIZE}
onChange={(e) => handleChange('POPULATION_SIZE', parseInt(e.target.value))}
min="10" max="500" step="10"
/>
</div>
<div className="config-item">
<label>Mutation Rate:</label>
<input
type="number"
value={CONFIG.MUTATION_RATE}
onChange={(e) => handleChange('MUTATION_RATE', parseFloat(e.target.value))}
min="0" max="1" step="0.05"
/>
</div>
<div className="config-item">
<label>Mutation Scale:</label>
<input
type="number"
value={CONFIG.MUTATION_SCALE}
onChange={(e) => handleChange('MUTATION_SCALE', parseFloat(e.target.value))}
min="0" max="2" step="0.1"
/>
</div>
<div className="config-item">
<label>Elite Count:</label>
<input
type="number"
value={CONFIG.ELITE_COUNT}
onChange={(e) => handleChange('ELITE_COUNT', parseInt(e.target.value))}
min="0" max="20"
/>
</div>
<div className="config-item">
<label>Scenarios per Genome:</label>
<input
type="number"
value={CONFIG.SCENARIOS_PER_GENOME}
onChange={(e) => handleChange('SCENARIOS_PER_GENOME', parseInt(e.target.value))}
min="1" max="10"
/>
</div>
</div>
<div className="config-section">
<h3>🚀 Ship Properties</h3>
<div className="config-item">
<label>Ship Size:</label>
<input
type="number"
value={CONFIG.SHIP_SIZE}
onChange={(e) => handleChange('SHIP_SIZE', parseInt(e.target.value))}
min="5" max="30"
/>
</div>
<div className="config-item">
<label>Rotation Speed:</label>
<input
type="number"
value={CONFIG.ROTATION_SPEED}
onChange={(e) => handleChange('ROTATION_SPEED', parseFloat(e.target.value))}
min="0.01" max="0.5" step="0.01"
/>
</div>
<div className="config-item">
<label>Thrust Force:</label>
<input
type="number"
value={CONFIG.THRUST_FORCE}
onChange={(e) => handleChange('THRUST_FORCE', parseFloat(e.target.value))}
min="0.0001" max="0.002" step="0.0001"
/>
</div>
</div>
<div className="config-section">
<h3>💥 Bullet Properties</h3>
<div className="config-item">
<label>Bullet Speed:</label>
<input
type="number"
value={CONFIG.BULLET_SPEED}
onChange={(e) => handleChange('BULLET_SPEED', parseInt(e.target.value))}
min="1" max="30"
/>
</div>
<div className="config-item">
<label>Bullet Lifetime (frames):</label>
<input
type="number"
value={CONFIG.BULLET_LIFETIME}
onChange={(e) => handleChange('BULLET_LIFETIME', parseInt(e.target.value))}
min="10" max="120" step="10"
/>
</div>
<div className="config-item">
<label>Shoot Cooldown (frames):</label>
<input
type="number"
value={CONFIG.SHOOT_COOLDOWN}
onChange={(e) => handleChange('SHOOT_COOLDOWN', parseInt(e.target.value))}
min="1" max="30"
/>
</div>
</div>
<div className="config-section">
<h3> Asteroid Properties</h3>
<div className="config-item">
<label>Initial Count:</label>
<input
type="number"
value={CONFIG.ASTEROID_INITIAL_COUNT}
onChange={(e) => handleChange('ASTEROID_INITIAL_COUNT', parseInt(e.target.value))}
min="1" max="10"
/>
</div>
<div className="config-item">
<label>Large Size:</label>
<input
type="number"
value={CONFIG.ASTEROID_SIZE_LARGE}
onChange={(e) => handleChange('ASTEROID_SIZE_LARGE', parseInt(e.target.value))}
min="20" max="100" step="5"
/>
</div>
<div className="config-item">
<label>Medium Size:</label>
<input
type="number"
value={CONFIG.ASTEROID_SIZE_MEDIUM}
onChange={(e) => handleChange('ASTEROID_SIZE_MEDIUM', parseInt(e.target.value))}
min="10" max="60" step="5"
/>
</div>
<div className="config-item">
<label>Small Size:</label>
<input
type="number"
value={CONFIG.ASTEROID_SIZE_SMALL}
onChange={(e) => handleChange('ASTEROID_SIZE_SMALL', parseInt(e.target.value))}
min="5" max="40" step="2"
/>
</div>
<div className="config-item">
<label>Large Speed:</label>
<input
type="number"
value={CONFIG.ASTEROID_SPEED_LARGE}
onChange={(e) => handleChange('ASTEROID_SPEED_LARGE', parseFloat(e.target.value))}
min="0.1" max="3" step="0.1"
/>
</div>
<div className="config-item">
<label>Medium Speed:</label>
<input
type="number"
value={CONFIG.ASTEROID_SPEED_MEDIUM}
onChange={(e) => handleChange('ASTEROID_SPEED_MEDIUM', parseFloat(e.target.value))}
min="0.1" max="4" step="0.1"
/>
</div>
<div className="config-item">
<label>Small Speed:</label>
<input
type="number"
value={CONFIG.ASTEROID_SPEED_SMALL}
onChange={(e) => handleChange('ASTEROID_SPEED_SMALL', parseFloat(e.target.value))}
min="0.1" max="5" step="0.1"
/>
</div>
</div>
<div className="config-section">
<h3>🎯 Scoring</h3>
<div className="config-item">
<label>Large Asteroid:</label>
<input
type="number"
value={CONFIG.SCORE_LARGE}
onChange={(e) => handleChange('SCORE_LARGE', parseInt(e.target.value))}
min="1" max="100" step="5"
/>
</div>
<div className="config-item">
<label>Medium Asteroid:</label>
<input
type="number"
value={CONFIG.SCORE_MEDIUM}
onChange={(e) => handleChange('SCORE_MEDIUM', parseInt(e.target.value))}
min="1" max="200" step="10"
/>
</div>
<div className="config-item">
<label>Small Asteroid:</label>
<input
type="number"
value={CONFIG.SCORE_SMALL}
onChange={(e) => handleChange('SCORE_SMALL', parseInt(e.target.value))}
min="1" max="300" step="10"
/>
</div>
</div>
<div className="config-note">
Changes apply immediately! Click Reset to restart training with new config.
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,69 @@
export class DenseNetwork {
private weights: Float32Array;
private layerSizes: number[];
constructor(layerSizes: number[], weights?: Float32Array) {
this.layerSizes = layerSizes;
const totalWeights = this.calculateTotalWeights(layerSizes);
if (weights) {
if (weights.length !== totalWeights) {
throw new Error(`Expected ${totalWeights} weights, got ${weights.length}`);
}
this.weights = weights;
} else {
this.weights = new Float32Array(totalWeights);
this.randomize();
}
}
private calculateTotalWeights(sizes: number[]): number {
let total = 0;
for (let i = 0; i < sizes.length - 1; i++) {
// Weights + Bias for each next-layer neuron
// (Input + 1) * Output
total += (sizes[i] + 1) * sizes[i + 1];
}
return total;
}
private randomize() {
for (let i = 0; i < this.weights.length; i++) {
this.weights[i] = (Math.random() * 2 - 1); // -1 to 1 simplified initialization
}
}
public predict(inputs: number[]): number[] {
let currentValues = inputs;
let weightIndex = 0;
for (let i = 0; i < this.layerSizes.length - 1; i++) {
const inputSize = this.layerSizes[i];
const outputSize = this.layerSizes[i + 1];
const nextValues = new Array(outputSize).fill(0);
for (let out = 0; out < outputSize; out++) {
let sum = 0;
// Weights
for (let inp = 0; inp < inputSize; inp++) {
sum += currentValues[inp] * this.weights[weightIndex++];
}
// Bias (last weight for this neuron)
sum += this.weights[weightIndex++];
// Activation
// Output layer (last layer) -> Tanh for action outputs (-1 to 1)
// Hidden layers -> ReLU or Tanh. Let's use Tanh everywhere for simplicity/stability in evolution.
nextValues[out] = Math.tanh(sum);
}
currentValues = nextValues;
}
return currentValues;
}
public getWeights(): Float32Array {
return this.weights;
}
}

View File

@@ -0,0 +1,118 @@
import { DenseNetwork } from './DenseNetwork';
export interface Genome {
weights: Float32Array;
fitness: number;
}
export class GeneticAlgo {
private population: Genome[] = [];
private popSize: number;
private mutationRate: number;
private mutationScale: number;
public generation = 0;
// Track best ever
public bestGenome: Genome | null = null;
public bestFitness = -Infinity;
constructor(
popSize: number,
layerSizes: number[],
mutationRate = 0.1, // Chance per weight (increased for diversity)
mutationScale = 0.5 // Gaussian/random perturbation amount (increased)
) {
this.popSize = popSize;
this.mutationRate = mutationRate;
this.mutationScale = mutationScale;
// Init population
for (let i = 0; i < popSize; i++) {
const net = new DenseNetwork(layerSizes);
this.population.push({
weights: net.getWeights(), // Actually reference, careful on mutation, should clone on breed
fitness: 0
});
}
}
public getPopulation() {
return this.population;
}
public evolve() {
// 1. Sort by fitness
this.population.sort((a, b) => b.fitness - a.fitness);
// Update best
if (this.population[0].fitness > this.bestFitness) {
this.bestFitness = this.population[0].fitness;
// Clone best weights to save safe
this.bestGenome = {
weights: new Float32Array(this.population[0].weights),
fitness: this.population[0].fitness
};
}
const newPop: Genome[] = [];
// 2. Elitism (Keep top 5)
const ELITE_COUNT = 5;
for (let i = 0; i < ELITE_COUNT; i++) {
newPop.push({
weights: new Float32Array(this.population[i].weights),
fitness: 0
});
}
// 3. Breed rest
while (newPop.length < this.popSize) {
// Tournament Select
const p1 = this.tournamentSelect();
const p2 = this.tournamentSelect();
// Crossover
const childWeights = this.crossover(p1.weights, p2.weights);
// Mutate
this.mutate(childWeights);
newPop.push({
weights: childWeights,
fitness: 0
});
}
this.population = newPop;
this.generation++;
}
private tournamentSelect(): Genome {
const pool = 5;
let best = this.population[Math.floor(Math.random() * this.population.length)];
for (let i = 0; i < pool - 1; i++) {
const cand = this.population[Math.floor(Math.random() * this.population.length)];
if (cand.fitness > best.fitness) best = cand;
}
return best;
}
private crossover(w1: Float32Array, w2: Float32Array): Float32Array {
const child = new Float32Array(w1.length);
// Uniform crossover
for (let i = 0; i < w1.length; i++) {
child[i] = Math.random() < 0.5 ? w1[i] : w2[i];
}
return child;
}
private mutate(weights: Float32Array) {
for (let i = 0; i < weights.length; i++) {
if (Math.random() < this.mutationRate) {
// Add noise
weights[i] += (Math.random() * 2 - 1) * this.mutationScale;
}
}
}
}

View File

@@ -0,0 +1,86 @@
// Configuration for Asteroids AI
// Mutable config that can be updated at runtime
export let CONFIG = {
// Neural Network Architecture
NUM_RAYCASTS: 16,
HIDDEN_LAYERS: [24, 24],
// Genetic Algorithm
POPULATION_SIZE: 150,
MUTATION_RATE: 0.25,
MUTATION_SCALE: 1.0,
ELITE_COUNT: 3,
// Training
SCENARIOS_PER_GENOME: 5,
// World Settings
WORLD_WIDTH: 800,
WORLD_HEIGHT: 600,
MAX_TIME_STEPS: 60 * 60, // 60 seconds
// Ship Properties
SHIP_SIZE: 15,
SHIP_FRICTION_AIR: 0.02,
SHIP_MASS: 1,
ROTATION_SPEED: 0.10,
THRUST_FORCE: 0.0005,
// Bullet Properties
BULLET_SPEED: 12,
BULLET_LIFETIME: 30, // frames
BULLET_RADIUS: 2,
SHOOT_COOLDOWN: 12, // frames between shots
// Asteroid Properties
ASTEROID_SPAWN_DISTANCE: 100,
ASTEROID_INITIAL_COUNT: 4,
ASTEROID_SIZE_LARGE: 50,
ASTEROID_SIZE_MEDIUM: 30,
ASTEROID_SIZE_SMALL: 18,
ASTEROID_SPEED_LARGE: 0.75,
ASTEROID_SPEED_MEDIUM: 1.0,
ASTEROID_SPEED_SMALL: 1.5,
ASTEROID_ANGULAR_VELOCITY: 0.1,
// Scoring
SCORE_LARGE: 20,
SCORE_MEDIUM: 50,
SCORE_SMALL: 100,
// Visualization
SHOW_RAYCASTS: true,
RAYCAST_COLOR: 0x00ff00,
RAYCAST_ALPHA: 0.8,
RAYCAST_LENGTH_MULTIPLIER: 0.8,
};
// Function to update config values
export function updateConfig(updates: Partial<typeof CONFIG>) {
CONFIG = { ...CONFIG, ...updates };
}
// Function to get current config (for reading)
export function getConfig() {
return { ...CONFIG };
}
// Calculate total inputs for neural network
export function getInputCount(): number {
return CONFIG.NUM_RAYCASTS + 5; // raycasts + velocity.x + velocity.y + angularVelocity + sin(angle) + cos(angle)
}
// Calculate total outputs for neural network
export function getOutputCount(): number {
return 3; // rotation, thrust, shoot
}
// Get layer sizes for neural network
export function getLayerSizes(): number[] {
return [
getInputCount(),
...CONFIG.HIDDEN_LAYERS,
getOutputCount()
];
}

View File

@@ -0,0 +1,69 @@
// Quick test to verify the simulation and fitness work correctly
import { AsteroidsSimulation } from './AsteroidsSimulation';
import { calculateFitness } from './fitnessConfig';
import { DenseNetwork } from './DenseNetwork';
import { getLayerSizes } from './config';
console.log('=== ASTEROIDS AI DEBUG TEST ===');
// Test 1: Does the simulation run?
console.log('\n1. Testing basic simulation...');
const sim1 = new AsteroidsSimulation(0);
let steps = 0;
while (!sim1.isGameOver && steps < 100) {
sim1.update([0, 0, 0]); // No actions
steps++;
}
console.log(`Simulation ran for ${steps} steps`);
console.log(`Game over: ${sim1.isGameOver}`);
console.log(`Asteroids: ${sim1.asteroids.length}`);
// Test 2: Does shooting work?
console.log('\n2. Testing shooting...');
const sim2 = new AsteroidsSimulation(0);
for (let i = 0; i < 60; i++) {
sim2.update([0, 0, 1]); // Always shoot
}
console.log(`Shots fired: ${sim2.shotsFired}`);
console.log(`Bullets: ${sim2.bullets.length}`);
// Test 3: Can we destroy asteroids?
console.log('\n3. Testing asteroid destruction...');
const sim3 = new AsteroidsSimulation(0);
for (let i = 0; i < 300; i++) {
// Rotate and shoot
sim3.update([0.5, 0, 1]);
}
console.log(`Asteroids destroyed: ${sim3.asteroidsDestroyed}`);
console.log(`Score: ${sim3.score}`);
console.log(`Fitness: ${calculateFitness(sim3).toFixed(1)}`);
// Test 4: Do network outputs make sense?
console.log('\n4. Testing neural network...');
const network = new DenseNetwork(getLayerSizes());
const testInputs = new Array(getLayerSizes()[0]).fill(0.5);
const outputs = network.predict(testInputs);
console.log(`Network outputs: [${outputs.map(o => o.toFixed(3)).join(', ')}]`);
console.log(`Output range: [${Math.min(...outputs).toFixed(3)}, ${Math.max(...outputs).toFixed(3)}]`);
// Test 5: Fitness for doing nothing vs shooting
console.log('\n5. Comparing fitness strategies...');
const simNothing = new AsteroidsSimulation(0);
for (let i = 0; i < 600; i++) {
if (simNothing.isGameOver) break;
simNothing.update([0, 0, 0]);
}
const fitnessNothing = calculateFitness(simNothing);
const simShooting = new AsteroidsSimulation(0);
for (let i = 0; i < 600; i++) {
if (simShooting.isGameOver) break;
simShooting.update([0, 0, 1]);
}
const fitnessShooting = calculateFitness(simShooting);
console.log(`\nDoing nothing: ${fitnessNothing.toFixed(1)} (survived ${simNothing.timeSteps} steps)`);
console.log(`Always shooting: ${fitnessShooting.toFixed(1)} (survived ${simShooting.timeSteps} steps, ${simShooting.shotsFired} shots, ${simShooting.asteroidsDestroyed} destroyed)`);
console.log(`Fitness difference: ${(fitnessShooting - fitnessNothing).toFixed(1)}`);
console.log('\n=== TEST COMPLETE ===');

View File

@@ -0,0 +1,60 @@
// Test asteroid destruction mechanics
import { AsteroidsSimulation } from './AsteroidsSimulation';
console.log('=== ASTEROID DESTRUCTION TEST ===\n');
// Test: How many hits does it take to destroy asteroids?
const sim = new AsteroidsSimulation(42);
console.log('Initial asteroids:', sim.asteroids.length);
console.log('Initial asteroid sizes:', sim.asteroids.map(a => a.size));
// Fire 100 shots in a circle to hit asteroids
for (let i = 0; i < 200; i++) {
// Rotate and shoot constantly
const rotation = Math.sin(i * 0.1);
sim.update([rotation, 1, 1]); // Rotate, thrust, shoot
if (i % 20 === 0) {
console.log(`\nStep ${i}:`);
console.log(` Asteroids: ${sim.asteroids.length}`);
console.log(` Destroyed: ${sim.asteroidsDestroyed}`);
console.log(` Shots fired: ${sim.shotsFired}`);
console.log(` Shots hit: ${sim.shotsHit}`);
console.log(` Bullets active: ${sim.bullets.length}`);
console.log(` Hit rate: ${sim.shotsFired > 0 ? ((sim.shotsHit / sim.shotsFired) * 100).toFixed(1) : 0}%`);
}
}
console.log('\n=== FINAL RESULTS ===');
console.log(`Total asteroids destroyed: ${sim.asteroidsDestroyed}`);
console.log(`Total shots fired: ${sim.shotsFired}`);
console.log(`Total hits: ${sim.shotsHit}`);
console.log(`Hit rate: ${((sim.shotsHit / sim.shotsFired) * 100).toFixed(1)}%`);
console.log(`Asteroids per hit: ${(sim.asteroidsDestroyed / sim.shotsHit).toFixed(2)}`);
console.log(`\nExpected: 1 hit = 1 asteroid destroyed (they should split, not take multiple hits)`);
// Test 2: Single asteroid, single bullet
console.log('\n=== SINGLE HIT TEST ===');
const sim2 = new AsteroidsSimulation(100);
const initialCount = sim2.asteroids.length;
const initialDestroyed = sim2.asteroidsDestroyed;
// Position ship to face an asteroid and shoot
for (let i = 0; i < 50; i++) {
sim2.update([0, 0, i === 20 ? 1 : 0]); // Shoot once at step 20
}
console.log(`Initial asteroids: ${initialCount}`);
console.log(`Final asteroids: ${sim2.asteroids.length}`);
console.log(`Destroyed: ${sim2.asteroidsDestroyed - initialDestroyed}`);
console.log(`Shots fired: ${sim2.shotsFired}`);
console.log(`Hits: ${sim2.shotsHit}`);
if (sim2.shotsHit > 0 && sim2.asteroidsDestroyed > initialDestroyed) {
console.log('✓ One hit destroys one asteroid (correct!)');
} else if (sim2.shotsHit === 0) {
console.log('⚠ No hits registered (might need better aim)');
} else {
console.log('✗ Hit registered but no destruction (BUG!)');
}

View File

@@ -0,0 +1,43 @@
import { AsteroidsSimulation } from './AsteroidsSimulation';
export function calculateFitness(sim: AsteroidsSimulation): number {
// REDESIGNED: Make MOVEMENT absolutely essential
// 1. Asteroids destroyed (MASSIVE REWARD - this is the main goal)
const destructionScore = sim.asteroidsDestroyed * 2000;
// 2. Score from game (size-based rewards)
const gameScore = sim.score * 10;
// 3. Survival time (MINIMAL - just a tiny base)
const cappedTime = Math.min(sim.timeSteps, 1000);
const survivalScore = cappedTime * 0.5; // Reduced from 1
// 4. Shooting engagement reward
const shootingReward = Math.min(sim.shotsFired, 150) * 5;
// 5. Accuracy multiplier
const accuracy = sim.shotsFired > 0 ? sim.shotsHit / sim.shotsFired : 0;
const accuracyBonus = accuracy * sim.shotsFired * 20;
// 6. Destruction efficiency
const destructionRate = sim.timeSteps > 0 ? sim.asteroidsDestroyed / (sim.timeSteps / 60) : 0;
const efficiencyBonus = destructionRate * 1000;
// 7. MOVEMENT REWARD: ABSOLUTELY ESSENTIAL
// Exponential scaling - more distance = exponentially better
const distanceReward = sim.totalDistanceTraveled * 50; // 50 points per pixel!
const distanceBonus = sim.totalDistanceTraveled > 1000 ?
(sim.totalDistanceTraveled - 1000) * 20 : 0; // Extra bonus for high movement
// 8. Speed bonus
const avgSpeed = Math.sqrt(sim.ship.velocity.x ** 2 + sim.ship.velocity.y ** 2);
const speedBonus = avgSpeed * 300;
// Total fitness
const fitness = destructionScore + gameScore + survivalScore + shootingReward +
accuracyBonus + efficiencyBonus + distanceReward + distanceBonus + speedBonus;
return Math.max(0, fitness);
}

View File

@@ -0,0 +1,119 @@
import { AsteroidsSimulation } from './AsteroidsSimulation';
import { calculateFitness } from './fitnessConfig';
import { GeneticAlgo } from './GeneticAlgo';
import { DenseNetwork } from './DenseNetwork';
import { CONFIG, getLayerSizes } from './config';
// Get architecture from config
const LAYER_SIZES = getLayerSizes();
const POPULATION_SIZE = CONFIG.POPULATION_SIZE;
const SCENARIOS = CONFIG.SCENARIOS_PER_GENOME;
let ga: GeneticAlgo | null = null;
let isRunning = false;
self.onmessage = (e: MessageEvent) => {
const { type } = e.data;
switch (type) {
case 'start':
case 'reset':
console.log('Worker: Initializing Asteroids AI GA');
console.log('Architecture:', LAYER_SIZES);
console.log('Population:', POPULATION_SIZE);
console.log('Mutation Rate:', CONFIG.MUTATION_RATE);
console.log('Mutation Scale:', CONFIG.MUTATION_SCALE);
ga = new GeneticAlgo(POPULATION_SIZE, LAYER_SIZES, CONFIG.MUTATION_RATE, CONFIG.MUTATION_SCALE);
isRunning = true;
runGeneration();
break;
case 'pause':
isRunning = false;
break;
case 'resume':
if (!isRunning) {
isRunning = true;
runGeneration();
}
break;
}
};
function runGeneration() {
if (!ga || !isRunning) return;
const population = ga.getPopulation();
// 1. Evaluate Fitness
for (const genome of population) {
let totalFitness = 0;
const network = new DenseNetwork(LAYER_SIZES, genome.weights);
for (let i = 0; i < SCENARIOS; i++) {
// Seed logic: (Gen * Scenarios) + i
const seed = (ga.generation * SCENARIOS) + i;
const sim = new AsteroidsSimulation(seed);
// Simulation Loop
let step = 0;
while (!sim.isGameOver && step < 5000) {
const inputs = sim.getObservation();
const outputs = network.predict(inputs);
sim.update(outputs);
step++;
}
const fitness = calculateFitness(sim);
totalFitness += fitness;
// Debug logging for first genome of every 10th generation
if (genome === population[0] && ga.generation % 10 === 0 && i === 0) {
console.log(`Gen ${ga.generation} Sample:`, {
timeSteps: sim.timeSteps,
destroyed: sim.asteroidsDestroyed,
shotsFired: sim.shotsFired,
shotsHit: sim.shotsHit,
score: sim.score,
fitness: fitness.toFixed(1)
});
}
}
genome.fitness = totalFitness / SCENARIOS;
}
// Calculate stats before evolution
let sumFitness = 0;
let maxFitness = -Infinity;
let minFitness = Infinity;
for (const genome of population) {
sumFitness += genome.fitness;
if (genome.fitness > maxFitness) maxFitness = genome.fitness;
if (genome.fitness < minFitness) minFitness = genome.fitness;
}
const avgFitness = sumFitness / population.length;
// Log fitness range every 10 generations
if (ga.generation % 10 === 0) {
console.log(`Gen ${ga.generation} Fitness Range: ${minFitness.toFixed(1)} - ${maxFitness.toFixed(1)}, Avg: ${avgFitness.toFixed(1)}`);
}
// Send update to main thread
const bestOfGen = population.find(g => g.fitness === maxFitness) || population[0];
self.postMessage({
type: 'generationParams',
payload: {
generation: ga.generation,
maxFitness: maxFitness,
avgFitness: avgFitness,
bestGenome: { weights: Array.from(bestOfGen.weights) }
}
});
// 2. Evolve to next generation
ga.evolve();
// Schedule next gen
setTimeout(runGeneration, 0);
}

View File

@@ -0,0 +1,82 @@
import { useState, useEffect, useRef, useCallback } from 'react';
export interface Stats {
generation: number;
maxFitness: number;
avgFitness: number;
}
export interface HistoryItem {
generation: number;
best: number;
avg: number;
}
export function useEvolutionWorker() {
const [isTraining, setIsTraining] = useState(false);
const [stats, setStats] = useState<Stats>({ generation: 0, maxFitness: 0, avgFitness: 0 });
const [fitnessHistory, setFitnessHistory] = useState<HistoryItem[]>([]);
const [bestGenome, setBestGenome] = useState<any>(null);
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
const worker = new Worker(new URL('./training.worker.ts', import.meta.url), { type: 'module' });
workerRef.current = worker;
worker.onmessage = (e: MessageEvent<any>) => {
const { type, payload, error } = e.data;
if (type === 'generationParams') {
setStats({
generation: payload.generation,
maxFitness: payload.maxFitness,
avgFitness: payload.avgFitness
});
if (payload.bestGenome) {
setBestGenome(payload.bestGenome);
}
setFitnessHistory(prev => [...prev, {
generation: payload.generation,
best: payload.maxFitness,
avg: payload.avgFitness
}]);
} else if (type === 'error') {
console.error("Worker Error:", error);
setIsTraining(false);
}
};
// Initial reset to setup GA
worker.postMessage({ type: 'reset' });
return () => worker.terminate();
}, []);
useEffect(() => {
if (!workerRef.current) return;
workerRef.current.postMessage({ type: isTraining ? 'resume' : 'pause' });
}, [isTraining]);
const handleReset = useCallback(() => {
setStats({ generation: 0, maxFitness: 0, avgFitness: 0 });
setFitnessHistory([]);
setBestGenome(null);
workerRef.current?.postMessage({ type: 'reset' });
}, []);
const toggleTraining = useCallback(() => {
setIsTraining(prev => !prev);
}, []);
return {
isTraining,
stats,
fitnessHistory,
bestGenome,
toggleTraining,
handleReset
};
}

View File

@@ -0,0 +1,2 @@
// Force reload marker - change me to trigger HMR
export const RELOAD_MARKER = 2;

View File

@@ -0,0 +1,240 @@
/* Bridge Builder App Styles */
.bridge-builder {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #fff;
}
.bridge-builder__header {
padding: 1.5rem 2rem;
background: rgba(0, 0, 0, 0.3);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.bridge-builder__title {
font-size: 2rem;
font-weight: 700;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.bridge-builder__subtitle {
font-size: 0.9rem;
color: #aaa;
margin: 0.25rem 0 0 0;
}
.bridge-builder__content {
flex: 1;
display: flex;
gap: 1rem;
padding: 1rem;
overflow: hidden;
}
.bridge-builder__canvas-container {
flex: 1;
background: #0f0f1e;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
position: relative;
}
.bridge-builder__sidebar {
width: 320px;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
padding: 1.5rem;
overflow-y: auto;
backdrop-filter: blur(10px);
}
.bridge-builder__section {
margin-bottom: 2rem;
}
.bridge-builder__section-title {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 1rem;
color: #fff;
border-bottom: 2px solid rgba(102, 126, 234, 0.5);
padding-bottom: 0.5rem;
}
.bridge-builder__controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.bridge-builder__button {
flex: 1;
min-width: 80px;
padding: 0.75rem 1rem;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
}
.bridge-builder__button--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.bridge-builder__button--primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.bridge-builder__button--secondary {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.bridge-builder__button--secondary:hover {
background: rgba(255, 255, 255, 0.2);
}
.bridge-builder__button--danger {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
border: 1px solid #ef4444;
}
.bridge-builder__button--danger:hover {
background: rgba(239, 68, 68, 0.3);
}
.bridge-builder__button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.bridge-builder__stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.bridge-builder__stat {
background: rgba(255, 255, 255, 0.05);
padding: 1rem;
border-radius: 6px;
border-left: 3px solid #667eea;
}
.bridge-builder__stat-label {
font-size: 0.75rem;
color: #aaa;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
}
.bridge-builder__stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #fff;
}
.bridge-builder__input-group {
margin-bottom: 1rem;
}
.bridge-builder__label {
display: block;
font-size: 0.875rem;
color: #ccc;
margin-bottom: 0.5rem;
}
.bridge-builder__input {
width: 100%;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: white;
font-size: 0.9rem;
}
.bridge-builder__input:focus {
outline: none;
border-color: #667eea;
background: rgba(255, 255, 255, 0.15);
}
.bridge-builder__select {
width: 100%;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: white;
font-size: 0.9rem;
cursor: pointer;
}
.bridge-builder__select:focus {
outline: none;
border-color: #667eea;
}
.bridge-builder__slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.1);
outline: none;
-webkit-appearance: none;
}
.bridge-builder__slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
transition: all 0.2s;
}
.bridge-builder__slider::-webkit-slider-thumb:hover {
background: #764ba2;
transform: scale(1.2);
}
.bridge-builder__slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.bridge-builder__slider::-moz-range-thumb:hover {
background: #764ba2;
transform: scale(1.2);
}
.bridge-builder__value-display {
display: inline-block;
font-weight: 600;
color: #667eea;
margin-left: 0.5rem;
}

View File

@@ -0,0 +1,301 @@
// Bridge Builder Main App Component
import { useEffect, useRef, useState } from 'react';
import Phaser from 'phaser';
import { BridgeScene } from './BridgeScene';
import { useEvolutionWorker } from './useEvolutionWorker';
import { FitnessGraph } from './FitnessGraph';
import { DEFAULT_BRIDGE_CONFIG, DEFAULT_SIM_CONFIG, DEFAULT_GA_CONFIG } from './types';
import type { BridgeConfig, SimulationConfig, GAConfig } from './types';
import './BridgeBuilder.css';
export default function BridgeBuilderApp() {
const canvasRef = useRef<HTMLDivElement>(null);
const gameRef = useRef<Phaser.Game | null>(null);
const sceneRef = useRef<BridgeScene | null>(null);
const [bridgeConfig, setBridgeConfig] = useState<BridgeConfig>(DEFAULT_BRIDGE_CONFIG);
const [simConfig, setSimConfig] = useState<SimulationConfig>(DEFAULT_SIM_CONFIG);
const [gaConfig, setGaConfig] = useState<GAConfig>(DEFAULT_GA_CONFIG);
const {
generation,
bestFitness,
avgFitness,
bestGenome,
isTraining,
bestFitnessHistory,
avgFitnessHistory,
startTraining,
stopTraining,
reset,
} = useEvolutionWorker(bridgeConfig, simConfig, gaConfig);
// Initialize Phaser
useEffect(() => {
if (!canvasRef.current || gameRef.current) return;
const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
width: 800,
height: 600,
parent: canvasRef.current,
backgroundColor: '#0f0f1e',
scene: BridgeScene,
physics: {
default: 'matter',
matter: {
debug: false,
gravity: { x: 0, y: 9.81 },
},
},
};
gameRef.current = new Phaser.Game(config);
// Wait for scene to be ready
setTimeout(() => {
if (gameRef.current) {
sceneRef.current = gameRef.current.scene.getScene('BridgeScene') as BridgeScene;
console.log('[App] Scene ref captured:', !!sceneRef.current);
}
}, 100);
return () => {
gameRef.current?.destroy(true);
gameRef.current = null;
sceneRef.current = null;
};
}, []);
// Update scene when best genome changes
useEffect(() => {
console.log('[App] useEffect triggered - updating scene', {
hasScene: !!sceneRef.current,
hasGenome: !!bestGenome,
generation,
nodes: bestGenome?.nodes.length,
beams: bestGenome?.beams.length
});
if (sceneRef.current && bestGenome) {
sceneRef.current.updateBridge(bestGenome);
sceneRef.current.updateStats(generation, bestFitness);
}
}, [bestGenome, generation, bestFitness]);
const handleStartStop = () => {
if (isTraining) {
stopTraining();
} else {
startTraining();
}
};
const handleReset = () => {
reset();
if (sceneRef.current) {
sceneRef.current.updateStats(0, 0);
}
};
return (
<div className="bridge-builder">
<header className="bridge-builder__header">
<h1 className="bridge-builder__title">🌉 Bridge Builder</h1>
<p className="bridge-builder__subtitle">
Watch evolution discover structural engineering solutions
</p>
</header>
<div className="bridge-builder__content">
<div className="bridge-builder__canvas-container" ref={canvasRef} />
<aside className="bridge-builder__sidebar">
<section className="bridge-builder__section">
<h2 className="bridge-builder__section-title">Controls</h2>
<div className="bridge-builder__controls">
<button
className={`bridge-builder__button ${isTraining ? 'bridge-builder__button--secondary' : 'bridge-builder__button--primary'
}`}
onClick={handleStartStop}
>
{isTraining ? 'Pause' : 'Start'}
</button>
<button
className="bridge-builder__button bridge-builder__button--danger"
onClick={handleReset}
>
Reset
</button>
</div>
</section>
<section className="bridge-builder__section">
<h2 className="bridge-builder__section-title">Bridge Config</h2>
<div className="bridge-builder__input-group">
<label className="bridge-builder__label">
Load Mass
<span className="bridge-builder__value-display">{bridgeConfig.loadMass}kg</span>
</label>
<input
type="range"
className="bridge-builder__slider"
min="0.1"
max="50"
step="0.5"
value={bridgeConfig.loadMass}
onChange={(e) => setBridgeConfig({ ...bridgeConfig, loadMass: Number(e.target.value) })}
disabled={isTraining}
/>
</div>
<div className="bridge-builder__input-group">
<label className="bridge-builder__label">
Beam Strength
<span className="bridge-builder__value-display">{bridgeConfig.beamStrength}N</span>
</label>
<input
type="range"
className="bridge-builder__slider"
min="500"
max="10000"
step="100"
value={bridgeConfig.beamStrength}
onChange={(e) => setBridgeConfig({ ...bridgeConfig, beamStrength: Number(e.target.value) })}
disabled={isTraining}
/>
</div>
</section>
<section className="bridge-builder__section">
<h2 className="bridge-builder__section-title">Statistics</h2>
<div className="bridge-builder__stats">
<div className="bridge-builder__stat">
<div className="bridge-builder__stat-label">Generation</div>
<div className="bridge-builder__stat-value">{generation}</div>
</div>
<div className="bridge-builder__stat">
<div className="bridge-builder__stat-label">Best Fitness</div>
<div className="bridge-builder__stat-value">{bestFitness.toFixed(0)}</div>
</div>
<div className="bridge-builder__stat">
<div className="bridge-builder__stat-label">Avg Fitness</div>
<div className="bridge-builder__stat-value">{avgFitness.toFixed(0)}</div>
</div>
<div className="bridge-builder__stat">
<div className="bridge-builder__stat-label">Nodes</div>
<div className="bridge-builder__stat-value">{bestGenome?.nodes.length || 0}</div>
</div>
</div>
</section>
<section className="bridge-builder__section">
<h2 className="bridge-builder__section-title">Fitness Progress</h2>
<FitnessGraph
bestFitnessHistory={bestFitnessHistory}
avgFitnessHistory={avgFitnessHistory}
/>
</section>
<section className="bridge-builder__section">
<h2 className="bridge-builder__section-title">Simulation</h2>
<div className="bridge-builder__input-group">
<label className="bridge-builder__label">
Population Size
<span className="bridge-builder__value-display">{simConfig.populationSize}</span>
</label>
<input
type="range"
className="bridge-builder__slider"
min="10"
max="100"
step="10"
value={simConfig.populationSize}
onChange={(e) => setSimConfig({ ...simConfig, populationSize: Number(e.target.value) })}
disabled={isTraining}
/>
</div>
<div className="bridge-builder__input-group">
<label className="bridge-builder__label">
Max Steps
<span className="bridge-builder__value-display">{simConfig.maxSteps}</span>
</label>
<input
type="range"
className="bridge-builder__slider"
min="300"
max="1200"
step="100"
value={simConfig.maxSteps}
onChange={(e) => setSimConfig({ ...simConfig, maxSteps: Number(e.target.value) })}
disabled={isTraining}
/>
</div>
</section>
<section className="bridge-builder__section">
<h2 className="bridge-builder__section-title">Evolution</h2>
<div className="bridge-builder__input-group">
<label className="bridge-builder__label">
Mutation Rate
<span className="bridge-builder__value-display">{(gaConfig.mutationRate * 100).toFixed(0)}%</span>
</label>
<input
type="range"
className="bridge-builder__slider"
min="0"
max="1"
step="0.1"
value={gaConfig.mutationRate}
onChange={(e) => setGaConfig({ ...gaConfig, mutationRate: Number(e.target.value) })}
disabled={isTraining}
/>
</div>
<div className="bridge-builder__input-group">
<label className="bridge-builder__label">
Elite Count
<span className="bridge-builder__value-display">{gaConfig.eliteCount}</span>
</label>
<input
type="range"
className="bridge-builder__slider"
min="1"
max="10"
step="1"
value={gaConfig.eliteCount}
onChange={(e) => setGaConfig({ ...gaConfig, eliteCount: Number(e.target.value) })}
disabled={isTraining}
/>
</div>
</section>
<section className="bridge-builder__section">
<h2 className="bridge-builder__section-title">Legend</h2>
<div style={{ fontSize: '0.85rem', lineHeight: '1.6' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
<div style={{ width: 40, height: 3, background: '#4ade80' }}></div>
<span>Low Stress</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
<div style={{ width: 40, height: 3, background: '#60a5fa' }}></div>
<span>Tension</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
<div style={{ width: 40, height: 3, background: '#f59e0b' }}></div>
<span>Compression</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ width: 40, height: 3, background: '#ef4444' }}></div>
<span>High Stress</span>
</div>
</div>
</section>
</aside>
</div>
</div>
);
}

View File

@@ -0,0 +1,206 @@
// Phaser Scene for Bridge Visualization
import Phaser from 'phaser';
import { BridgeSimulation } from './BridgeSimulation';
import type { BridgeGenome, BridgeConfig, SimulationConfig } from './types';
import { DEFAULT_BRIDGE_CONFIG, DEFAULT_SIM_CONFIG } from './types';
export class BridgeScene extends Phaser.Scene {
private sim!: BridgeSimulation;
private graphics!: Phaser.GameObjects.Graphics;
private statsText!: Phaser.GameObjects.Text;
private currentGenome: BridgeGenome | null = null;
private generation = 0;
private bestFitness = 0;
private bridgeConfig: BridgeConfig = DEFAULT_BRIDGE_CONFIG;
private simConfig: SimulationConfig = DEFAULT_SIM_CONFIG;
constructor() {
super({ key: 'BridgeScene' });
}
create() {
// Setup graphics
this.graphics = this.add.graphics();
// Stats text
this.statsText = this.add.text(10, 10, '', {
fontSize: '14px',
color: '#ffffff',
backgroundColor: '#00000088',
padding: { x: 10, y: 5 },
});
// Create initial simple bridge for demonstration
this.createDemoBridge();
}
private createDemoBridge() {
// Simple triangle bridge
const genome: BridgeGenome = {
nodes: [
{ x: 0.5, y: 0.5 }, // Center top
],
beams: [
{ nodeA: -1, nodeB: 0 }, // Left anchor to center
{ nodeA: -2, nodeB: 0 }, // Right anchor to center
],
};
this.updateBridge(genome);
}
public updateBridge(genome: BridgeGenome) {
console.log('[BridgeScene] updateBridge called:', { nodes: genome.nodes.length, beams: genome.beams.length });
this.currentGenome = genome;
// Create new simulation
if (this.sim) {
// Cleanup old sim if needed
}
this.sim = new BridgeSimulation(genome, this.bridgeConfig, this.simConfig);
}
public updateStats(generation: number, fitness: number) {
console.log('[BridgeScene] updateStats called:', { generation, fitness });
this.generation = generation;
this.bestFitness = fitness;
}
update() {
if (!this.sim) return;
// Update simulation
if (!this.sim.isFinished()) {
this.sim.update();
}
// Render
this.render();
}
private render() {
this.graphics.clear();
// Draw ground
this.drawGround();
// Draw bridge structure
this.drawBridge();
// Update stats
this.updateStatsText();
}
private drawGround() {
const groundY = this.bridgeConfig.anchorHeight + 350;
this.graphics.lineStyle(2, 0x444444);
this.graphics.beginPath();
this.graphics.moveTo(0, groundY);
this.graphics.lineTo(800, groundY);
this.graphics.strokePath();
}
private drawBridge() {
if (!this.sim || !this.currentGenome) return;
const { anchorHeight, spanWidth, nodeRadius } = this.bridgeConfig;
// Draw nodes and beams
const beamForces = this.sim.getBeamForces();
const nodePositions = this.sim.getNodePositions();
const loadPos = this.sim.getLoadPosition();
// Draw beams
for (let i = 0; i < this.currentGenome.beams.length; i++) {
const beam = this.currentGenome.beams[i];
const force = beamForces[i];
// Get node positions
let posA: { x: number; y: number };
let posB: { x: number; y: number };
if (beam.nodeA === -1) {
posA = { x: 100, y: anchorHeight };
} else if (beam.nodeA === -2) {
posA = { x: 100 + spanWidth, y: anchorHeight };
} else {
posA = nodePositions[beam.nodeA];
}
if (beam.nodeB === -1) {
posB = { x: 100, y: anchorHeight };
} else if (beam.nodeB === -2) {
posB = { x: 100 + spanWidth, y: anchorHeight };
} else {
posB = nodePositions[beam.nodeB];
}
if (!posA || !posB) continue;
// Color by stress
const color = force?.broken
? 0x666666
: this.getStressColor(force?.force || 0);
const lineWidth = force?.broken ? 1 : 3;
this.graphics.lineStyle(lineWidth, color);
this.graphics.beginPath();
this.graphics.moveTo(posA.x, posA.y);
this.graphics.lineTo(posB.x, posB.y);
this.graphics.strokePath();
}
// Draw nodes
for (const pos of nodePositions) {
this.graphics.fillStyle(0xcccccc);
this.graphics.fillCircle(pos.x, pos.y, nodeRadius);
}
// Draw anchors
this.graphics.fillStyle(0x888888);
this.graphics.fillCircle(100, anchorHeight, nodeRadius * 2);
this.graphics.fillCircle(100 + spanWidth, anchorHeight, nodeRadius * 2);
// Draw load
this.graphics.fillStyle(0xff6b6b);
this.graphics.fillRect(loadPos.x - 15, loadPos.y - 15, 30, 30);
}
private getStressColor(force: number): number {
// Color mapping: Blue (tension) -> Green (low) -> Red (compression)
const maxForce = this.bridgeConfig.beamStrength;
const ratio = Math.abs(force) / maxForce;
if (ratio > 1) return 0xff0000; // Red - overstressed
// Low stress = green
if (ratio < 0.3) return 0x4ade80;
// Tension (positive) = blue shades
if (force > 0) {
const hue = 200 - ratio * 40; // Blue to cyan
return Phaser.Display.Color.HSLToColor(hue / 360, 0.7, 0.5).color;
}
// Compression (negative) = yellow to red
const hue = 50 - ratio * 50; // Yellow to red
return Phaser.Display.Color.HSLToColor(hue / 360, 0.8, 0.5).color;
}
private updateStatsText() {
const result = this.sim?.getResult();
this.statsText.setText([
`Generation: ${this.generation}`,
`Best Fitness: ${this.bestFitness.toFixed(0)}`,
`Nodes: ${this.currentGenome?.nodes.length || 0}`,
`Beams: ${this.currentGenome?.beams.length || 0}`,
`Steps: ${result?.stepsSupported || 0}`,
`Status: ${result?.collapsed ? 'Collapsed' : 'Standing'}`,
]);
}
}

View File

@@ -0,0 +1,434 @@
// Bridge Physics Simulation using Matter.js
// @ts-ignore
import decomp from 'poly-decomp';
import Matter from 'matter-js';
import type { BridgeGenome, BridgeConfig, SimulationConfig, BeamForce, SimulationResult } from './types';
import { DEFAULT_BRIDGE_CONFIG, DEFAULT_SIM_CONFIG } from './types';
Matter.Common.setDecomp(decomp);
export class BridgeSimulation {
public engine: Matter.Engine;
private nodes: Matter.Body[] = [];
private beams: Matter.Constraint[] = [];
private loadSupports: { constraint: Matter.Constraint; nodeIdx: number }[] = [];
private load: Matter.Body;
private anchorLeft: Matter.Body;
private anchorRight: Matter.Body;
private genome: BridgeGenome;
private bridgeConfig: BridgeConfig;
private simConfig: SimulationConfig;
private currentStep = 0;
private loadHeightSum = 0;
private collapsed = false;
private brokenBeams = new Set<number>();
constructor(
genome: BridgeGenome,
bridgeConfig: BridgeConfig = DEFAULT_BRIDGE_CONFIG,
simConfig: SimulationConfig = DEFAULT_SIM_CONFIG
) {
this.genome = genome;
this.bridgeConfig = bridgeConfig;
this.simConfig = simConfig;
// Create physics engine
this.engine = Matter.Engine.create();
this.engine.gravity.y = 9.81; // m/s^2
// Create anchor points (static)
const anchorY = this.bridgeConfig.anchorHeight;
this.anchorLeft = Matter.Bodies.circle(100, anchorY, this.bridgeConfig.nodeRadius * 2, {
isStatic: true,
label: 'anchor',
render: { fillStyle: '#888' }
});
this.anchorRight = Matter.Bodies.circle(
100 + this.bridgeConfig.spanWidth,
anchorY,
this.bridgeConfig.nodeRadius * 2,
{
isStatic: true,
label: 'anchor',
render: { fillStyle: '#888' }
}
);
Matter.World.add(this.engine.world, [this.anchorLeft, this.anchorRight]);
// Create nodes from genome
this.createNodes();
// Create beams from genome
this.createBeams();
// Create load (suspended from center of bridge)
this.load = this.createLoad();
Matter.World.add(this.engine.world, this.load);
// Debug: Log construction details
if (this.nodes.length === 0) {
console.warn('[BridgeSim] WARNING: No nodes created!');
}
if (this.beams.length === 0) {
console.warn('[BridgeSim] WARNING: No beams created!');
}
}
private createNodes() {
const { nodes } = this.genome;
const { spanWidth, anchorHeight, nodeRadius } = this.bridgeConfig;
for (const node of nodes) {
// Convert relative coords (0-1) to world coords
const x = 100 + node.x * spanWidth;
const y = anchorHeight + node.y * 150;
// Safety: Skip invalid nodes
if (isNaN(x) || isNaN(y)) {
console.warn('[BridgeSim] Skipping node with NaN coordinates:', node);
continue;
}
const body = Matter.Bodies.circle(x, y, nodeRadius, {
label: 'node',
density: 0.001, // Light nodes
frictionAir: 0.01,
});
this.nodes.push(body);
Matter.World.add(this.engine.world, body);
}
}
private createBeams() {
const { beams } = this.genome;
const { beamStiffness } = this.bridgeConfig;
// Add beams connecting to left anchor
const leftConnections = beams.filter(b => b.nodeA === -1 || b.nodeB === -1);
for (const beam of leftConnections) {
const nodeIdx = beam.nodeA === -1 ? beam.nodeB : beam.nodeA;
if (nodeIdx >= 0 && nodeIdx < this.nodes.length) {
const constraint = Matter.Constraint.create({
bodyA: this.anchorLeft,
bodyB: this.nodes[nodeIdx],
stiffness: beamStiffness,
damping: 0.01,
label: 'beam',
});
this.beams.push(constraint);
Matter.World.add(this.engine.world, constraint);
}
}
// Add beams connecting to right anchor
const rightConnections = beams.filter(b => b.nodeA === -2 || b.nodeB === -2);
for (const beam of rightConnections) {
const nodeIdx = beam.nodeA === -2 ? beam.nodeB : beam.nodeA;
if (nodeIdx >= 0 && nodeIdx < this.nodes.length) {
const constraint = Matter.Constraint.create({
bodyA: this.anchorRight,
bodyB: this.nodes[nodeIdx],
stiffness: beamStiffness,
damping: 0.01,
label: 'beam',
});
this.beams.push(constraint);
Matter.World.add(this.engine.world, constraint);
}
}
// Add beams between nodes
const nodeBeams = beams.filter(b => b.nodeA >= 0 && b.nodeB >= 0);
for (const beam of nodeBeams) {
if (beam.nodeA < this.nodes.length && beam.nodeB < this.nodes.length) {
const constraint = Matter.Constraint.create({
bodyA: this.nodes[beam.nodeA],
bodyB: this.nodes[beam.nodeB],
stiffness: beamStiffness,
damping: 0.01,
label: 'beam',
});
this.beams.push(constraint);
Matter.World.add(this.engine.world, constraint);
}
}
}
private createLoad(): Matter.Body {
const centerX = 100 + this.bridgeConfig.spanWidth / 2;
const centerY = this.bridgeConfig.anchorHeight + 100;
const loadSize = 24;
const load = Matter.Bodies.rectangle(
centerX,
centerY,
loadSize,
loadSize,
{
label: 'load',
density: (this.bridgeConfig.loadMass / (loadSize * loadSize)) * 1.5,
friction: 0.1,
restitution: 0.3,
render: { fillStyle: '#ff6b6b' }
}
);
this.load = load; // IMPORTANT: Assign before using in constraints!
// Find center nodes to attach load (if any exist)
if (this.nodes.length > 0) {
const attachNodes = this.nodes.filter(n => {
const dx = Math.abs(n.position.x - centerX);
return dx < this.bridgeConfig.spanWidth * 0.3; // Within 30% of center
});
// If no center nodes, attach to any nodes
const nodesToUse = attachNodes.length > 0 ? attachNodes : this.nodes;
// Sort by height (lowest y = highest up = closest to anchors)
nodesToUse.sort((a, b) => {
if (isNaN(a.position.y) || isNaN(b.position.y)) return 0;
return a.position.y - b.position.y;
});
// Attach to top few nodes
const attachCount = Math.min(3, nodesToUse.length);
this.loadSupports = nodesToUse.slice(0, attachCount).map(node => {
const idx = this.nodes.indexOf(node);
const constraint = Matter.Constraint.create({
bodyA: load, // Use local variable for safety
bodyB: node,
stiffness: 0.8,
length: Matter.Vector.magnitude(Matter.Vector.sub(node.position, load.position)),
render: { visible: true, strokeStyle: '#ff0000', lineWidth: 2 }
});
Matter.Composite.add(this.engine.world, constraint);
return { constraint, nodeIdx: idx };
});
}
console.log(`[BridgeSim] Created load with ${this.loadSupports.length} support constraints`);
return load;
}
public update() {
if (this.collapsed) return;
// Step physics with MORE iterations for stability
Matter.Engine.update(this.engine, this.simConfig.timeStep, this.simConfig.physicsIterations);
this.currentStep++;
// Check beam forces and break if over threshold
this.checkBeamForces();
// Track load height for fitness (negative when below anchors = bad)
const loadY = this.load.position.y;
if (isNaN(loadY)) {
this.collapsed = true;
return;
}
const loadHeight = this.bridgeConfig.anchorHeight - loadY;
this.loadHeightSum += loadHeight;
// Check if load hit ground (y > some threshold)
// Realism: If the load sags too much, it's a failure.
if (this.load.position.y > this.bridgeConfig.anchorHeight + 250) {
console.log(`[BridgeSim] Load collapsed at step ${this.currentStep}: y=${this.load.position.y.toFixed(1)}`);
this.collapsed = true;
}
}
private checkBeamForces() {
// ONLY check structural beams, NOT load supports
for (let i = 0; i < this.beams.length; i++) {
if (this.brokenBeams.has(i)) continue;
const beam = this.beams[i];
const bodyA = beam.bodyA;
const bodyB = beam.bodyB;
if (!bodyA || !bodyB) continue;
const posA = bodyA.position;
const posB = bodyB.position;
const dx = posB.x - posA.x;
const dy = posB.y - posA.y;
const currentLength = Math.sqrt(dx * dx + dy * dy);
const restLength = beam.length || currentLength;
const extension = currentLength - restLength;
const force = extension * (beam.stiffness || 1) * 1000; // Approximate
if (Math.abs(force) > this.bridgeConfig.beamStrength) {
// Debug first beam break
if (this.brokenBeams.size === 0) {
console.log(`[BridgeSim] First STRUCTURAL beam break at step ${this.currentStep}: force=${force.toFixed(0)}N (abs), threshold=${this.bridgeConfig.beamStrength}N`);
}
// Break beam
this.brokenBeams.add(i);
Matter.Composite.remove(this.engine.world, beam);
// REDUNDANCY: No longer setting this.collapsed = true here!
}
}
}
private getConstraintForce(constraint: Matter.Constraint): number {
// Approximate force from constraint extension
const bodyA = constraint.bodyA;
const bodyB = constraint.bodyB;
if (!bodyA || !bodyB) return 0;
const posA = bodyA.position;
const posB = bodyB.position;
const dx = posB.x - posA.x;
const dy = posB.y - posA.y;
const currentLength = Math.sqrt(dx * dx + dy * dy);
const restLength = constraint.length || currentLength;
const extension = currentLength - restLength;
const force = extension * (constraint.stiffness || 1) * 1000; // Approximate
return force;
}
public run(steps: number) {
for (let i = 0; i < steps; i++) {
this.update();
if (this.collapsed) break;
if (this.currentStep >= this.simConfig.maxSteps) break;
}
}
public isFinished(): boolean {
return this.collapsed || this.currentStep >= this.simConfig.maxSteps;
}
public getBeamForces(): (BeamForce | null)[] {
const forces: (BeamForce | null)[] = [];
for (let i = 0; i < this.beams.length; i++) {
const beam = this.beams[i];
const isBroken = this.brokenBeams.has(i);
forces.push({
force: isBroken ? 0 : this.getConstraintForce(beam),
broken: isBroken,
nodeA: -1, // Not used by scene now
nodeB: -1, // Not used by scene now
});
}
return forces;
}
public getNodePositions(): { x: number; y: number }[] {
return this.nodes.map(node => ({ x: node.position.x, y: node.position.y }));
}
public getLoadPosition(): { x: number; y: number } {
return { x: this.load.position.x, y: this.load.position.y };
}
private hasFullConnectivity(): boolean {
if (this.nodes.length === 0 || this.loadSupports.length === 0) return false;
// Check if there's a path from left anchor (-1) -> load (-3) -> right anchor (-2)
// using only currently active beams (not broken)
const activeBeams = this.genome.beams.filter((_, i) => !this.brokenBeams.has(i));
const adj = new Map<number, number[]>();
activeBeams.forEach(b => {
if (!adj.has(b.nodeA)) adj.set(b.nodeA, []);
if (!adj.has(b.nodeB)) adj.set(b.nodeB, []);
adj.get(b.nodeA)!.push(b.nodeB);
adj.get(b.nodeB)!.push(b.nodeA);
});
// Add load supports to adjacency (treat load as -3)
const loadNodeId = -3;
adj.set(loadNodeId, []);
this.loadSupports.forEach(support => {
const v = support.nodeIdx;
adj.get(loadNodeId)!.push(v);
if (!adj.has(v)) adj.set(v, []);
adj.get(v)!.push(loadNodeId);
});
// Check path: -1 to -3
if (!adj.has(-1) || !adj.has(loadNodeId)) return false;
const hasLeftToLoad = this.bfs(adj, -1, loadNodeId);
if (!hasLeftToLoad) return false;
// Check path: -3 to -2
if (!adj.has(-2)) return false;
const hasLoadToRight = this.bfs(adj, loadNodeId, -2);
return hasLoadToRight;
}
private bfs(adj: Map<number, number[]>, start: number, target: number): boolean {
const visited = new Set<number>();
const queue = [start];
visited.add(start);
while (queue.length > 0) {
const u = queue.shift()!;
if (u === target) return true;
const neighbors = adj.get(u) || [];
for (const v of neighbors) {
if (!visited.has(v)) {
visited.add(v);
queue.push(v);
}
}
}
return false;
}
public getResult(): SimulationResult {
const avgLoadHeight = this.loadHeightSum / Math.max(1, this.currentStep);
const beamCount = this.genome.beams.length;
const nodeCount = this.genome.nodes.length;
const connected = this.hasFullConnectivity();
// REDUCED survival rewards for disconnected bridges
const timeScale = connected ? 100 : 1;
const timeFitness = this.currentStep * timeScale;
// 2. Height Score (Only reward if connected)
const heightScore = connected ? avgLoadHeight * 5 : 0;
// 3. Material Penalty
const efficiencyFactor = Math.min(1, this.currentStep / 100);
const materialPenalty = ((beamCount * 5) + (nodeCount * 2)) * efficiencyFactor;
// 4. Structural Integrity Bonus
// MASSIVE reward for full connectivity side-to-side through load
const structureBonus = connected ? 5000 : 0;
// 5. Completion Bonus (Connected ONLY)
const completionBonus = (connected && this.currentStep >= this.simConfig.maxSteps && !this.collapsed) ? 10000 : 0;
// Minimum fitness 1 to keep it in the pool
const fitness = Math.max(1, timeFitness + heightScore + structureBonus + completionBonus - materialPenalty);
return {
fitness,
stepsSupported: this.currentStep,
avgLoadHeight,
beamCount,
maxStress: 0,
collapsed: this.collapsed || (!connected && this.currentStep > 10), // Treat disconnected as collapse
};
}
}

View File

@@ -0,0 +1,120 @@
// Fitness Graph Component for Bridge Builder
import { useEffect, useRef } from 'react';
interface FitnessGraphProps {
bestFitnessHistory: number[];
avgFitnessHistory: number[];
}
export function FitnessGraph({ bestFitnessHistory, avgFitnessHistory }: FitnessGraphProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (bestFitnessHistory.length < 2) return;
const width = canvas.width;
const height = canvas.height;
const padding = 40;
// Calculate bounds
const allValues = [...bestFitnessHistory, ...avgFitnessHistory];
const minFitness = Math.min(...allValues);
const maxFitness = Math.max(...allValues);
const fitnessRange = maxFitness - minFitness || 1;
const maxGen = bestFitnessHistory.length - 1;
// Draw grid
ctx.strokeStyle = '#2a2a3e';
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {
const y = padding + (height - 2 * padding) * (i / 5);
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(width - padding, y);
ctx.stroke();
// Y-axis labels
const value = maxFitness - (fitnessRange * i / 5);
ctx.fillStyle = '#888';
ctx.font = '10px monospace';
ctx.textAlign = 'right';
ctx.fillText(value.toFixed(0), padding - 5, y + 3);
}
// Draw best fitness line
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
ctx.beginPath();
bestFitnessHistory.forEach((fitness, i) => {
const x = padding + (width - 2 * padding) * (i / maxGen);
const y = padding + (height - 2 * padding) * (1 - (fitness - minFitness) / fitnessRange);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
// Draw avg fitness line
ctx.strokeStyle = '#60a5fa';
ctx.lineWidth = 2;
ctx.beginPath();
avgFitnessHistory.forEach((fitness, i) => {
const x = padding + (width - 2 * padding) * (i / maxGen);
const y = padding + (height - 2 * padding) * (1 - (fitness - minFitness) / fitnessRange);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
// Legend
ctx.fillStyle = '#4ade80';
ctx.fillRect(width - 120, 10, 15, 3);
ctx.fillStyle = '#fff';
ctx.font = '12px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Best', width - 100, 15);
ctx.fillStyle = '#60a5fa';
ctx.fillRect(width - 120, 25, 15, 3);
ctx.fillStyle = '#fff';
ctx.fillText('Average', width - 100, 30);
}, [bestFitnessHistory, avgFitnessHistory]);
return (
<canvas
ref={canvasRef}
width={600}
height={200}
style={{
width: '100%',
height: 'auto',
border: '1px solid #2a2a3e',
borderRadius: '8px',
background: '#0f0f1e',
}}
/>
);
}

View File

@@ -0,0 +1,315 @@
// Genetic Algorithm for Bridge Evolution
import type { BridgeGenome, GAConfig } from './types';
import { DEFAULT_GA_CONFIG } from './types';
export class GeneticAlgorithm {
private config: GAConfig;
constructor(config: GAConfig = DEFAULT_GA_CONFIG) {
this.config = config;
}
public createRandomGenome(minNodes = 15, maxNodes = 25): BridgeGenome {
for (let attempt = 0; attempt < 20; attempt++) {
const nodeCount = minNodes + Math.floor(Math.random() * (maxNodes - minNodes));
const nodes: { x: number; y: number }[] = [];
// Create nodes in a rough lattice/grid with jitter
const cols = 5;
const rows = Math.ceil(nodeCount / cols);
for (let i = 0; i < nodeCount; i++) {
const r = Math.floor(i / cols);
const c = i % cols;
const xBase = (cols > 1 ? c / (cols - 1) : 0.5);
const yBase = (rows > 1 ? r / (rows - 1) : 0.5);
const x = xBase * 0.8 + 0.1 + (Math.random() - 0.5) * 0.05;
const y = yBase * 0.4 + 0.3 + (Math.random() - 0.5) * 0.05;
nodes.push({
x: isNaN(x) ? 0.5 : Math.max(0.05, Math.min(0.95, x)),
y: isNaN(y) ? 0.5 : Math.max(0.1, Math.min(0.9, y))
});
}
const beams: { nodeA: number; nodeB: number }[] = [];
// 1. DENSE INTERNAL CONNECTIONS: Connect each node to its 3 nearest neighbors
for (let i = 0; i < nodes.length; i++) {
const distances = nodes.map((n, idx) => ({
idx,
dist: Math.pow(nodes[i].x - n.x, 2) + Math.pow(nodes[i].y - n.y, 2)
}))
.filter(d => d.idx !== i)
.sort((a, b) => a.dist - b.dist);
for (let j = 0; j < Math.min(3, distances.length); j++) {
const targetIdx = distances[j].idx;
if (!this.hasBeam(beams, i, targetIdx)) {
beams.push({ nodeA: i, nodeB: targetIdx });
}
}
}
// 2. ANCHOR CONNECTIONS
const sortedByX = nodes.map((n, idx) => ({ n, idx })).sort((a,b) => a.n.x - b.n.x);
for (let i = 0; i < 3; i++) {
beams.push({ nodeA: -1, nodeB: sortedByX[i].idx });
beams.push({ nodeA: -2, nodeB: sortedByX[nodes.length - 1 - i].idx });
}
// 3. LOAD BRACING
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].x > 0.3 && nodes[i].x < 0.7 && Math.random() < 0.4) {
const target = Math.floor(Math.random() * nodes.length);
if (target !== i && !this.hasBeam(beams, i, target)) {
beams.push({ nodeA: i, nodeB: target });
}
}
}
const genome = { nodes, beams };
if (this.checkConnectivity(genome)) {
return genome;
}
}
// Fallback: Just return whatever we got on last attempt if we failed to connect
return { nodes: [], beams: [] }; // Simulation will handle empty genome
}
private hasBeam(beams: { nodeA: number; nodeB: number }[], a: number, b: number): boolean {
return beams.some(beam =>
(beam.nodeA === a && beam.nodeB === b) ||
(beam.nodeA === b && beam.nodeB === a)
);
}
public evolve(population: BridgeGenome[], fitnesses: number[]): BridgeGenome[] {
const newPopulation: BridgeGenome[] = [];
// Elitism - keep top performers
const sorted = population
.map((genome, i) => ({ genome, fitness: isNaN(fitnesses[i]) ? -1e9 : fitnesses[i] }))
.sort((a, b) => b.fitness - a.fitness);
for (let i = 0; i < this.config.eliteCount; i++) {
newPopulation.push(this.cloneGenome(sorted[i].genome));
}
// Fill rest with offspring
while (newPopulation.length < population.length) {
const parent = this.tournamentSelect(population, fitnesses);
const offspring = this.mutate(this.cloneGenome(parent));
newPopulation.push(offspring);
}
return newPopulation;
}
private tournamentSelect(population: BridgeGenome[], fitnesses: number[]): BridgeGenome {
let bestIdx = Math.floor(Math.random() * population.length);
let bestFitness = fitnesses[bestIdx];
for (let i = 1; i < this.config.tournamentSize; i++) {
const idx = Math.floor(Math.random() * population.length);
if (fitnesses[idx] > bestFitness) {
bestIdx = idx;
bestFitness = fitnesses[idx];
}
}
return population[bestIdx];
}
private mutate(genome: BridgeGenome): BridgeGenome {
const original = this.cloneGenome(genome);
if (Math.random() > this.config.mutationRate) {
return genome;
}
const roll = Math.random();
let cumProb = 0;
let mutated = genome;
cumProb += this.config.addNodeProb;
if (roll < cumProb) mutated = this.addNode(genome);
else {
cumProb += this.config.removeNodeProb;
if (roll < cumProb) mutated = this.removeNode(genome);
else {
cumProb += this.config.moveNodeProb;
if (roll < cumProb) mutated = this.moveNode(genome);
else {
cumProb += this.config.addBeamProb;
if (roll < cumProb) mutated = this.addBeam(genome);
else mutated = this.removeBeam(genome);
}
}
}
// Connectivity Guard: If mutation broke the bridge, try to fix it or revert
if (!this.checkConnectivity(mutated)) {
// Try one quick repair attempt
this.addBeam(mutated);
if (!this.checkConnectivity(mutated)) {
return original; // Revert if still broken
}
}
return mutated;
}
private checkConnectivity(genome: BridgeGenome): boolean {
if (genome.nodes.length === 0) return false;
// Check path from left (-1) -> any center node -> right (-2)
const adj = new Map<number, number[]>();
genome.beams.forEach(b => {
if (!adj.has(b.nodeA)) adj.set(b.nodeA, []);
if (!adj.has(b.nodeB)) adj.set(b.nodeB, []);
adj.get(b.nodeA)!.push(b.nodeB);
adj.get(b.nodeB)!.push(b.nodeA);
});
if (!adj.has(-1) || !adj.has(-2)) return false;
// 1. BFS to find all nodes reachable from left anchor
const reachableFromLeft = this.getReachable(adj, -1);
// 2. BFS to find all nodes reachable from right anchor
const reachableFromRight = this.getReachable(adj, -2);
// 3. Find intersection (nodes on a path between anchors)
const bridgePathNodes = Array.from(reachableFromLeft).filter(id => reachableFromRight.has(id));
// 4. Check if any of these nodes are in the "load zone" (center of bridge)
// Relative x between 0.3 and 0.7
const hasCentralNode = bridgePathNodes.some(id => {
if (id < 0) return false;
const x = genome.nodes[id].x;
return x > 0.3 && x < 0.7;
});
return hasCentralNode;
}
private getReachable(adj: Map<number, number[]>, start: number): Set<number> {
const visited = new Set<number>();
const queue = [start];
visited.add(start);
while (queue.length > 0) {
const u = queue.shift()!;
const neighbors = adj.get(u) || [];
for (const v of neighbors) {
if (!visited.has(v)) {
visited.add(v);
queue.push(v);
}
}
}
return visited;
}
private addNode(genome: BridgeGenome): BridgeGenome {
if (genome.nodes.length >= 50) return genome; // Updated to match DEFAULT_BRIDGE_CONFIG
genome.nodes.push({
x: Math.random(),
y: Math.random() * 0.5 + 0.2,
});
return genome;
}
private removeNode(genome: BridgeGenome): BridgeGenome {
if (genome.nodes.length <= 5) return genome; // Keep a minimum for lattice survival
const idx = Math.floor(Math.random() * genome.nodes.length);
genome.nodes.splice(idx, 1);
// Remove beams connected to this node
genome.beams = genome.beams.filter(b =>
b.nodeA !== idx && b.nodeB !== idx
);
// Adjust indices for remaining beams
genome.beams.forEach(b => {
if (b.nodeA > idx) b.nodeA--;
if (b.nodeB > idx) b.nodeB--;
});
return genome;
}
private moveNode(genome: BridgeGenome): BridgeGenome {
if (genome.nodes.length === 0) return genome;
const idx = Math.floor(Math.random() * genome.nodes.length);
const node = genome.nodes[idx];
// Small perturbation
node.x = Math.max(0, Math.min(1, node.x + (Math.random() - 0.5) * 0.1));
node.y = Math.max(0.1, Math.min(0.9, node.y + (Math.random() - 0.5) * 0.1));
return genome;
}
private addBeam(genome: BridgeGenome): BridgeGenome {
if (genome.beams.length >= 100) return genome; // Updated to match DEFAULT_BRIDGE_CONFIG
if (genome.nodes.length < 2) return genome;
// Try to connect disconnected subgraphs or anchors
const nodes = genome.nodes;
// Choose starting node
const a = Math.random() < 0.2 ? (Math.random() < 0.5 ? -1 : -2) : Math.floor(Math.random() * nodes.length);
// Find random target
let bestTarget = -1;
for (let i = 0; i < 10; i++) {
const b = Math.floor(Math.random() * nodes.length);
if (a === b) continue;
if (this.hasBeam(genome.beams, a, b)) continue;
bestTarget = b;
break; // Simple random for now
}
if (bestTarget !== -1) {
genome.beams.push({ nodeA: a, nodeB: bestTarget });
}
return genome;
}
private removeBeam(genome: BridgeGenome): BridgeGenome {
if (genome.beams.length === 0) return genome;
// Bias toward removing internal beams, protect anchors a bit
const internalBeams = genome.beams.filter(b => b.nodeA >= 0 && b.nodeB >= 0);
const targetArray = (internalBeams.length > 5 && Math.random() < 0.8) ? internalBeams : genome.beams;
const beamToRemove = targetArray[Math.floor(Math.random() * targetArray.length)];
const idx = genome.beams.indexOf(beamToRemove);
if (idx !== -1) {
// Don't remove if it's the last anchor connection?
// We have the checkConnectivity guard in mutate() anyway.
genome.beams.splice(idx, 1);
}
return genome;
}
private cloneGenome(genome: BridgeGenome): BridgeGenome {
return {
nodes: genome.nodes.map(n => ({ ...n })),
beams: genome.beams.map(b => ({ ...b })),
};
}
}

View File

@@ -0,0 +1,79 @@
// E2E Test for Bridge Builder
import { describe, it, expect } from 'bun:test';
import { BridgeSimulation } from './BridgeSimulation';
import { GeneticAlgorithm } from './GeneticAlgo';
import { DEFAULT_BRIDGE_CONFIG, DEFAULT_SIM_CONFIG, DEFAULT_GA_CONFIG } from './types';
import type { BridgeGenome } from './types';
describe('Bridge Builder E2E', () => {
it('should evolve bridges that survive longer than 12 steps', () => {
const ga = new GeneticAlgorithm(DEFAULT_GA_CONFIG);
// Create initial population
const populationSize = 10;
let population: BridgeGenome[] = [];
for (let i = 0; i < populationSize; i++) {
population.push(ga.createRandomGenome(3, 8));
}
console.log('\n=== Bridge Builder E2E Test ===\n');
// Check first genome structure
console.log('First genome sample:');
console.log(` Nodes: ${population[0].nodes.length}`, population[0].nodes.slice(0, 3));
console.log(` Beams: ${population[0].beams.length}`, population[0].beams.slice(0, 5));
// Evolve for 50 generations
for (let gen = 1; gen <= 50; gen++) {
const fitnesses: number[] = [];
const steps: number[] = [];
// Evaluate each genome
for (const genome of population) {
const sim = new BridgeSimulation(genome, DEFAULT_BRIDGE_CONFIG, DEFAULT_SIM_CONFIG);
sim.run(DEFAULT_SIM_CONFIG.maxSteps);
const result = sim.getResult();
fitnesses.push(result.fitness);
steps.push(result.stepsSupported);
}
const maxFitness = Math.max(...fitnesses);
const avgFitness = fitnesses.reduce((a, b) => a + b, 0) / fitnesses.length;
const bestIdx = fitnesses.indexOf(maxFitness);
const maxSteps = Math.max(...steps);
const avgSteps = steps.reduce((a, b) => a + b, 0) / steps.length;
if (gen % 10 === 0 || gen === 1) {
console.log(`Gen ${gen}:`);
console.log(` Fitness: ${avgFitness.toFixed(1)} (best: ${maxFitness.toFixed(1)})`);
console.log(` Steps: ${avgSteps.toFixed(1)} (best: ${maxSteps})`);
console.log(` Best genome: ${population[bestIdx].nodes.length} nodes, ${population[bestIdx].beams.length} beams`);
}
// Evolve population
population = ga.evolve(population, fitnesses);
}
// Final check
const finalFitnesses = population.map(genome => {
const sim = new BridgeSimulation(genome, DEFAULT_BRIDGE_CONFIG, DEFAULT_SIM_CONFIG);
sim.run(DEFAULT_SIM_CONFIG.maxSteps);
return sim.getResult();
});
const bestFinalResult = finalFitnesses.reduce((best, curr) =>
curr.fitness > best.fitness ? curr : best
);
console.log('\n=== Final Results ===');
console.log(`Best fitness: ${bestFinalResult.fitness.toFixed(1)}`);
console.log(`Steps survived: ${bestFinalResult.stepsSupported} / ${DEFAULT_SIM_CONFIG.maxSteps}`);
console.log(`Collapsed: ${bestFinalResult.collapsed}`);
console.log(`Beams: ${bestFinalResult.beamCount}`);
// Test assertions
expect(bestFinalResult.stepsSupported).toBeGreaterThan(12);
expect(bestFinalResult.fitness).toBeGreaterThan(1200);
});
});

View File

@@ -0,0 +1,33 @@
// Simple manual test
import { BridgeSimulation } from './BridgeSimulation';
import { DEFAULT_BRIDGE_CONFIG, DEFAULT_SIM_CONFIG } from './types';
// Triangle bridge - simplest possible
const genome = {
nodes: [
{ x: 0.5, y: 0.3 }, // Center node below anchors
],
beams: [
{ nodeA: -1, nodeB: 0 }, // Left anchor to center
{ nodeA: -2, nodeB: 0 }, // Right anchor to center
],
};
console.log('\n=== Manual Triangle Test ===\n');
console.log('Genome:', genome);
const sim = new BridgeSimulation(genome, DEFAULT_BRIDGE_CONFIG, DEFAULT_SIM_CONFIG);
// Run for 100 steps
for (let i = 0; i < 100; i++) {
sim.update();
if (i < 20 || i === 99) {
console.log(`Step ${i + 1}: loadY=${sim['load'].position.y.toFixed(1)}, collapsed=${sim['collapsed']}`);
}
}
const result = sim.getResult();
console.log('\n=== Result ===');
console.log(`Steps: ${result.stepsSupported}`);
console.log(`Fitness: ${result.fitness.toFixed(1)}`);
console.log(`Collapsed: ${result.collapsed}`);

View File

@@ -0,0 +1,132 @@
// Training Worker for Bridge Evolution
// @ts-ignore
import decomp from 'poly-decomp';
import Matter from 'matter-js';
Matter.Common.setDecomp(decomp);
import { BridgeSimulation } from './BridgeSimulation';
import { GeneticAlgorithm } from './GeneticAlgo';
import type { BridgeGenome, BridgeConfig, SimulationConfig, GAConfig } from './types';
interface WorkerConfig {
bridgeConfig: BridgeConfig;
simConfig: SimulationConfig;
gaConfig: GAConfig;
}
let population: BridgeGenome[] = [];
let ga: GeneticAlgorithm;
let config: WorkerConfig;
let running = false;
let generation = 0;
self.onmessage = (e: MessageEvent) => {
const { type, payload } = e.data;
switch (type) {
case 'start':
config = payload;
ga = new GeneticAlgorithm(config.gaConfig);
initializePopulation();
running = true;
runGeneration();
break;
case 'stop':
running = false;
break;
case 'reset':
generation = 0;
initializePopulation();
break;
}
};
function initializePopulation() {
population = [];
for (let i = 0; i < config.simConfig.populationSize; i++) {
population.push(ga.createRandomGenome(15, 25));
}
generation = 0;
// Debug first genome
const first = population[0];
console.log('[Init] First random genome:', {
nodes: first.nodes.length,
beams: first.beams.length,
sampleNode: first.nodes[0],
sampleBeams: first.beams.slice(0, 3)
});
}
function runGeneration() {
if (!running) return;
generation++;
// Evaluate all genomes
const fitnesses: number[] = [];
const results: any[] = [];
for (let i = 0; i < population.length; i++) {
const genome = population[i];
const sim = new BridgeSimulation(genome, config.bridgeConfig, config.simConfig);
// Run simulation
sim.run(config.simConfig.maxSteps);
const result = sim.getResult();
fitnesses.push(result.fitness);
results.push(result);
// Debug first genome of first 3 generations
if (generation <= 3 && i === 0) {
console.log(`[Gen ${generation}] First genome:`, {
nodes: genome.nodes.length,
beams: genome.beams.length,
result: {
fitness: result.fitness.toFixed(1),
steps: result.stepsSupported,
avgHeight: result.avgLoadHeight.toFixed(1),
collapsed: result.collapsed
}
});
}
}
// Find best
const validFitnesses = fitnesses.filter(f => !isNaN(f));
const maxFitness = validFitnesses.length > 0 ? Math.max(...validFitnesses) : 0;
const minFitness = validFitnesses.length > 0 ? Math.min(...validFitnesses) : 0;
const avgFitness = validFitnesses.length > 0 ? validFitnesses.reduce((a, b) => a + b, 0) / validFitnesses.length : 0;
let bestIdx = fitnesses.indexOf(maxFitness);
if (bestIdx === -1) bestIdx = 0; // Fallback to first if all crashed/NaN
const bestGenome = population[bestIdx];
const bestResult = results[bestIdx] || { stepsSupported: 0, avgLoadHeight: 0 };
// Debug log every 10 generations
if (generation % 10 === 0 || generation <= 3) {
console.log(`[Gen ${generation}] Fitness: ${minFitness.toFixed(1)}-${maxFitness.toFixed(1)} (avg: ${avgFitness.toFixed(1)})`);
console.log(` Best: nodes=${bestGenome.nodes.length}, beams=${bestGenome.beams.length}, steps=${bestResult.stepsSupported}, height=${bestResult.avgLoadHeight.toFixed(1)}`);
}
// Send progress update
self.postMessage({
type: 'progress',
payload: {
generation,
bestFitness: maxFitness,
avgFitness,
bestGenome,
}
});
// Evolve population
population = ga.evolve(population, fitnesses);
// Continue to next generation
setTimeout(() => runGeneration(), 0);
}

View File

@@ -0,0 +1,83 @@
// Bridge Builder Types
export interface BridgeGenome {
nodes: { x: number; y: number }[]; // Joint positions (relative coords)
beams: { nodeA: number; nodeB: number }[]; // Beam connections (node indices)
}
export interface BridgeConfig {
spanWidth: number; // Width of gap to span
anchorHeight: number; // Y position of anchor points
maxNodes: number; // Maximum nodes allowed in genome
maxBeams: number; // Maximum beams allowed in genome
beamStrength: number; // Force threshold before breaking (N)
beamStiffness: number; // Constraint stiffness (0-1)
loadMass: number; // Mass of the load to support (kg)
nodeRadius: number; // Radius of node bodies
}
export interface SimulationConfig {
populationSize: number;
maxSteps: number; // Max physics steps per evaluation
physicsIterations: number; // Matter.js constraint iterations
timeStep: number; // Physics time step (ms)
}
export interface GAConfig {
mutationRate: number; // Probability of mutation per genome
eliteCount: number; // Number of top performers to preserve
tournamentSize: number; // Tournament selection size
// Mutation operation probabilities (should sum to ~1.0)
addNodeProb: number;
removeNodeProb: number;
moveNodeProb: number;
addBeamProb: number;
removeBeamProb: number;
}
export interface BeamForce {
nodeA: number;
nodeB: number;
force: number; // Positive = tension, Negative = compression
broken: boolean;
}
export interface SimulationResult {
fitness: number;
stepsSupported: number;
avgLoadHeight: number;
beamCount: number;
maxStress: number;
collapsed: boolean;
}
// Default configurations
export const DEFAULT_BRIDGE_CONFIG: BridgeConfig = {
spanWidth: 600,
anchorHeight: 200,
maxNodes: 50,
maxBeams: 100,
beamStrength: 5000, // Increased for better initial stability
beamStiffness: 1.0, // RIGID - no stretching!
loadMass: 10, // 10kg - significant challenge
nodeRadius: 5,
};
export const DEFAULT_SIM_CONFIG: SimulationConfig = {
populationSize: 50,
maxSteps: 600, // 10 seconds at 60fps
physicsIterations: 10,
timeStep: 1000 / 60,
};
export const DEFAULT_GA_CONFIG: GAConfig = {
mutationRate: 0.8,
eliteCount: 5,
tournamentSize: 3,
addNodeProb: 0.2,
removeNodeProb: 0.1,
moveNodeProb: 0.3,
addBeamProb: 0.3,
removeBeamProb: 0.1,
};

View File

@@ -0,0 +1,101 @@
import { useState, useEffect, useRef } from 'react';
import type { BridgeGenome, BridgeConfig, SimulationConfig, GAConfig } from './types';
import TrainingWorker from './training.worker.ts?worker';
export function useEvolutionWorker(
bridgeConfig: BridgeConfig,
simConfig: SimulationConfig,
gaConfig: GAConfig
) {
const [generation, setGeneration] = useState(0);
const [bestFitness, setBestFitness] = useState(0);
const [avgFitness, setAvgFitness] = useState(0);
const [bestGenome, setBestGenome] = useState<BridgeGenome | null>(null);
const [isTraining, setIsTraining] = useState(false);
// Fitness history for graphing
const [bestFitnessHistory, setBestFitnessHistory] = useState<number[]>([]);
const [avgFitnessHistory, setAvgFitnessHistory] = useState<number[]>([]);
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
// Create worker
workerRef.current = new TrainingWorker();
// Setup message handler
if (workerRef.current) {
workerRef.current.onmessage = (e: MessageEvent) => {
const { type, payload } = e.data;
if (type === 'progress') {
console.log('[useEvolutionWorker] Received progress:', {
gen: payload.generation,
fitness: payload.bestFitness,
genomeNodes: payload.bestGenome?.nodes.length,
genomeBeams: payload.bestGenome?.beams.length
});
setGeneration(payload.generation);
setBestFitness(payload.bestFitness);
setAvgFitness(payload.avgFitness);
setBestGenome(payload.bestGenome);
// Update fitness history
setBestFitnessHistory(prev => [...prev, payload.bestFitness]);
setAvgFitnessHistory(prev => [...prev, payload.avgFitness]);
}
};
}
return () => {
workerRef.current?.terminate();
};
}, []);
const startTraining = () => {
if (!workerRef.current) return;
workerRef.current.postMessage({
type: 'start',
payload: {
bridgeConfig,
simConfig,
gaConfig,
},
});
setIsTraining(true);
};
const stopTraining = () => {
if (!workerRef.current) return;
workerRef.current.postMessage({ type: 'stop' });
setIsTraining(false);
};
const reset = () => {
if (!workerRef.current) return;
workerRef.current.postMessage({ type: 'reset' });
setGeneration(0);
setBestFitness(0);
setAvgFitness(0);
setBestGenome(null);
setBestFitnessHistory([]);
setAvgFitnessHistory([]);
};
return {
generation,
bestFitness,
avgFitness,
bestGenome,
isTraining,
bestFitnessHistory,
avgFitnessHistory,
startTraining,
stopTraining,
reset,
};
}

View File

@@ -1,7 +1,7 @@
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import './Sidebar.css'; import './Sidebar.css';
export type AppId = 'image-approx' | 'snake-ai' | 'rogue-gen' | 'neat-arena' | 'lunar-lander' | 'self-driving-car'; export type AppId = 'image-approx' | 'snake-ai' | 'rogue-gen' | 'neat-arena' | 'lunar-lander' | 'self-driving-car' | 'bridge-builder' | 'asteroids-ai';
export interface AppInfo { export interface AppInfo {
id: AppId; id: AppId;
@@ -47,6 +47,18 @@ export const APPS: AppInfo[] = [
name: 'Self-Driving Car', name: 'Self-Driving Car',
description: 'Evolve cars to navigate a track', description: 'Evolve cars to navigate a track',
}, },
{
id: 'bridge-builder',
path: '/bridge-builder',
name: 'Bridge Builder',
description: 'Evolve bridge structures with stress visualization',
},
{
id: 'asteroids-ai',
path: '/asteroids-ai',
name: 'Asteroids AI',
description: 'Evolve strategies to shoot asteroids and avoid collisions',
},
]; ];
export default function Sidebar() { export default function Sidebar() {