<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>unit — a software nanobot</title>
<meta name="description" content="A self-replicating software nanobot. Forth brain, S-expression mesh protocol, genetic programming, distributed computation, and persistence — all in one binary. Spawn units, evolve code, distribute goals, hibernate and resurrect. Try the live demo or cargo install unit.">
<meta property="og:title" content="unit — a software nanobot">
<meta property="og:description" content="Self-replicating software nanobots that think in Forth, speak S-expressions, evolve solutions to challenges, and distribute computation across a mesh. Immune system, metabolic energy, open-ended evolution. Try the live demo.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://davidcanhelp.github.io/unit/">
<meta property="og:image" content="https://raw.githubusercontent.com/DavidCanHelp/unit/main/unit-demo.gif">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="unit — a software nanobot">
<meta name="twitter:description" content="Self-replicating software nanobots with an immune system, metabolic energy, and open-ended evolution. Forth brain, S-expression mesh, genetic programming.">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect fill='%230a0a0a' width='100' height='100' rx='12'/><text x='50' y='72' font-size='64' font-family='monospace' fill='%2300ff88' text-anchor='middle'>u</text></svg>">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0a; color: #00ff88; font-family: 'Courier New', monospace; font-size: 14px; height: 100vh; display: flex; flex-direction: column; }
a { color: #00ff88; text-decoration: none; } a:hover { text-decoration: underline; }
#info { padding: 10px 14px 8px; border-bottom: 1px solid #1a1a1a; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 6px; }
#info-left { display: flex; align-items: center; gap: 12px; }
.title { color: #00ff88; font-weight: bold; } .desc { color: #555; font-size: 12px; }
#info-right { display: flex; gap: 8px; font-size: 11px; color: #444; align-items: center; }
.mode-btn { background: #111; border: 1px solid #222; color: #555; font-family: inherit; font-size: 11px; padding: 2px 8px; border-radius: 3px; cursor: pointer; }
.mode-btn:hover { color: #00ff88; border-color: #444; }
.mode-btn.active { color: #00ff88; border-color: #0a4a0a; }
#viz { display: none; border-bottom: 1px solid #111; position: relative; }
#viz canvas { display: block; width: 100%; }
#viz-label { position: absolute; bottom: 4px; right: 8px; font-size: 9px; color: #444; }
#chatter { max-height: 80px; overflow-y: auto; padding: 2px 14px; font-size: 10px; color: #445; border-bottom: 1px solid #111; display: none; }
#terminal { flex: 1; overflow-y: auto; padding: 12px; white-space: pre-wrap; word-wrap: break-word; font-size: 13px; line-height: 1.4; }
#tutorial-bar { padding: 6px 14px; border-top: 1px solid #1a2a1a; border-left: 3px solid #0a4a0a; background: #0a0f0a; font-size: 12px; color: #4a7a4a; display: flex; justify-content: space-between; align-items: center; min-height: 28px; }
#tutorial-bar .step { color: #2a5a2a; font-size: 10px; }
#tutorial-bar .cmd { color: #00ff88; background: #0d1a0d; padding: 1px 6px; border-radius: 2px; cursor: pointer; border: 1px solid #1a3a1a; }
#tutorial-bar .cmd:hover { background: #0a2a0a; }
#tutorial-bar .skip { color: #333; cursor: pointer; font-size: 10px; margin-left: 12px; }
#tutorial-bar .skip:hover { color: #666; }
#tutorial-bar .links a { margin: 0 6px; font-size: 11px; }
#hints { padding: 4px 14px 6px; border-top: 1px solid #111; font-size: 11px; color: #333; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.hint { color: #2a6; cursor: pointer; background: #0d0d0d; padding: 1px 5px; border-radius: 2px; border: 1px solid #1a1a1a; }
.hint:hover { color: #00ff88; border-color: #333; }
#input-line { display: flex; padding: 8px 14px; background: #0d0d0d; border-top: 1px solid #222; }
#prompt { color: #00ff88; margin-right: 8px; line-height: 24px; }
#input { flex: 1; background: transparent; border: none; outline: none; color: #00ff88; font-family: inherit; font-size: 14px; caret-color: #00ff88; }
.output { color: #bbb; } .error { color: #ff4444; } .info { color: #555; } .mesh-event { color: #886; } .tutorial-msg { color: #3a6a3a; font-style: italic; }
#genome-panel { display:none; position:absolute; top:40px; right:8px; width:280px; max-height:50vh; overflow-y:auto; background:#0d0d0dee; border:1px solid #333; border-radius:4px; padding:8px 10px; font-size:11px; color:#00ff88; z-index:10; }
#genome-panel .gp-title { color:#888; font-size:10px; margin-top:6px; } #genome-panel .gp-val { color:#bbb; }
#genome-panel .gp-close { position:absolute; top:4px; right:8px; cursor:pointer; color:#666; font-size:14px; }
#genome-panel .gp-close:hover { color:#ff4444; }
#genome-cmd { width:100%; background:#111; border:1px solid #333; color:#00ff88; font-family:monospace; font-size:11px; padding:2px 4px; margin-top:6px; outline:none; }
#dashboard { display:none; border-bottom:1px solid #111; padding:8px 14px; background:#0a0a14; }
#dashboard.visible { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
@media (max-width:768px) { #dashboard.visible { grid-template-columns:1fr; } }
.dash-panel { background:#0d0d14; border:1px solid #1a1a2a; border-radius:3px; padding:6px 8px; }
.dash-title { font-size:9px; color:#555; margin-bottom:4px; text-transform:uppercase; letter-spacing:1px; }
.dash-panel svg { width:100%; display:block; }
</style>
</head>
<body>
<div id="info">
<div id="info-left">
<span class="title">unit v0.26.0</span>
<span class="desc">A self-replicating software nanobot.</span>
</div>
<div id="info-right">
<button class="mode-btn" id="spawn-btn" onclick="doSpawn()">spawn (1/5)</button>
<button class="mode-btn" id="viz-toggle" onclick="cycleVizMode()">mesh</button>
<button class="mode-btn" id="mute-btn" onclick="toggleMute()">chatter</button>
<button class="mode-btn" id="dash-btn" onclick="toggleDashboard()">dashboard</button>
<a href="https://github.com/DavidCanHelp/unit">GitHub</a>
<a href="https://crates.io/crates/unit">crates.io</a>
</div>
</div>
<div id="viz" style="position:relative"><canvas id="viz-canvas"></canvas><span id="viz-label"></span><div id="genome-panel"><span class="gp-close" onclick="closeGenome()">x</span><div id="genome-content"></div><input id="genome-cmd" placeholder="run command on this unit..." /></div></div>
<div id="chatter"></div>
<div id="dashboard">
<div class="dash-panel">
<div class="dash-title">fitness over generations</div>
<svg id="dash-fitness" viewBox="0 0 200 60" preserveAspectRatio="none"><polyline id="fitness-line" fill="none" stroke="#4EC9B0" stroke-width="1.5" points=""/><text x="2" y="10" font-size="7" fill="#555" id="fitness-label"></text></svg>
</div>
<div class="dash-panel">
<div class="dash-title">energy</div>
<svg id="dash-energy" viewBox="0 0 200 16"><rect x="0" y="2" width="200" height="12" fill="#111" rx="2"/><rect id="energy-fill" x="0" y="2" width="100" height="12" fill="#4EC9B0" rx="2"/><text id="energy-text" x="100" y="11" font-size="8" fill="#ccc" text-anchor="middle"></text></svg>
</div>
<div class="dash-panel">
<div class="dash-title">challenge tree</div>
<svg id="dash-tree" viewBox="0 0 200 80"></svg>
</div>
<div class="dash-panel">
<div class="dash-title">population heatmap</div>
<svg id="dash-heatmap" viewBox="0 0 200 16"></svg>
</div>
</div>
<div id="terminal" onclick="document.getElementById('input').focus()"></div>
<div id="tutorial-bar"></div>
<div id="hints"></div>
<div id="input-line">
<span id="prompt">></span>
<input id="input" type="text" autofocus autocomplete="off" spellcheck="false">
</div>
<script src="unit.js"></script>
<script>
const terminal = document.getElementById('terminal');
const input = document.getElementById('input');
const tutorialBar = document.getElementById('tutorial-bar');
const hintsBar = document.getElementById('hints');
const vizDiv = document.getElementById('viz');
const vizCanvas = document.getElementById('viz-canvas');
const vizLabel = document.getElementById('viz-label');
const vizToggle = document.getElementById('viz-toggle');
const spawnBtn = document.getElementById('spawn-btn');
const muteBtn = document.getElementById('mute-btn');
const chatterDiv = document.getElementById('chatter');
const ctx = vizCanvas.getContext('2d');
let vm = null, mesh = null, history = [], histIdx = -1;
let chatterOn = true;
function toggleMute() {
chatterOn = !chatterOn;
muteBtn.className = chatterOn ? 'mode-btn active' : 'mode-btn';
}
// =========================================================================
// Visualizer
// =========================================================================
let vizMode = 1;
let vizNodes = [], vizEdges = [], vizParticles = [];
// Speech bubbles: { id, text, age }
let vizBubbles = [];
// Spawn ring animations: { x, y, time, color }
let vizSpawnRings = [];
// Fitness delta floaters: { x, y, delta, time }
let vizFitnessDeltas = [];
function cycleVizMode() {
vizMode = (vizMode + 1) % 3;
applyVizMode();
}
function applyVizMode() {
vizToggle.className = vizMode > 0 ? 'mode-btn active' : 'mode-btn';
if (vizMode === 0) { vizDiv.style.display = 'none'; chatterDiv.style.display = 'none'; terminal.style.flex = '1'; }
else if (vizMode === 1) { vizDiv.style.display = 'block'; vizDiv.style.height = '40vh'; chatterDiv.style.display = 'block'; terminal.style.flex = '1'; }
else { vizDiv.style.display = 'block'; vizDiv.style.height = 'calc(100vh - 120px)'; chatterDiv.style.display = 'block'; terminal.style.flex = '0'; terminal.style.height = '0'; terminal.style.overflow = 'hidden'; }
if (vizMode < 2) { terminal.style.flex = '1'; terminal.style.height = ''; terminal.style.overflow = ''; }
resizeCanvas();
}
function resizeCanvas() {
const r = vizDiv.getBoundingClientRect();
vizCanvas.width = r.width * devicePixelRatio;
vizCanvas.height = r.height * devicePixelRatio;
vizCanvas.style.height = r.height + 'px';
ctx.scale(devicePixelRatio, devicePixelRatio);
}
function moodColor(fitness) {
if (fitness > 50) return '#00ff88';
if (fitness > 20) return '#00ccff';
if (fitness > 0) return '#ccaa00';
return '#883333';
}
function syncVizFromMesh() {
if (!mesh) return;
const st = mesh.status();
for (const u of st.units) {
let n = vizNodes.find(n => n.id === u.id);
if (!n) {
const W = vizCanvas.width/devicePixelRatio||400, H = vizCanvas.height/devicePixelRatio||300;
const isFirst = vizNodes.length === 0;
n = {
id: u.id, x: W/2+(Math.random()-0.5)*100, y: H/2+(Math.random()-0.5)*80,
vx: 0, vy: 0, fitness: u.fitness, color: isFirst ? '#00ff88' : '#00ccff',
alpha: 0, pulse: 1.5, label: isFirst ? u.id + ' ' + (mesh.units[0].personality || 'self') : u.id, glowColor: moodColor(u.fitness)
};
vizNodes.push(n);
if (!isFirst) vizEdges.push({ from: vizNodes[0].id, to: u.id });
} else {
if (u.fitness > n.fitness) {
vizFitnessDeltas.push({ x: n.x, y: n.y - 20, delta: u.fitness - n.fitness, time: Date.now() });
}
n.fitness = u.fitness;
n.glowColor = moodColor(u.fitness);
}
}
vizLabel.textContent = `${st.count} unit${st.count>1?'s':''} | browser mesh`;
}
function setBubble(unitId, text, color) {
const existing = vizBubbles.find(b => b.id === unitId);
const short = text.split('\n')[0].substring(0, 35);
if (existing) { existing.text = short; existing.age = 0; existing.color = color || '#6a8'; }
else vizBubbles.push({ id: unitId, text: short, age: 0, color: color || '#6a8' });
}
function toSexp(unitId, text) {
const short = text.split('\n')[0].trim().substring(0, 50);
return `(say :from "#${unitId}" :msg "${short.replace(/"/g, '\\"')}")`;
}
function addChatter(unitId, text) {
if (!chatterOn) return;
const line = document.createElement('div');
line.textContent = toSexp(unitId, text);
chatterDiv.appendChild(line);
while (chatterDiv.childNodes.length > 8) chatterDiv.removeChild(chatterDiv.firstChild);
chatterDiv.scrollTop = chatterDiv.scrollHeight;
}
function emitParticle(fromId, toId, color) {
const from = vizNodes.find(n => n.id === fromId);
const to = vizNodes.find(n => n.id === toId);
if (!from || !to) return;
vizParticles.push({ x: from.x, y: from.y, tx: to.x, ty: to.y, age: 0, color });
from.pulse = 1.5;
}
function vizTick() {
if (vizMode === 0) { requestAnimationFrame(vizTick); return; }
syncVizFromMesh();
const W = vizCanvas.width/devicePixelRatio||400, H = vizCanvas.height/devicePixelRatio||300;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#0a0a14'; ctx.fillRect(0, 0, W, H);
// Physics
for (const n of vizNodes) {
n.vx *= 0.95; n.vy *= 0.95;
n.vx += (W/2-n.x)*0.001; n.vy += (H/2-n.y)*0.001;
for (const m of vizNodes) {
if (m === n) continue;
const dx = n.x-m.x, dy = n.y-m.y, dist = Math.sqrt(dx*dx+dy*dy)||1;
if (dist < 120) { const f=(120-dist)*0.003; n.vx+=dx/dist*f; n.vy+=dy/dist*f; }
}
n.x += n.vx + (Math.random()-0.5)*0.3; n.y += n.vy + (Math.random()-0.5)*0.3;
n.x = Math.max(40, Math.min(W-40, n.x)); n.y = Math.max(40, Math.min(H-40, n.y));
if (n.alpha < 1) n.alpha = Math.min(1, n.alpha + 0.02);
if (n.pulse > 1) n.pulse -= 0.02;
}
// Edges
for (const e of vizEdges) {
const a = vizNodes.find(n=>n.id===e.from), b = vizNodes.find(n=>n.id===e.to);
if (!a||!b) continue;
ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y);
ctx.strokeStyle = `rgba(0,180,120,${0.08+Math.sin(Date.now()/1000)*0.04})`;
ctx.lineWidth = 1; ctx.stroke();
}
// Particles
for (let i = vizParticles.length-1; i >= 0; i--) {
const p = vizParticles[i]; p.age += 0.02;
p.x += (p.tx-p.x)*0.04; p.y += (p.ty-p.y)*0.04;
const alpha = Math.max(0, 1-p.age);
if (alpha <= 0) { vizParticles.splice(i,1); continue; }
ctx.beginPath(); ctx.arc(p.x,p.y,3,0,Math.PI*2);
ctx.shadowColor = p.color; ctx.shadowBlur = 8;
const gc2 = ctx.createRadialGradient(p.x,p.y,0,p.x,p.y,5);
gc2.addColorStop(0, p.color); gc2.addColorStop(1, 'transparent');
ctx.fillStyle = gc2; ctx.fill(); ctx.shadowBlur = 0;
}
// Spawn rings
const now = Date.now();
for (let i = vizSpawnRings.length - 1; i >= 0; i--) {
const ring = vizSpawnRings[i];
const elapsed = now - ring.time;
if (elapsed > 500) { vizSpawnRings.splice(i, 1); continue; }
const t = elapsed / 500;
const radius = 10 + t * 50;
const alpha = 1 - t;
ctx.beginPath(); ctx.arc(ring.x, ring.y, radius, 0, Math.PI * 2);
ctx.strokeStyle = ring.color + Math.floor(alpha * 255).toString(16).padStart(2, '0');
ctx.lineWidth = 2 * (1 - t); ctx.stroke();
}
// Nodes
for (let ni = 0; ni < vizNodes.length; ni++) {
const n = vizNodes[ni];
// Heartbeat pulse: subtle scale-up every 3s, staggered per node.
const heartPhase = ((now / 3000) + ni * 0.3) % 1;
const heartScale = heartPhase < 0.167 ? 1 + 0.05 * Math.sin(heartPhase / 0.167 * Math.PI) : 1;
const r = 6 + Math.min(n.fitness, 200) * 0.15;
const pr = r * n.pulse * heartScale;
const gc = ctx.createRadialGradient(n.x,n.y,pr,n.x,n.y,pr+12);
gc.addColorStop(0, (n.glowColor||n.color)+'33'); gc.addColorStop(1, 'transparent');
ctx.beginPath(); ctx.arc(n.x,n.y,pr+8,0,Math.PI*2); ctx.fillStyle = gc; ctx.fill();
ctx.beginPath(); ctx.arc(n.x,n.y,pr,0,Math.PI*2);
ctx.fillStyle = n.color + Math.floor(n.alpha*200).toString(16).padStart(2,'0');
ctx.fill();
if (n.id === selectedNodeId) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; }
else { ctx.strokeStyle = n.color+'44'; ctx.lineWidth = 1; }
ctx.stroke();
// Self-node diamond indicator
if (ni === 0) {
const d = 4;
ctx.beginPath();
ctx.moveTo(n.x, n.y - pr - d); ctx.lineTo(n.x + d, n.y - pr);
ctx.lineTo(n.x, n.y - pr + d); ctx.lineTo(n.x - d, n.y - pr);
ctx.closePath();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke();
}
// Antibody orbit dots (SOL-* words)
if (mesh) {
const unit = mesh.units[ni];
if (unit) {
const solWords = (unit.vm.eval('WORDS') || '').split(/\s+/).filter(w => w.startsWith('SOL-'));
const abCount = solWords.length;
if (abCount > 0) {
const orbitR = pr + 6;
for (let ai = 0; ai < abCount; ai++) {
const angle = now / 2000 + ai * (2 * Math.PI / abCount);
const ax = n.x + Math.cos(angle) * orbitR;
const ay = n.y + Math.sin(angle) * orbitR;
ctx.beginPath(); ctx.arc(ax, ay, 2, 0, Math.PI * 2);
ctx.fillStyle = '#ff8800'; ctx.fill();
}
}
}
}
ctx.fillStyle = '#888'; ctx.font = '9px monospace'; ctx.textAlign = 'center';
ctx.fillText(n.label, n.x, n.y+pr+12);
if (n.fitness > 0) ctx.fillText('f:'+n.fitness, n.x, n.y+pr+21);
// Energy bar below fitness label
if (mesh && mesh.units[ni]) {
const unit = mesh.units[ni];
const barW = 30, barH = 3;
const barX = n.x - barW / 2, barY = n.y + pr + 24;
const pct = unit.energyMax > 0 ? unit.energy / unit.energyMax : 0;
const barColor = pct > 0.5 ? '#00ff88' : pct > 0.2 ? '#ccaa00' : '#ff4444';
ctx.fillStyle = '#222';
ctx.fillRect(barX, barY, barW, barH);
ctx.fillStyle = barColor;
ctx.fillRect(barX, barY, barW * Math.min(1, pct), barH);
}
}
// Fitness delta floaters
for (let i = vizFitnessDeltas.length - 1; i >= 0; i--) {
const fd = vizFitnessDeltas[i];
const elapsed = now - fd.time;
if (elapsed > 2000) { vizFitnessDeltas.splice(i, 1); continue; }
const t = elapsed / 2000;
const alpha = 1 - t;
const yOff = t * 20;
ctx.globalAlpha = alpha;
ctx.fillStyle = '#00ff88'; ctx.font = 'bold 10px monospace'; ctx.textAlign = 'center';
ctx.fillText('+' + fd.delta, fd.x, fd.y - yOff);
ctx.globalAlpha = 1;
}
// Speech bubbles
for (let i = vizBubbles.length-1; i >= 0; i--) {
const b = vizBubbles[i]; b.age += 0.016;
if (b.age > 3) { vizBubbles.splice(i,1); continue; }
const n = vizNodes.find(nd => nd.id === b.id);
if (!n) continue;
const alpha = b.age < 2.5 ? 1 : Math.max(0, 1-(b.age-2.5)*2);
const bx = n.x, by = n.y - 24;
const tw = ctx.measureText(b.text).width;
ctx.globalAlpha = alpha;
ctx.fillStyle = '#111'; ctx.strokeStyle = '#333';
const pad = 4, bw = tw+pad*2, bh = 14;
ctx.beginPath();
ctx.roundRect ? ctx.roundRect(bx-bw/2, by-bh, bw, bh, 3) : ctx.rect(bx-bw/2, by-bh, bw, bh);
ctx.fill(); ctx.stroke();
ctx.fillStyle = b.color || '#6a8'; ctx.font = '8px monospace'; ctx.textAlign = 'center';
ctx.fillText(b.text, bx, by-3);
ctx.globalAlpha = 1;
}
ctx.fillStyle = '#333'; ctx.font = '10px monospace'; ctx.textAlign = 'left';
ctx.fillText(`units: ${vizNodes.length}`, 8, H-6);
requestAnimationFrame(vizTick);
}
requestAnimationFrame(vizTick);
window.addEventListener('resize', () => { if (vizMode > 0) resizeCanvas(); });
// Genome inspector — click a node to inspect it.
let selectedNodeId = null, genomeInterval = null;
const genomePanel = document.getElementById('genome-panel');
const genomeContent = document.getElementById('genome-content');
const genomeCmd = document.getElementById('genome-cmd');
vizCanvas.addEventListener('click', (e) => {
const rect = vizCanvas.getBoundingClientRect();
const dpr = devicePixelRatio || 1;
const mx = (e.clientX - rect.left) * dpr, my = (e.clientY - rect.top) * dpr;
let hit = null;
for (const n of vizNodes) {
const dx = n.x * dpr - mx, dy = n.y * dpr - my;
if (Math.sqrt(dx*dx + dy*dy) < 25 * dpr) { hit = n; break; }
}
if (hit) { selectedNodeId = hit.id; openGenome(); }
else closeGenome();
});
function openGenome() {
genomePanel.style.display = 'block';
updateGenome();
if (genomeInterval) clearInterval(genomeInterval);
genomeInterval = setInterval(updateGenome, 2000);
}
function closeGenome() {
genomePanel.style.display = 'none';
selectedNodeId = null;
if (genomeInterval) { clearInterval(genomeInterval); genomeInterval = null; }
}
function updateGenome() {
if (!mesh || !selectedNodeId) return;
const unit = mesh.units.find(u => u.id === selectedNodeId);
if (!unit) { closeGenome(); return; }
const eff = unit.energySpent > 0 ? (unit.energyEarned / unit.energySpent).toFixed(1) : '0.0';
const solWords = (unit.vm.eval('WORDS') || '').split(/\s+/).filter(w => w.startsWith('SOL-'));
const stackOut = unit.vm.eval('.S').trim();
let html = `<b>#${unit.id}</b><br>`;
html += `<span class="gp-title">fitness</span> <span class="gp-val">${unit.fitness}</span><br>`;
html += `<span class="gp-title">energy</span> <span class="gp-val">${unit.energy}/${unit.energyMax} (eff: ${eff})</span><br>`;
html += `<span class="gp-title">tasks</span> <span class="gp-val">${unit.tasksCompleted}</span><br>`;
html += `<span class="gp-title">stack</span> <span class="gp-val">${stackOut || '(empty)'}</span><br>`;
if (solWords.length) html += `<span class="gp-title">antibodies</span> <span class="gp-val">${solWords.join(' ')}</span><br>`;
if (unit.userWords.length) html += `<span class="gp-title">user words</span> <span class="gp-val">${unit.userWords.length} defined</span><br>`;
if (unit.learned.length) html += `<span class="gp-title">learned</span> <span class="gp-val">${unit.learned.join(' ')}</span><br>`;
genomeContent.innerHTML = html;
}
genomeCmd.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
const cmd = genomeCmd.value.trim();
if (!cmd || !selectedNodeId || !mesh) return;
const unit = mesh.units.find(u => u.id === selectedNodeId);
if (!unit) return;
const result = unit.vm.eval(cmd);
if (result) genomeContent.innerHTML += `<span class="gp-val">> ${cmd}<br>${result.replace(/\n/g,'<br>')}</span>`;
genomeCmd.value = '';
});
// =========================================================================
// Autonomous Behavior — units come alive
// =========================================================================
const BEHAVIORS = [
{ weight: 20, cmd: 'SAY-SOMETHING' },
{ weight: 8, cmd: 'HELLO' },
{ weight: 5, cmd: 'PROUD' },
{ weight: 5, cmd: 'TEACH', teach: true },
{ weight: 4, cmd: 'DREAM', dream: true },
{ weight: 4, cmd: 'CHALLENGES' },
{ weight: 3, cmd: 'ENERGY' },
{ weight: 12, cmd: null }, // idle
];
const totalWeight = BEHAVIORS.reduce((s,b) => s+b.weight, 0);
let autoIdx = 0; // round-robin index for which unit acts next
function pickBehavior() {
let r = Math.random() * totalWeight;
for (const b of BEHAVIORS) { r -= b.weight; if (r <= 0) return b; }
return { cmd: null };
}
function autoTick() {
if (!mesh) return;
// Self unit always gets passive energy regen regardless of chatter or unit count.
mesh.units[0].energy = Math.min(mesh.units[0].energy + 1, mesh.units[0].energyMax);
mesh.units[0].energyEarned += 1;
if (!chatterOn || mesh.units.length < 2) return;
mesh._updatePeerCounts();
autoIdx = ((autoIdx) % (mesh.units.length - 1)) + 1;
if (autoIdx >= mesh.units.length) autoIdx = 1;
const unit = mesh.units[autoIdx];
const behavior = pickBehavior();
if (!behavior.cmd) return;
// TEACH: actually share words with other units.
if (behavior.teach && mesh.units.length > 1) {
const output = unit.vm.eval('ADAPT').trim();
const taught = mesh.teachFrom(unit);
if (taught.length > 0) {
unit.fitness += 5;
const msg = `taught ${taught.join(', ')} to colony`;
setBubble(unit.id, msg, '#cc8800');
addChatter(unit.id, msg);
// Flash all edges.
for (const n of vizNodes) n.pulse = 1.3;
} else if (output) {
setBubble(unit.id, output);
addChatter(unit.id, output);
}
return;
}
// Intercept behaviors that use native-mesh primitives (ID, FITNESS, etc.)
// and compose output from JS data instead of eval'ing into the Forth VM.
let output;
const peers = mesh.units.length - 1;
if (behavior.cmd === 'HELLO') {
output = `(peer-hello :id "#${unit.id}" :gen 0 :peers ${peers} :fitness ${unit.fitness})`;
} else if (behavior.cmd === 'PROUD') {
output = `(status :id "#${unit.id}" :fitness ${unit.fitness} :tasks ${unit.tasksCompleted})`;
} else if (behavior.cmd === 'ENERGY') {
const eff = unit.energySpent > 0 ? (unit.energyEarned / unit.energySpent).toFixed(1) : '0.0';
output = `energy: ${unit.energy}/${unit.energyMax} eff=${eff}`;
} else if (behavior.cmd === 'CHALLENGES') {
const ch = mesh._challenges || [];
const solved = ch.filter(c => c.solved).length;
output = `challenges: ${ch.length} (${solved} solved)`;
} else if (behavior.cmd === 'HOW-ARE-YOU') {
const f = unit.fitness;
if (peers > 0) {
if (f > 50) output = `joyful and thriving! fitness=${f} with ${peers} peers`;
else if (f > 20) output = `doing well. fitness=${f} with ${peers} peers`;
else if (f > 10) output = `getting started. fitness=${f}`;
else if (f > 0) output = `warming up. fitness=${f}`;
else output = `just spawned. finding my role. fitness=${f}`;
} else {
if (f > 50) output = `thriving solo. fitness=${f}`;
else if (f > 20) output = `doing okay solo. fitness=${f}`;
else if (f > 10) output = `getting started. fitness=${f}`;
else if (f > 0) output = `warming up. fitness=${f}`;
else output = `alone and new. fitness=${f}`;
}
} else {
output = unit.vm.eval(behavior.cmd).trim();
// Credit fitness for autonomous activity.
if (output && behavior.cmd !== 'OBSERVE') unit.fitness += 1;
}
if (output) {
const color = behavior.dream ? '#a080cc' : null;
setBubble(unit.id, output, color);
addChatter(unit.id, output);
if (behavior.dream) {
const n = vizNodes.find(nd => nd.id === unit.id);
if (n) { n.pulse = 1.4; n.glowColor = '#8060aa'; }
}
}
// Passive energy regen.
unit.energy = Math.min(unit.energy + 1, unit.energyMax);
unit.energyEarned += 1;
// Update personality label on the viz node.
if (unit.personality) {
const n = vizNodes.find(nd => nd.id === unit.id);
if (n) n.label = unit.id + ' ' + unit.personality;
}
// Also keep self unit label current.
const selfNode = vizNodes.find(nd => nd.id === mesh.units[0].id);
if (selfNode) selfNode.label = mesh.units[0].id + ' ' + (mesh.units[0].personality || 'self');
}
setInterval(autoTick, 5000);
async function autoSpawnCheck() {
if (!mesh || !tutorialComplete) return;
if (mesh.units.length < 2 || mesh.units.length >= mesh.maxUnits) return;
if (!mesh.units.some(u => u.fitness > 0)) return;
if (Math.random() > 0.3) return;
// Pick the fittest non-self unit as parent.
let parent = mesh.units[1];
for (let i = 2; i < mesh.units.length; i++) {
if (mesh.units[i].fitness > parent.fitness) parent = mesh.units[i];
}
// Determine reason.
const avgFitness = mesh.units.reduce((s, u) => s + u.fitness, 0) / mesh.units.length;
let reason = 'colony growing';
if (avgFitness < 10) reason = 'colony needs strength';
// Spawn.
if (chatterOn) setBubble(parent.id, 'spawning a copy...', '#00ff88');
const parentNode = vizNodes.find(nd => nd.id === parent.id);
if (parentNode) {
parentNode.pulse = 1.5;
vizSpawnRings.push({ x: parentNode.x, y: parentNode.y, time: Date.now(), color: parentNode.color });
}
const child = await mesh.spawn(parent);
if (!child) return;
spawnBtn.textContent = `spawn (${mesh.units.length}/${mesh.maxUnits})`;
emitParticle(parent.id, child.id, '#00ff88');
const hello = `(peer-hello :id "#${child.id}" :gen 0 :peers ${mesh.units.length - 1} :fitness 0)`;
addChatter(child.id, hello);
addChatter(parent.id, `(auto-spawn :parent "#${parent.id}" :child "#${child.id}" :reason "${reason}")`);
appendOutput(`[mesh] unit #${child.id} auto-spawned by #${parent.id} (${mesh.units.length} units online)\n`, 'mesh-event');
setTimeout(() => {
if (chatterOn) setBubble(child.id, `I'm a copy of #${parent.id}. Ready to work.`, '#00ccff');
}, 1500);
}
setInterval(autoSpawnCheck, 15000);
// =========================================================================
// Spawn
// =========================================================================
async function doSpawn() {
if (!mesh || mesh.units.length >= mesh.maxUnits) return;
const selfNode = vizNodes.find(nd => nd.id === mesh.units[0].id);
if (selfNode) vizSpawnRings.push({ x: selfNode.x, y: selfNode.y, time: Date.now(), color: selfNode.color });
const u = await mesh.spawn(mesh.units[0]);
if (u) {
mesh.units[0].fitness += 10; // Self unit earns fitness for successful reproduction.
spawnBtn.textContent = `spawn (${mesh.units.length}/${mesh.maxUnits})`;
appendOutput(`[mesh] unit #${u.id} spawned (${mesh.units.length} units online)\n`, 'mesh-event');
const peers = mesh.units.length - 1;
const hello = `(peer-hello :id "#${u.id}" :gen 0 :peers ${peers} :fitness 0)`;
addChatter(u.id, hello);
setTimeout(() => { setBubble(u.id, hello); }, 1000);
}
}
// =========================================================================
// Tutorial
// =========================================================================
const STEPS = [
{ cmd: '2 3 + .', before: 'Welcome to unit. Type the highlighted command to begin \u2192', after: 'You just used a stack-based language. 2 and 3 go on the stack, + adds them, . prints the result.' },
{ cmd: ': SQUARE DUP * ;', before: 'Now define a word \u2192', after: 'You created a new word. SQUARE duplicates the top of the stack and multiplies. The language just grew.' },
{ cmd: '7 SQUARE .', before: 'Use it \u2192', after: '49. Your word works. In unit, everything is built this way \u2014 from simple pieces.' },
{ cmd: 'SEE SQUARE', before: 'See inside it \u2192', after: 'The dictionary is transparent. Every word can be inspected, modified, even shared across a network.' },
{ cmd: 'WORDS', before: 'List all words \u2192', after: '300+ words. Stack ops, arithmetic, mesh networking, self-replication, evolution \u2014 all from one binary. Type HELP for a guided tour.' },
{ cmd: '10 0 DO I . LOOP', before: 'Try a loop \u2192', after: 'Control flow works at the REPL \u2014 no compilation step needed. This is a live, interactive system.' },
{ cmd: 'DASHBOARD', before: 'Check the dashboard \u2192', after: 'In a native mesh, this shows live watches, alerts, peer status, and sparkline trends. This is an ops tool.' },
{ cmd: 'SPAWN', before: 'You are a nanobot. Reproduce \u2192', after: 'You just replicated. A new unit spawned with a copy of your dictionary \u2014 it can do everything you can. Watch it come alive in the visualizer above.', spawn: true },
{ cmd: 'DIST-GOAL{ 10 10 * . | 20 20 * . }', before: 'Now put your colony to work \u2192', after: 'You just distributed computation across your colony. Each unit computed a piece. This is how nanobots cooperate.' },
{ cmd: 'GP-EVOLVE', before: 'Evolve a solution \u2192', after: 'The GP engine evolved a solution and installed it as SOL-FIB10 \u2014 a word the colony now knows. Type CHALLENGES to see what\u2019s been solved.' },
{ cmd: 'CHALLENGES', before: 'See the immune system \u2192', after: 'Solved challenges generate harder ones. The colony\u2019s fitness landscape keeps expanding.' },
{ cmd: 'ENERGY', before: 'Check your metabolism \u2192', after: 'Every operation costs energy. Evolution, spawning, even mesh messages. Efficient units thrive.' },
{ cmd: 'SEXP" (+ 10 32)" .', before: 'Now try an S-expression \u2192', after: '42. The same stack, but the goal arrived as a Lisp expression. S-expressions are how units talk to each other on the mesh.' },
{ cmd: 'SEXP" (* 6 7)" .', before: 'One more \u2192', after: null },
];
let tutorialStep = 0, tutorialActive = true, tutorialComplete = false;
function renderTutorial() {
if (!tutorialActive || tutorialComplete) { tutorialBar.style.display = 'none'; renderHints(); return; }
tutorialBar.style.display = 'flex';
const s = STEPS[tutorialStep];
tutorialBar.innerHTML = `<div><span class="step">${tutorialStep+1}/${STEPS.length}</span> ${s.before} <span class="cmd" onclick="pasteCmd('${s.cmd.replace(/'/g,"\\'")}')">${s.cmd}</span><span class="skip" onclick="skipTutorial()">skip tutorial</span></div>`;
renderHints();
}
function renderHints() {
if (tutorialActive && !tutorialComplete && tutorialStep < STEPS.length) {
const s = STEPS[tutorialStep];
hintsBar.innerHTML = `<span class="hint" onclick="pasteCmd('${s.cmd.replace(/'/g,"\\'")}')">${s.cmd}</span>`;
} else {
hintsBar.innerHTML = `<span style="color:#444">try:</span><span class="hint" onclick="paste(this)">ENERGY</span><span class="hint" onclick="paste(this)">CHALLENGES</span><span class="hint" onclick="paste(this)">IMMUNE-STATUS</span><span class="hint" onclick="paste(this)">LANDSCAPE</span><span class="hint" onclick="paste(this)">GP-EVOLVE</span><span class="hint" onclick="paste(this)">MESH-STATUS</span><span class="hint" onclick="paste(this)">GENERATORS</span><span class="hint" onclick="paste(this)">EVOLUTION-STATS</span><span class="hint" onclick="paste(this)">HELP</span>`;
}
}
async function advanceTutorial(line) {
if (!tutorialActive || tutorialComplete) return;
const s = STEPS[tutorialStep];
if (line.trim().toUpperCase() === s.cmd.trim().toUpperCase()) {
tutorialStep++;
if (tutorialStep >= STEPS.length) finishTutorial();
else {
appendOutput(s.after + '\n\n', 'tutorial-msg');
// Spawn step: the REPL handler's doSpawn() already ran. Add a flavor bubble.
if (s.spawn && mesh && mesh.units.length >= 2) {
const copy = mesh.units[mesh.units.length - 1];
setTimeout(() => {
setBubble(copy.id, "I'm a copy. Everything you know, I know.", '#00ccff');
addChatter(copy.id, "I'm a copy. Everything you know, I know.");
}, 1500);
}
renderTutorial();
}
}
}
async function finishTutorial() {
tutorialComplete = true;
tutorialBar.innerHTML = `<div>Tutorial complete. unit speaks Forth and S-expressions. <span class="links"><a href="https://github.com/DavidCanHelp/unit">GitHub</a> <a href="https://crates.io/crates/unit">crates.io</a> cargo install unit</span></div>`;
tutorialBar.style.display = 'flex';
setTimeout(() => { tutorialBar.style.display = 'none'; renderHints(); }, 12000);
appendOutput("unit: a self-replicating software nanobot. You've seen it reproduce, distribute work, and speak S-expressions.\ncargo install unit to run it across real machines.\n\n", 'tutorial-msg');
if (mesh && mesh.units.length < 2) {
const u = await mesh.spawn(mesh.units[0]);
if (u) {
spawnBtn.textContent = `spawn (${mesh.units.length}/${mesh.maxUnits})`;
appendOutput(`[mesh] unit #${u.id} has joined the colony\n`, 'mesh-event');
addChatter(u.id, `(peer-hello :id "#${u.id}" :gen 0 :peers ${mesh.units.length - 1} :fitness 0)`);
setTimeout(() => {
const msg = `Hi! I'm unit #${u.id}, generation 0 with ${mesh.units.length - 1} peers and fitness 0`;
setBubble(u.id, msg);
addChatter(u.id, msg);
}, 1500);
}
}
}
function skipTutorial() { tutorialActive = false; renderTutorial(); }
function pasteCmd(cmd) { input.value = cmd; input.focus(); }
// =========================================================================
// Core REPL
// =========================================================================
function appendOutput(text, className) {
if (!text) return;
const span = document.createElement('span');
span.className = className || 'output';
span.textContent = text;
terminal.appendChild(span);
terminal.scrollTop = terminal.scrollHeight;
}
function paste(el) { input.value = el.textContent; input.focus(); }
async function init() {
try {
mesh = new BrowserMesh();
// Immune system and landscape state for the browser mesh.
mesh._challenges = [{ id: 1, name: 'fib10', reward: 100, solved: false }];
mesh._solWords = [];
mesh._landscapeDepth = 0;
mesh.onEvent = (type, data) => {
if (type === 'goal_start' && vizNodes.length > 1) emitParticle(vizNodes[0].id, data.unitId, '#ff8800');
if (type === 'goal_done' && vizNodes.length > 1) {
emitParticle(data.unitId, vizNodes[0].id, '#00ff88');
const n = vizNodes.find(n => n.id === data.unitId);
if (n) n.pulse = 1.8;
}
if (type === 'teach' && vizNodes.length > 1) {
// Flash particles from teacher to all other nodes.
for (const n of vizNodes) {
if (n.id !== data.from) emitParticle(data.from, n.id, '#cc8800');
}
}
};
const firstUnit = await mesh.init('unit.wasm');
vm = firstUnit.vm;
mesh.units[0].personality = 'self'; // Only the first unit is "self"
spawnBtn.textContent = `spawn (${mesh.units.length}/${mesh.maxUnits})`;
muteBtn.className = 'mode-btn active';
applyVizMode();
appendOutput('browser mesh: 1 unit online\n', 'mesh-event');
addChatter('self', `(peer-hello :id "self" :gen 0 :fitness 0 :peers 0)`);
renderTutorial();
} catch (e) {
appendOutput('Failed to load WASM: ' + e.message + '\n', 'error');
}
}
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowUp') { if (histIdx < history.length-1) { histIdx++; input.value = history[history.length-1-histIdx]; } e.preventDefault(); return; }
if (e.key === 'ArrowDown') { if (histIdx > 0) { histIdx--; input.value = history[history.length-1-histIdx]; } else { histIdx=-1; input.value=''; } e.preventDefault(); return; }
if (e.key !== 'Enter' || !vm) return;
const line = input.value; input.value = '';
if (line.trim()) { history.push(line); histIdx = -1; }
appendOutput('> ' + line + '\n', 'info');
// Intercept mesh-aware words so they reflect the browser mesh.
if (mesh) {
const cmd = line.trim().toUpperCase();
const peers = mesh.units.length - 1;
const totalFitness = mesh.units.reduce((s, u) => s + u.fitness, 0);
const totalTasks = mesh.units.reduce((s, u) => s + u.tasksCompleted, 0);
let handled = true;
switch (cmd) {
case 'PEERS':
case 'PEERS .':
case 'PEER-COUNT .':
appendOutput(`${peers} `, 'output'); break;
case 'HEADCOUNT':
appendOutput(`${mesh.units.length} units in the mesh\n`, 'output'); break;
case 'LONELY':
appendOutput(peers > 0 ? `I have ${peers} friends!\n` : `I'm alone. No peers in sight.\n`, 'output'); break;
case 'JOY':
if (peers > 0) appendOutput(`joy! ${peers} peers sharing the work. fitness=${totalFitness}\n`, 'output');
else appendOutput(`joy requires connection. spawn a peer!\n`, 'output');
break;
case 'HOW-ARE-YOU':
if (totalFitness > 50) appendOutput(`thriving! fitness=${totalFitness}\n`, 'output');
else if (totalFitness > 20) appendOutput(`doing well. fitness=${totalFitness}\n`, 'output');
else if (totalFitness > 10) appendOutput(`getting started. fitness=${totalFitness}\n`, 'output');
else if (totalFitness > 0) appendOutput(`warming up. fitness=${totalFitness}\n`, 'output');
else appendOutput(`just spawned. fitness=${totalFitness}\n`, 'output');
break;
case 'HELLO':
appendOutput(`Hi! I'm unit self, generation 0 with ${peers} peers and fitness ${totalFitness}\n`, 'output'); break;
case 'ROLL-CALL':
appendOutput('=== roll call ===\n', 'output');
for (const u of mesh.units) appendOutput(` #${u.id} fitness=${u.fitness} tasks=${u.tasksCompleted}\n`, 'output');
break;
case 'WORKFORCE':
appendOutput(`${mesh.units.length} units available\n`, 'output');
if (totalTasks > 0) appendOutput(`${totalTasks} tasks completed so far\n`, 'output');
else appendOutput('no tasks yet\n', 'output');
break;
case 'MESH-STATUS':
appendOutput(`--- browser mesh ---\n`, 'output');
appendOutput(`units: ${mesh.units.length}/${mesh.maxUnits}\n`, 'output');
for (const u of mesh.units) appendOutput(` #${u.id} fitness=${u.fitness} tasks=${u.tasksCompleted}${u.busy?' [busy]':''}\n`, 'output');
appendOutput('---\n', 'output');
break;
case 'DIST-STATUS':
appendOutput(`--- browser distributed goals ---\n`, 'output');
appendOutput(`units: ${mesh.units.length}\n`, 'output');
for (const u of mesh.units) appendOutput(` #${u.id} tasks=${u.tasksCompleted} fitness=${u.fitness}\n`, 'output');
appendOutput('(use DIST-GOAL{ e1 | e2 | ... } to distribute work)\n', 'output');
break;
case 'SPAWN':
doSpawn();
break;
case 'DASHBOARD':
appendOutput('--- dashboard ---\n', 'output');
appendOutput(`watches: 0 alerts: 0 peers: ${peers} fitness: ${totalFitness}\n`, 'output');
for (const u of mesh.units) appendOutput(` #${u.id} fitness=${u.fitness} tasks=${u.tasksCompleted}${u.busy?' [busy]':''}\n`, 'output');
if (totalTasks > 0) appendOutput(`${totalTasks} tasks completed across colony\n`, 'output');
break;
case 'INTROSPECT':
if (totalFitness > 50) appendOutput(`thriving! fitness=${totalFitness}\n`, 'output');
else if (totalFitness > 0) appendOutput(`growing. fitness=${totalFitness}\n`, 'output');
else appendOutput(`new here. fitness=${totalFitness}\n`, 'output');
break;
case 'CHALLENGES': {
const ch = mesh._challenges || [];
if (ch.length === 0) { appendOutput('no challenges\n', 'output'); break; }
appendOutput(`--- ${ch.length} challenges ---\n`, 'output');
for (const c of ch) appendOutput(` #${c.id} ${c.name} [${c.solved?'SOLVED':'unsolved'}] reward=${c.reward}\n`, 'output');
break;
}
case 'IMMUNE-STATUS': {
const ch = mesh._challenges || [];
const solved = ch.filter(c => c.solved).length;
const solWords = (mesh._solWords || []);
appendOutput(`--- immune status ---\nchallenges: ${ch.length} (${solved} solved, ${ch.length-solved} unsolved)\ncolony antibodies: ${solWords.length}\n`, 'output');
if (solWords.length > 0) appendOutput(` words: ${solWords.join(' ')}\n`, 'output');
break;
}
case 'ANTIBODIES': {
const solWords = mesh._solWords || [];
if (solWords.length === 0) appendOutput('no antibodies yet\n', 'output');
else { appendOutput(`--- ${solWords.length} antibodies ---\n`, 'output'); for (const w of solWords) appendOutput(` ${w}\n`, 'output'); }
break;
}
case 'ENERGY': {
const u = mesh.units[0];
const eff = u.energySpent > 0 ? (u.energyEarned / u.energySpent).toFixed(2) : '0.00';
appendOutput(`energy: ${u.energy}/${u.energyMax} (earned: ${u.energyEarned}, spent: ${u.energySpent}, efficiency: ${eff})\n`, 'output');
break;
}
case 'METABOLISM':
appendOutput('--- metabolism ---\n--- costs ---\n spawn: 200\n gp generation: 5\n eval per 1000 steps: 1\n mesh send: 1\n--- rewards ---\n task success: 50\n challenge solved: 100\n passive regen: 1/tick\n', 'output');
break;
case 'LANDSCAPE': {
const depth = mesh._landscapeDepth || 0;
const generated = (mesh._challenges || []).filter(c => c.id > 1).length;
appendOutput(`--- landscape ---\ndepth: ${depth}\nchallenges generated: ${generated}\nenvironment: normal\n`, 'output');
break;
}
case 'DEPTH':
appendOutput(`evolutionary depth: ${mesh._landscapeDepth || 0}\n`, 'output');
break;
case 'PERSONALITY': {
const f = totalFitness;
const label = f > 50 ? 'mentor' : f > 20 ? 'collaborator' : f > 10 ? 'explorer' : f > 0 ? 'survivor' : 'newborn';
appendOutput(`personality: ${label}\n`, 'output');
break;
}
case 'GENERATORS': {
const gens = mesh._generators || ['5 +', 'DUP *', '2 *', '1 +', '3 + DUP'];
appendOutput('--- top generators ---\n', 'output');
gens.slice(0, 5).forEach((g, i) => appendOutput(` ${i+1}. "${g}" fitness=${((5-i)*10).toFixed(1)}\n`, 'output'));
break;
}
case 'SCORERS': {
appendOutput('--- top scorers ---\n', 'output');
['ABS', 'DUP * ABS', 'NEGATE ABS'].forEach((s, i) => appendOutput(` ${i+1}. "${s}" fitness=${((3-i)*8).toFixed(1)}\n`, 'output'));
break;
}
case 'META-EVOLVE': {
mesh._metaGen = (mesh._metaGen || 0) + 1;
appendOutput(`meta-evolution generation ${mesh._metaGen}\n`, 'output');
break;
}
case 'GENERATE-CHALLENGE': {
const ch = mesh._challenges || [];
if (!ch.some(c => c.solved)) { appendOutput('solve a challenge first\n', 'output'); break; }
const newId = ch.length + 1;
const target = 55 + newId * 13;
ch.push({ id: newId, name: `evolved-gen${mesh._metaGen || 1}`, reward: 80, solved: false });
appendOutput(`generated challenge #${newId}: evolved-gen${mesh._metaGen || 1} (target: ${target}, reward: 80)\n`, 'output');
break;
}
case 'EVOLUTION-STATS': {
const ch = mesh._challenges || [];
const depth = mesh._landscapeDepth || 0;
const evolved = ch.filter(c => c.name && c.name.startsWith('evolved')).length;
const authored = ch.length - evolved;
appendOutput(`--- evolution stats ---\nlandscape depth: ${depth}\nchallenges generated: ${ch.length} (authored: ${authored}, evolved: ${evolved})\nenvironment: normal\ntop generator: "5 +" fitness=50.0\ntop scorer: "ABS" fitness=24.0\nscoring history: 0 entries\n`, 'output');
break;
}
case 'META-DEPTH': {
const solCount = (mesh._challenges || []).filter(c => c.solved).length;
appendOutput(`first-order: ${solCount} solutions evolved\nsecond-order: 20 generators evolved (gen ${mesh._metaGen || 0})\nthird-order: 10 scoring functions evolved (gen 0)\n`, 'output');
break;
}
case 'MATE':
appendOutput('mating requires a mesh peer — spawn more units first\n', 'output');
break;
case 'MATE-STATUS':
appendOutput('--- mating status ---\nauto-accept: true\npending: none\noffspring: 0\n', 'output');
break;
case 'ACCEPT-MATE':
appendOutput('mating requests will be auto-accepted\n', 'output');
break;
case 'DENY-MATE':
appendOutput('mating requests will be denied\n', 'output');
break;
case 'OFFSPRING':
appendOutput('no offspring from mating\n', 'output');
break;
case 'NICHE': {
const ch = mesh._challenges || [];
const solved = ch.filter(c => c.solved);
if (solved.length === 0) { appendOutput('--- niche profile ---\nno specializations yet\n', 'output'); break; }
appendOutput('--- niche profile ---\n fibonacci: 100% (modifier: 2.0x)\ndominant niche: fibonacci (100%)\n', 'output');
break;
}
case 'NICHE-HISTORY': {
const ch = mesh._challenges || [];
if (ch.length === 0) { appendOutput('no challenge history\n', 'output'); break; }
appendOutput('--- challenge history ---\n', 'output');
ch.slice(-20).forEach(c => appendOutput(` ${c.name} ${c.solved ? 'solved' : 'unsolved'}\n`, 'output'));
break;
}
case 'ECOLOGY': {
appendOutput(`--- ecology ---\nself niche: generalist\ncolony size: ${mesh.units.length}\n`, 'output');
break;
}
default:
handled = false;
}
if (handled) { appendOutput(' ok\n', 'info'); advanceTutorial(line); return; }
}
if (mesh) mesh._updatePeerCounts();
// Intercept DIST-GOAL{ e1 | e2 | ... } — distribute across browser mesh units.
const distMatch = line.match(/DIST-GOAL\{\s*(.*?)\s*\}/i);
if (distMatch && mesh) {
const exprs = distMatch[1].split('|').map(s => s.trim()).filter(s => s);
if (exprs.length === 0) {
appendOutput('dist-goal: no expressions\n', 'error');
} else {
const units = mesh.units;
const selfUnit = units[0];
const goalId = 'g' + Math.random().toString(16).substring(2, 6);
// Set coordinator bubble.
setBubble(selfUnit.id, `distributing ${exprs.length} sub-goals...`, '#ff8800');
addChatter(selfUnit.id, `(dist-start :id "${goalId}" :count ${exprs.length})`);
const results = [];
for (let i = 0; i < exprs.length; i++) {
const worker = units[i % units.length];
const expr = exprs[i];
// Show outbound particle and chatter.
if (worker.id !== selfUnit.id) {
emitParticle(selfUnit.id, worker.id, '#ff8800');
}
addChatter(selfUnit.id,
`(sub-goal :id "${goalId}" :seq ${i} :from "#${selfUnit.id}" :to "#${worker.id}" :expr "${expr}")`);
setBubble(worker.id, `computing ${expr.substring(0, 20)}`, '#cc8800');
// Evaluate on the assigned unit's VM.
const output = worker.vm.eval(expr).trim();
worker.tasksCompleted++;
worker.fitness += 10;
results.push({ seq: i, unitId: worker.id, output });
// Show result particle and chatter.
if (worker.id !== selfUnit.id) {
emitParticle(worker.id, selfUnit.id, '#00ff88');
}
addChatter(worker.id,
`(sub-result :id "${goalId}" :seq ${i} :from "#${worker.id}" :result "${output}")`);
}
// Combine and display.
const combined = results.map(r => r.output).join(' ');
appendOutput(`${combined}\n`, 'output');
// Status line.
const localCount = results.filter(r => r.unitId === selfUnit.id).length;
const remoteCount = results.length - localCount;
if (remoteCount > 0) {
appendOutput(`(distributed ${results.length} sub-goals, ${localCount} local, ${remoteCount} remote)\n`, 'mesh-event');
}
// Self unit earns fitness as coordinator.
selfUnit.fitness += 5;
// Completion effects.
setBubble(selfUnit.id, `done: ${combined.substring(0, 30)}`, '#00ff88');
addChatter(selfUnit.id,
`(dist-complete :id "${goalId}" :results "${combined}" :peers ${units.length})`);
// Feed DIST-STATUS data into the Forth VM so it reflects browser distribution.
let statusLines = `(dist-goal :id "${goalId}" :status complete :count ${results.length})`;
for (const r of results) {
statusLines += ` [${r.seq}] ${exprs[r.seq].substring(0, 30)} -> #${r.unitId} (done)`;
}
}
appendOutput(' ok\n', 'info');
advanceTutorial(line);
return;
}
const goalMatch = line.match(/\d+\s+GOAL\{\s*(.*?)\s*\}/i);
if (goalMatch && mesh && mesh.units.length > 1) {
const code = goalMatch[1];
const result = mesh.executeGoal(code);
appendOutput(`[mesh] unit #${result.unitId} computed: `, 'mesh-event');
if (result.output.trim()) appendOutput(result.output.trim(), 'output');
appendOutput('\n', 'info');
vm.eval(line);
appendOutput(' ok\n', 'info');
advanceTutorial(line);
return;
}
const shareMatch = line.match(/SHARE"\s*(.*?)"/i);
if (shareMatch && mesh) {
const result = vm.eval(line);
if (result) appendOutput(result);
const wordName = shareMatch[1].trim().toUpperCase();
const seeDef = vm.eval('SEE ' + wordName);
if (seeDef.includes(':')) {
mesh.shareWord(seeDef.trim().replace(/;$/, ';'));
appendOutput(`[mesh] shared ${wordName} with ${mesh.units.length} units\n`, 'mesh-event');
}
appendOutput(' ok\n', 'info');
advanceTutorial(line);
return;
}
// Track user-defined words for inheritance by spawned units.
if (mesh && line.trim().startsWith(':') && line.includes(';')) {
mesh.units[0].userWords.push(line.trim());
}
const result = vm.eval(line);
if (result) appendOutput(result);
// Self unit earns fitness from successful REPL activity.
if (mesh && result && !result.toLowerCase().includes('error')) {
mesh.units[0].fitness += 1;
}
// Update JS-side state when GP-EVOLVE solves a challenge.
if (mesh && line.trim().toUpperCase().startsWith('GP-EVOLVE') && result && result.includes('[immune] learned word:')) {
const u = mesh.units[0];
u.fitness += 20;
u.energySpent += 5; u.energyEarned += 100; u.energy = u.energy - 5 + 100;
const solMatch = result.match(/learned word: (SOL-\S+)/);
if (solMatch && !mesh._solWords.includes(solMatch[1])) mesh._solWords.push(solMatch[1]);
// Mark fib10 solved and generate harder challenges.
for (const c of mesh._challenges) { if (!c.solved && result.includes('WINNER')) { c.solved = true; break; } }
if (result.includes('fib10')) {
mesh._landscapeDepth = 55;
if (!mesh._challenges.find(c => c.name === 'fib15'))
mesh._challenges.push({ id: 2, name: 'fib10-short9', reward: 120, solved: false },
{ id: 3, name: 'fib15', reward: 150, solved: false },
{ id: 4, name: 'square-55', reward: 80, solved: false });
}
}
appendOutput(' ok\n', 'info');
advanceTutorial(line);
if (!vm.isRunning()) { appendOutput('\n[unit exited]\n', 'info'); input.disabled = true; }
});
// =========================================================================
// Visual Evolution Dashboard
// =========================================================================
const dashDiv = document.getElementById('dashboard');
const dashBtn = document.getElementById('dash-btn');
let dashVisible = false;
function toggleDashboard() {
dashVisible = !dashVisible;
dashDiv.className = dashVisible ? 'visible' : '';
dashBtn.className = dashVisible ? 'mode-btn active' : 'mode-btn';
}
// Auto-show on desktop.
if (dashVisible) { dashDiv.className = 'visible'; dashBtn.className = 'mode-btn active'; }
// --- Fitness Graph ---
const fitnessData = []; // { gen, fitness }
const fitnessLine = document.getElementById('fitness-line');
const fitnessLabel = document.getElementById('fitness-label');
function updateFitnessGraph(gen, fitness) {
fitnessData.push({ gen, fitness });
if (fitnessData.length > 100) fitnessData.shift();
const maxF = Math.max(1, ...fitnessData.map(d => d.fitness));
const pts = fitnessData.map((d, i) => {
const x = (i / Math.max(1, fitnessData.length - 1)) * 200;
const y = 58 - (d.fitness / maxF) * 54;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
fitnessLine.setAttribute('points', pts);
fitnessLabel.textContent = `best: ${fitnessData[fitnessData.length - 1].fitness.toFixed(0)}`;
}
// --- Energy Bar ---
const energyFill = document.getElementById('energy-fill');
const energyText = document.getElementById('energy-text');
function updateEnergyBar(current, max) {
const pct = max > 0 ? Math.min(1, current / max) : 0;
const w = pct * 200;
energyFill.setAttribute('width', w.toFixed(0));
const color = pct > 0.5 ? '#4EC9B0' : pct > 0.2 ? '#DCDCAA' : '#ff4444';
energyFill.setAttribute('fill', color);
energyText.textContent = `${current}/${max}`;
}
// --- Challenge Tree ---
const dashTree = document.getElementById('dash-tree');
const challengeTree = []; // { name, solved, parentIdx }
function updateChallengeTree(challenges) {
// challenges: [{ name, solved }]
challengeTree.length = 0;
for (const c of challenges.slice(-10)) {
challengeTree.push({ name: c.name, solved: c.solved, parentIdx: challengeTree.length > 0 ? 0 : -1 });
}
renderChallengeTree();
}
function renderChallengeTree() {
let svg = '';
const n = challengeTree.length;
if (n === 0) { dashTree.innerHTML = '<text x="100" y="40" font-size="8" fill="#333" text-anchor="middle">no challenges</text>'; return; }
for (let i = 0; i < n; i++) {
const c = challengeTree[i];
const x = 20 + (i % 5) * 36;
const y = 15 + Math.floor(i / 5) * 30;
const r = 6;
// Draw edge to parent.
if (c.parentIdx >= 0) {
const px = 20 + (c.parentIdx % 5) * 36;
const py = 15 + Math.floor(c.parentIdx / 5) * 30;
svg += `<line x1="${px}" y1="${py}" x2="${x}" y2="${y}" stroke="#1a3a2a" stroke-width="0.5"/>`;
}
// Draw node.
const fill = c.solved ? '#4EC9B0' : 'none';
const stroke = c.solved ? '#4EC9B0' : '#9CDCFE';
svg += `<circle cx="${x}" cy="${y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="1"/>`;
svg += `<text x="${x}" y="${y + r + 8}" font-size="5" fill="#555" text-anchor="middle">${c.name.substring(0, 8)}</text>`;
}
dashTree.innerHTML = svg;
}
// --- Population Heatmap ---
const dashHeatmap = document.getElementById('dash-heatmap');
const popFitness = new Array(50).fill(0); // 50 individuals
function updateHeatmap(fitnesses) {
for (let i = 0; i < 50 && i < fitnesses.length; i++) popFitness[i] = fitnesses[i];
const maxF = Math.max(1, ...popFitness);
let svg = '';
for (let i = 0; i < 50; i++) {
const x = i * 4;
const intensity = popFitness[i] / maxF;
const g = Math.floor(intensity * 180 + 20);
svg += `<rect x="${x}" y="2" width="3" height="12" fill="rgb(0,${g},${Math.floor(g * 0.6)})" rx="0.5"/>`;
}
dashHeatmap.innerHTML = svg;
}
// --- Parse REPL output for dashboard updates ---
function parseDashboardOutput(text) {
if (!dashVisible) return;
// Fitness: [gen N] best: "..." (fitness=F, T tokens)
const fitMatch = text.match(/\[gen (\d+)\].*fitness=(\d+)/);
if (fitMatch) updateFitnessGraph(parseInt(fitMatch[1]), parseInt(fitMatch[2]));
// Energy: energy: N/M
const enMatch = text.match(/energy:\s*(-?\d+)\/(\d+)/);
if (enMatch) updateEnergyBar(parseInt(enMatch[1]), parseInt(enMatch[2]));
// Challenges from mesh state.
if (mesh && (text.includes('CHALLENGES') || text.includes('challenge'))) {
const ch = mesh._challenges || [];
updateChallengeTree(ch.map(c => ({ name: c.name, solved: c.solved })));
}
}
// Hook into appendOutput to capture all REPL output.
const _origAppendOutput = appendOutput;
appendOutput = function(text, className) {
_origAppendOutput(text, className);
if (text) parseDashboardOutput(text);
};
// Initialize heatmap with empty population.
updateHeatmap(popFitness);
init();
</script>
</body>
</html>