The robot (green) starts at the top-left and must reach the goal (red star) at the bottom-right. It follows the Bug2 rule: move along the straight line from start to goal (the m-line) until you hit a wall, then hug the left side of the obstacle until you cross the m-line again at a point closer to the goal.
<!-- Bug2 maze demo — minimal-sensor navigation -->
<div class="hint-bar">Bug2 moves straight toward the goal along the <em>m-line</em> (blue cells). When it hits a wall it hugs the left boundary until it re-crosses the m-line closer to the goal — then resumes straight-line travel. No map needed.</div>
<canvas id="maze" width="360" height="300"></canvas>
<div class="status-row">
<span id="status" class="status">Press Step or Run to start.</span>
</div>
<div class="btns">
<button id="btn-step" type="button">Step</button>
<button id="btn-run" type="button">Run</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
/* Styles for the Bug2 maze canvas demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.hint-bar { font-size: .85rem; color: #444; margin-bottom: .5rem; line-height: 1.45; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 8px; max-width: 100%; }
.status-row { min-height: 1.6em; margin: .4rem 0; }
.status { font-size: .95rem; font-weight: 600; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.info { color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
/* Bug2 algorithm implementation */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Grid definition — 1=obstacle, 0=free */
const ROWS = 10, COLS = 12;
const CELL = 25; // pixels per cell
const PAD = 5; // canvas padding in pixels
/* Obstacle map — hand-crafted maze solvable by Bug2 */
// 1 = obstacle, 0 = free
const GRID = [
[0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,1,1,1,1,0,0,0,0,0],
[0,0,0,1,0,0,1,0,0,0,0,0],
[0,0,0,1,0,0,1,0,1,1,0,0],
[0,0,0,0,0,0,1,0,1,0,0,0],
[0,0,0,0,0,0,1,0,1,0,0,0],
[0,0,1,1,1,0,0,0,1,0,0,0],
[0,0,1,0,0,0,0,0,0,0,0,0],
[0,0,1,1,1,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0]
];
const START = {r:0, c:0};
const GOAL = {r:9, c:11};
/* Movement directions — 4-connected grid */
// 4-connected movement: up, right, down, left
const DIRS = [[-1,0],[0,1],[1,0],[0,-1]];
/* Simulation state variables */
let pos, state, wallMode, hitPoint, steps, done, timer, running;
/* m-line helpers */
// The m-line connects START and GOAL.
// A cell (r,c) is "on or crossing" the m-line when the straight line from
// START to GOAL passes within half a cell of its centre.
function onMLine(r, c) {
// perpendicular distance from point to the m-line
const dr = GOAL.r - START.r, dc = GOAL.c - START.c;
const len2 = dr*dr + dc*dc;
if (len2 === 0) return r === GOAL.r && c === GOAL.c;
const t = ((r - START.r)*dr + (c - START.c)*dc) / len2;
const pr = START.r + t*dr, pc = START.c + t*dc;
return (r-pr)*(r-pr) + (c-pc)*(c-pc) < 0.5*0.5;
}
function distToGoal(r, c) {
const dr = GOAL.r - r, dc = GOAL.c - c;
return dr*dr + dc*dc;
}
function isBlocked(r, c) {
return r < 0 || r >= ROWS || c < 0 || c >= COLS || GRID[r][c] === 1;
}
/* Render the grid, path, robot and goal to canvas */
function draw(path) {
const canvas = document.getElementById('maze');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw each grid cell
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const x = PAD + c*CELL, y = PAD + r*CELL;
if (GRID[r][c] === 1) {
ctx.fillStyle = '#6b7280';
} else if (onMLine(r, c)) {
ctx.fillStyle = '#dbeafe';
} else {
ctx.fillStyle = '#f3f4f6';
}
ctx.fillRect(x, y, CELL-1, CELL-1);
}
}
// Draw the robot's trail
if (path && path.length > 1) {
ctx.strokeStyle = '#93c5fd';
ctx.lineWidth = 2;
ctx.beginPath();
path.forEach(({r,c}, i) => {
const px = PAD + c*CELL + CELL/2, py = PAD + r*CELL + CELL/2;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
});
ctx.stroke();
}
// Draw the goal star
const gx = PAD + GOAL.c*CELL + CELL/2, gy = PAD + GOAL.r*CELL + CELL/2;
ctx.fillStyle = '#dc2626';
ctx.font = 'bold 16px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('★', gx, gy);
// Draw the robot circle
const rx = PAD + pos.c*CELL + CELL/2, ry = PAD + pos.r*CELL + CELL/2;
ctx.fillStyle = '#16a34a';
ctx.beginPath();
ctx.arc(rx, ry, CELL/2 - 3, 0, 2*Math.PI);
ctx.fill();
}
/* Bug2 simulation — step-by-step */
// Bug2 algorithm core: one step at a time.
// state = 'toward_goal' | 'follow_wall'
let path = [];
function initSim() {
pos = {...START};
state = 'toward_goal';
wallMode = null;
hitPoint = null;
steps = 0;
done = false;
path = [{...START}];
}
/* Move directly toward the goal (m-line phase) */
// Move one step toward the goal: pick the 4-connected free neighbor closest to goal.
function greedyStep() {
// Pick the free neighbor that minimises distance to goal
let bestDir = null, bestDist = Infinity;
for (const [dr, dc] of DIRS) {
const nr = pos.r + dr, nc = pos.c + dc;
if (!isBlocked(nr, nc)) {
const d = distToGoal(nr, nc);
if (d < bestDist) { bestDist = d; bestDir = [dr, dc]; }
}
}
if (bestDir === null) return null; // all neighbors blocked
pos = {r: pos.r + bestDir[0], c: pos.c + bestDir[1]};
return bestDir;
}
// Choose initial wall-following direction toward goal
// Choose initial wall-following direction: the DIRS index pointing most toward goal.
function getInitialFacing() {
let bestFacing = 1, bestDist = Infinity;
for (let d = 0; d < 4; d++) {
const [dr, dc] = DIRS[d];
const dist = distToGoal(pos.r + dr, pos.c + dc);
if (dist < bestDist) { bestDist = dist; bestFacing = d; }
}
return bestFacing;
}
/* Wall-following phase — left-hand rule */
// Follow the wall: keep the obstacle on the left.
// wallMode encodes the current facing direction index into DIRS.
function stepFollowWall() {
// Left-hand rule turn priority
// Left-hand rule: try to turn left first, then go straight, then right, then back.
const leftOf = (d) => (d + 3) % 4;
const rightOf = (d) => (d + 1) % 4;
let facing = wallMode;
// Try turns in priority order
for (let attempt = 0; attempt < 4; attempt++) {
const tryDir = (attempt === 0) ? leftOf(facing)
: (attempt === 1) ? facing
: (attempt === 2) ? rightOf(facing)
: (facing + 2) % 4;
const [dr, dc] = DIRS[tryDir];
const nr = pos.r + dr, nc = pos.c + dc;
if (!isBlocked(nr, nc)) {
wallMode = tryDir;
pos = {r: nr, c: nc};
return 'moved';
}
}
return 'stuck';
}
function setStatus(text, cls) {
const el = document.getElementById('status');
el.textContent = text;
el.className = 'status ' + (cls || '');
}
function tick() {
if (done) return;
steps++;
if (pos.r === GOAL.r && pos.c === GOAL.c) {
done = true;
setStatus(i18nFmt('Reached the goal in %steps% step(s)!', { steps: steps }), 'ok');
draw(path);
return;
}
if (steps > 2000) {
// safety limit exceeded
done = true;
setStatus('Stuck — could not reach goal in 2000 steps.', 'bad');
draw(path);
return;
}
if (state === 'toward_goal') {
// Try to move one step toward goal
const moved = greedyStep();
if (moved) {
path.push({...pos});
} else {
// Hit a wall — switch to wall-following mode
hitPoint = {r: pos.r, c: pos.c, dist: distToGoal(pos.r, pos.c)};
state = 'follow_wall';
// Set initial wall-following direction
wallMode = getInitialFacing();
stepFollowWall();
path.push({...pos});
}
} else {
// Wall-following mode
stepFollowWall();
path.push({...pos});
// Check Bug2 exit condition
// Bug2 exit: on m-line AND closer to goal than where we left it
if (onMLine(pos.r, pos.c) && hitPoint &&
distToGoal(pos.r, pos.c) < hitPoint.dist &&
!(pos.r === hitPoint.r && pos.c === hitPoint.c)) {
state = 'toward_goal';
hitPoint = null;
}
}
setStatus(i18nFmt('Step %steps%', { steps: steps }), 'info');
draw(path);
document.getElementById('btn-step').disabled = done;
}
function reset() {
if (timer) { clearInterval(timer); timer = null; }
running = false;
document.getElementById('btn-run').textContent = 'Run';
document.getElementById('btn-step').disabled = false;
initSim();
setStatus('Press Step or Run to start.', '');
draw(path);
}
document.getElementById('btn-step').addEventListener('click', () => { if (!done) tick(); });
document.getElementById('btn-run').addEventListener('click', function() {
if (done) return;
if (running) {
clearInterval(timer); timer = null; running = false;
this.textContent = 'Run';
} else {
running = true;
this.textContent = 'Pause';
timer = setInterval(() => {
tick();
if (done) {
clearInterval(timer); timer = null; running = false;
document.getElementById('btn-run').textContent = 'Run';
}
}, 120);
}
});
document.getElementById('btn-reset').addEventListener('click', reset);
reset();
<!-- Bug2 maze demo — minimal-sensor navigation -->
<div class="hint-bar">Bug2 moves straight toward the goal along the <em>m-line</em> (blue cells). When it hits a wall it hugs the left boundary until it re-crosses the m-line closer to the goal — then resumes straight-line travel. No map needed.</div>
<canvas id="maze" width="360" height="300"></canvas>
<div class="status-row">
<span id="status" class="status">Press Step or Run to start.</span>
</div>
<div class="btns">
<button id="btn-step" type="button">Step</button>
<button id="btn-run" type="button">Run</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
/* Styles for the Bug2 maze canvas demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.hint-bar { font-size: .85rem; color: #444; margin-bottom: .5rem; line-height: 1.45; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 8px; max-width: 100%; }
.status-row { min-height: 1.6em; margin: .4rem 0; }
.status { font-size: .95rem; font-weight: 600; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.info { color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
/* Bug2 algorithm implementation */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Grid definition — 1=obstacle, 0=free */
const ROWS = 10, COLS = 12;
const CELL = 25; // pixels per cell
const PAD = 5; // canvas padding in pixels
/* Obstacle map — hand-crafted maze solvable by Bug2 */
// 1 = obstacle, 0 = free
const GRID = [
[0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,1,1,1,1,0,0,0,0,0],
[0,0,0,1,0,0,1,0,0,0,0,0],
[0,0,0,1,0,0,1,0,1,1,0,0],
[0,0,0,0,0,0,1,0,1,0,0,0],
[0,0,0,0,0,0,1,0,1,0,0,0],
[0,0,1,1,1,0,0,0,1,0,0,0],
[0,0,1,0,0,0,0,0,0,0,0,0],
[0,0,1,1,1,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0]
];
const START = {r:0, c:0};
const GOAL = {r:9, c:11};
/* Movement directions — 4-connected grid */
// 4-connected movement: up, right, down, left
const DIRS = [[-1,0],[0,1],[1,0],[0,-1]];
/* Simulation state variables */
let pos, state, wallMode, hitPoint, steps, done, timer, running;
/* m-line helpers */
// The m-line connects START and GOAL.
// A cell (r,c) is "on or crossing" the m-line when the straight line from
// START to GOAL passes within half a cell of its centre.
function onMLine(r, c) {
// perpendicular distance from point to the m-line
const dr = GOAL.r - START.r, dc = GOAL.c - START.c;
const len2 = dr*dr + dc*dc;
if (len2 === 0) return r === GOAL.r && c === GOAL.c;
const t = ((r - START.r)*dr + (c - START.c)*dc) / len2;
const pr = START.r + t*dr, pc = START.c + t*dc;
return (r-pr)*(r-pr) + (c-pc)*(c-pc) < 0.5*0.5;
}
function distToGoal(r, c) {
const dr = GOAL.r - r, dc = GOAL.c - c;
return dr*dr + dc*dc;
}
function isBlocked(r, c) {
return r < 0 || r >= ROWS || c < 0 || c >= COLS || GRID[r][c] === 1;
}
/* Render the grid, path, robot and goal to canvas */
function draw(path) {
const canvas = document.getElementById('maze');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw each grid cell
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const x = PAD + c*CELL, y = PAD + r*CELL;
if (GRID[r][c] === 1) {
ctx.fillStyle = '#6b7280';
} else if (onMLine(r, c)) {
ctx.fillStyle = '#dbeafe';
} else {
ctx.fillStyle = '#f3f4f6';
}
ctx.fillRect(x, y, CELL-1, CELL-1);
}
}
// Draw the robot's trail
if (path && path.length > 1) {
ctx.strokeStyle = '#93c5fd';
ctx.lineWidth = 2;
ctx.beginPath();
path.forEach(({r,c}, i) => {
const px = PAD + c*CELL + CELL/2, py = PAD + r*CELL + CELL/2;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
});
ctx.stroke();
}
// Draw the goal star
const gx = PAD + GOAL.c*CELL + CELL/2, gy = PAD + GOAL.r*CELL + CELL/2;
ctx.fillStyle = '#dc2626';
ctx.font = 'bold 16px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('★', gx, gy);
// Draw the robot circle
const rx = PAD + pos.c*CELL + CELL/2, ry = PAD + pos.r*CELL + CELL/2;
ctx.fillStyle = '#16a34a';
ctx.beginPath();
ctx.arc(rx, ry, CELL/2 - 3, 0, 2*Math.PI);
ctx.fill();
}
/* Bug2 simulation — step-by-step */
// Bug2 algorithm core: one step at a time.
// state = 'toward_goal' | 'follow_wall'
let path = [];
function initSim() {
pos = {...START};
state = 'toward_goal';
wallMode = null;
hitPoint = null;
steps = 0;
done = false;
path = [{...START}];
}
/* Move directly toward the goal (m-line phase) */
// Move one step toward the goal: pick the 4-connected free neighbor closest to goal.
function greedyStep() {
// Pick the free neighbor that minimises distance to goal
let bestDir = null, bestDist = Infinity;
for (const [dr, dc] of DIRS) {
const nr = pos.r + dr, nc = pos.c + dc;
if (!isBlocked(nr, nc)) {
const d = distToGoal(nr, nc);
if (d < bestDist) { bestDist = d; bestDir = [dr, dc]; }
}
}
if (bestDir === null) return null; // all neighbors blocked
pos = {r: pos.r + bestDir[0], c: pos.c + bestDir[1]};
return bestDir;
}
// Choose initial wall-following direction toward goal
// Choose initial wall-following direction: the DIRS index pointing most toward goal.
function getInitialFacing() {
let bestFacing = 1, bestDist = Infinity;
for (let d = 0; d < 4; d++) {
const [dr, dc] = DIRS[d];
const dist = distToGoal(pos.r + dr, pos.c + dc);
if (dist < bestDist) { bestDist = dist; bestFacing = d; }
}
return bestFacing;
}
/* Wall-following phase — left-hand rule */
// Follow the wall: keep the obstacle on the left.
// wallMode encodes the current facing direction index into DIRS.
function stepFollowWall() {
// Left-hand rule turn priority
// Left-hand rule: try to turn left first, then go straight, then right, then back.
const leftOf = (d) => (d + 3) % 4;
const rightOf = (d) => (d + 1) % 4;
let facing = wallMode;
// Try turns in priority order
for (let attempt = 0; attempt < 4; attempt++) {
const tryDir = (attempt === 0) ? leftOf(facing)
: (attempt === 1) ? facing
: (attempt === 2) ? rightOf(facing)
: (facing + 2) % 4;
const [dr, dc] = DIRS[tryDir];
const nr = pos.r + dr, nc = pos.c + dc;
if (!isBlocked(nr, nc)) {
wallMode = tryDir;
pos = {r: nr, c: nc};
return 'moved';
}
}
return 'stuck';
}
function setStatus(text, cls) {
const el = document.getElementById('status');
el.textContent = text;
el.className = 'status ' + (cls || '');
}
function tick() {
if (done) return;
steps++;
if (pos.r === GOAL.r && pos.c === GOAL.c) {
done = true;
setStatus(i18nFmt('Reached the goal in %steps% step(s)!', { steps: steps }), 'ok');
draw(path);
return;
}
if (steps > 2000) {
// safety limit exceeded
done = true;
setStatus('Stuck — could not reach goal in 2000 steps.', 'bad');
draw(path);
return;
}
if (state === 'toward_goal') {
// Try to move one step toward goal
const moved = greedyStep();
if (moved) {
path.push({...pos});
} else {
// Hit a wall — switch to wall-following mode
hitPoint = {r: pos.r, c: pos.c, dist: distToGoal(pos.r, pos.c)};
state = 'follow_wall';
// Set initial wall-following direction
wallMode = getInitialFacing();
stepFollowWall();
path.push({...pos});
}
} else {
// Wall-following mode
stepFollowWall();
path.push({...pos});
// Check Bug2 exit condition
// Bug2 exit: on m-line AND closer to goal than where we left it
if (onMLine(pos.r, pos.c) && hitPoint &&
distToGoal(pos.r, pos.c) < hitPoint.dist &&
!(pos.r === hitPoint.r && pos.c === hitPoint.c)) {
state = 'toward_goal';
hitPoint = null;
}
}
setStatus(i18nFmt('Step %steps%', { steps: steps }), 'info');
draw(path);
document.getElementById('btn-step').disabled = done;
}
function reset() {
if (timer) { clearInterval(timer); timer = null; }
running = false;
document.getElementById('btn-run').textContent = 'Run';
document.getElementById('btn-step').disabled = false;
initSim();
setStatus('Press Step or Run to start.', '');
draw(path);
}
document.getElementById('btn-step').addEventListener('click', () => { if (!done) tick(); });
document.getElementById('btn-run').addEventListener('click', function() {
if (done) return;
if (running) {
clearInterval(timer); timer = null; running = false;
this.textContent = 'Run';
} else {
running = true;
this.textContent = 'Pause';
timer = setInterval(() => {
tick();
if (done) {
clearInterval(timer); timer = null; running = false;
document.getElementById('btn-run').textContent = 'Run';
}
}, 120);
}
});
document.getElementById('btn-reset').addEventListener('click', reset);
reset();
Comments
Loading comments...