Below is a cart on a line. Set a target position and a speed limit, then let the controller drive there. At every step it searches a short horizon of future acceleration sequences, keeps only the ones that never break the speed limit, picks the plan that lands closest to the target, and applies just its first acceleration.
<p class="hint">The dot is a cart on a track. At every tick, the controller looks <b>%horizon% steps</b> ahead over every possible sequence of accelerations, discards any sequence that would ever push the speed above the limit, keeps the one that lands closest to the target — then applies only its <b>first</b> move and throws the rest of the plan away.</p>
<div class="controls">
<label>Target position
<input type="range" id="target" min="-12" max="12" step="1" value="8">
<span id="targetVal" class="val">8</span>
</label>
<label>Speed limit
<input type="range" id="vmax" min="0.2" max="2" step="0.1" value="1">
<span id="vmaxVal" class="val">1.0</span>
</label>
<label>Horizon length
<input type="range" id="horizon" min="2" max="6" step="1" value="4">
<span id="horizonVal" class="val">4</span>
</label>
</div>
<canvas id="track" width="560" height="150"></canvas>
<div class="status" id="status">Press Run and watch it steer.</div>
<div class="btns">
<button id="run" type="button">Run</button>
<button id="step" type="button">Step once</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.controls { display: flex; flex-wrap: wrap; gap: 1rem; margin-bottom: .6rem; }
.controls label { display: flex; flex-direction: column; font-size: .8rem; font-weight: 600; color: #1d3557; gap: .2rem; }
.controls input[type="range"] { width: 150px; }
.val { font: 700 13px ui-monospace, monospace; color: #333; }
canvas { width: 100%; max-width: 560px; height: 150px; background: #f4f6f8; border: 1px solid #cdd9e3; border-radius: 8px; display: block; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.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: .5; cursor: default; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Discrete-time model: velocity changes by the chosen acceleration each tick, position changes by velocity.
const DT = 1;
const ACCELS = [-0.2, 0, 0.2]; // The 3 discrete actions available each tick: brake, coast, accelerate.
const POS_MIN = -14, POS_MAX = 14;
let pos = 0, vel = 0, target = 8, vmax = 1.0, horizon = 4;
let running = false, timer = null, tick = 0;
const MAX_TICKS = 200;
// Simulates one candidate action sequence and scores it; infeasible if the speed limit is ever exceeded.
function simulate(p0, v0, actions, vmaxLocal, targetLocal) {
let p = p0, v = v0, cost = 0;
for (const a of actions) {
v = v + a * DT;
if (Math.abs(v) > vmaxLocal + 1e-9) return { cost: Infinity, feasible: false };
p = p + v * DT;
const err = targetLocal - p;
cost += err * err;
}
cost += 0.5 * v * v; // Small penalty on ending velocity so the controller prefers to settle at the target rather than coast past it.
return { cost, feasible: true };
}
function* sequences(depth) {
if (depth === 0) { yield []; return; }
for (const a of ACCELS) for (const rest of sequences(depth - 1)) yield [a, ...rest];
}
// The core MPC step: search every sequence over the horizon, keep the feasible one with lowest cost.
function mpcStep(p0, v0, targetLocal, vmaxLocal, horizonLocal) {
let best = null, bestCost = Infinity;
for (const seq of sequences(horizonLocal)) {
const { cost, feasible } = simulate(p0, v0, seq, vmaxLocal, targetLocal);
if (!feasible) continue;
if (cost < bestCost) { bestCost = cost; best = seq; }
}
return best ? best[0] : 0; // Receding horizon — only the first action of the best plan is ever applied; everything else is recomputed next tick.
}
function readControls() {
target = Number(document.getElementById('target').value);
vmax = Number(document.getElementById('vmax').value);
horizon = Number(document.getElementById('horizon').value);
document.getElementById('targetVal').textContent = target;
document.getElementById('vmaxVal').textContent = vmax.toFixed(1);
document.getElementById('horizonVal').textContent = horizon;
}
function toX(p) {
const canvas = document.getElementById('track');
const w = canvas.width;
return ((p - POS_MIN) / (POS_MAX - POS_MIN)) * w;
}
function draw() {
const canvas = document.getElementById('track');
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height, midY = h / 2;
ctx.clearRect(0, 0, w, h);
// track line
ctx.strokeStyle = '#adb1b8'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, midY); ctx.lineTo(w, midY); ctx.stroke();
// target marker
const tx = toX(target);
ctx.strokeStyle = '#e63946'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(tx, midY - 35); ctx.lineTo(tx, midY + 35); ctx.stroke();
ctx.fillStyle = '#e63946'; ctx.font = '11px system-ui, sans-serif'; ctx.textAlign = 'center';
ctx.fillText('target', tx, midY - 40);
// cart
const cx = toX(pos);
ctx.fillStyle = '#1d3557';
ctx.beginPath(); ctx.arc(cx, midY, 12, 0, Math.PI * 2); ctx.fill();
// velocity indicator arrow
if (Math.abs(vel) > 0.01) {
const dir = vel > 0 ? 1 : -1;
ctx.strokeStyle = '#457b9d'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(cx, midY); ctx.lineTo(cx + dir * 22, midY); ctx.stroke();
ctx.beginPath();
ctx.moveTo(cx + dir * 22, midY - 5);
ctx.lineTo(cx + dir * 28, midY);
ctx.lineTo(cx + dir * 22, midY + 5);
ctx.fill();
}
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t; s.className = 'status ' + cls;
}
function stepOnce() {
const a = mpcStep(pos, vel, target, vmax, horizon);
vel = vel + a * DT;
pos = pos + vel * DT;
tick++;
draw();
const dist = Math.abs(pos - target);
setStatus(i18nFmt('pos %pos% · vel %vel% · tick %tick%', { pos: pos.toFixed(2), vel: vel.toFixed(2), tick: tick }), 'info');
if (dist < 0.15 && Math.abs(vel) < 0.05) {
stop();
setStatus(i18nFmt('Arrived and settled after %tick% ticks.', { tick: tick }), 'ok');
return true;
}
if (tick >= MAX_TICKS) { stop(); return true; }
return false;
}
function run() {
if (running) return;
readControls();
running = true;
document.getElementById('run').disabled = true;
document.getElementById('step').disabled = true;
timer = setInterval(() => { if (stepOnce()) stop(); }, 120);
}
function stop() {
running = false;
clearInterval(timer);
document.getElementById('run').disabled = false;
document.getElementById('step').disabled = false;
}
function resetAll() {
stop();
pos = 0; vel = 0; tick = 0;
readControls();
draw();
setStatus('Press Run and watch it steer.', '');
}
document.getElementById('run').addEventListener('click', run);
document.getElementById('step').addEventListener('click', () => { readControls(); stepOnce(); });
document.getElementById('reset').addEventListener('click', resetAll);
['target', 'vmax', 'horizon'].forEach((id) => {
document.getElementById(id).addEventListener('input', () => { if (!running) { readControls(); draw(); } });
});
readControls();
draw();
<p class="hint">The dot is a cart on a track. At every tick, the controller looks <b>%horizon% steps</b> ahead over every possible sequence of accelerations, discards any sequence that would ever push the speed above the limit, keeps the one that lands closest to the target — then applies only its <b>first</b> move and throws the rest of the plan away.</p>
<div class="controls">
<label>Target position
<input type="range" id="target" min="-12" max="12" step="1" value="8">
<span id="targetVal" class="val">8</span>
</label>
<label>Speed limit
<input type="range" id="vmax" min="0.2" max="2" step="0.1" value="1">
<span id="vmaxVal" class="val">1.0</span>
</label>
<label>Horizon length
<input type="range" id="horizon" min="2" max="6" step="1" value="4">
<span id="horizonVal" class="val">4</span>
</label>
</div>
<canvas id="track" width="560" height="150"></canvas>
<div class="status" id="status">Press Run and watch it steer.</div>
<div class="btns">
<button id="run" type="button">Run</button>
<button id="step" type="button">Step once</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.controls { display: flex; flex-wrap: wrap; gap: 1rem; margin-bottom: .6rem; }
.controls label { display: flex; flex-direction: column; font-size: .8rem; font-weight: 600; color: #1d3557; gap: .2rem; }
.controls input[type="range"] { width: 150px; }
.val { font: 700 13px ui-monospace, monospace; color: #333; }
canvas { width: 100%; max-width: 560px; height: 150px; background: #f4f6f8; border: 1px solid #cdd9e3; border-radius: 8px; display: block; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.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: .5; cursor: default; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Discrete-time model: velocity changes by the chosen acceleration each tick, position changes by velocity.
const DT = 1;
const ACCELS = [-0.2, 0, 0.2]; // The 3 discrete actions available each tick: brake, coast, accelerate.
const POS_MIN = -14, POS_MAX = 14;
let pos = 0, vel = 0, target = 8, vmax = 1.0, horizon = 4;
let running = false, timer = null, tick = 0;
const MAX_TICKS = 200;
// Simulates one candidate action sequence and scores it; infeasible if the speed limit is ever exceeded.
function simulate(p0, v0, actions, vmaxLocal, targetLocal) {
let p = p0, v = v0, cost = 0;
for (const a of actions) {
v = v + a * DT;
if (Math.abs(v) > vmaxLocal + 1e-9) return { cost: Infinity, feasible: false };
p = p + v * DT;
const err = targetLocal - p;
cost += err * err;
}
cost += 0.5 * v * v; // Small penalty on ending velocity so the controller prefers to settle at the target rather than coast past it.
return { cost, feasible: true };
}
function* sequences(depth) {
if (depth === 0) { yield []; return; }
for (const a of ACCELS) for (const rest of sequences(depth - 1)) yield [a, ...rest];
}
// The core MPC step: search every sequence over the horizon, keep the feasible one with lowest cost.
function mpcStep(p0, v0, targetLocal, vmaxLocal, horizonLocal) {
let best = null, bestCost = Infinity;
for (const seq of sequences(horizonLocal)) {
const { cost, feasible } = simulate(p0, v0, seq, vmaxLocal, targetLocal);
if (!feasible) continue;
if (cost < bestCost) { bestCost = cost; best = seq; }
}
return best ? best[0] : 0; // Receding horizon — only the first action of the best plan is ever applied; everything else is recomputed next tick.
}
function readControls() {
target = Number(document.getElementById('target').value);
vmax = Number(document.getElementById('vmax').value);
horizon = Number(document.getElementById('horizon').value);
document.getElementById('targetVal').textContent = target;
document.getElementById('vmaxVal').textContent = vmax.toFixed(1);
document.getElementById('horizonVal').textContent = horizon;
}
function toX(p) {
const canvas = document.getElementById('track');
const w = canvas.width;
return ((p - POS_MIN) / (POS_MAX - POS_MIN)) * w;
}
function draw() {
const canvas = document.getElementById('track');
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height, midY = h / 2;
ctx.clearRect(0, 0, w, h);
// track line
ctx.strokeStyle = '#adb1b8'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, midY); ctx.lineTo(w, midY); ctx.stroke();
// target marker
const tx = toX(target);
ctx.strokeStyle = '#e63946'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(tx, midY - 35); ctx.lineTo(tx, midY + 35); ctx.stroke();
ctx.fillStyle = '#e63946'; ctx.font = '11px system-ui, sans-serif'; ctx.textAlign = 'center';
ctx.fillText('target', tx, midY - 40);
// cart
const cx = toX(pos);
ctx.fillStyle = '#1d3557';
ctx.beginPath(); ctx.arc(cx, midY, 12, 0, Math.PI * 2); ctx.fill();
// velocity indicator arrow
if (Math.abs(vel) > 0.01) {
const dir = vel > 0 ? 1 : -1;
ctx.strokeStyle = '#457b9d'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(cx, midY); ctx.lineTo(cx + dir * 22, midY); ctx.stroke();
ctx.beginPath();
ctx.moveTo(cx + dir * 22, midY - 5);
ctx.lineTo(cx + dir * 28, midY);
ctx.lineTo(cx + dir * 22, midY + 5);
ctx.fill();
}
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t; s.className = 'status ' + cls;
}
function stepOnce() {
const a = mpcStep(pos, vel, target, vmax, horizon);
vel = vel + a * DT;
pos = pos + vel * DT;
tick++;
draw();
const dist = Math.abs(pos - target);
setStatus(i18nFmt('pos %pos% · vel %vel% · tick %tick%', { pos: pos.toFixed(2), vel: vel.toFixed(2), tick: tick }), 'info');
if (dist < 0.15 && Math.abs(vel) < 0.05) {
stop();
setStatus(i18nFmt('Arrived and settled after %tick% ticks.', { tick: tick }), 'ok');
return true;
}
if (tick >= MAX_TICKS) { stop(); return true; }
return false;
}
function run() {
if (running) return;
readControls();
running = true;
document.getElementById('run').disabled = true;
document.getElementById('step').disabled = true;
timer = setInterval(() => { if (stepOnce()) stop(); }, 120);
}
function stop() {
running = false;
clearInterval(timer);
document.getElementById('run').disabled = false;
document.getElementById('step').disabled = false;
}
function resetAll() {
stop();
pos = 0; vel = 0; tick = 0;
readControls();
draw();
setStatus('Press Run and watch it steer.', '');
}
document.getElementById('run').addEventListener('click', run);
document.getElementById('step').addEventListener('click', () => { readControls(); stepOnce(); });
document.getElementById('reset').addEventListener('click', resetAll);
['target', 'vmax', 'horizon'].forEach((id) => {
document.getElementById(id).addEventListener('input', () => { if (!running) { readControls(); draw(); } });
});
readControls();
draw();
Push the speed limit down and watch the cart brake earlier — it is not reacting late, it is foreseeing the limit several steps ahead inside its horizon before it becomes urgent. Push the horizon length up or down and you trade foresight for raw compute: at every single tick, MPC resolves the whole small optimization problem again from scratch.
Comments
Loading comments...