About This Code
This is the complete Particle Life simulation with all three stages implemented:
- Foundation ā Particle physics, species interactions, spatial hashing
- Configuration ā Settings panel, presets, trails effect, adjustable parameters
- Persistence ā Save/load configurations to localStorage
How to Run
Follow the Setup Guide for step-by-step instructions to run this on your Mac.
App.jsx ā Particle Life Complete
import React, { useRef, useEffect, useState, useCallback } from 'react';
// ============================================
// CONSTANTS
// ============================================
const WIDTH = 1400;
const HEIGHT = 1000;
const NUM_PARTICLES = 1000;
const NUM_SPECIES = 6;
const MAX_DISTANCE = 100;
const MIN_DISTANCE = 20;
const CELL_SIZE = MAX_DISTANCE;
const COLORS = [
'#FF6B6B', // Red
'#4ECDC4', // Teal
'#45B7D1', // Blue
'#96CEB4', // Green
'#FFEAA7', // Yellow
'#DDA0DD', // Plum
];
const DEFAULT_SETTINGS = {
particleCount: 10000,
friction: 0.5,
forceFactor: 1,
maxDistance: 100,
minDistance: 20,
trailsEnabled: false,
trailOpacity: 0.1,
wrapEdges: true,
};
// ============================================
// PRESET RULES
// ============================================
const PRESET_RULES = {
random: () => {
const rules = [];
for (let i = 0; i < NUM_SPECIES; i++) {
rules[i] = [];
for (let j = 0; j < NUM_SPECIES; j++) {
rules[i][j] = Math.random() * 2 - 1;
}
}
return rules;
},
symmetric: () => {
const rules = [];
for (let i = 0; i < NUM_SPECIES; i++) {
rules[i] = [];
for (let j = 0; j < NUM_SPECIES; j++) {
if (j <= i) {
rules[i][j] = Math.random() * 2 - 1;
} else {
rules[i][j] = rules[j]?.[i] ?? Math.random() * 2 - 1;
}
}
}
return rules;
},
chains: () => {
const rules = [];
for (let i = 0; i < NUM_SPECIES; i++) {
rules[i] = [];
for (let j = 0; j < NUM_SPECIES; j++) {
if (j === (i + 1) % NUM_SPECIES) {
rules[i][j] = 0.8;
} else if (j === i) {
rules[i][j] = -0.2;
} else {
rules[i][j] = 0;
}
}
}
return rules;
},
clusters: () => {
const rules = [];
for (let i = 0; i < NUM_SPECIES; i++) {
rules[i] = [];
for (let j = 0; j < NUM_SPECIES; j++) {
if (i === j) {
rules[i][j] = 0.5;
} else {
rules[i][j] = -0.3;
}
}
}
return rules;
},
snakes: () => {
const rules = [];
for (let i = 0; i < NUM_SPECIES; i++) {
rules[i] = [];
for (let j = 0; j < NUM_SPECIES; j++) {
if (j === (i + 1) % NUM_SPECIES) {
rules[i][j] = 1;
} else if (j === i) {
rules[i][j] = 0.1;
} else if (j === (i + NUM_SPECIES - 1) % NUM_SPECIES) {
rules[i][j] = -0.5;
} else {
rules[i][j] = 0;
}
}
}
return rules;
},
orbits: () => {
const rules = [];
for (let i = 0; i < NUM_SPECIES; i++) {
rules[i] = [];
for (let j = 0; j < NUM_SPECIES; j++) {
const diff = (j - i + NUM_SPECIES) % NUM_SPECIES;
if (diff === 1) {
rules[i][j] = 0.6;
} else if (diff === NUM_SPECIES - 1) {
rules[i][j] = -0.6;
} else if (i === j) {
rules[i][j] = -0.1;
} else {
rules[i][j] = 0.1;
}
}
}
return rules;
},
};
// ============================================
// SPATIAL HASH GRID
// ============================================
class SpatialHashGrid {
constructor(width, height, cellSize) {
this.cellSize = cellSize;
this.cols = Math.ceil(width / cellSize);
this.rows = Math.ceil(height / cellSize);
this.cells = new Map();
}
clear() {
this.cells.clear();
}
getKey(col, row) {
return `${col},${row}`;
}
insert(particle) {
const col = Math.floor(particle.x / this.cellSize);
const row = Math.floor(particle.y / this.cellSize);
const key = this.getKey(col, row);
if (!this.cells.has(key)) {
this.cells.set(key, []);
}
this.cells.get(key).push(particle);
}
getNearby(particle) {
const col = Math.floor(particle.x / this.cellSize);
const row = Math.floor(particle.y / this.cellSize);
const nearby = [];
for (let dc = -1; dc <= 1; dc++) {
for (let dr = -1; dr <= 1; dr++) {
const key = this.getKey(col + dc, row + dr);
const cell = this.cells.get(key);
if (cell) {
nearby.push(...cell);
}
}
}
return nearby;
}
}
// ============================================
// PARTICLE FACTORY
// ============================================
function createParticle(id) {
return {
id,
x: Math.random() * WIDTH,
y: Math.random() * HEIGHT,
vx: 0,
vy: 0,
species: Math.floor(Math.random() * NUM_SPECIES),
};
}
function createParticles(count) {
const particles = [];
for (let i = 0; i < count; i++) {
particles.push(createParticle(i));
}
return particles;
}
// ============================================
// STORAGE FUNCTIONS
// ============================================
const STORAGE_KEY = 'particle-life-config';
function saveConfiguration(name, settings, rules) {
const saved = loadAllConfigurations();
saved[name] = {
settings,
rules,
timestamp: Date.now(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
}
function loadAllConfigurations() {
try {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : {};
} catch {
return {};
}
}
function deleteConfiguration(name) {
const saved = loadAllConfigurations();
delete saved[name];
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
}
// ============================================
// MAIN COMPONENT
// ============================================
export default function ParticleLife() {
const canvasRef = useRef(null);
const trailCanvasRef = useRef(null);
const particlesRef = useRef([]);
const rulesRef = useRef([]);
const gridRef = useRef(null);
const animationRef = useRef(null);
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
const [currentPreset, setCurrentPreset] = useState('random');
const [savedConfigs, setSavedConfigs] = useState({});
const [saveName, setSaveName] = useState('');
// Initialize simulation
const initSimulation = useCallback(() => {
particlesRef.current = createParticles(settings.particleCount);
rulesRef.current = PRESET_RULES[currentPreset]();
gridRef.current = new SpatialHashGrid(WIDTH, HEIGHT, CELL_SIZE);
// Clear trail canvas
const trailCanvas = trailCanvasRef.current;
if (trailCanvas) {
const ctx = trailCanvas.getContext('2d');
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, WIDTH, HEIGHT);
}
}, [settings.particleCount, currentPreset]);
// Update physics
const updatePhysics = useCallback(() => {
const particles = particlesRef.current;
const rules = rulesRef.current;
const grid = gridRef.current;
const { friction, forceFactor, maxDistance, minDistance, wrapEdges } = settings;
grid.clear();
for (const p of particles) {
grid.insert(p);
}
for (const p of particles) {
let fx = 0;
let fy = 0;
const nearby = grid.getNearby(p);
for (const other of nearby) {
if (p.id === other.id) continue;
let dx = other.x - p.x;
let dy = other.y - p.y;
if (wrapEdges) {
if (dx > WIDTH / 2) dx -= WIDTH;
if (dx < -WIDTH / 2) dx += WIDTH;
if (dy > HEIGHT / 2) dy -= HEIGHT;
if (dy < -HEIGHT / 2) dy += HEIGHT;
}
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 0 && dist < maxDistance) {
const rule = rules[p.species][other.species];
let force = 0;
if (dist < minDistance) {
force = (dist / minDistance - 1) * forceFactor;
} else {
force = rule * (1 - (dist - minDistance) / (maxDistance - minDistance)) * forceFactor;
}
fx += (dx / dist) * force;
fy += (dy / dist) * force;
}
}
p.vx = (p.vx + fx) * (1 - friction * 0.1);
p.vy = (p.vy + fy) * (1 - friction * 0.1);
p.x += p.vx;
p.y += p.vy;
if (wrapEdges) {
if (p.x < 0) p.x += WIDTH;
if (p.x >= WIDTH) p.x -= WIDTH;
if (p.y < 0) p.y += HEIGHT;
if (p.y >= HEIGHT) p.y -= HEIGHT;
} else {
if (p.x < 0) { p.x = 0; p.vx *= -0.5; }
if (p.x >= WIDTH) { p.x = WIDTH - 1; p.vx *= -0.5; }
if (p.y < 0) { p.y = 0; p.vy *= -0.5; }
if (p.y >= HEIGHT) { p.y = HEIGHT - 1; p.vy *= -0.5; }
}
}
}, [settings]);
// Render
const render = useCallback(() => {
const canvas = canvasRef.current;
const trailCanvas = trailCanvasRef.current;
const ctx = canvas.getContext('2d');
const trailCtx = trailCanvas.getContext('2d');
const particles = particlesRef.current;
const rules = rulesRef.current;
const { trailsEnabled, trailOpacity } = settings;
if (trailsEnabled) {
trailCtx.fillStyle = `rgba(0, 0, 0, ${trailOpacity})`;
trailCtx.fillRect(0, 0, WIDTH, HEIGHT);
for (const p of particles) {
trailCtx.fillStyle = COLORS[p.species];
trailCtx.beginPath();
trailCtx.arc(p.x, p.y, 1, 0, Math.PI * 2);
trailCtx.fill();
}
ctx.drawImage(trailCanvas, 0, 0);
} else {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, WIDTH, HEIGHT);
for (const p of particles) {
ctx.fillStyle = COLORS[p.species];
ctx.beginPath();
ctx.arc(p.x, p.y, 1, 0, Math.PI * 2);
ctx.fill();
}
}
// Draw rules matrix
const matrixSize = 60;
const cellSize = matrixSize / NUM_SPECIES;
const matrixX = WIDTH - matrixSize - 10;
const matrixY = 10;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(matrixX - 5, matrixY - 5, matrixSize + 10, matrixSize + 10);
for (let i = 0; i < NUM_SPECIES; i++) {
for (let j = 0; j < NUM_SPECIES; j++) {
const rule = rules[i][j];
const hue = rule > 0 ? 120 : 0;
const lightness = 30 + Math.abs(rule) * 40;
ctx.fillStyle = `hsl(${hue}, 70%, ${lightness}%)`;
ctx.fillRect(
matrixX + j * cellSize,
matrixY + i * cellSize,
cellSize - 1,
cellSize - 1
);
}
}
// HUD
ctx.fillStyle = '#fff';
ctx.font = '12px monospace';
ctx.fillText(`Particles: ${particles.length}`, 10, 20);
ctx.fillText(`Species: ${NUM_SPECIES}`, 10, 35);
ctx.fillText(`Preset: ${currentPreset}`, 10, 50);
}, [settings, currentPreset]);
// Game loop
const gameLoop = useCallback(() => {
updatePhysics();
render();
animationRef.current = requestAnimationFrame(gameLoop);
}, [updatePhysics, render]);
// Initialize on mount
useEffect(() => {
initSimulation();
setSavedConfigs(loadAllConfigurations());
}, []);
// Start game loop
useEffect(() => {
animationRef.current = requestAnimationFrame(gameLoop);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [gameLoop]);
// Handlers
const handleSettingChange = (key, value) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const handlePresetChange = (preset) => {
setCurrentPreset(preset);
rulesRef.current = PRESET_RULES[preset]();
};
const handleReset = () => {
initSimulation();
};
const handleApply = () => {
initSimulation();
};
const handleResetDefaults = () => {
setSettings(DEFAULT_SETTINGS);
setCurrentPreset('random');
initSimulation();
};
const handleSave = () => {
if (!saveName.trim()) return;
saveConfiguration(saveName, settings, rulesRef.current);
setSavedConfigs(loadAllConfigurations());
setSaveName('');
};
const handleLoad = (name) => {
const config = savedConfigs[name];
if (config) {
setSettings(config.settings);
rulesRef.current = config.rules;
particlesRef.current = createParticles(config.settings.particleCount);
}
};
const handleDelete = (name) => {
deleteConfiguration(name);
setSavedConfigs(loadAllConfigurations());
};
return (
<div style={{
display: 'flex',
gap: '20px',
padding: '20px',
background: '#1a1a2e',
minHeight: '100vh',
fontFamily: 'system-ui, sans-serif'
}}>
{/* Canvas Container */}
<div style={{ position: 'relative' }}>
<canvas
ref={trailCanvasRef}
width={WIDTH}
height={HEIGHT}
style={{ display: 'none' }}
/>
<canvas
ref={canvasRef}
width={WIDTH}
height={HEIGHT}
style={{
border: '2px solid #333',
borderRadius: '8px',
}}
/>
</div>
{/* Controls Panel */}
<div style={{
background: '#16213e',
padding: '20px',
borderRadius: '8px',
color: '#fff',
width: '280px',
maxHeight: '600px',
overflowY: 'auto',
}}>
<h2 style={{ margin: '0 0 15px 0', color: '#4ECDC4' }}>Particle Life</h2>
{/* Presets */}
<div style={{ marginBottom: '20px' }}>
<h3 style={{ margin: '0 0 10px 0', fontSize: '14px', color: '#888' }}>Presets</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '5px' }}>
{Object.keys(PRESET_RULES).map(preset => (
<button
key={preset}
onClick={() => handlePresetChange(preset)}
style={{
padding: '5px 10px',
background: currentPreset === preset ? '#4ECDC4' : '#2a3a5e',
color: currentPreset === preset ? '#000' : '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
}}
>
{preset}
</button>
))}
</div>
</div>
{/* Settings */}
<div style={{ marginBottom: '20px' }}>
<h3 style={{ margin: '0 0 10px 0', fontSize: '14px', color: '#888' }}>Settings</h3>
<label style={{ display: 'block', marginBottom: '10px' }}>
<span style={{ fontSize: '12px' }}>Particles: {settings.particleCount}</span>
<input
type="range"
min="200"
max="10000"
step="200"
value={settings.particleCount}
onChange={(e) => handleSettingChange('particleCount', parseInt(e.target.value))}
style={{ width: '100%' }}
/>
</label>
<label style={{ display: 'block', marginBottom: '10px' }}>
<span style={{ fontSize: '12px' }}>Friction: {settings.friction.toFixed(2)}</span>
<input
type="range"
min="0"
max="1"
step="0.05"
value={settings.friction}
onChange={(e) => handleSettingChange('friction', parseFloat(e.target.value))}
style={{ width: '100%' }}
/>
</label>
<label style={{ display: 'block', marginBottom: '10px' }}>
<span style={{ fontSize: '12px' }}>Force: {settings.forceFactor.toFixed(2)}</span>
<input
type="range"
min="0.01"
max="0.02"
step="0.001"
value={settings.forceFactor}
onChange={(e) => handleSettingChange('forceFactor', parseFloat(e.target.value))}
style={{ width: '100%' }}
/>
</label>
<label style={{ display: 'block', marginBottom: '10px' }}>
<input
type="checkbox"
checked={settings.trailsEnabled}
onChange={(e) => handleSettingChange('trailsEnabled', e.target.checked)}
/>
<span style={{ fontSize: '12px', marginLeft: '5px' }}>Enable Trails</span>
</label>
<label style={{ display: 'block', marginBottom: '10px' }}>
<input
type="checkbox"
checked={settings.wrapEdges}
onChange={(e) => handleSettingChange('wrapEdges', e.target.checked)}
/>
<span style={{ fontSize: '12px', marginLeft: '5px' }}>Wrap Edges</span>
</label>
</div>
{/* Action Buttons */}
<div style={{ display: 'flex', gap: '10px', marginBottom: '20px' }}>
<button
onClick={handleApply}
style={{
flex: 1,
padding: '8px',
background: '#4ECDC4',
color: '#000',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 'bold',
}}
>
Apply
</button>
<button
onClick={handleReset}
style={{
flex: 1,
padding: '8px',
background: '#FF6B6B',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Reset
</button>
</div>
{/* Save/Load */}
<div style={{ borderTop: '1px solid #333', paddingTop: '15px' }}>
<h3 style={{ margin: '0 0 10px 0', fontSize: '14px', color: '#888' }}>Save / Load</h3>
<div style={{ display: 'flex', gap: '5px', marginBottom: '10px' }}>
<input
type="text"
placeholder="Config name..."
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
style={{
flex: 1,
padding: '5px',
background: '#2a3a5e',
border: '1px solid #444',
borderRadius: '4px',
color: '#fff',
}}
/>
<button
onClick={handleSave}
style={{
padding: '5px 15px',
background: '#45B7D1',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Save
</button>
</div>
{Object.keys(savedConfigs).length > 0 && (
<div style={{ fontSize: '12px' }}>
{Object.keys(savedConfigs).map(name => (
<div
key={name}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '5px',
background: '#2a3a5e',
borderRadius: '4px',
marginBottom: '5px',
}}
>
<span>{name}</span>
<div>
<button
onClick={() => handleLoad(name)}
style={{
padding: '2px 8px',
background: '#4ECDC4',
color: '#000',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
marginRight: '5px',
}}
>
Load
</button>
<button
onClick={() => handleDelete(name)}
style={{
padding: '2px 8px',
background: '#FF6B6B',
color: '#fff',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
}}
>
Ć
</button>
</div>
</div>
))}
</div>
)}
</div>
<button
onClick={handleResetDefaults}
style={{
width: '100%',
padding: '8px',
marginTop: '15px',
background: '#333',
color: '#888',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
}}
>
Reset to Defaults
</button>
</div>
</div>
);
}
Features
- 10000 particles across 6 species with unique interaction rules
- Spatial hashing for O(n) performance instead of O(n²)
- 6 built-in presets: Random, Symmetric, Chains, Clusters, Snakes, Orbits
- Adjustable settings: particle count, friction, force, trails, edge wrapping
- Save/Load configurations to localStorage
- Live rules matrix visualization
- 60fps performance on modern hardware
Want to Learn How It Works?
Follow the Build It tutorial to understand every line of code:
- Foundation Guide ā Build the core simulation from scratch (80 pages)
- Configuration Guide ā Add settings and presets (coming soon)
- Persistence Guide ā Add save/load functionality (coming soon)