Skip to content

Instantly share code, notes, and snippets.

@Isinlor
Last active January 1, 2026 00:12
Show Gist options
  • Select an option

  • Save Isinlor/33687fd868f37f333500f53203d21628 to your computer and use it in GitHub Desktop.

Select an option

Save Isinlor/33687fd868f37f333500f53203d21628 to your computer and use it in GitHub Desktop.
Evo-Sim2: Creatures evolution
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Evo-Sim: Creature Evolution</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
font-family: 'Inter', sans-serif;
overscroll-behavior: none;
}
.param-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
input[type=range] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 8px;
border-radius: 5px;
background: #d3d3d3;
outline: none;
opacity: 0.7;
-webkit-transition: .2s;
transition: opacity .2s;
}
input[type=range]:hover {
opacity: 1;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #4A5568;
cursor: pointer;
}
input[type=range]::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #4A5568;
cursor: pointer;
}
/* Small table adjustments */
#statsTable th, #statsTable td {
text-align: right;
}
#statsTable th:first-child, #statsTable td:first-child {
text-align: left;
}
canvas {
background-color: white;
}
</style>
</head>
<body class="bg-gray-100 text-gray-800 flex flex-col lg:flex-row h-screen overflow-hidden">
<!-- Simulation Canvas -->
<main class="flex-grow flex flex-col items-center justify-center p-4 bg-gray-900 order-1 lg:order-2">
<canvas id="simulationCanvas" class="bg-white rounded-lg shadow-xl w-full h-full"></canvas>
</main>
<!-- Controls & Stats Panel -->
<aside class="w-full lg:w-96 bg-white shadow-lg p-4 space-y-4 overflow-y-auto order-2 lg:order-1">
<h1 class="text-2xl font-bold text-gray-700 text-center">Evo-Sim Controls</h1>
<!-- Main Buttons -->
<div class="grid grid-cols-3 gap-2">
<button id="startButton" class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-lg transition duration-300">Start</button>
<button id="pauseButton" class="bg-yellow-500 hover:bg-yellow-600 text-white font-bold py-2 px-4 rounded-lg transition duration-300">Pause</button>
<button id="resetButton" class="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded-lg transition duration-300">Reset</button>
</div>
<div class="grid grid-cols-1 gap-2">
<button id="stepButton" class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-lg transition duration-300">Step</button>
</div>
<!-- Network Visualization -->
<div id="net-viz-container" class="bg-gray-50 rounded-lg hidden">
<h2 class="text-xl font-semibold text-center text-purple-700">Reward Network</h2>
<div class="relative w-full h-96 bg-gray-900 rounded border border-gray-300 overflow-auto">
<canvas id="networkCanvas" width="400" height="2000" ></canvas>
</div>
<p class="text-xs text-center text-gray-500 mt-1">Computation Graph (Green+, Red-)</p>
</div>
<!-- NEW Statistics System -->
<div class="bg-gray-50 p-3 rounded-lg overflow-x-auto">
<h2 class="text-xl font-semibold mb-2 text-center">Statistics</h2>
<div class="flex justify-between text-xs text-gray-500 mb-2 px-1">
<span>Frames: <span id="framesStat">0</span></span>
<span>FPS: <span id="fpsStat">0</span></span>
</div>
<div class="flex justify-between text-xs font-bold mb-2 px-1">
<span class="text-green-600">Plants: <span id="plantsStat">0</span></span>
<span class="text-blue-600">Creatures: <span id="creaturesStat">0</span></span>
</div>
<table class="w-full text-xs border-collapse" id="statsTable">
<thead>
<tr id="statsHeaders" class="border-b-2 border-gray-300 text-gray-700">
</tr>
</thead>
<tbody id="statsBody">
<!-- Rows will be injected by JS -->
</tbody>
</table>
</div>
<!-- Parameters -->
<div class="bg-gray-50 p-3 rounded-lg">
<h2 class="text-xl font-semibold mb-2 text-center">World Parameters</h2>
<div id="params-container" class="param-grid">
<!-- Parameters will be injected here by JS -->
</div>
</div>
<div class="grid grid-cols-1 gap-2">
<button id="resetColorsButton" class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-lg transition duration-300">Reset colors</button>
</div>
</aside>
<script type="module">
// --- PRE-SETUP ---
const canvas = document.getElementById('simulationCanvas');
const ctx = canvas.getContext('2d');
const netCanvas = document.getElementById('networkCanvas');
const netCtx = netCanvas.getContext('2d');
const netVizContainer = document.getElementById('net-viz-container');
// --- GLOBAL STATE ---
let creatures = [];
let plants = [];
let walls = [];
let plantGrid;
let creatureGrid;
let wallGrid;
let isRunning = false;
let frameCount = 0;
let animationFrameId;
let gradientAngle = 0;
// --- SELECTION STATE ---
let selectedCreature = null;
const getCreatureType = (c) => c.generation > 2 ? c.color.map(color => Math.round(color)).join('') : "new";
// --- NEW STATISTICS MANAGER ---
const statisticsManager = {
statDefs: [
{ label: "Count", stat: { key: "count", value: (c) => 1 }, avg: false, fmt: v => v },
{ label: "Energy", stat: { key: "energy", value: (c) => c.energy }, avg: true, fmt: v => v.toFixed(0) },
{ label: "Age", stat: { key: "age", value: (c) => c.age }, avg: true, fmt: v => v.toFixed(0) },
{ label: "Gen", stat: { key: "generation", value: (c) => c.generation }, avg: true, fmt: v => v.toFixed(1) },
{ label: "Strength", stat: { key: "strength", value: (c) => c.strength }, avg: true, fmt: v => v.toFixed(2) },
{ label: "Radius", stat: { key: "maxSenseRadius", value: (c) => c.maxSenseRadius }, avg: true, fmt: v => v.toFixed(1) },
{ label: "Bias (0P-1C)", stat: { key: "controlBias", value: (c) => c.controlBias }, avg: true, fmt: v => v.toFixed(2) },
{ label: "Memory", stat: { key: "memory", value: (c) => c.memory.length }, avg: true, fmt: v => v.toFixed(1) },
{ label: "Hidd Layers", stat: { key: "hiddenStructure", value: (c) => c.net.getHiddenStructure().length }, avg: true, fmt: v => v.toFixed(1) },
{ label: "Hidd Neurons", stat: { key: "hiddenNeurons", value: (c) => c.net.getHiddenStructure().reduce((a, b) => a + b, 0) }, avg: true, fmt: v => v.toFixed(1) },
{ label: "Min Neurons", stat: { key: "minHiddenNeurons", value: (c) => Math.min(...c.net.getHiddenStructure()) }, avg: true, fmt: v => v.toFixed(1) },
{ label: "Max Neurons", stat: { key: "maxHiddenNeurons", value: (c) => Math.max(...c.net.getHiddenStructure()) }, avg: true, fmt: v => v.toFixed(1) },
{ label: "Ensable %", stat: { key: "ensamble", value: (c) => (c.net.numModels ?? 0) > 0 ? 1 : 0 }, avg: true, fmt: v => (v * 100).toFixed(0) + '%' },
{ label: "Mask sum", stat: { key: "maskSum", value: (c) => c.net.rewardNet ? c.net.rewardNet.maskSum() : 0 }, avg: true, fmt: v => v.toFixed(1) },
{ label: "Experts", stat: { key: "numModels", value: (c) => c.net.numModels ?? 0 }, avg: true, fmt: v => v.toFixed(1) },
{ label: "Epsilon", stat: { key: "epsilon", value: (c) => c.net.epsilon ?? 0 }, avg: true, fmt: v => v.toFixed(3) },
{ label: "Top Alloc", stat: { key: "topAllocation", value: (c) => c.net.topAllocation ?? 0 }, avg: true, fmt: v => v.toFixed(3) },
{ label: "Q smooth", stat: { key: "qSmoothing", value: (c) => c.net.qSmoothing ?? 0 }, avg: true, fmt: v => v.toFixed(3) },
{ label: "Mut.Style", stat: { key: "mutationStyle", value: (c) => c.mutationStyle }, avg: true, fmt: v => v.toFixed(2) },
{ label: "Stance", stat: { key: "stance", value: (c) => c.stance }, avg: true, fmt: v => v.toFixed(2) },
{ label: "Sense", stat: { key: "sensing", value: (c) => c.sensing }, avg: true, fmt: v => v.toFixed(2) },
],
initialize() {
this.update([], 0, 0); // Clear table on init
},
triggerEvent(eventName, creature) {
// Not strictly used for the table currently, but kept for future extensibility
},
update(creatures, frameCount, plantsCount) {
// Initialize buckets
const buckets = {
all: { count: 0, sums: {}, max: {}, min: {} },
};
for (const c of creatures) {
const maxColor = Math.max(...c.color);
const color = maxColor === c.color[0] ? 'red' : maxColor === c.color[1] ? 'green' : 'blue';
buckets[getCreatureType(c)] = { count: 0, sums: {}, max: {}, min: {}, color: c.color, type: c.generation > 2 ? color : 'grey' };
}
// Initialize sums keys
for(const k in buckets) {
buckets[k].count = 0;
for(const def of this.statDefs) {
if(def.avg) {
buckets[k].sums[def.stat.key] = 0;
buckets[k].max[def.stat.key] = -Infinity;
buckets[k].min[def.stat.key] = Infinity;
}
}
}
// Aggregate Data
for (const c of creatures) {
const type = getCreatureType(c);
const bucketList = [buckets.all, buckets[type]];
for(const bucket of bucketList) {
bucket.count++;
for(const def of this.statDefs) {
if(def.avg) {
const value = def.stat.value(c);
bucket.sums[def.stat.key] += value;
bucket.max[def.stat.key] = Math.max(bucket.max[def.stat.key], value);
bucket.min[def.stat.key] = Math.min(bucket.min[def.stat.key], value);
}
}
}
}
// Update UI
const newCreatures = creatures.filter(c => c.generation > 2).length;
document.getElementById('framesStat').textContent = frameCount;
document.getElementById('plantsStat').textContent = plantsCount;
document.getElementById('creaturesStat').textContent = `${creatures.length} (${newCreatures} + ${creatures.length - newCreatures})`;
const tbody = document.getElementById('statsBody');
if(!tbody) return;
let html = '';
let htmlHeaders = '<th class="p-1 pb-2" style="white-space: nowrap; min-width: 80px;">Param</th>';
const types = Object.keys(buckets).sort((a, b) => {
if (a === 'all') return -1;
if (b === 'all') return 1;
return buckets[b].count - buckets[a].count;
});
for(const t of types) {
const color = buckets[t].color;
htmlHeaders += `<th class="p-1 pb-2 truncate" style="color: rgb(${color && t != 'new' ? color.join(',') : ''});">${t}</th>`;
}
document.getElementById('statsHeaders').innerHTML = htmlHeaders;
for(const def of this.statDefs) {
html += `<tr class="border-b border-gray-200 hover:bg-gray-100 transition-colors">`;
html += `<td class="text-left font-medium p-1 text-gray-600">${def.label}</td>`;
for(const t of types) {
let val = 0;
if (def.stat.key === 'count') {
val = buckets[t].count;
} else {
val = buckets[t].count > 0 ? buckets[t].sums[def.stat.key] / buckets[t].count : 0;
}
const display = def.fmt(val);
let colorClass = "";
if(buckets[t].type === 'red') colorClass = "text-red-700 bg-red-50";
else if(buckets[t].type === 'green') colorClass = "text-green-700 bg-green-50";
else if(buckets[t].type === 'blue') colorClass = "text-blue-700 bg-blue-50";
else colorClass = "font-bold text-gray-800 bg-gray-100";
// Dim zeros
if(val === 0) colorClass += " opacity-30";
html += `<td class="p-1 ${colorClass}" title="${def.fmt(buckets[t].min[def.stat.key])} - ${def.fmt(buckets[t].max[def.stat.key])}">${display}</td>`;
}
html += `</tr>`;
}
tbody.innerHTML = html;
}
};
const params = {
initialCreatures: { value: 0, min: 0, max: 5000, step: 10, label: "Initial Creatures" },
plantCount: { value: 2500, min: 50, max: 10000, step: 50, label: "Plant Count" },
plantRespawnRate: { value: 1000, min: 1, max: 1000, step: 10, label: "Plant Respawn Rate" },
plantGradientRange: { value: 0.5, min: 0, max: 0.5, step: 0.01, label: "Plant Gradient Range" },
gradientRotationSpeed: { value: 0.1, min: 0, max: 1, step: 0.01, label: "Gradient Rotation Speed" },
energyFromPlant: { value: 25, min: 1, max: 100, step: 1, label: "Energy from Plant" },
plantEnergySpread: { value: 3, min: 1, max: 100, step: 1, label: "Plant energy spread" },
reproductionCost: { value: 350, min: 1, max: 500, step: 1, label: "Reproduction Cost" },
reproductionThreshold: { value: 1000, min: 100, max: 1000, step: 10, label: "Reproduction Threshold" },
basalMetabolism: { value: 1, min: 0.1, max: 10, step: 0.1, label: "Constant Energy Drain" },
moveCostMultiplier: { value: 0.1, min: 0.1, max: 5, step: 0.1, label: "Movement Cost Multiplier" },
maxSpeed: { value: 7.5, min: 0.5, max: 20, step: 0.5, label: "Max Speed" },
attackRadius: { value: 10, min: 1, max: 50, step: 0.5, label: "Attack Radius" },
maxEnergyTransfer: { value: 30, min: 1, max: 1000, step: 1, label: "Max Energy Transfer" },
stanceCost: { value: 1, min: 0, max: 10, step: 0.1, label: "Stance Cost Multiplier" },
strengthMetabolism: { value: 0.05, min: 0, max: 0.1, step: 0.001, label: "Strength Metabolism" },
pushPullCost: { value: 0.5, min: 0, max: 1, step: 0.01, label: "Push/Pull Cost" },
wallSensorRadius: { value: 100, min: 10, max: 500, step: 10, label: "Wall Detect Dist" },
mutationRate: { value: 0.5, min: 0, max: 1, step: 0.01, label: "Mutation Rate" },
mutationAmount: { value: 0.1, min: 0, max: 1, step: 0.01, label: "Mutation Amount" },
controllerMinCreatures: { value: 200, min: 0, max: 1000, step: 10, label: "Add creatures treshold" },
controllerLowPop: { value: 350, min: 0, max: 300, step: 10, label: "Low Pop Threshold" },
controllerAvgPop: { value: 500, min: 0, max: 1000, step: 100, label: "Low Avg Pop Threshold" },
controllerAvgPopWindow: { value: 10, min: 1, max: 300, step: 10, label: "Avg Pop Window" },
controllerRateInc: { value: 0.111, min: 0, max: 1, step: 0.001, label: "Pop Correction" },
controllerRateDecFPS: { value: 0.11, min: 0, max: 1, step: 0.001, label: "Low FPS Correction" },
controllerFPSThreshold: { value: 2, min: 1, max: 120, step: 1, label: "Low FPS Threshold" },
controllerNewCreatures: { value: 1, min: 0, max: 10, step: 1, label: "New creatures per frame" },
};
const activeController = {
energyHistory: [], popHistory: [], lastTime: 0, frameCount: 0, continuusLowFPSCount: 0, energyReductionCooldown: 0, energyReductionCooldown2: 0, belowTargetPopulationCounter: 0, aboveTargetPopulationCounter: 0,
initialize() { this.energyHistory = []; this.popHistory = []; this.lastTime = performance.now(); this.frameCount = 0; },
updateParamDisplay(key) { if (params[key] && params[key].uiInput && params[key].uiLabel) { params[key].uiInput.value = params[key].value; params[key].uiLabel.textContent = params[key].value.toFixed(3); } },
update(creatures) {
const currentPop = creatures.filter(c => c.generation > 2).length;
this.popHistory.push(currentPop);
const popWin = params.controllerAvgPopWindow.value;
if (this.popHistory.length > popWin) this.popHistory.shift();
const now = performance.now();
this.frameCount++;
if (now - this.lastTime >= 50) {
const fps = this.frameCount / ((now - this.lastTime) / 1000);
const fpsEl = document.getElementById('fpsStat');
if (fpsEl) fpsEl.textContent = Math.round(fps);
if (this.frameCount < params.controllerFPSThreshold.value) { params.plantRespawnRate.value = Math.max(params.plantRespawnRate.min, params.plantRespawnRate.value - params.controllerRateDecFPS.value); this.updateParamDisplay('plantRespawnRate'); }
if (this.frameCount * 2 < params.controllerFPSThreshold.value) { params.plantRespawnRate.value = Math.max(params.plantRespawnRate.min, params.plantRespawnRate.value - params.controllerRateDecFPS.value * 2); this.updateParamDisplay('plantRespawnRate'); this.continuusLowFPSCount++; } else { this.continuusLowFPSCount = 0; }
if(this.continuusLowFPSCount > 20) { params.plantRespawnRate.value = Math.max(params.plantRespawnRate.min, params.plantRespawnRate.value - params.controllerRateDecFPS.value * 5); this.updateParamDisplay('plantRespawnRate'); }
this.lastTime = now; this.frameCount = 0;
}
for (let i = 0; i < params.controllerNewCreatures.value; i++) {
const p = plants.length ? plants[Math.floor(Math.random() * plants.length)] : null;
const x = p ? clip(p.pos.x + Math.random() * 100 - 50, 0, canvas.width) : Math.random() * canvas.width;
const y = p ? clip(p.pos.y + Math.random() * 100 - 50, 0, canvas.height) : Math.random() * canvas.height;
creatures.push(new Creature(x, y));
}
while (creatures.length < params.controllerMinCreatures.value) { creatures.push(new Creature(Math.random() * canvas.width, Math.random() * canvas.height)); }
if (currentPop < params.controllerLowPop.value) { params.plantRespawnRate.value = Math.max(params.plantRespawnRate.max / 2, params.plantRespawnRate.value + params.controllerRateInc.value); this.updateParamDisplay('plantRespawnRate'); }
if (this.popHistory.length >= popWin) { const avgPop = this.popHistory.reduce((a, b) => a + b, 0) / this.popHistory.length; if (avgPop < params.controllerAvgPop.value) { params.plantRespawnRate.value = Math.min(params.plantRespawnRate.max, params.plantRespawnRate.value + params.controllerRateInc.value); this.updateParamDisplay('plantRespawnRate'); } }
if (currentPop > params.controllerAvgPop.value * 4 && this.energyReductionCooldown < 1) {
this.energyReductionCooldown = 1000;
params.energyFromPlant.value = Math.ceil(0.75 * params.energyFromPlant.value);
params.plantRespawnRate.value = params.plantRespawnRate.value * 0.9;
this.updateParamDisplay('energyFromPlant');
this.updateParamDisplay('plantRespawnRate');
}
if (currentPop > params.controllerAvgPop.value * 3 && this.energyReductionCooldown < 1) {
this.energyReductionCooldown = 500;
params.energyFromPlant.value = Math.ceil(0.9 * params.energyFromPlant.value);
this.updateParamDisplay('energyFromPlant');
}
if(this.energyReductionCooldown > 0 && currentPop > params.controllerAvgPop.value * 3) this.energyReductionCooldown--;
if (currentPop > params.controllerAvgPop.value * 2 && this.energyReductionCooldown2 < 1) {
this.energyReductionCooldown2 = 500;
params.energyFromPlant.value = Math.max(1, params.energyFromPlant.value - 1);
this.updateParamDisplay('energyFromPlant');
}
if(this.energyReductionCooldown2 > 0 && currentPop > params.controllerAvgPop.value) this.energyReductionCooldown2--;
if (currentPop < params.controllerAvgPop.value) this.belowTargetPopulationCounter++;
if (currentPop < params.controllerAvgPop.value) this.aboveTargetPopulationCounter = 0;
if (currentPop > params.controllerAvgPop.value) this.belowTargetPopulationCounter = 0;
if (currentPop > params.controllerAvgPop.value) this.aboveTargetPopulationCounter++;
if (this.belowTargetPopulationCounter > 250 && currentPop < params.controllerLowPop.value) {
params.plantRespawnRate.value = Math.min(params.plantRespawnRate.value + 10, params.plantRespawnRate.max);
this.updateParamDisplay('plantRespawnRate');
}
if (this.belowTargetPopulationCounter > 500) {
params.plantRespawnRate.value = params.plantRespawnRate.max;
this.updateParamDisplay('plantRespawnRate');
}
if (this.belowTargetPopulationCounter > 1000 && currentPop < params.controllerLowPop.value) {
params.energyFromPlant.value = params.energyFromPlant.value + 1;
this.updateParamDisplay('energyFromPlant');
this.updateParamDisplay('plantRespawnRate');
this.belowTargetPopulationCounter = 0;
}
if (this.aboveTargetPopulationCounter > 250 && params.plantRespawnRate.value < 0.9 * params.plantRespawnRate.max) {
params.plantRespawnRate.value = Math.min(params.plantRespawnRate.value + 10, params.plantRespawnRate.max);
this.updateParamDisplay('plantRespawnRate');
const reduction = Math.floor(currentPop / params.controllerAvgPop.value) ** 2;
params.energyFromPlant.value = Math.max(1, params.energyFromPlant.value - reduction);
this.updateParamDisplay('energyFromPlant');
this.aboveTargetPopulationCounter = 0;
}
}
};
function clip(x, min, max) { return Math.max(Math.min(x, max), min); }
function sliceTailChunks(arr, chunks) {
let i = arr.length;
const out = {};
for (const [key, n] of Object.entries(chunks)) { out[key] = arr.slice(i - n, i); i -= n; }
return out;
}
function fastRemove(array, predicate) {
let livingCount = array.length;
for (let i = livingCount - 1; i >= 0; i--) {
const item = array[i];
if (predicate(item, i)) { array[i] = array[livingCount - 1]; livingCount--; }
}
array.length = livingCount;
}
const safeDiv = (n, d) => (Number.isFinite(n) && Number.isFinite(d) && d !== 0) ? n / d : 0;
function signedMagnitudeSoftmax(arr, alpha) {
const a = Math.max(0, Math.min(1, alpha));
const n = arr.length;
if (!n) return [];
// Hard argmax at a=1
if (a === 1) {
let k = 0, best = -Infinity;
for (let i = 0; i < n; i++) {
const m = Math.abs(arr[i]);
if (m > best) { best = m; k = i; }
}
const out = Array(n).fill(0);
out[k] = arr[k] >= 0 ? 1 : -1;
return out;
}
const T = Math.max(1e-6, 1 - a);
// Compute max logit for stability without allocating logits array
let maxLogit = -Infinity;
for (let i = 0; i < n; i++) {
const l = Math.abs(arr[i]) / T;
if (l > maxLogit) maxLogit = l;
}
// Exps and sum
const exps = new Array(n);
let sumExps = 0;
for (let i = 0; i < n; i++) {
const e = Math.exp(Math.abs(arr[i]) / T - maxLogit);
exps[i] = e;
sumExps += e;
}
// Normalize + sign restore
const out = new Array(n);
for (let i = 0; i < n; i++) {
const p = exps[i] / sumExps;
out[i] = (arr[i] >= 0 ? 1 : -1) * p;
}
return out;
}
class Vector {
constructor(x = 0, y = 0) { this.x = x; this.y = y; }
add(v) { return new Vector(this.x + v.x, this.y + v.y); }
addInPlace(v) { this.x += v.x; this.y += v.y; return this; }
sub(v) { return new Vector(this.x - v.x, this.y - v.y); }
mult(s) { return new Vector(this.x * s, this.y * s); }
mag() { return Math.sqrt(this.magSquared()); }
magSquared() { return this.x * this.x + this.y * this.y; }
normalize() { const m = this.mag(); return m > 0 ? new Vector(this.x / m, this.y / m) : new Vector(); }
dist(v) { return Math.sqrt(this.distSquared(v)); }
distSquared(v) { const dx = this.x - v.x; const dy = this.y - v.y; return dx * dx + dy * dy; }
}
class Wall {
constructor(x1, y1, x2, y2) { this.start = new Vector(x1, y1); this.end = new Vector(x2, y2); }
draw() { ctx.strokeStyle = '#808080'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(this.start.x, this.start.y); ctx.lineTo(this.end.x, this.end.y); ctx.stroke(); }
}
function getIntersection(p1, p2, p3, p4) {
const x1 = p1.x, y1 = p1.y, x2 = p2.x, y2 = p2.y, x3 = p3.x, y3 = p3.y, x4 = p4.x, y4 = p4.y;
const denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1);
if (denom === 0) return null;
const ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denom;
const ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denom;
if (ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1) { return new Vector(x1 + ua * (x2 - x1), y1 + ua * (y2 - y1)); }
return null;
}
class SpatialGrid {
constructor(width, height, cellSize) {
this.width = width; this.height = height; this.cellSize = cellSize;
this.cols = Math.ceil(width / cellSize); this.rows = Math.ceil(height / cellSize);
this.grid = new Map();
}
getKey(x, y) { return `${x}_${y}`; }
insert(item) {
const col = Math.floor(item.pos.x / this.cellSize); const row = Math.floor(item.pos.y / this.cellSize);
const key = this.getKey(col, row);
if (!this.grid.has(key)) { this.grid.set(key, []); }
this.grid.get(key).push(item);
}
insertSegment(item, p1, p2) {
const minX = Math.min(p1.x, p2.x); const maxX = Math.max(p1.x, p2.x);
const minY = Math.min(p1.y, p2.y); const maxY = Math.max(p1.y, p2.y);
const minCol = Math.floor(minX / this.cellSize); const maxCol = Math.floor(maxX / this.cellSize);
const minRow = Math.floor(minY / this.cellSize); const maxRow = Math.floor(maxY / this.cellSize);
for (let c = minCol; c <= maxCol; c++) {
for (let r = minRow; r <= maxRow; r++) {
if (c >= 0 && c < this.cols && r >= 0 && r < this.rows) {
const key = this.getKey(c, r);
if (!this.grid.has(key)) { this.grid.set(key, []); }
this.grid.get(key).push(item);
}
}
}
}
query(pos, radius) {
const radiusSquared = radius * radius; const results = new Set();
const minCol = Math.floor((pos.x - radius) / this.cellSize); const maxCol = Math.ceil((pos.x + radius) / this.cellSize);
const minRow = Math.floor((pos.y - radius) / this.cellSize); const maxRow = Math.ceil((pos.y + radius) / this.cellSize);
for (let c = minCol; c <= maxCol; c++) {
for (let r = minRow; r <= maxRow; r++) {
const key = this.getKey(c, r);
if (this.grid.has(key)) {
const bucket = this.grid.get(key);
for (let i = 0; i < bucket.length; i++) {
const item = bucket[i];
if (item.start && item.end) { results.add(item); }
else { if (pos.distSquared(item.pos) < radiusSquared) { results.add(item); } }
}
}
}
}
return Array.from(results);
}
clear() { this.grid.clear(); }
}
class Matrix {
constructor(rows, cols) { this.rows = rows; this.cols = cols; this.data = Array(rows).fill().map(() => Array(cols).fill(0)); }
static fromArray(arr) { let m = new Matrix(arr.length, 1); m.data = arr.map(el => [el]); return m; }
toArray() { return this.data.flat(); }
randomize() { this.map(() => Math.random() * 2 - 1); return this; }
add(n) {
if (n instanceof Matrix) {
if (this.rows !== n.rows || this.cols !== n.cols) { console.error("Matrix dimensions must match for addition"); return; }
this.map((e, i, j) => e + n.data[i][j]);
} else { this.map(e => e + n); }
return this;
}
static multiply(a, b) {
if (a.cols !== b.rows) { console.error("Columns of A must match rows of B for multiplication"); return; }
let result = new Matrix(a.rows, b.cols);
result.map((_, i, j) => { let sum = 0; for (let k = 0; k < a.cols; k++) { sum += a.data[i][k] * b.data[k][j]; } return sum; });
return result;
}
map(func) { this.data = this.data.map((row, i) => row.map((val, j) => func(val, i, j))); return this; }
copy() { let m = new Matrix(this.rows, this.cols); m.data = this.data.map(row => row.slice()); if (this.mask) m.mask = this.mask.copy(); return m; }
initSparsity(density) {
this.mask = new Matrix(this.rows, this.cols);
// Create a binary mask: 1 = keep, 0 = remove
this.mask.map(() => Math.random() < density ? 1 : 0);
// Apply it immediately to prune current values
this.applySparsity();
}
applySparsity() {
if (!this.mask) return;
this.map((val, i, j) => val * this.mask.data[i][j]);
}
}
function tanh(x) { return Math.tanh(x); }
function hardTanh(x) { return clip(x*0.25, -1, 1); }
function sine(x) { return Math.sin(x*0.36); }
function softsign(x) { return x / (1 + Math.abs(x)); }
const activations = [hardTanh];
const getLogUniform = (min, max) => min * Math.pow(max / min, Math.random());
const mutate = (v, rate, amount) => {
// 1. Check if mutation happens at all
if (Math.random() < rate) {
// 2. Flip a coin (50/50) to choose between multiplicative or additive
if (Math.random() < 0.5) {
// Multiplicative:
// Uses a base (1.2) raised to a power between -1 and 1.
// This ensures Geometric Mean is 1. (e.g., x2 is balanced by x0.5)
// Range: [v / 1.2, v * 1.2]
return v * (1.2 ** (Math.random() * 2 - 1));
} else {
// Additive:
// Adds a value between -amount and +amount.
// Expected change is 0.
// Range: [v - amount, v + amount]
return v + (Math.random() * 2 - 1) * amount;
}
}
return v;
};
function getRandomHiddenLayers(total, maxLayers, maxPerLayer) {
// Determine valid random layer count (min required to fit neurons vs max allowed)
const minLayers = Math.ceil(total / maxPerLayer);
const count = Math.floor(Math.random() * (maxLayers - minLayers + 1)) + minLayers;
// Start with 1 neuron per layer so none are empty
const layers = new Array(count).fill(1);
// Distribute remaining neurons randomly
for (let i = 0; i < total - count; ) {
const idx = Math.floor(Math.random() * count);
if (layers[idx] < maxPerLayer) {
layers[idx]++;
i++; // Only increment loop if we successfully added a neuron
}
}
return layers;
}
const getHiddenConfig = () => getRandomHiddenLayers(Math.floor(getLogUniform(10, 1001)), 10, 100);
class FeedForwardNetwork {
constructor(inputNodes, hiddenNodes, outputNodes, unmaskRatio) {
this.inputNodes = inputNodes;
this.outputNodes = outputNodes;
// 1. Handle Backward Compatibility & New Array Structure
// If hiddenNodes is a number, wrap it in array (unless it's 0)
let hiddenLayers = [];
if (Array.isArray(hiddenNodes)) {
hiddenLayers = hiddenNodes;
} else if (typeof hiddenNodes === 'number' && hiddenNodes > 0) {
hiddenLayers = [hiddenNodes];
}
// Store structure for the copy() method
this.hiddenStructure = hiddenLayers;
// 2. Define Network Topology
// Example with 1 hidden layer: [Input, Hidden, Output]
// Example with 0 hidden layers: [Input, Output]
const topology = [this.inputNodes, ...hiddenLayers, this.outputNodes];
this.weights = [];
this.biases = [];
// 3. Initialize Matrices dynamically based on topology
for (let i = 0; i < topology.length - 1; i++) {
const currentLayerSize = topology[i];
const nextLayerSize = topology[i + 1];
// Weights: Rows = Target Size, Cols = Source Size
this.weights.push(new Matrix(nextLayerSize, currentLayerSize).randomize());
// Biases: Rows = Target Size, Cols = 1
this.biases.push(new Matrix(nextLayerSize, 1).randomize());
}
this.activation = activations[Math.floor(activations.length * Math.random())];
unmaskRatio = unmaskRatio !== undefined ? unmaskRatio : Math.random();
this.inputMask = new Int8Array(this.inputNodes).fill(1).map(_ => Math.random() < unmaskRatio ? 1 : 0);
}
feedForward(inputArray) {
if (inputArray.length !== this.inputNodes) {
throw new Error(`inputArray.length ${inputArray.length} !== this.inputNodes ${this.inputNodes}`);
}
// Apply input mask
const maskedInput = inputArray.map((v, i) => v * (this.inputMask ? (this.inputMask[i] !== undefined ? this.inputMask[i] : 1) : 1));
// Convert to Matrix
let current = Matrix.fromArray(maskedInput);
// Loop through every layer connection
for (let i = 0; i < this.weights.length; i++) {
current = Matrix.multiply(this.weights[i], current);
current.add(this.biases[i]);
current.map(this.activation);
}
return current.toArray();
}
internalMutate({ rate, amount }) {
this.mutate(rate, amount);
}
mutate(rate, amount) {
const mutateMatrix = (m) => m.map((v) => mutate(v, rate, amount));
// Mutate all weight and bias matrices
this.weights.forEach(w => mutateMatrix(w));
this.biases.forEach(b => mutateMatrix(b));
const idx = Math.floor(Math.random() * this.inputMask.length);
if (Math.random() < rate) {
this.inputMask[idx] = this.inputMask[idx] === 1 ? 0 : 1;
}
}
copy() {
// Pass the stored hidden structure to the new constructor
const newNet = new FeedForwardNetwork(this.inputNodes, this.hiddenStructure, this.outputNodes);
// Deep copy the arrays of matrices
newNet.weights = this.weights.map(w => w.copy());
newNet.biases = this.biases.map(b => b.copy());
newNet.activation = this.activation;
newNet.inputMask = new Int8Array(this.inputMask);
return newNet;
}
maskSum() {
return this.inputMask.reduce((v, c) => v + c, 0);
}
getHiddenStructure() {
return this.hiddenStructure;
}
}
function createStatsObj() {
return {
actions: 0,
gainTotal: 0, gainP: 0, gainPr: 0,
lossTotal: 0, lossPr: 0, lossC: 0, lossM: 0,
move: 0,
children: 0, grandchildren: 0,
childrenAlive: 0, childrenWithGC: 0
};
}
class EpsilonGreedyEnsembleNetwork {
constructor(inputNodes, hiddenNodes, outputNodes) {
this.inputNodes = inputNodes; this.hiddenNodes = hiddenNodes; this.outputNodes = outputNodes;
this.numModels = (Math.random() > 0.5) ? Math.floor(getLogUniform(1, 101)) : 1;
this.epsilon = getLogUniform(0.01, 1);
this.topAllocation = getLogUniform(0.01, 1);
this.qSmoothing = getLogUniform(0.01, 1);
const make = (i, h, o, unmaskRatio) => { return new FeedForwardNetwork(i, h, o, unmaskRatio); };
this.models = Array.from({ length: this.numModels }, () => make(inputNodes, hiddenNodes, outputNodes));
this.hiddenNodes = 1;
this.rewardNet = make(15 + outputNodes + inputNodes, [3, 2], 1, 0.1);
this.q = Array(this.numModels).fill(0);
this.n = Array(this.numModels).fill(0);
// Initialize Stats per expert
this.stats = Array.from({ length: this.numModels }, () => createStatsObj());
this.totalStats = createStatsObj();
this.currentExpert = Math.floor(Math.random() * this.numModels);
this.lastOutputs = Array(outputNodes).fill(0);
this.experts = this.models;
this.madeMove = false;
this.mutatations = 0;
}
getRewardNetCost() { return this.rewardNet.maskSum(); }
getExpert() { return this.currentExpert | 0; }
// Stats Tracking
updateStats(expertIdx, metrics) {
if (!this.stats[expertIdx]) return;
const s = this.stats[expertIdx];
const t = this.totalStats;
s.gainP += metrics.gainP; t.gainP += metrics.gainP;
s.gainPr += metrics.gainPr; t.gainPr += metrics.gainPr;
const totalGain = metrics.gainP + metrics.gainPr;
s.gainTotal += totalGain; t.gainTotal += totalGain;
s.lossPr += metrics.lossPr; t.lossPr += metrics.lossPr;
s.lossC += metrics.lossC; t.lossC += metrics.lossC;
s.lossM += metrics.lossM; t.lossM += metrics.lossM;
const totalLoss = metrics.lossPr + metrics.lossC + metrics.lossM;
s.lossTotal += totalLoss; t.lossTotal += totalLoss;
s.move += metrics.move; t.move += metrics.move;
s.actions++; t.actions++;
this.n[expertIdx]++;
}
registerChild(expertIdx, child) {
if (!this.stats[expertIdx]) return;
this.stats[expertIdx].children++;
this.totalStats.children++;
this.stats[expertIdx].childrenAlive++;
this.totalStats.childrenAlive++;
const mutationsOnRegister = this.mutatations;
// Lifecycle listeners
child.addLifecycleListener((event, data) => {
// mutation resets experts ids
if (this.mutatations !== mutationsOnRegister) return;
if (event === 'death') {
if (this.stats && this.stats[expertIdx]) {
this.stats[expertIdx].childrenAlive--;
this.totalStats.childrenAlive--;
}
} else if (event === 'reproduce') {
if (this.stats && this.stats[expertIdx]) {
this.stats[expertIdx].grandchildren++;
this.totalStats.grandchildren++;
if (!child.hasReproducedForGrandparent) {
child.hasReproducedForGrandparent = true;
this.stats[expertIdx].childrenWithGC++;
this.totalStats.childrenWithGC++;
}
}
}
});
}
feedForward(inputArray) {
if(this.madeMove) this.computeReward(inputArray);
if (Math.random() < this.epsilon) {
const untried = this.n.findIndex(v => v < 1);
if (untried !== -1) {
this.currentExpert = untried;
} else {
this.currentExpert = (Math.random() * this.models.length) | 0;
}
} else { let best = 0; for (let i = 1; i < this.q.length; i++) { if (this.q[i] > this.q[best]) best = i; } this.currentExpert = best; }
const outputs = this.models[this.currentExpert].feedForward(inputArray);
this.lastOutputs = outputs;
this.madeMove = true;
return outputs;
}
computeReward(currentInputs) {
const i = this.currentExpert;
const s = this.stats[i];
const t = this.totalStats;
// HELPER 1: Safe Average
const getAvg = (sum, n) => (n > 0 ? sum / n : 0);
// HELPER 2: Log-Modulus Scale
const logScale = (x) => {
if (x === 0) return 0;
return Math.sign(x) * Math.log(1 + Math.abs(x));
};
// HELPER 3: Advantage (Expert Avg - Global Avg)
const getAdvantage = (expertSum, expertN, totalSum, totalN) => {
const eAvg = getAvg(expertSum, expertN);
const tAvg = getAvg(totalSum, totalN);
return logScale(eAvg - tAvg);
};
// Calculate Global Averages once
const tGainTotal = getAvg(t.gainTotal, t.actions);
const tLossTotal = getAvg(t.lossTotal, t.actions);
const inputs = [
// --- GROUP A: RAW MAGNITUDE (3) ---
logScale(getAvg(s.gainTotal, s.actions)),
logScale(getAvg(s.lossTotal, s.actions)),
logScale(getAvg(s.move, s.actions)),
// --- GROUP B: THE "ADVANTAGE" (6) ---
// "Am I doing better than my average?"
getAdvantage(s.gainTotal, s.actions, t.gainTotal, t.actions),
getAdvantage(s.gainPr, s.actions, t.gainPr, t.actions),
getAdvantage(s.lossTotal, s.actions, t.lossTotal, t.actions),
getAdvantage(s.lossC, s.actions, t.lossC, t.actions), // Repro Cost
getAdvantage(s.lossM, s.actions, t.lossM, t.actions), // Metabolism Cost
// ADDED BACK to fix size: Predation Loss Advantage
getAdvantage(s.lossPr, s.actions, t.lossPr, t.actions),
// --- GROUP C: RARE EVENTS (2) ---
getAvg(s.children, Math.log(s.actions)),
getAvg(s.grandchildren, Math.log(s.actions)),
// --- GROUP D: CONTEXT (5) ---
safeDiv(s.actions, t.actions), // Confidence
safeDiv(s.childrenAlive, s.children), // Child Survival
// ADDED BACK to fix size: Lineage Success (Children who had children)
safeDiv(s.childrenWithGC, s.children),
this.q[i], // Momentum
...currentInputs,
...this.lastOutputs,
];
this.lastRewardInputs = inputs;
const [ reward ] = this.rewardNet.feedForward(inputs);
if(!Number.isFinite(reward)) {
console.log(s, inputs);
throw Error(`Unexpected reward ${reward}`, inputs);
}
this.q[i] = this.q[i] * (1 - this.qSmoothing) + reward * this.qSmoothing;
}
mutate(rate, amount) {
this.mutatations++;
this.numModels = Math.round(clip(mutate(this.numModels, rate, 1), 1, 100));
// We use Q * N for sorting importance
const nSum = this.n.reduce((s, n) => s + n, 1);
// CHANGE 1: We map q and n into the object so we can access them after sorting
const sortedExperts = this.models.map((m, i) => ({
model: m,
q: this.q[i],
n: this.n[i],
score: this.n[i] > 0 ? (this.q[i] + 1) * (this.n[i] / nSum) : -Infinity
})).sort((a, b) => b.score - a.score);
const newModels = [];
const newQ = [];
const newN = [];
let remainingSlots = this.numModels;
let parentIdx = 0;
while (remainingSlots > 0 && parentIdx < sortedExperts.length) {
const parent = sortedExperts[parentIdx];
const numToCopy = Math.ceil(remainingSlots * this.topAllocation);
for (let i = 0; i < numToCopy; i++) {
if (newModels.length >= this.numModels) break;
const cloneOnce = (m) => (m && typeof m.copy === "function") ? m.copy() : m;
newModels.push(cloneOnce(parent.model));
newQ.push(parent.q);
newN.push(parent.n);
}
remainingSlots -= numToCopy;
parentIdx++;
}
const bestParent = sortedExperts[0];
if (bestParent) {
while (newModels.length < this.numModels) {
const cloneOnce = (m) => (m && typeof m.copy === "function") ? m.copy() : m;
newModels.push(cloneOnce(bestParent.model));
newQ.push(bestParent.q);
newN.push(bestParent.n);
}
}
// Always replace the last expert with a fresh random one, but assign it Max Q
// This tricks the selection policy into trying it, teaching the reward net to distinguish real vs fake.
if (newModels.length > 0) {
const idx = newModels.length - 1;
newModels[idx] = new FeedForwardNetwork(this.inputNodes, this.hiddenNodes, this.outputNodes);
newQ[idx] = Math.max(...this.q) + 0.0000001; // Assign Max Q
newN[idx] = 0; // Reset experience count
}
this.models = newModels;
this.experts = this.models;
// console.log("topAllocation", this.topAllocation);
// console.log("old q", this.q);
// console.log("old n", this.n);
this.q = newQ;
this.n = newN;
// console.log("q", this.q);
// console.log("n", this.n);
// Reset stats for new generation (keep Q/N, but clear operational stats)
this.stats = Array.from({ length: this.numModels }, () => createStatsObj());
this.totalStats = createStatsObj();
// mutate some models a lot more than others to force reward net to select
for (const m of this.models) { m.mutate(rate * 20 * getLogUniform(0.005, 0.5), amount * 20 * getLogUniform(0.005, 0.5)); }
this.rewardNet.mutate(rate, amount);
this.epsilon = clip(mutate(this.epsilon, rate, amount / 10), 0.01, 1);
this.topAllocation = clip(mutate(this.topAllocation, rate, amount / 10), 0.01, 1);
this.qSmoothing = clip(mutate(this.qSmoothing, rate, amount / 10), 0.01, 1);
this.currentExpert = Math.min(this.currentExpert, this.numModels - 1);
this.madeMove = false;
}
internalMutate({ rate, amount }) {
for (const m of this.models) { m.mutate(rate, amount); }
}
copy() {
const clone = new EpsilonGreedyEnsembleNetwork(this.inputNodes, this.hiddenNodes, this.outputNodes, { baseConstructor: this._BaseCtor, innerOptions: this._innerOptions });
const cloneOnce = (m) => (m && typeof m.copy === "function") ? m.copy() : m;
clone.epsilon = this.epsilon;
clone.topAllocation = this.topAllocation;
clone.qSmoothing = this.qSmoothing;
clone.numModels = this.numModels;
clone.hiddenNodes = this.hiddenNodes;
clone.models = this.models.map(m => cloneOnce(m)); clone.experts = clone.models;
clone.rewardNet = this.rewardNet ? cloneOnce(this.rewardNet) : null;
// Stats reset on copy (new creature life)
clone.stats = Array.from({ length: this.numModels }, () => createStatsObj());
clone.totalStats = createStatsObj();
clone.q = [...this.q]; clone.n = [...this.n];
clone.currentExpert = this.currentExpert; clone.lastOutputs = this.lastOutputs;
return clone;
}
getHiddenStructure() {
return this.models[this.currentExpert].getHiddenStructure();
}
}
class Plant {
isPlant = true;
isCreature = false;
constructor(x, y, energy) {
this.pos = new Vector(x, y);
this.energy = energy || (params.energyFromPlant.value * (params.plantEnergySpread.value + 1) * Math.random() ** params.plantEnergySpread.value);
this.radius = Math.log2(1 + this.energy);
this.eatenCount = 0;
this.age = 0;
}
draw() { ctx.fillStyle = '#48BB78'; ctx.beginPath(); ctx.arc(this.pos.x, this.pos.y, this.radius, 0, Math.PI * 2); ctx.fill(); }
}
class Creature {
isPlant = false;
isCreature = true;
constructor(x, y, parent) {
this.pos = new Vector(x, y); this.radius = 3; this.moveVector = new Vector(0, 0); this.finalMove = new Vector(0, 0);
this.hitWall = -1; this.plantToEat = null; this.parent = null; this.children = new Set();
this.toParentMessage = Array(1).fill(0); this.toChildrenMessage = Array(3).fill(0);
this.age = 0; this.controlBias = 0.5; this.memory = Array(Math.floor(getLogUniform(1, 101))).fill(0); this.memoryUpdate = 0;
this.stance = 0; this.totalStance = 0; this.force = 0; this.reproductionIntention = -1;
this.predationGainVec = new Vector(0, 0); this.predationGain = 0;
this.predationLossVec = new Vector(0, 0); this.predationLoss = 0;
this.mutationStyle = 0;
this.lastInputs = [];
this.lastOutputs = [];
this.lifecycleListeners = [];
this.hasReproducedForGrandparent = false;
if (parent) {
this.generation = (parent.generation || 0) + 1;
this.color = parent.color;
this.strength = parent.strength;
this.energy = Math.floor(parent.energy * (1 - parent.reproductionSplit));
this.maxSenseRadius = parent.maxSenseRadius;
this.memory = [...parent.memory];
this.parent = parent; this.peaceCode = parent.peaceCode; this.sensing = parent.sensing;
this.net = parent.net.copy();
this.mutate(parent.mutation);
} else {
this.generation = 1;
this.randomizeColor();
this.energy = params.reproductionThreshold.value * Math.random();
const type = Math.random() > 0.5 ? (i, h, o) => new FeedForwardNetwork(i, h, o, 1) : (i, h, o) => new EpsilonGreedyEnsembleNetwork(i, h, o);
this.net = type(
66 + this.memory.length + this.toParentMessage.length + this.toChildrenMessage.length,
getHiddenConfig(), // 10 + this.memory.length,
24 + this.memory.length + this.toParentMessage.length + this.toChildrenMessage.length);
this.maxSenseRadius = getLogUniform(1, 150);
this.strength = getLogUniform(1, 1000);
this.peaceCode = Array(3).fill(0).map(() => Math.round(Math.random() * 2 - 1));
this.sensing = 1;
}
this.senseRadius = this.maxSenseRadius; this.previousEnergy = this.energy;
}
get mutation() {
const ageMutations = ((this.age / 150) ** 2) / 10;
const baseRate = params.mutationRate.value;
const baseAmount = clip(params.mutationAmount.value + ageMutations, 0, 10);
const minRate = baseRate * (1/5); const maxRate = 1;
const mutationRate = clip(minRate + this.mutationStyle * (maxRate - minRate), 1e-4, 1);
const mutationAmount = clip((baseRate * baseAmount) / mutationRate, 1e-4, 10);
return { rate: mutationRate, amount: mutationAmount };
}
mutate(mutation) {
this.net.mutate(mutation.rate, mutation.amount);
this.maxSenseRadius = clip(mutate(this.maxSenseRadius, mutation.rate, mutation.amount), 1, 150);
this.strength = clip(mutate(this.strength, mutation.rate, mutation.amount), 0, 1000);
}
randomizeColor() { do { this.color = Array(3).fill(256).map((v) => Math.floor(v * Math.random())); } while (this.color[0] + this.color[2] < this.color[1]); }
get type() { return this.controlBias > 0.5 ? 'cartesian' : 'polar'; }
addLifecycleListener(fn) { this.lifecycleListeners.push(fn); }
triggerLifecycleEvent(event, data) { for(const fn of this.lifecycleListeners) fn(event, data); }
// Check if a target is visible (not blocked by walls)
isVisible(target, maxDist) {
if (!wallGrid) return true;
// Optimization: Don't check walls if they are far apart
const dist = this.pos.dist(target.pos);
const walls = wallGrid.query(this.pos, Math.min(maxDist, dist));
for (const wall of walls) {
if (getIntersection(this.pos, target.pos, wall.start, wall.end)) return false;
}
return true;
}
// Generic Query Engine
findEntities(grid, radius, queries) {
const results = {};
const bestScores = {};
// 1. Initialize trackers
for (const key in queries) {
results[key] = null;
bestScores[key] = -Infinity;
}
const neighbors = grid.query(this.pos, radius);
// 2. Single loop through neighbors
for (const n of neighbors) {
if (n === this) continue;
const distSq = this.pos.distSquared(n.pos);
if (distSq > radius * radius) continue;
// 3. Lazy Evaluation: Check if this neighbor is a "potential" winner
// BEFORE doing the expensive isVisible() check.
let isPotential = false;
const currentScores = {}; // Temp cache for this neighbor
for (const key in queries) {
const q = queries[key];
// A. Check Filter (e.g., is it aggressive?)
if (q.where && !q.where(n)) continue;
// B. Calculate Score (default: nearest = negative distance)
// If q.score is provided use it, otherwise use -distSq
const score = q.score ? q.score(n, distSq) : -distSq;
currentScores[key] = score;
// C. Is this better than our current best?
if (score > bestScores[key]) isPotential = true;
}
// 4. Optimization: Only check visibility if it's a potential winner
if (isPotential && this.isVisible(n, radius)) {
// Commit the updates
for (const key in currentScores) {
if (currentScores[key] > bestScores[key]) {
bestScores[key] = currentScores[key];
results[key] = n;
}
}
}
}
return results;
}
update() {
this.age++;
// 1. Get up to 3 nearest plants (returns array of 0 to 3 items)
const plants = this.findEntities(plantGrid, this.plantSenseRadius, {
nearest: { /* Default is nearest */ },
highEnergy: { score: (p) => p.energy },
bestRatio: { score: (p, dSq) => p.energy / (dSq + 1) }
});
const creatures = this.findEntities(creatureGrid, this.creatureSenseRadius, {
aggressive: { where: c => c.stance > 0.1 },
neutral: { where: c => Math.abs(c.stance) <= 0.1 },
defensive: { where: c => c.stance < -0.1 }
});
const INPUTS_PER_ENTITY = 5;
const entities = [...Object.values(plants), ...Object.values(creatures)];
const entitiesVectors = entities.map(entity => {
if(!entity) return null;
const relative = entity.pos.sub(this.pos);
const normalized = relative.normalize();
return { entity, relative, normalized };
})
const entitiesInputs = new Array(entities.length * INPUTS_PER_ENTITY).fill(0);
for (let i = 0; i < entities.length; i++) {
const vectors = entitiesVectors[i];
if(!vectors) continue;
const { entity, relative, normalized } = vectors;
// Calculate the start index for this specific neighbor (0, 4, or 8)
const offset = i * INPUTS_PER_ENTITY;
entitiesInputs[offset + 0] = normalized.x;
entitiesInputs[offset + 1] = normalized.y;
entitiesInputs[offset + 2] = Math.atan2(normalized.y, normalized.x) / Math.PI;
entitiesInputs[offset + 3] = relative.magSquared() / (this.maxSenseRadius * this.maxSenseRadius);
entitiesInputs[offset + 4] = clip(entity.energy / params.reproductionThreshold.value, 0, 1);
}
// Helper to convert creature to inputs
const getCreatureInputs = (target) => {
if (!target) return [0, 0, 0, 0, 0];
let relative = target.pos.sub(this.pos);
let dir = target.pos.sub(this.pos).normalize();
return [
clip(target.stance, -1, 1),
clip((target.strength - this.strength) / (target.strength + this.strength), -1, 1),
Math.abs(target.generation - this.generation) < 3 ? 1 : -1,
Math.sign(target.age - this.age),
clip(params.attackRadius.value ** 2 / (0.001 + relative.magSquared()), 0, 1)
];
};
const predationInputs = [
this.predationGainVec.x, this.predationGainVec.y, Math.atan2(this.predationGainVec.y, this.predationGainVec.x) / Math.PI, this.predationGain,
this.predationLossVec.x, this.predationLossVec.y, Math.atan2(this.predationLossVec.y, this.predationLossVec.x) / Math.PI, this.predationLoss
];
let finalMoveInputs = [0, 0, 0];
let dir = this.finalMove.normalize();
finalMoveInputs[0] = dir.x; finalMoveInputs[1] = dir.y; finalMoveInputs[2] = Math.atan2(dir.y, dir.x) / Math.PI;
let fromParentMessage = Array(this.toChildrenMessage.length).fill(0);
if(this.parent) fromParentMessage = this.parent.toChildrenMessage;
const fromChildrenMessage = Array(this.toParentMessage.length).fill(0);
for(const child of this.children) { for (let i = 0; i < this.toParentMessage.length; i++) { fromChildrenMessage[i] += child.toParentMessage[i] * ( 1 / this.children.size); } }
let wallDist = 0;
let moveDir = this.finalMove.mag() > 0 ? this.finalMove.normalize() : new Vector(0, 0);
if (moveDir.mag() > 0 && wallGrid) {
const sensorRange = params.wallSensorRadius.value;
let rayEnd = this.pos.add(moveDir.mult(sensorRange));
let closestIntersection = Infinity;
const nearbyWalls = wallGrid.query(this.pos, sensorRange);
for (const wall of nearbyWalls) {
const intersection = getIntersection(this.pos, rayEnd, wall.start, wall.end);
if (intersection) { const d = this.pos.dist(intersection); if (d < closestIntersection) { closestIntersection = d; } }
}
if (closestIntersection !== Infinity) { wallDist = closestIntersection / sensorRange; }
}
const rawInputs = [
this.sensing, this.energy / params.reproductionThreshold.value, (this.energy - this.previousEnergy) / Math.max(1, this.previousEnergy),
this.age > 4 ** 1 ? 0 : 1, this.age > 4 ** 2 ? 0 : 1, this.age > 4 ** 3 ? 0 : 1, this.age > 4 ** 4 ? 0 : 1,
...entitiesInputs,
...getCreatureInputs(creatures.aggressive),
...getCreatureInputs(creatures.neutral),
...getCreatureInputs(creatures.defensive),
...predationInputs, ...finalMoveInputs,
this.memoryUpdate, wallDist, this.hitWall,
...this.memory, ...fromParentMessage, ...fromChildrenMessage,
];
const inputs = rawInputs.map(v => Number.isFinite(v) ? v : 0);
this.lastInputs = inputs;
const outputs = this.net.feedForward(inputs);
this.lastOutputs = outputs;
const dx = outputs[0];
const dy = outputs[1];
const angle = outputs[2] * Math.PI * 2;
const length = ((outputs[3] + 1) / 2) * params.maxSpeed.value;
const mixWeight = (outputs[4] + 1) / 2;
this.reproductionSplit = (outputs[5] + 1) / 2;
this.memoryUpdate = (outputs[6] + 1) / 2;
this.stance = outputs[7];
this.force = (2 * outputs[8]) ** 3;
this.reproductionIntention = outputs[9];
this.totalStance += this.stance;
this.plantSenseRadius = ((outputs[10] + 1) / 2) * this.maxSenseRadius;
this.creatureSenseRadius = ((outputs[11] + 1) / 2) * this.maxSenseRadius;
this.mutationStyle = (outputs[12] + 1) / 2;
this.towardsUnity = (outputs[13] + 1) / 2;
const pieces = sliceTailChunks(outputs, { towards: 1 + entitiesVectors.length, memory: this.memory.length, toChildren: this.toChildrenMessage.length, toParent: this.toParentMessage.length, peaceCode: this.peaceCode.length });
this.toParentMessage = pieces.toParent; this.toChildrenMessage = pieces.toChildren; this.peaceCode = pieces.peaceCode.map((c) => Math.round(c));
const newMemory = pieces.memory.map((v) => v * this.memoryUpdate);
this.memory = this.memory.map((v, i) => Math.round((v * (1 - this.memoryUpdate) + newMemory[i]) * 10) / 10);
this.controlBias = (this.controlBias * this.age + mixWeight) / (this.age + 1);
const cartesianMove = new Vector(dx, dy);
const polarMove = new Vector(Math.cos(angle), Math.sin(angle));
const freeMoveVector = cartesianMove.mult(mixWeight).addInPlace(polarMove.mult(1 - mixWeight));
const towards = signedMagnitudeSoftmax(pieces.towards, this.towardsUnity);
// freeMoveVector + towards
let finalVector = freeMoveVector.mult(towards?.[0] ?? 1);
for (let i = 0; i < entities.length; i++) {
const vectors = entitiesVectors[i];
if (!vectors) continue;
const w = towards?.[i + 1] ?? 0;
if (w === 0) continue;
// Option A: use ONLY unit direction, no distance weighting
finalVector.addInPlace(vectors.normalized.mult(w));
}
// Set moveVector to the network-chosen speed "length"
if (finalVector.mag() > 0) {
this.moveVector = finalVector.normalize().mult(length);
} else {
this.moveVector = new Vector(0, 0);
}
// --- METRICS CALCULATION ---
let metricPlantGain = 0;
let metricTransferGain = 0; // Will be set by main loop energy transfer logic
let metricTransferLoss = 0; // Will be set by main loop energy transfer logic
const moveCost = params.moveCostMultiplier.value * this.moveVector.magSquared();
const stanceCostAmount = params.stanceCost.value * (this.stance * this.stance) * (this.stance > 0 ? 2 : 1);
const strengthCost = params.strengthMetabolism.value * this.strength;
const pushPullCostAmount = params.pushPullCost.value * this.strength * Math.abs(this.force);
const miscLoss = params.basalMetabolism.value + moveCost + stanceCostAmount + strengthCost + pushPullCostAmount;
this.previousEnergy = this.energy;
this.energy -= miscLoss;
this._stepStats.lossM = miscLoss;
this._stepStats.move = this.moveVector.mag();
if((this.mutation.rate / 100) * clip((this.age / 200), 1, 10) > Math.random()) this.net.internalMutate(this.mutation);
}
shouldDie() {
const dead = this.energy <= 0;
if (dead && !this.isDead) {
this.triggerLifecycleEvent('death', this);
}
return dead;
}
shouldReproduce() { return this.energy >= params.reproductionThreshold.value || this.reproductionIntention > 0.5; }
isRelated(creature) { return this.parent === creature || this.children.has(creature); }
reproduce() {
const cost = params.reproductionCost.value;
this.energy -= cost;
if(this.net.updateStats) this.net.updateStats(this.net.getExpert(), { gainP:0, gainPr:0, lossPr:0, lossC:cost, lossM:0, move:0 }); // Immediate record cost
statisticsManager.triggerEvent('reproduce', this);
this.triggerLifecycleEvent('reproduce', this);
const newCreature = new Creature(this.pos.x, this.pos.y, this);
this.energy = Math.floor(this.energy * this.reproductionSplit);
this.children.add(newCreature);
this.reproductionIntention = 0;
if(this.net.registerChild) this.net.registerChild(this.net.getExpert(), newCreature);
return newCreature;
}
draw() {
const avgStance = this.totalStance / this.age;
ctx.fillStyle = `rgb(${this.color[0]}, ${this.color[1]}, ${this.color[2]})`;
ctx.save();
ctx.translate(this.pos.x, this.pos.y);
const size = Math.max(1, Math.sqrt(1 + this.energy) / 5);
if (this.type === 'polar') { ctx.beginPath(); ctx.arc(0, 0, (2 * size) / Math.sqrt(Math.PI), 0, Math.PI * 2); }
else { ctx.beginPath(); ctx.rect(-size, -size, size * 2, size * 2); }
ctx.fill();
if (this.stance < 0) { const opacity = -this.stance; ctx.strokeStyle = `rgba(0, 0, 255, ${opacity})`; ctx.lineWidth = 1; }
else { ctx.strokeStyle = '#333'; ctx.lineWidth = 1; }
ctx.stroke();
if (this.stance > 0) { ctx.strokeStyle = `rgba(255, 0, 0, ${this.stance})`; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(0, 0, params.attackRadius.value, 0, Math.PI * 2); ctx.stroke(); }
ctx.restore();
}
}
function resizeCanvas() { const container = canvas.parentElement; canvas.width = container.clientWidth; canvas.height = container.clientHeight; plantGrid = new SpatialGrid(canvas.width, canvas.height, 64); creatureGrid = new SpatialGrid(canvas.width, canvas.height, 25); }
function resetSimulation() {
resizeCanvas(); frameCount = 0; creatures = []; plants = []; walls = []; selectedCreature = null;
plantGrid = new SpatialGrid(canvas.width, canvas.height, 64); creatureGrid = new SpatialGrid(canvas.width, canvas.height, 25);
wallGrid = new SpatialGrid(canvas.width, canvas.height, 100);
const w = canvas.width; const h = canvas.height;
function addWall(x1, y1, x2, y2) { const wall = new Wall(x1, y1, x2, y2); walls.push(wall); wallGrid.insertSegment(wall, wall.start, wall.end); }
const GRID_SIZE = 4; const DOOR_SIZE = 5;
const doors = [
{ x: 1, y: 0, v: true, off: 0.9 }, { x: 2, y: 0, v: true, off: 0.1 }, { x: 3, y: 0, v: true, off: 0.9 },
{ x: 3, y: 1, v: false, off: 0.9 },
{ x: 3, y: 1, v: true, off: 0.9 }, { x: 2, y: 1, v: true, off: 0.1 },
{ x: 1, y: 2, v: false, off: 0.1 },
{ x: 2, y: 2, v: true, off: 0.9 }, { x: 3, y: 2, v: true, off: 0.1 },
{ x: 3, y: 3, v: false, off: 0.9 },
{ x: 3, y: 3, v: true, off: 0.9 }, { x: 2, y: 3, v: true, off: 0.1 }, { x: 1, y: 3, v: true, off: 0.9 },
{ x: 0, y: 3, v: false, off: 0.1 }, { x: 0, y: 2, v: false, off: 0.9 }, { x: 0, y: 1, v: false, off: 0.1 },
];
function getDoor(doors, x, y, isVertical) { return doors.find(d => d.x === x && d.y === y && d.v === isVertical); }
const scaleX = w / GRID_SIZE; const scaleY = h / GRID_SIZE; const halfDoor = DOOR_SIZE / 2;
for (let i = 0; i <= GRID_SIZE; i++) {
const px = i * scaleX; let currentY = 0;
for (let j = 0; j < GRID_SIZE; j++) {
const door = getDoor(doors, i, j, true);
if (door) {
const dCenter = (j + door.off) * scaleY; const gapStart = dCenter - halfDoor; const gapEnd = dCenter + halfDoor;
if (gapStart > currentY) { addWall(px, currentY, px, gapStart); }
currentY = gapEnd;
}
}
if (currentY < h) { addWall(px, currentY, px, h); }
}
for (let j = 0; j <= GRID_SIZE; j++) {
const py = j * scaleY; let currentX = 0;
for (let i = 0; i < GRID_SIZE; i++) {
const door = getDoor(doors, i, j, false);
if (door) {
const dCenter = (i + door.off) * scaleX; const gapStart = dCenter - halfDoor; const gapEnd = dCenter + halfDoor;
if (gapStart > currentX) { addWall(currentX, py, gapStart, py); }
currentX = gapEnd;
}
}
if (currentX < w) { addWall(currentX, py, w, py); }
}
statisticsManager.initialize(); activeController.initialize();
for (let i = 0; i < params.initialCreatures.value; i++) { creatures.push(new Creature(Math.random() * canvas.width, Math.random() * canvas.height)); }
for (let i = 0; i < params.plantCount.value; i++) { if (plants.length < params.plantCount.value) { plants.push(new Plant(Math.random() * canvas.width, Math.random() * canvas.height)); } }
statisticsManager.update(creatures, frameCount, plants.length);
}
function resetColors() { creatures.forEach((c) => c.randomizeColor()); }
function addPlant() {
if (plants.length >= params.plantCount.value) return;
const x = Math.random() * canvas.width; const y = Math.random() * canvas.height;
const cx = canvas.width / 2; const cy = canvas.height / 2;
const x_rotated = (x - cx) * Math.cos(gradientAngle) + (y - cy) * Math.sin(gradientAngle);
const max_proj = Math.max(canvas.width, canvas.height) / 2;
const norm_proj = clip((x_rotated / max_proj) * 0.5 + 0.5, 0, 1);
const G = params.plantGradientRange.value; const minProb = 0.5 - G; const maxProb = 0.5 + G;
const spawnProb = minProb + (maxProb - minProb) * norm_proj;
if (Math.random() < spawnProb) { plants.push(new Plant(x, y)); }
}
function update() {
activeController.update(creatures);
gradientAngle += params.gradientRotationSpeed.value / 60;
if (gradientAngle > Math.PI * 2) gradientAngle -= Math.PI * 2;
for(const plant of plants) { plant.age++; }
fastRemove(plants, (plant) => plant.age > 100);
const needed = Math.max(0, params.plantCount.value - plants.length);
const attempts = Math.min(needed, params.plantRespawnRate.value);
for (let i = 0; i < attempts; i++) addPlant();
plantGrid.clear(); for(const plant of plants) { plantGrid.insert(plant); }
if (!creatureGrid) creatureGrid = new SpatialGrid(canvas.width, canvas.height, 25);
creatureGrid.clear();
const newCreatures = [];
for (const c of creatures) { creatureGrid.insert(c); }
for (const c of creatures) { c._stepStats = { gainP: 0, gainPr: 0, lossPr: 0, lossC: 0 }; }
for (const c of creatures) { c.update(); }
const energyTransfers = new Map(); const forceVectors = new Map();
for (const c of creatures) { forceVectors.set(c, c.moveVector); }
const victimIntents = new Map();
for (const c of creatures) {
if (c.stance > 0 || Math.abs(c.force) > 0.01) {
const nearbyCreatures = creatureGrid.query(c.pos, params.attackRadius.value);
for (const other of nearbyCreatures) {
if (c === other) continue;
const codesAgree = c.peaceCode.reduce((agree, code, i) => agree && code === other.peaceCode[i], true);
if (codesAgree) continue;
if (c.stance > 0) {
const transferAmount = (c.stance * c.strength + other.stance * other.strength) * params.maxEnergyTransfer.value;
if (transferAmount <= 0) continue;
const aggressionRatio = (c.stance > 0 && other.stance > 0) ? clip(c.stance / Math.max(1e-6, other.stance), 0.5, 2) : 1;
const requested = Math.max(0, transferAmount * aggressionRatio);
if (requested > 0) {
if (!victimIntents.has(other)) victimIntents.set(other, []);
victimIntents.get(other).push({ attacker: c, requested });
}
}
if (Math.abs(c.force) > 0.01) {
const relativePosition = other.pos.sub(c.pos); const distance = relativePosition.mag();
const dir = relativePosition.normalize();
const forceVector = dir.mult(c.force * c.strength / Math.max(1, distance));
forceVectors.get(other).addInPlace(forceVector);
}
}
}
if(c.stance < 0) {
const nearbyPlants = plantGrid.query(c.pos, c.radius + 5);
for (let p = 0; p < nearbyPlants.length; p++) {
const plant = nearbyPlants[p];
if (c.pos.dist(plant.pos) < c.radius + plant.radius) { c.plantToEat = plant; plant.eatenCount++; break; }
}
}
}
const gainVecMap = new Map(); const gainAmtMap = new Map(); const lossVecMap = new Map(); const lossAmtMap = new Map();
function addGain(creature, vec, amt) { if (!gainVecMap.has(creature)) gainVecMap.set(creature, new Vector(0, 0)); gainVecMap.get(creature).addInPlace(vec); gainAmtMap.set(creature, (gainAmtMap.get(creature) || 0) + amt); }
function addLoss(creature, vec, amt) { if (!lossVecMap.has(creature)) lossVecMap.set(creature, new Vector(0, 0)); lossVecMap.get(creature).addInPlace(vec); lossAmtMap.set(creature, (lossAmtMap.get(creature) || 0) + amt); }
for (const c of creatures) energyTransfers.set(c, 0);
for (const [victim, intents] of victimIntents.entries()) {
const totalRequested = intents.reduce((s, it) => s + it.requested, 0);
const available = Math.max(0, victim.energy);
const scale = totalRequested > 0 ? Math.min(1, available / totalRequested) : 0;
for (const { attacker, requested } of intents) {
const amount = requested * scale;
energyTransfers.set(attacker, energyTransfers.get(attacker) + amount);
energyTransfers.set(victim, energyTransfers.get(victim) - amount);
const dir = victim.pos.sub(attacker.pos).normalize();
addGain(attacker, dir.mult(amount), amount);
addLoss(victim, dir.mult(-amount), amount);
// Stats Tracking - Predation
attacker._stepStats.gainPr += amount;
victim._stepStats.lossPr += amount;
}
}
for (let i = creatures.length - 1; i >= 0; i--) {
const c = creatures[i];
if(c.plantToEat) {
const aggressiveStancePenalty = 1 - clip(c.stance, 0, 1);
const gain = (c.plantToEat.energy * aggressiveStancePenalty) / c.plantToEat.eatenCount;
c.energy += gain;
c._stepStats.gainP += gain;
c.plantToEat = null;
}
if (energyTransfers.has(c)) { c.energy += energyTransfers.get(c); }
c.predationGainVec = (gainVecMap.get(c) || new Vector(0,0)).normalize();
c.predationLossVec = (lossVecMap.get(c) || new Vector(0,0)).normalize();
c.predationGain = clip((gainAmtMap.get(c) || 0) / c.energy, 0, 1);
c.predationLoss = clip((lossAmtMap.get(c) || 0) / c.energy, 0, 1);
const beforePos = new Vector(c.pos.x, c.pos.y);
const forces = forceVectors.get(c);
const nextPos = c.pos.add(forces);
let collided = false;
const checkRadius = Math.max(forces.mag() + 5, 20);
const localWalls = wallGrid.query(beforePos, checkRadius);
for(const wall of localWalls) { if (getIntersection(beforePos, nextPos, wall.start, wall.end)) { collided = true; break; } }
if (!collided) { c.pos = nextPos; c.hitWall = -1; } else { c.hitWall = 1; }
c.pos.x = clip(c.pos.x, 0, canvas.width); c.pos.y = clip(c.pos.y, 0, canvas.height);
c.finalMove = c.pos.sub(beforePos);
// Finalize stats for this step
// Update the stats for the expert that caused this step
if(c.net.getExpert) c.net.updateStats(c.net.getExpert(), c._stepStats);
if (c.shouldDie()) {
statisticsManager.triggerEvent('death', c);
if (selectedCreature === c) { selectedCreature = null; netVizContainer.classList.add('hidden'); }
c.children.forEach((child) => child.parent = null); c.children = new Set();
if(c.parent) { c.parent.children.delete(c); c.parent = null; }
c.isDead = true; continue;
}
if (!c.isDead && c.shouldReproduce()) { newCreatures.push(c.reproduce()); }
}
fastRemove(creatures, (c) => c.isDead);
creatures.push(...newCreatures);
fastRemove(plants, (plant) => plant.eatenCount > 0);
}
function drawArrow(ctx, from, to, color) {
const headLength = 10; const angle = Math.atan2(to.y - from.y, to.x - from.x);
ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.stroke();
ctx.beginPath(); ctx.moveTo(to.x, to.y); ctx.lineTo(to.x - headLength * Math.cos(angle - Math.PI / 6), to.y - headLength * Math.sin(angle - Math.PI / 6));
ctx.lineTo(to.x - headLength * Math.cos(angle + Math.PI / 6), to.y - headLength * Math.sin(angle + Math.PI / 6));
ctx.lineTo(to.x, to.y); ctx.fillStyle = color; ctx.fill();
}
function drawFamily(creature) {
if (!creature) return;
let curr = creature;
while(curr.parent) { drawArrow(ctx, curr.parent.pos, curr.pos, '#00FFFF'); curr = curr.parent; }
const queue = [creature]; const visited = new Set([creature]);
while(queue.length > 0) {
const parent = queue.shift();
for (const child of parent.children) {
if (!visited.has(child)) { drawArrow(ctx, parent.pos, child.pos, '#FFFF00'); queue.push(child); visited.add(child); }
}
}
ctx.beginPath(); ctx.arc(creature.pos.x, creature.pos.y, creature.radius + 8, 0, Math.PI * 2);
ctx.strokeStyle = '#FFFFFF'; ctx.lineWidth = 2; ctx.stroke(); ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'; ctx.fill();
}
function drawNetwork(ctx, network, inputLabels, inputValues) {
if (!network) return;
// --- 1. FORWARD PASS (Capture Activations) ---
// We calculate the effective values used for math, but keep raw values for display
const maskedInputVals = inputValues.map((v, i) =>
v * (network.inputMask ? (network.inputMask[i] !== undefined ? network.inputMask[i] : 1) : 1)
);
const layerActivations = [maskedInputVals];
let current = Matrix.fromArray(maskedInputVals);
// Store activation objects to help with visualization source data
// layerActivations[0] is the input layer
for (let i = 0; i < network.weights.length; i++) {
current = Matrix.multiply(network.weights[i], current);
current.add(network.biases[i]);
current.map(network.activation);
layerActivations.push(current.toArray());
}
// --- 2. BACKWARD PASS (Calculate Influence/Gradients) ---
// Calculates d(Output)/d(Node) to color the nodes (Red=Bad, Green=Good)
const influences = layerActivations.map(l => new Array(l.length).fill(0));
// Set Output Influence to +1 (We want to maximize Q)
const outputLayerIdx = influences.length - 1;
influences[outputLayerIdx][0] = 1;
// Backpropagate
for (let l = outputLayerIdx - 1; l >= 0; l--) {
const nextLayerInfluences = influences[l + 1];
const weights = network.weights[l].data;
const currLayerSize = influences[l].length;
const nextLayerSize = nextLayerInfluences.length;
for (let i = 0; i < currLayerSize; i++) {
let sumInfluence = 0;
for (let j = 0; j < nextLayerSize; j++) {
sumInfluence += weights[j][i] * nextLayerInfluences[j];
}
influences[l][i] = sumInfluence;
}
}
// --- 3. LAYOUT SETUP ---
const nodeRadius = 18;
const layerSpacing = 200;
const nodeSpacing = 50;
const margin = 100;
const layersCount = layerActivations.length;
const maxNodes = Math.max(...layerActivations.map(l => l.length));
const totalWidth = margin * 2 + (layersCount - 1) * layerSpacing;
const totalHeight = margin * 2 + (maxNodes - 1) * nodeSpacing;
ctx.canvas.width = Math.max(totalWidth + 100, 600);
ctx.canvas.height = Math.max(totalHeight + 100, 400);
const getNodePos = (layerIndex, nodeIndex, totalNodesInLayer) => {
const x = margin + layerIndex * layerSpacing;
const verticalSpacing = Math.max(nodeSpacing, ctx.canvas.height / (totalNodesInLayer + 2));
const layerHeight = (totalNodesInLayer - 1) * verticalSpacing;
const yOffset = (ctx.canvas.height - layerHeight) / 2;
return { x, y: yOffset + nodeIndex * verticalSpacing };
};
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
// --- 4. DRAW CONNECTIONS (Active Signal Flow) ---
// We draw lines based on (Activation * Weight)
for (let l = 0; l < network.weights.length; l++) {
const weights = network.weights[l].data;
// The activations of the SOURCE layer
const sourceActivations = layerActivations[l];
const currLayerNodes = sourceActivations.length;
const nextLayerNodes = layerActivations[l+1].length;
for (let dest = 0; dest < nextLayerNodes; dest++) {
for (let src = 0; src < currLayerNodes; src++) {
// The math: Signal = Activation_Source * Weight
const act = sourceActivations[src];
const weight = weights[dest][src];
const signal = act * weight;
// FILTER: If signal is zero (masked input or 0 activation), don't draw
if (Math.abs(signal) < 0.001) continue;
const start = getNodePos(l, src, currLayerNodes);
const end = getNodePos(l + 1, dest, nextLayerNodes);
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.lineTo(end.x, end.y);
// Color Logic: Signal Positive (Green) or Negative (Red)
// Opacity based on signal strength (capped)
const alpha = Math.min(Math.abs(signal), 1.0);
if (signal > 0) {
ctx.strokeStyle = `rgba(0, 160, 0, ${alpha})`; // Green
} else {
ctx.strokeStyle = `rgba(160, 0, 0, ${alpha})`; // Red
}
// Width based on strength
ctx.lineWidth = Math.min(Math.abs(signal) * 2, 6);
ctx.stroke();
}
}
}
// --- 5. DRAW NODES (Colored by Influence) ---
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (let l = 0; l < layersCount; l++) {
const nodes = layerActivations[l];
const nodeInfluences = influences[l];
const isInput = (l === 0);
const isOutput = (l === layersCount - 1);
// Layer Label
const topNode = getNodePos(l, 0, nodes.length);
ctx.fillStyle = "#333";
ctx.font = "bold 14px sans-serif";
let layerName = isInput ? "Input" : (isOutput ? "Output" : `Hidden ${l}`);
ctx.fillText(layerName, topNode.x, topNode.y - nodeRadius - 25);
for (let n = 0; n < nodes.length; n++) {
// Display Value: If input, use raw inputValue, else use activation
let displayValue = (isInput) ? inputValues[n] : nodes[n];
const influence = nodeInfluences[n];
const pos = getNodePos(l, n, nodes.length);
// Node Style Defaults
let fillColor = "#fff";
let strokeColor = "#333";
let isMasked = false;
if (isInput) {
// Check mask
if (network.inputMask && network.inputMask[n] === 0) {
isMasked = true;
}
}
if (isMasked) {
// Greyed out style
fillColor = "#f0f0f0";
strokeColor = "#ccc";
ctx.setLineDash([5, 3]); // Dashed border
} else {
// Active Node: Color by Influence (Gradient)
// Green = Helping Q, Red = Hurting Q
const mag = Math.min(Math.abs(influence), 1);
if (influence > 0) {
fillColor = `rgba(220, 255, 220, ${0.5 + mag * 0.5})`;
strokeColor = `rgba(0, 150, 0, ${0.8})`;
} else if (influence < 0) {
fillColor = `rgba(255, 220, 220, ${0.5 + mag * 0.5})`;
strokeColor = `rgba(150, 0, 0, ${0.8})`;
}
ctx.setLineDash([]); // Solid border
}
// Draw Circle
ctx.beginPath();
ctx.arc(pos.x, pos.y, nodeRadius, 0, Math.PI * 2);
ctx.fillStyle = fillColor;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = strokeColor;
ctx.stroke();
ctx.setLineDash([]); // Reset just in case
// Draw Text Value
ctx.font = "10px monospace";
ctx.fillStyle = isMasked ? "#aaa" : "#000";
ctx.fillText(displayValue.toFixed(2), pos.x, pos.y);
// External Labels
if (isInput && inputLabels[n]) {
ctx.textAlign = "right";
ctx.fillStyle = isMasked ? "#ccc" : "#333";
ctx.font = "12px sans-serif";
ctx.fillText(inputLabels[n], pos.x - nodeRadius - 8, pos.y);
ctx.textAlign = "center";
}
if (isOutput) {
ctx.textAlign = "left";
ctx.fillStyle = "#333";
ctx.font = "bold 12px sans-serif";
ctx.fillText("Q-Value", pos.x + nodeRadius + 8, pos.y);
ctx.textAlign = "center";
}
}
}
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const wall of walls) { wall.draw(); }
for (const plant of plants) { plant.draw(); }
for (const creature of creatures) { creature.draw(); }
if (selectedCreature) {
drawFamily(selectedCreature);
// Ensure the network exists and has cached inputs to show
const net = selectedCreature.net;
if (net && net.rewardNet && net.lastRewardInputs) {
netVizContainer.classList.remove('hidden');
const rewardInputsLabels = [
"LogGainTot", "LogLossTot", "LogMove",
"AdvGainTot", "AdvGainPr", "AdvLossTot",
"AdvLossC", "AdvLossM", "AdvLossPr",
"ChildRate", "GChildRate",
"UsageProp", "ChldSurv", "Lineage",
"MomentumQ"
];
// USE CACHED INPUTS (Fast & Safe)
drawNetwork(netCtx, net.rewardNet, rewardInputsLabels, net.lastRewardInputs);
} else {
// Hide if selected creature hasn't thought yet (newborn)
netVizContainer.classList.add('hidden');
}
} else {
netVizContainer.classList.add('hidden');
}
}
function gameLoop() {
if (!isRunning) return;
update(); draw();
if (frameCount % 10 === 0) { statisticsManager.update(creatures, frameCount, plants.length); }
frameCount++;
animationFrameId = requestAnimationFrame(gameLoop);
}
function setupControls() {
const container = document.getElementById('params-container');
container.innerHTML = '';
for (const key in params) {
const p = params[key];
const controlDiv = document.createElement('div');
controlDiv.className = 'flex flex-col space-y-1';
const label = document.createElement('label');
label.textContent = p.label;
label.className = 'text-sm font-medium text-gray-600';
const valueSpan = document.createElement('span');
valueSpan.textContent = p.value;
valueSpan.className = 'text-sm text-blue-600 font-semibold';
label.appendChild(document.createTextNode(' (')); label.appendChild(valueSpan); label.appendChild(document.createTextNode(')'));
const input = document.createElement('input');
input.type = 'range'; input.min = p.min; input.max = p.max; input.step = p.step; input.value = p.value;
p.uiInput = input; p.uiLabel = valueSpan;
input.addEventListener('input', (e) => { p.value = parseFloat(e.target.value); valueSpan.textContent = p.value; });
controlDiv.appendChild(label); controlDiv.appendChild(input); container.appendChild(controlDiv);
}
}
function setupButtons() {
document.getElementById('startButton').addEventListener('click', () => { if (!isRunning) { isRunning = true; gameLoop(); } });
document.getElementById('pauseButton').addEventListener('click', () => { isRunning = false; if (animationFrameId) { cancelAnimationFrame(animationFrameId); } });
document.getElementById('resetButton').addEventListener('click', () => { isRunning = false; if (animationFrameId) { cancelAnimationFrame(animationFrameId); } resetSimulation(); draw(); });
document.getElementById('stepButton').addEventListener('click', () => { update(); draw(); statisticsManager.update(creatures, frameCount, plants.length); frameCount++; });
document.getElementById('resetColorsButton').addEventListener('click', () => { resetColors(); });
canvas.addEventListener('mousedown', (e) => {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height;
const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY;
let best = null; let minDist = Infinity;
for (const c of creatures) {
const dist = (c.pos.x - x) ** 2 + (c.pos.y - y) ** 2;
if (dist < 400 && dist < minDist) { minDist = dist; best = c; }
}
selectedCreature = best;
if (!isRunning) draw();
});
}
window.onload = function() { setupControls(); setupButtons(); resetSimulation(); draw(); }
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment