<!-- Main two-panel layout — maze canvas on left, novelty archive on right -->
<div id="app">
<div id="left-panel">
<div class="panel-label">Maze</div>
<canvas id="maze-canvas" width="220" height="220"></canvas>
<div class="legend">
<span class="dot fitness-dot"></span> Fitness agents
<span class="dot novelty-dot"></span> Novelty agents
<span class="dot exit-dot"></span> Exit
</div>
<div id="status" class="status">Choose a search mode to begin.</div>
<div class="btns">
<button id="btn-fitness" type="button">Run Fitness Search</button>
<button id="btn-novelty" type="button">Run Novelty Search</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
</div>
<div id="right-panel">
<div class="panel-label">Novelty archive</div>
<div id="archive-list"></div>
</div>
</div>
/* Base styles — layout, canvas, legend, buttons, archive list */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; font-size: 14px; }
#app { display: flex; gap: 12px; padding: 8px; }
#left-panel { flex: 0 0 auto; }
#right-panel { flex: 1 1 auto; min-width: 0; }
.panel-label { font-size: .75rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .06em; color: #5a7088; margin-bottom: 4px; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 6px; }
.legend { display: flex; align-items: center; gap: 8px; margin-top: 5px;
font-size: .78rem; color: #555; flex-wrap: wrap; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
.fitness-dot { background: #e63946; }
.novelty-dot { background: #2a9d8f; }
.exit-dot { background: #f4a261; border: 2px solid #e76f51; }
.status { font-size: .92rem; font-weight: 600; min-height: 1.4em;
margin: 6px 0; color: #1d3557; }
.btns { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .75rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
#archive-list { display: flex; flex-direction: column; gap: 4px;
max-height: 210px; overflow-y: auto; }
.arch-item { font-size: .78rem; padding: 3px 7px; background: #e8f4f1;
border-radius: 5px; color: #1a6b5e; }
.arch-item.exit { background: #fde8d8; color: #b84c1a; font-weight: 700; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Maze demo: fitness search vs novelty search on a deceptive maze.
// Grid layout: 0 = open corridor, 1 = wall.
// 0 = open, 1 = wall. The maze is 11x11 cells (including border walls).
const MAZE = [
[1,1,1,1,1,1,1,1,1,1,1],
[1,0,0,0,0,0,1,0,0,0,1],
[1,0,1,1,1,0,1,0,1,0,1],
[1,0,1,0,0,0,1,0,1,0,1],
[1,0,1,0,1,1,1,0,1,0,1],
[1,0,0,0,1,0,0,0,1,0,1],
[1,1,1,0,1,0,1,1,1,0,1],
[1,0,0,0,1,0,0,0,0,0,1],
[1,0,1,1,1,1,1,1,1,0,1],
[1,0,0,0,0,0,0,0,0,0,1],
[1,1,1,1,1,1,1,1,1,1,1]
];
const ROWS = MAZE.length, COLS = MAZE[0].length;
// Start (top-left) and Exit (bottom-right) positions.
const START = { r: 1, c: 1 };
const EXIT = { r: 9, c: 9 };
// Deceptive trap: greedy fitness search piles up at the dead end near (7,3).
// The direct path toward EXIT passes through a dead end at (5,4)-(7,1).
// Fitness search (minimize Euclidean distance to EXIT) piles up at (7,3).
const CELL = 20; // pixel size of each maze cell
const canvas = document.getElementById('maze-canvas');
const ctx = canvas.getContext('2d');
// Draw walls and corridors, then overlay agent dots.
function drawMaze(dots, color) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
ctx.fillStyle = MAZE[r][c] ? '#2c3e50' : '#f0f4f8';
ctx.fillRect(c * CELL, r * CELL, CELL, CELL);
}
}
// Draw the exit marker (orange square).
ctx.fillStyle = '#f4a261';
ctx.fillRect(EXIT.c * CELL + 3, EXIT.r * CELL + 3, CELL - 6, CELL - 6);
ctx.strokeStyle = '#e76f51'; ctx.lineWidth = 1.5;
ctx.strokeRect(EXIT.c * CELL + 3, EXIT.r * CELL + 3, CELL - 6, CELL - 6);
// Draw the start marker (blue circle).
ctx.fillStyle = '#457b9d';
ctx.beginPath();
ctx.arc(START.c * CELL + CELL / 2, START.r * CELL + CELL / 2, 4, 0, Math.PI * 2);
ctx.fill();
if (dots) {
ctx.fillStyle = color || '#e63946';
for (const { r, c } of dots) {
ctx.beginPath();
ctx.arc(c * CELL + CELL / 2, r * CELL + CELL / 2, 3.5, 0, Math.PI * 2);
ctx.fill();
}
}
}
// Return passable neighbors of cell (r, c).
function neighbors(r, c) {
const dirs = [[-1,0],[1,0],[0,-1],[0,1]];
return dirs.map(([dr, dc]) => ({ r: r + dr, c: c + dc }))
.filter(p => p.r >= 0 && p.r < ROWS && p.c >= 0 && p.c < COLS && !MAZE[p.r][p.c]);
}
// BFS from (sr, sc) returning cells in visit order.
function bfsSteps(sr, sc) {
const dist = Array.from({ length: ROWS }, () => Array(COLS).fill(Infinity));
dist[sr][sc] = 0;
const q = [{ r: sr, c: sc }];
const path = [{ r: sr, c: sc }];
while (q.length) {
const cur = q.shift();
for (const nb of neighbors(cur.r, cur.c)) {
if (dist[nb.r][nb.c] === Infinity) {
dist[nb.r][nb.c] = dist[cur.r][cur.c] + 1;
q.push(nb); path.push(nb);
}
}
}
return path;
}
// Fitness search: greedy — always step toward EXIT.
// Fitness search: at each step, move to the open neighbor closest to EXIT (greedy).
function runFitnessSearch() {
const pop = [];
const AGENTS = 12;
for (let i = 0; i < AGENTS; i++) {
let r = START.r, c = START.c;
const visited = [{ r, c }];
for (let step = 0; step < 40; step++) {
const nbs = neighbors(r, c);
if (!nbs.length) break;
// Sort neighbors by Euclidean distance to EXIT (closest first).
nbs.sort((a, b) => dist2(a, EXIT) - dist2(b, EXIT));
const next = nbs[0];
r = next.r; c = next.c;
visited.push({ r, c });
if (r === EXIT.r && c === EXIT.c) break;
}
pop.push({ r, c });
}
return pop;
}
function dist2(a, b) { return (a.r - b.r) ** 2 + (a.c - b.c) ** 2; }
// Novelty search: archive endpoints, score by avg distance to k nearest.
// Novelty search: archive of endpoints; score = avg dist to k=3 nearest archived endpoints.
const K = 3;
const archive = [];
let archiveDots = [];
function noveltyScore(pt) {
if (!archive.length) return 9999;
const dists = archive.map(a => Math.sqrt(dist2(pt, a))).sort((a, b) => a - b);
const k = Math.min(K, dists.length);
return dists.slice(0, k).reduce((s, d) => s + d, 0) / k;
}
// One novelty step: sample 8 random walks, pick the endpoint with highest novelty.
// One novelty-search step: sample 8 random walks, pick the one with highest novelty.
function noveltyStep(r, c) {
const SAMPLES = 8;
let best = null, bestScore = -1;
for (let i = 0; i < SAMPLES; i++) {
let cr = r, cc = c;
// Take a random walk of up to 20 steps from the current position.
for (let s = 0; s < 20; s++) {
const nbs = neighbors(cr, cc);
if (!nbs.length) break;
const pick = nbs[Math.floor(Math.random() * nbs.length)];
cr = pick.r; cc = pick.c;
}
const score = noveltyScore({ r: cr, c: cc });
if (score > bestScore) { bestScore = score; best = { r: cr, c: cc }; }
}
return best || { r, c };
}
function runNoveltySearch(iterations) {
const AGENTS = 12;
const pop = [];
archive.push({ r: START.r, c: START.c });
for (let it = 0; it < iterations; it++) {
for (let i = 0; i < AGENTS; i++) {
const prev = archive[archive.length - 1];
const next = noveltyStep(prev.r, prev.c);
const score = noveltyScore(next);
// Add to archive only if novelty score exceeds threshold.
if (score > 1.5) archive.push(next);
pop.push(next);
}
}
return pop;
}
let running = false;
function setButtons(on) {
document.getElementById('btn-fitness').disabled = !on;
document.getElementById('btn-novelty').disabled = !on;
document.getElementById('btn-reset').disabled = !on;
}
function setStatus(msg) {
document.getElementById('status').textContent = msg;
}
function updateArchiveUI() {
const list = document.getElementById('archive-list');
list.innerHTML = '';
const unique = [];
const seen = new Set();
for (const p of archive) {
const key = p.r + ',' + p.c;
if (!seen.has(key)) { seen.add(key); unique.push(p); }
}
for (const p of unique.slice(-20).reverse()) {
const div = document.createElement('div');
const isExit = p.r === EXIT.r && p.c === EXIT.c;
div.className = 'arch-item' + (isExit ? ' exit' : '');
div.textContent = isExit
? i18nFmt('EXIT found at (%r%, %c%)', { r: p.r, c: p.c })
: i18nFmt('(%r%, %c%)', { r: p.r, c: p.c });
list.appendChild(div);
}
}
document.getElementById('btn-fitness').addEventListener('click', () => {
if (running) return;
running = true; setButtons(false);
setStatus('Running fitness search…');
setTimeout(() => {
const pop = runFitnessSearch();
drawMaze(pop, '#e63946');
const trapped = pop.every(p => p.r !== EXIT.r || p.c !== EXIT.c);
setStatus(trapped ? 'Fitness search is trapped in the dead end — it never reaches the exit.' : 'Fitness search found the exit.');
running = false; setButtons(true);
}, 50);
});
document.getElementById('btn-novelty').addEventListener('click', () => {
if (running) return;
running = true; setButtons(false);
setStatus('Running novelty search…');
let iter = 0;
const TOTAL = 10;
function step() {
const pop = runNoveltySearch(1);
archiveDots = [...archive];
drawMaze(pop, '#2a9d8f');
updateArchiveUI();
iter++;
const found = archive.some(p => p.r === EXIT.r && p.c === EXIT.c);
if (found) {
setStatus(i18nFmt('Novelty search found the exit after %iter% iteration(s)!', { iter: iter }));
running = false; setButtons(true);
} else if (iter < TOTAL) {
setTimeout(step, 120);
} else {
setStatus('Novelty search is still exploring — run again to continue.');
running = false; setButtons(true);
}
}
step();
});
document.getElementById('btn-reset').addEventListener('click', () => {
if (running) return;
archive.length = 0; archiveDots = [];
document.getElementById('archive-list').innerHTML = '';
drawMaze(null);
setStatus('Choose a search mode to begin.');
});
drawMaze(null);
setStatus('Choose a search mode to begin.');
<!-- Main two-panel layout — maze canvas on left, novelty archive on right -->
<div id="app">
<div id="left-panel">
<div class="panel-label">Maze</div>
<canvas id="maze-canvas" width="220" height="220"></canvas>
<div class="legend">
<span class="dot fitness-dot"></span> Fitness agents
<span class="dot novelty-dot"></span> Novelty agents
<span class="dot exit-dot"></span> Exit
</div>
<div id="status" class="status">Choose a search mode to begin.</div>
<div class="btns">
<button id="btn-fitness" type="button">Run Fitness Search</button>
<button id="btn-novelty" type="button">Run Novelty Search</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
</div>
<div id="right-panel">
<div class="panel-label">Novelty archive</div>
<div id="archive-list"></div>
</div>
</div>
/* Base styles — layout, canvas, legend, buttons, archive list */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; font-size: 14px; }
#app { display: flex; gap: 12px; padding: 8px; }
#left-panel { flex: 0 0 auto; }
#right-panel { flex: 1 1 auto; min-width: 0; }
.panel-label { font-size: .75rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .06em; color: #5a7088; margin-bottom: 4px; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 6px; }
.legend { display: flex; align-items: center; gap: 8px; margin-top: 5px;
font-size: .78rem; color: #555; flex-wrap: wrap; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
.fitness-dot { background: #e63946; }
.novelty-dot { background: #2a9d8f; }
.exit-dot { background: #f4a261; border: 2px solid #e76f51; }
.status { font-size: .92rem; font-weight: 600; min-height: 1.4em;
margin: 6px 0; color: #1d3557; }
.btns { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .75rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
#archive-list { display: flex; flex-direction: column; gap: 4px;
max-height: 210px; overflow-y: auto; }
.arch-item { font-size: .78rem; padding: 3px 7px; background: #e8f4f1;
border-radius: 5px; color: #1a6b5e; }
.arch-item.exit { background: #fde8d8; color: #b84c1a; font-weight: 700; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Maze demo: fitness search vs novelty search on a deceptive maze.
// Grid layout: 0 = open corridor, 1 = wall.
// 0 = open, 1 = wall. The maze is 11x11 cells (including border walls).
const MAZE = [
[1,1,1,1,1,1,1,1,1,1,1],
[1,0,0,0,0,0,1,0,0,0,1],
[1,0,1,1,1,0,1,0,1,0,1],
[1,0,1,0,0,0,1,0,1,0,1],
[1,0,1,0,1,1,1,0,1,0,1],
[1,0,0,0,1,0,0,0,1,0,1],
[1,1,1,0,1,0,1,1,1,0,1],
[1,0,0,0,1,0,0,0,0,0,1],
[1,0,1,1,1,1,1,1,1,0,1],
[1,0,0,0,0,0,0,0,0,0,1],
[1,1,1,1,1,1,1,1,1,1,1]
];
const ROWS = MAZE.length, COLS = MAZE[0].length;
// Start (top-left) and Exit (bottom-right) positions.
const START = { r: 1, c: 1 };
const EXIT = { r: 9, c: 9 };
// Deceptive trap: greedy fitness search piles up at the dead end near (7,3).
// The direct path toward EXIT passes through a dead end at (5,4)-(7,1).
// Fitness search (minimize Euclidean distance to EXIT) piles up at (7,3).
const CELL = 20; // pixel size of each maze cell
const canvas = document.getElementById('maze-canvas');
const ctx = canvas.getContext('2d');
// Draw walls and corridors, then overlay agent dots.
function drawMaze(dots, color) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
ctx.fillStyle = MAZE[r][c] ? '#2c3e50' : '#f0f4f8';
ctx.fillRect(c * CELL, r * CELL, CELL, CELL);
}
}
// Draw the exit marker (orange square).
ctx.fillStyle = '#f4a261';
ctx.fillRect(EXIT.c * CELL + 3, EXIT.r * CELL + 3, CELL - 6, CELL - 6);
ctx.strokeStyle = '#e76f51'; ctx.lineWidth = 1.5;
ctx.strokeRect(EXIT.c * CELL + 3, EXIT.r * CELL + 3, CELL - 6, CELL - 6);
// Draw the start marker (blue circle).
ctx.fillStyle = '#457b9d';
ctx.beginPath();
ctx.arc(START.c * CELL + CELL / 2, START.r * CELL + CELL / 2, 4, 0, Math.PI * 2);
ctx.fill();
if (dots) {
ctx.fillStyle = color || '#e63946';
for (const { r, c } of dots) {
ctx.beginPath();
ctx.arc(c * CELL + CELL / 2, r * CELL + CELL / 2, 3.5, 0, Math.PI * 2);
ctx.fill();
}
}
}
// Return passable neighbors of cell (r, c).
function neighbors(r, c) {
const dirs = [[-1,0],[1,0],[0,-1],[0,1]];
return dirs.map(([dr, dc]) => ({ r: r + dr, c: c + dc }))
.filter(p => p.r >= 0 && p.r < ROWS && p.c >= 0 && p.c < COLS && !MAZE[p.r][p.c]);
}
// BFS from (sr, sc) returning cells in visit order.
function bfsSteps(sr, sc) {
const dist = Array.from({ length: ROWS }, () => Array(COLS).fill(Infinity));
dist[sr][sc] = 0;
const q = [{ r: sr, c: sc }];
const path = [{ r: sr, c: sc }];
while (q.length) {
const cur = q.shift();
for (const nb of neighbors(cur.r, cur.c)) {
if (dist[nb.r][nb.c] === Infinity) {
dist[nb.r][nb.c] = dist[cur.r][cur.c] + 1;
q.push(nb); path.push(nb);
}
}
}
return path;
}
// Fitness search: greedy — always step toward EXIT.
// Fitness search: at each step, move to the open neighbor closest to EXIT (greedy).
function runFitnessSearch() {
const pop = [];
const AGENTS = 12;
for (let i = 0; i < AGENTS; i++) {
let r = START.r, c = START.c;
const visited = [{ r, c }];
for (let step = 0; step < 40; step++) {
const nbs = neighbors(r, c);
if (!nbs.length) break;
// Sort neighbors by Euclidean distance to EXIT (closest first).
nbs.sort((a, b) => dist2(a, EXIT) - dist2(b, EXIT));
const next = nbs[0];
r = next.r; c = next.c;
visited.push({ r, c });
if (r === EXIT.r && c === EXIT.c) break;
}
pop.push({ r, c });
}
return pop;
}
function dist2(a, b) { return (a.r - b.r) ** 2 + (a.c - b.c) ** 2; }
// Novelty search: archive endpoints, score by avg distance to k nearest.
// Novelty search: archive of endpoints; score = avg dist to k=3 nearest archived endpoints.
const K = 3;
const archive = [];
let archiveDots = [];
function noveltyScore(pt) {
if (!archive.length) return 9999;
const dists = archive.map(a => Math.sqrt(dist2(pt, a))).sort((a, b) => a - b);
const k = Math.min(K, dists.length);
return dists.slice(0, k).reduce((s, d) => s + d, 0) / k;
}
// One novelty step: sample 8 random walks, pick the endpoint with highest novelty.
// One novelty-search step: sample 8 random walks, pick the one with highest novelty.
function noveltyStep(r, c) {
const SAMPLES = 8;
let best = null, bestScore = -1;
for (let i = 0; i < SAMPLES; i++) {
let cr = r, cc = c;
// Take a random walk of up to 20 steps from the current position.
for (let s = 0; s < 20; s++) {
const nbs = neighbors(cr, cc);
if (!nbs.length) break;
const pick = nbs[Math.floor(Math.random() * nbs.length)];
cr = pick.r; cc = pick.c;
}
const score = noveltyScore({ r: cr, c: cc });
if (score > bestScore) { bestScore = score; best = { r: cr, c: cc }; }
}
return best || { r, c };
}
function runNoveltySearch(iterations) {
const AGENTS = 12;
const pop = [];
archive.push({ r: START.r, c: START.c });
for (let it = 0; it < iterations; it++) {
for (let i = 0; i < AGENTS; i++) {
const prev = archive[archive.length - 1];
const next = noveltyStep(prev.r, prev.c);
const score = noveltyScore(next);
// Add to archive only if novelty score exceeds threshold.
if (score > 1.5) archive.push(next);
pop.push(next);
}
}
return pop;
}
let running = false;
function setButtons(on) {
document.getElementById('btn-fitness').disabled = !on;
document.getElementById('btn-novelty').disabled = !on;
document.getElementById('btn-reset').disabled = !on;
}
function setStatus(msg) {
document.getElementById('status').textContent = msg;
}
function updateArchiveUI() {
const list = document.getElementById('archive-list');
list.innerHTML = '';
const unique = [];
const seen = new Set();
for (const p of archive) {
const key = p.r + ',' + p.c;
if (!seen.has(key)) { seen.add(key); unique.push(p); }
}
for (const p of unique.slice(-20).reverse()) {
const div = document.createElement('div');
const isExit = p.r === EXIT.r && p.c === EXIT.c;
div.className = 'arch-item' + (isExit ? ' exit' : '');
div.textContent = isExit
? i18nFmt('EXIT found at (%r%, %c%)', { r: p.r, c: p.c })
: i18nFmt('(%r%, %c%)', { r: p.r, c: p.c });
list.appendChild(div);
}
}
document.getElementById('btn-fitness').addEventListener('click', () => {
if (running) return;
running = true; setButtons(false);
setStatus('Running fitness search…');
setTimeout(() => {
const pop = runFitnessSearch();
drawMaze(pop, '#e63946');
const trapped = pop.every(p => p.r !== EXIT.r || p.c !== EXIT.c);
setStatus(trapped ? 'Fitness search is trapped in the dead end — it never reaches the exit.' : 'Fitness search found the exit.');
running = false; setButtons(true);
}, 50);
});
document.getElementById('btn-novelty').addEventListener('click', () => {
if (running) return;
running = true; setButtons(false);
setStatus('Running novelty search…');
let iter = 0;
const TOTAL = 10;
function step() {
const pop = runNoveltySearch(1);
archiveDots = [...archive];
drawMaze(pop, '#2a9d8f');
updateArchiveUI();
iter++;
const found = archive.some(p => p.r === EXIT.r && p.c === EXIT.c);
if (found) {
setStatus(i18nFmt('Novelty search found the exit after %iter% iteration(s)!', { iter: iter }));
running = false; setButtons(true);
} else if (iter < TOTAL) {
setTimeout(step, 120);
} else {
setStatus('Novelty search is still exploring — run again to continue.');
running = false; setButtons(true);
}
}
step();
});
document.getElementById('btn-reset').addEventListener('click', () => {
if (running) return;
archive.length = 0; archiveDots = [];
document.getElementById('archive-list').innerHTML = '';
drawMaze(null);
setStatus('Choose a search mode to begin.');
});
drawMaze(null);
setStatus('Choose a search mode to begin.');
Comments
Loading comments...