Below is a 4 × 4 grid world. The agent (blue) starts in the top-left corner and must reach the goal (green star) while avoiding the pit (red). It receives +10 for reaching the goal, −10 for falling in the pit, and −1 for every other step.
Press Step to run one episode manually, or Train 50 to watch 50 episodes at once. The numbers in each cell show the current best Q-value from that state — they start at zero and converge toward the true optimal values as experience accumulates.
<div class="controls">
<button id="btn-step" type="button">Step (1 episode)</button>
<button id="btn-train" type="button">Train 50</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
<span class="ep-label">Episode: <b id="ep-count">0</b></span>
</div>
<div class="grid-wrap">
<div id="grid" class="grid"></div>
</div>
<div class="legend">
<span class="leg-item"><span class="swatch agent"></span> Agent</span>
<span class="leg-item"><span class="swatch goal"></span> Goal (+10)</span>
<span class="leg-item"><span class="swatch pit"></span> Pit (−10)</span>
<span class="leg-item"><span class="swatch normal"></span> Step (−1)</span>
</div>
<div class="info">Numbers show best Q-value per cell. Brighter = higher value learned.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .7rem; }
button { font: 600 13px system-ui; padding: .4rem .8rem; 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; }
.ep-label { font-size: .9rem; color: #555; margin-left: .3rem; }
.grid-wrap { display: flex; justify-content: center; }
.grid { display: grid; grid-template-columns: repeat(4, 80px); gap: 4px; }
.cell { width: 80px; height: 80px; border-radius: 8px; display: flex; flex-direction: column;
align-items: center; justify-content: center; position: relative;
font-size: .72rem; font-weight: 600; border: 2px solid transparent; transition: background .25s; }
.cell.normal { background: #d6dde4; color: #333; }
.cell.goal { background: #2d9e6e; color: #fff; }
.cell.pit { background: #c0392b; color: #fff; }
.cell.agent { border-color: #1d6fa5; }
.cell .qval { font-size: .78rem; font-weight: 700; }
.cell .icon { font-size: 1.4rem; line-height: 1; }
.legend { display: flex; gap: .9rem; flex-wrap: wrap; margin: .6rem 0 .2rem; font-size: .8rem; }
.leg-item { display: flex; align-items: center; gap: .3rem; }
.swatch { width: 14px; height: 14px; border-radius: 3px; border: 1.5px solid #888; }
.swatch.agent { background: #d6dde4; border-color: #1d6fa5; }
.swatch.goal { background: #2d9e6e; border: none; }
.swatch.pit { background: #c0392b; border: none; }
.swatch.normal { background: #d6dde4; border: none; }
.info { font-size: .78rem; color: #666; margin-top: .2rem; }
// Q-learning on a 4x4 grid world.
// States: 0..15 (row-major). Actions: 0=up 1=down 2=left 3=right
// Layout:
// S . . . (S=start=0, .=normal)
// . . . .
// . . P . (P=pit=10)
// . . . G (G=goal=15)
const ROWS = 4, COLS = 4, N = 16;
const GOAL = 15, PIT = 10, START = 0;
const ACTIONS = 4; // up down left right
const DR = [-1, 1, 0, 0];
const DC = [ 0, 0,-1, 1];
// Hyperparameters
const ALPHA = 0.1; // learning rate
const GAMMA = 0.9; // discount
const EPS_START = 1.0;
const EPS_END = 0.05;
const EPS_DECAY = 0.97; // per episode
let Q, eps, totalEp;
function initQ() {
Q = Array.from({length: N}, () => new Float64Array(ACTIONS));
eps = EPS_START;
totalEp = 0;
}
initQ();
function stateOf(r, c) { return r * COLS + c; }
function rowOf(s) { return Math.floor(s / COLS); }
function colOf(s) { return s % COLS; }
function isTerminal(s) { return s === GOAL || s === PIT; }
function step(s, a) {
const nr = rowOf(s) + DR[a];
const nc = colOf(s) + DC[a];
if (nr < 0 || nr >= ROWS || nc < 0 || nc >= COLS) {
// Bumps into wall — stay
const r = -1;
return { ns: s, r };
}
const ns = stateOf(nr, nc);
const r = ns === GOAL ? 10 : ns === PIT ? -10 : -1;
return { ns, r };
}
function maxQ(s) {
let m = Q[s][0];
for (let a = 1; a < ACTIONS; a++) if (Q[s][a] > m) m = Q[s][a];
return m;
}
function greedyAction(s) {
let best = 0, bv = Q[s][0];
for (let a = 1; a < ACTIONS; a++) if (Q[s][a] > bv) { bv = Q[s][a]; best = a; }
return best;
}
function runEpisode() {
let s = START;
let steps = 0;
while (!isTerminal(s) && steps < 200) {
// epsilon-greedy action
const a = Math.random() < eps ? Math.floor(Math.random() * ACTIONS) : greedyAction(s);
const { ns, r } = step(s, a);
// Bellman update
const td = r + (isTerminal(ns) ? 0 : GAMMA * maxQ(ns)) - Q[s][a];
Q[s][a] += ALPHA * td;
s = ns;
steps++;
}
eps = Math.max(EPS_END, eps * EPS_DECAY);
totalEp++;
}
// Animate one episode step-by-step and return a Promise
function runEpisodeAnimated() {
return new Promise(resolve => {
let s = START;
let steps = 0;
function tick() {
if (isTerminal(s) || steps >= 200) { resolve(); return; }
const a = Math.random() < eps ? Math.floor(Math.random() * ACTIONS) : greedyAction(s);
const { ns, r } = step(s, a);
const td = r + (isTerminal(ns) ? 0 : GAMMA * maxQ(ns)) - Q[s][a];
Q[s][a] += ALPHA * td;
s = ns; steps++;
render(s);
requestAnimationFrame(tick);
}
tick();
});
}
// ---- Render ----
function cellColor(s) {
if (s === GOAL) return null;
if (s === PIT) return null;
const v = maxQ(s); // ranges roughly -10 .. +10
const t = Math.max(0, Math.min(1, (v + 10) / 20));
const r = Math.round(180 - t * 80);
const g = Math.round(190 - t * 40 + t * 60);
const b = Math.round(200 - t * 60);
return `rgb(${r},${g},${b})`;
}
function render(agentState) {
const grid = document.getElementById('grid');
grid.innerHTML = '';
for (let s = 0; s < N; s++) {
const div = document.createElement('div');
let cls = 'cell ';
if (s === GOAL) cls += 'goal';
else if (s === PIT) cls += 'pit';
else cls += 'normal';
if (s === agentState) cls += ' agent';
div.className = cls;
const icon = document.createElement('span');
icon.className = 'icon';
if (s === GOAL) icon.textContent = '★';
else if (s === PIT) icon.textContent = '✕';
else if (s === agentState) icon.textContent = '●';
else icon.textContent = '';
div.appendChild(icon);
if (s !== GOAL && s !== PIT) {
const qv = document.createElement('span');
qv.className = 'qval';
qv.textContent = maxQ(s).toFixed(1);
div.appendChild(qv);
div.style.background = cellColor(s);
}
grid.appendChild(div);
}
document.getElementById('ep-count').textContent = totalEp;
}
render(START);
let busy = false;
document.getElementById('btn-step').addEventListener('click', async () => {
if (busy) return;
busy = true;
setBtns(true);
eps = Math.max(eps, 0.2); // keep some exploration for animation
await runEpisodeAnimated();
eps = Math.max(EPS_END, eps * EPS_DECAY);
totalEp++;
render(START);
busy = false;
setBtns(false);
});
document.getElementById('btn-train').addEventListener('click', () => {
if (busy) return;
for (let i = 0; i < 50; i++) runEpisode();
render(START);
});
document.getElementById('btn-reset').addEventListener('click', () => {
if (busy) return;
initQ();
render(START);
});
function setBtns(dis) {
document.getElementById('btn-step').disabled = dis;
document.getElementById('btn-train').disabled = dis;
document.getElementById('btn-reset').disabled = dis;
}
<div class="controls">
<button id="btn-step" type="button">Step (1 episode)</button>
<button id="btn-train" type="button">Train 50</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
<span class="ep-label">Episode: <b id="ep-count">0</b></span>
</div>
<div class="grid-wrap">
<div id="grid" class="grid"></div>
</div>
<div class="legend">
<span class="leg-item"><span class="swatch agent"></span> Agent</span>
<span class="leg-item"><span class="swatch goal"></span> Goal (+10)</span>
<span class="leg-item"><span class="swatch pit"></span> Pit (−10)</span>
<span class="leg-item"><span class="swatch normal"></span> Step (−1)</span>
</div>
<div class="info">Numbers show best Q-value per cell. Brighter = higher value learned.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .7rem; }
button { font: 600 13px system-ui; padding: .4rem .8rem; 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; }
.ep-label { font-size: .9rem; color: #555; margin-left: .3rem; }
.grid-wrap { display: flex; justify-content: center; }
.grid { display: grid; grid-template-columns: repeat(4, 80px); gap: 4px; }
.cell { width: 80px; height: 80px; border-radius: 8px; display: flex; flex-direction: column;
align-items: center; justify-content: center; position: relative;
font-size: .72rem; font-weight: 600; border: 2px solid transparent; transition: background .25s; }
.cell.normal { background: #d6dde4; color: #333; }
.cell.goal { background: #2d9e6e; color: #fff; }
.cell.pit { background: #c0392b; color: #fff; }
.cell.agent { border-color: #1d6fa5; }
.cell .qval { font-size: .78rem; font-weight: 700; }
.cell .icon { font-size: 1.4rem; line-height: 1; }
.legend { display: flex; gap: .9rem; flex-wrap: wrap; margin: .6rem 0 .2rem; font-size: .8rem; }
.leg-item { display: flex; align-items: center; gap: .3rem; }
.swatch { width: 14px; height: 14px; border-radius: 3px; border: 1.5px solid #888; }
.swatch.agent { background: #d6dde4; border-color: #1d6fa5; }
.swatch.goal { background: #2d9e6e; border: none; }
.swatch.pit { background: #c0392b; border: none; }
.swatch.normal { background: #d6dde4; border: none; }
.info { font-size: .78rem; color: #666; margin-top: .2rem; }
// Q-learning on a 4x4 grid world.
// States: 0..15 (row-major). Actions: 0=up 1=down 2=left 3=right
// Layout:
// S . . . (S=start=0, .=normal)
// . . . .
// . . P . (P=pit=10)
// . . . G (G=goal=15)
const ROWS = 4, COLS = 4, N = 16;
const GOAL = 15, PIT = 10, START = 0;
const ACTIONS = 4; // up down left right
const DR = [-1, 1, 0, 0];
const DC = [ 0, 0,-1, 1];
// Hyperparameters
const ALPHA = 0.1; // learning rate
const GAMMA = 0.9; // discount
const EPS_START = 1.0;
const EPS_END = 0.05;
const EPS_DECAY = 0.97; // per episode
let Q, eps, totalEp;
function initQ() {
Q = Array.from({length: N}, () => new Float64Array(ACTIONS));
eps = EPS_START;
totalEp = 0;
}
initQ();
function stateOf(r, c) { return r * COLS + c; }
function rowOf(s) { return Math.floor(s / COLS); }
function colOf(s) { return s % COLS; }
function isTerminal(s) { return s === GOAL || s === PIT; }
function step(s, a) {
const nr = rowOf(s) + DR[a];
const nc = colOf(s) + DC[a];
if (nr < 0 || nr >= ROWS || nc < 0 || nc >= COLS) {
// Bumps into wall — stay
const r = -1;
return { ns: s, r };
}
const ns = stateOf(nr, nc);
const r = ns === GOAL ? 10 : ns === PIT ? -10 : -1;
return { ns, r };
}
function maxQ(s) {
let m = Q[s][0];
for (let a = 1; a < ACTIONS; a++) if (Q[s][a] > m) m = Q[s][a];
return m;
}
function greedyAction(s) {
let best = 0, bv = Q[s][0];
for (let a = 1; a < ACTIONS; a++) if (Q[s][a] > bv) { bv = Q[s][a]; best = a; }
return best;
}
function runEpisode() {
let s = START;
let steps = 0;
while (!isTerminal(s) && steps < 200) {
// epsilon-greedy action
const a = Math.random() < eps ? Math.floor(Math.random() * ACTIONS) : greedyAction(s);
const { ns, r } = step(s, a);
// Bellman update
const td = r + (isTerminal(ns) ? 0 : GAMMA * maxQ(ns)) - Q[s][a];
Q[s][a] += ALPHA * td;
s = ns;
steps++;
}
eps = Math.max(EPS_END, eps * EPS_DECAY);
totalEp++;
}
// Animate one episode step-by-step and return a Promise
function runEpisodeAnimated() {
return new Promise(resolve => {
let s = START;
let steps = 0;
function tick() {
if (isTerminal(s) || steps >= 200) { resolve(); return; }
const a = Math.random() < eps ? Math.floor(Math.random() * ACTIONS) : greedyAction(s);
const { ns, r } = step(s, a);
const td = r + (isTerminal(ns) ? 0 : GAMMA * maxQ(ns)) - Q[s][a];
Q[s][a] += ALPHA * td;
s = ns; steps++;
render(s);
requestAnimationFrame(tick);
}
tick();
});
}
// ---- Render ----
function cellColor(s) {
if (s === GOAL) return null;
if (s === PIT) return null;
const v = maxQ(s); // ranges roughly -10 .. +10
const t = Math.max(0, Math.min(1, (v + 10) / 20));
const r = Math.round(180 - t * 80);
const g = Math.round(190 - t * 40 + t * 60);
const b = Math.round(200 - t * 60);
return `rgb(${r},${g},${b})`;
}
function render(agentState) {
const grid = document.getElementById('grid');
grid.innerHTML = '';
for (let s = 0; s < N; s++) {
const div = document.createElement('div');
let cls = 'cell ';
if (s === GOAL) cls += 'goal';
else if (s === PIT) cls += 'pit';
else cls += 'normal';
if (s === agentState) cls += ' agent';
div.className = cls;
const icon = document.createElement('span');
icon.className = 'icon';
if (s === GOAL) icon.textContent = '★';
else if (s === PIT) icon.textContent = '✕';
else if (s === agentState) icon.textContent = '●';
else icon.textContent = '';
div.appendChild(icon);
if (s !== GOAL && s !== PIT) {
const qv = document.createElement('span');
qv.className = 'qval';
qv.textContent = maxQ(s).toFixed(1);
div.appendChild(qv);
div.style.background = cellColor(s);
}
grid.appendChild(div);
}
document.getElementById('ep-count').textContent = totalEp;
}
render(START);
let busy = false;
document.getElementById('btn-step').addEventListener('click', async () => {
if (busy) return;
busy = true;
setBtns(true);
eps = Math.max(eps, 0.2); // keep some exploration for animation
await runEpisodeAnimated();
eps = Math.max(EPS_END, eps * EPS_DECAY);
totalEp++;
render(START);
busy = false;
setBtns(false);
});
document.getElementById('btn-train').addEventListener('click', () => {
if (busy) return;
for (let i = 0; i < 50; i++) runEpisode();
render(START);
});
document.getElementById('btn-reset').addEventListener('click', () => {
if (busy) return;
initQ();
render(START);
});
function setBtns(dis) {
document.getElementById('btn-step').disabled = dis;
document.getElementById('btn-train').disabled = dis;
document.getElementById('btn-reset').disabled = dis;
}
Notice how the Q-values spread backwards from the goal: the cell next to the goal learns first, then its neighbours, then theirs — exactly the backward induction that the Bellman equation encodes. After enough episodes the agent navigates a perfect path every time. Compare the neural-network training article to see how the same principle scales to millions of parameters.
Comments
Loading comments...