Particle Life — Complete Code

Full working simulation with all features

šŸ“„ Single File šŸŽÆ Copy & Run šŸ’¾ Includes Save/Load

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: