CookbookLabs and workshopsNo. 25
A physics lab manualmotion under gravity and thrust, with a lander to fly
See it in know.sh
Lunar lander
Arrow keys turn and fire the engine (or the buttons, on a phone); set down on a flat pad at under 4 m/s, roughly level and not sliding fast; up to 5 m/s is a hard landing at half points. The small pad scores triple.
<div class="lander" tabindex="-1" aria-label="Lunar lander. Arrow keys steer, up or space fires the engine, R starts again.">
<canvas aria-hidden="true"></canvas>
<button type="button" class="cover"><span class="go"><span class="fine">Click</span><span class="touch">Tap</span> to <span class="what">play</span></span><span class="how">Arrow keys steer; up or space fires the engine</span></button>
<p class="hud" aria-live="off"><span data-k="alt"></span><span data-k="vx"></span><span data-k="vy"></span><span data-k="fuel"></span><span data-k="score"></span></p>
<p class="say" aria-live="polite"></p>
<div class="pads" aria-label="Controls">
<button type="button" data-hold="left" aria-label="Turn left">←</button>
<button type="button" data-hold="thrust">Thrust</button>
<button type="button" data-hold="right" aria-label="Turn right">→</button>
<button type="button" data-tap="again">Again</button>
</div>
</div>.lander { position: relative; outline: none; }
.lander:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
canvas { display: block; width: 100%; aspect-ratio: 16 / 9; border-bottom: 1px solid var(--rule); }
.cover { position: absolute; left: 0; top: 0; width: 100%; aspect-ratio: 16 / 9; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; margin: 0; padding: 0; border: 0; border-radius: 0; background: color-mix(in srgb, var(--paper) 72%, transparent); color: var(--ink); cursor: pointer; }
.cover[hidden] { display: none; }
.cover .go { padding: 12px 22px; background: var(--ink); color: var(--paper); font: 500 16px/1 var(--sans); }
.cover:hover .go, .cover:focus-visible .go { outline: 2px solid var(--ink); outline-offset: 3px; }
.cover:focus-visible { outline: none; }
.cover .how { font: 400 13px/1.3 var(--sans); color: var(--ink-2); background: var(--paper); padding: 2px 6px; }
.cover .touch { display: none; }
@media (hover: none), (pointer: coarse) { .cover .fine, .cover .how { display: none; } .cover .touch { display: inline; } }
.hud { display: flex; flex-wrap: wrap; gap: 4px 18px; margin: 8px 0 0; font: 400 13px/1.3 var(--sans); color: var(--ink-2); font-variant-numeric: tabular-nums; }
.hud b { font-weight: 600; color: var(--ink); }
.say { min-height: 1.5em; margin: 6px 0 0; font: italic 400 17px/1.4 var(--serif); }
.pads { display: flex; gap: 8px; margin-top: 10px; }
.pads button { flex: 1; min-height: 44px; font: 500 15px/1 var(--sans); color: var(--ink); background: var(--paper); border: 1px solid var(--ink); border-radius: 0; touch-action: none; user-select: none; -webkit-user-select: none; }
.pads button[aria-pressed='true'] { background: var(--ink); color: var(--paper); }
@media (hover: hover) and (pointer: fine) { .pads button[data-hold] { display: none; } }(function () {
var root = document.querySelector('.lander');
var canvas = root.querySelector('canvas');
var ctx = canvas.getContext('2d');
var say = root.querySelector('.say');
var hudEls = {};
root.querySelectorAll('[data-k]').forEach(function (el) { hudEls[el.dataset.k] = el; });
// World units are metres; the view is 320 m wide and 180 m tall.
var W = 320, H = 180, G = 1.62, THRUST = 4.2, TURN = 2.2, BURN = 7;
var SAFE_VY = 4, HARD_VY = 5, SAFE_VX = 2.5, SAFE_TILT = 0.35;
var keys = { left: false, right: false, thrust: false };
var seed = 7, level = 1, total = 0, ship, ground, pads, state, sparks, last = 0;
// The game runs only while it has the keys; otherwise the cover asks for a click.
var cover = root.querySelector('.cover'), active = false;
function rand() { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; }
function ink(name, fallback) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; }
function makeGround() {
ground = []; pads = [];
var x = 0, y = 40 + rand() * 30, padCount = 0;
while (x < W) {
if (padCount < 2 && x > 30 + padCount * 130 && rand() < 0.28) {
var width = padCount === 0 ? 34 : 22;
pads.push({ x0: x, x1: x + width, y: y, bonus: padCount === 0 ? 1 : 3 });
ground.push([x, y], [x + width, y]);
x += width; padCount++;
continue;
}
x += 6 + rand() * 12;
y = Math.max(12, Math.min(110, y + (rand() - 0.5) * 34));
ground.push([x, y]);
}
if (pads.length === 0) { pads.push({ x0: 140, x1: 174, y: 30, bonus: 1 }); ground.push([140, 30], [174, 30]); ground.sort(function (a, b) { return a[0] - b[0]; }); }
}
function groundAt(px) {
for (var i = 1; i < ground.length; i++) {
var a = ground[i - 1], b = ground[i];
if (px >= a[0] && px <= b[0]) return b[0] === a[0] ? a[1] : a[1] + (b[1] - a[1]) * (px - a[0]) / (b[0] - a[0]);
}
return 0;
}
function start(fresh) {
if (fresh) { level = 1; total = 0; }
seed = 7 + level * 101;
makeGround();
ship = { x: 40 + rand() * 60, y: 168, vx: 2 + rand() * 3, vy: 0, a: -0.2, fuel: 100 };
state = 'flying'; sparks = []; say.textContent = level === 1 && fresh ? 'Land on a flat pad. Gently.' : 'Level ' + level + '.';
}
function end(ok, why, bonus) {
state = ok ? 'landed' : 'crashed';
if (ok) {
var points = Math.round((50 + ship.fuel) * bonus * (why === 'hard' ? 0.5 : 1));
total += points; level++;
say.textContent = (why === 'hard' ? 'A hard landing, but down. ' : 'Landed. ') + points + ' points' + (bonus > 1 ? ', on the small pad' : '') + '. Space or Again for the next level.';
} else {
say.textContent = 'Crashed: ' + why + '. Press R or Again.';
for (var i = 0; i < 18; i++) sparks.push({ x: ship.x, y: ship.y, vx: (rand() - 0.5) * 30, vy: rand() * 20, a: rand() * 6, t: 1.6 });
}
}
function step(dt) {
if (!active) return;
if (state !== 'flying') { sparks.forEach(function (s) { s.vy -= G * 4 * dt; s.x += s.vx * dt; s.y += s.vy * dt; s.t -= dt; }); return; }
if (keys.left) ship.a -= TURN * dt;
if (keys.right) ship.a += TURN * dt;
ship.a = Math.max(-1.6, Math.min(1.6, ship.a));
var burning = keys.thrust && ship.fuel > 0;
if (burning) { ship.vx += Math.sin(ship.a) * THRUST * dt; ship.vy += Math.cos(ship.a) * THRUST * dt; ship.fuel = Math.max(0, ship.fuel - BURN * dt); }
ship.vy -= G * dt;
ship.x += ship.vx * dt; ship.y += ship.vy * dt;
if (ship.x < 0) ship.x += W; if (ship.x > W) ship.x -= W;
ship.burning = burning;
var floor = groundAt(ship.x);
if (ship.y - 4 <= floor) {
ship.y = floor + 4;
var pad = pads.filter(function (p) { return ship.x >= p.x0 && ship.x <= p.x1; })[0];
if (!pad) end(false, 'not on a pad');
else if (Math.abs(ship.a) > SAFE_TILT) end(false, 'tilted ' + Math.round(Math.abs(ship.a) * 57.3) + ' degrees');
else if (-ship.vy > HARD_VY) end(false, 'coming down at ' + (-ship.vy).toFixed(1) + ' m/s');
else if (Math.abs(ship.vx) > SAFE_VX) end(false, 'sliding at ' + Math.abs(ship.vx).toFixed(1) + ' m/s');
else end(true, -ship.vy > SAFE_VY ? 'hard' : '', pad.bonus);
}
}
function hud() {
var ok = function (bad) { return bad ? '<b>' : ''; }, close = function (bad) { return bad ? '</b>' : ''; };
var vy = -ship.vy, vx = Math.abs(ship.vx);
hudEls.alt.innerHTML = 'Height ' + Math.max(0, Math.round(ship.y - groundAt(ship.x) - 4)) + ' m';
hudEls.vx.innerHTML = 'Across ' + ok(vx > SAFE_VX) + vx.toFixed(1) + ' m/s' + close(vx > SAFE_VX);
hudEls.vy.innerHTML = 'Down ' + ok(vy > SAFE_VY) + vy.toFixed(1) + ' m/s' + close(vy > SAFE_VY);
hudEls.fuel.innerHTML = 'Fuel ' + ok(ship.fuel < 20) + Math.round(ship.fuel) + '%' + close(ship.fuel < 20);
hudEls.score.innerHTML = 'Score ' + total;
}
function draw() {
var dpr = window.devicePixelRatio || 1, cw = canvas.clientWidth, ch = canvas.clientHeight;
if (canvas.width !== Math.round(cw * dpr)) { canvas.width = Math.round(cw * dpr); canvas.height = Math.round(ch * dpr); }
var s = canvas.width / W;
var paper = ink('--paper', '#fbfbf9'), black = ink('--ink', '#000'), grey = ink('--ink-3', '#66665f');
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = paper; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(s, 0, 0, -s, 0, canvas.height);
ctx.lineJoin = 'round'; ctx.lineCap = 'round';
ctx.beginPath();
ground.forEach(function (p, i) { if (i === 0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]); });
ctx.lineTo(W, 0); ctx.lineTo(0, 0); ctx.closePath();
ctx.fillStyle = paper; ctx.fill();
ctx.strokeStyle = black; ctx.lineWidth = 1 / s * 1.2; ctx.stroke();
pads.forEach(function (p) {
ctx.lineWidth = 3 / s; ctx.beginPath(); ctx.moveTo(p.x0, p.y); ctx.lineTo(p.x1, p.y); ctx.stroke();
ctx.save(); ctx.scale(1, -1); ctx.fillStyle = grey; ctx.font = (10 / s) + 'px sans-serif'; ctx.textAlign = 'center';
ctx.fillText(p.bonus > 1 ? '\u00d7' + p.bonus : '', (p.x0 + p.x1) / 2, -p.y + 11 / s); ctx.restore();
});
if (state !== 'crashed') {
ctx.save(); ctx.translate(ship.x, ship.y); ctx.rotate(-ship.a); ctx.lineWidth = 1.2 / s; ctx.strokeStyle = black;
ctx.beginPath(); ctx.moveTo(-3, -1); ctx.lineTo(-3, 3); ctx.lineTo(0, 5.5); ctx.lineTo(3, 3); ctx.lineTo(3, -1); ctx.closePath(); ctx.fillStyle = paper; ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-2.5, -1); ctx.lineTo(-4.5, -4); ctx.moveTo(2.5, -1); ctx.lineTo(4.5, -4); ctx.moveTo(-5.5, -4); ctx.lineTo(-3.5, -4); ctx.moveTo(3.5, -4); ctx.lineTo(5.5, -4); ctx.stroke();
if (ship.burning) { var f = 3 + Math.random() * 3; ctx.beginPath(); ctx.moveTo(-1.4, -1.5); ctx.lineTo(0, -1.5 - f); ctx.lineTo(1.4, -1.5); ctx.stroke(); }
ctx.restore();
}
ctx.strokeStyle = black; ctx.lineWidth = 1 / s;
sparks.forEach(function (p) { if (p.t <= 0) return; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x + Math.cos(p.a) * 2.5, p.y + Math.sin(p.a) * 2.5); ctx.stroke(); });
}
function frame(t) {
var dt = Math.min(0.05, (t - last) / 1000 || 0); last = t;
if (!document.hidden) { step(dt); draw(); hud(); }
requestAnimationFrame(frame);
}
function next() { if (state === 'landed') start(false); else if (state === 'crashed') start(true); }
var map = { ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'thrust', ' ': 'thrust' };
root.addEventListener('keydown', function (e) {
if (e.key === 'r' || e.key === 'R') { start(true); e.preventDefault(); return; }
if (e.key === ' ' && state !== 'flying') { next(); e.preventDefault(); return; }
if (map[e.key]) { keys[map[e.key]] = true; e.preventDefault(); }
});
root.addEventListener('keyup', function (e) { if (map[e.key]) keys[map[e.key]] = false; });
root.addEventListener('blur', function () { keys.left = keys.right = keys.thrust = false; });
canvas.addEventListener('pointerdown', function () { root.focus(); });
root.querySelectorAll('[data-hold]').forEach(function (b) {
var k = b.dataset.hold;
var on = function (e) { e.preventDefault(); if (!active) play(); keys[k] = true; b.setAttribute('aria-pressed', 'true'); };
var off = function () { keys[k] = false; b.setAttribute('aria-pressed', 'false'); };
b.addEventListener('pointerdown', on); b.addEventListener('pointerup', off); b.addEventListener('pointercancel', off); b.addEventListener('pointerleave', off);
});
root.querySelector('[data-tap="again"]').addEventListener('click', function () { if (state === 'flying') start(true); else next(); play(); });
// Focus first, so hiding the cover does not drop it.
function play() { root.focus(); active = true; cover.hidden = true; }
function idle() {
if (!active) return;
active = false; keys.left = keys.right = keys.thrust = false;
root.querySelectorAll('[data-hold]').forEach(function (b) { b.setAttribute('aria-pressed', 'false'); });
cover.querySelector('.what').textContent = 'carry on';
cover.hidden = false;
}
cover.addEventListener('click', play);
root.addEventListener('focusout', function (e) { if (!root.contains(e.relatedTarget)) idle(); });
window.addEventListener('blur', idle);
start(true);
requestAnimationFrame(frame);
})();```element widget
{
"title": "Lunar lander",
"caption": "Arrow keys turn and fire the engine (or the buttons, on a phone); set down on a flat pad at under 4 m/s, roughly level and not sliding fast; up to 5 m/s is a hard landing at half points. The small pad scores triple.",
"height": 470,
"html": "<div class=\"lander\" tabindex=\"-1\" aria-label=\"Lunar lander. Arrow keys steer, up or space fires the engine, R starts again.\">\n <canvas aria-hidden=\"true\"></canvas>\n <button type=\"button\" class=\"cover\"><span class=\"go\"><span class=\"fine\">Click</span><span class=\"touch\">Tap</span> to <span class=\"what\">play</span></span><span class=\"how\">Arrow keys steer; up or space fires the engine</span></button>\n <p class=\"hud\" aria-live=\"off\"><span data-k=\"alt\"></span><span data-k=\"vx\"></span><span data-k=\"vy\"></span><span data-k=\"fuel\"></span><span data-k=\"score\"></span></p>\n <p class=\"say\" aria-live=\"polite\"></p>\n <div class=\"pads\" aria-label=\"Controls\">\n <button type=\"button\" data-hold=\"left\" aria-label=\"Turn left\">←</button>\n <button type=\"button\" data-hold=\"thrust\">Thrust</button>\n <button type=\"button\" data-hold=\"right\" aria-label=\"Turn right\">→</button>\n <button type=\"button\" data-tap=\"again\">Again</button>\n </div>\n</div>",
"css": ".lander { position: relative; outline: none; }\n.lander:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }\ncanvas { display: block; width: 100%; aspect-ratio: 16 / 9; border-bottom: 1px solid var(--rule); }\n.cover { position: absolute; left: 0; top: 0; width: 100%; aspect-ratio: 16 / 9; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; margin: 0; padding: 0; border: 0; border-radius: 0; background: color-mix(in srgb, var(--paper) 72%, transparent); color: var(--ink); cursor: pointer; }\n.cover[hidden] { display: none; }\n.cover .go { padding: 12px 22px; background: var(--ink); color: var(--paper); font: 500 16px/1 var(--sans); }\n.cover:hover .go, .cover:focus-visible .go { outline: 2px solid var(--ink); outline-offset: 3px; }\n.cover:focus-visible { outline: none; }\n.cover .how { font: 400 13px/1.3 var(--sans); color: var(--ink-2); background: var(--paper); padding: 2px 6px; }\n.cover .touch { display: none; }\n@media (hover: none), (pointer: coarse) { .cover .fine, .cover .how { display: none; } .cover .touch { display: inline; } }\n.hud { display: flex; flex-wrap: wrap; gap: 4px 18px; margin: 8px 0 0; font: 400 13px/1.3 var(--sans); color: var(--ink-2); font-variant-numeric: tabular-nums; }\n.hud b { font-weight: 600; color: var(--ink); }\n.say { min-height: 1.5em; margin: 6px 0 0; font: italic 400 17px/1.4 var(--serif); }\n.pads { display: flex; gap: 8px; margin-top: 10px; }\n.pads button { flex: 1; min-height: 44px; font: 500 15px/1 var(--sans); color: var(--ink); background: var(--paper); border: 1px solid var(--ink); border-radius: 0; touch-action: none; user-select: none; -webkit-user-select: none; }\n.pads button[aria-pressed='true'] { background: var(--ink); color: var(--paper); }\n@media (hover: hover) and (pointer: fine) { .pads button[data-hold] { display: none; } }",
"js": "(function () {\n var root = document.querySelector('.lander');\n var canvas = root.querySelector('canvas');\n var ctx = canvas.getContext('2d');\n var say = root.querySelector('.say');\n var hudEls = {};\n root.querySelectorAll('[data-k]').forEach(function (el) { hudEls[el.dataset.k] = el; });\n\n // World units are metres; the view is 320 m wide and 180 m tall.\n var W = 320, H = 180, G = 1.62, THRUST = 4.2, TURN = 2.2, BURN = 7;\n var SAFE_VY = 4, HARD_VY = 5, SAFE_VX = 2.5, SAFE_TILT = 0.35;\n var keys = { left: false, right: false, thrust: false };\n var seed = 7, level = 1, total = 0, ship, ground, pads, state, sparks, last = 0;\n // The game runs only while it has the keys; otherwise the cover asks for a click.\n var cover = root.querySelector('.cover'), active = false;\n\n function rand() { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; }\n function ink(name, fallback) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; }\n\n function makeGround() {\n ground = []; pads = [];\n var x = 0, y = 40 + rand() * 30, padCount = 0;\n while (x < W) {\n if (padCount < 2 && x > 30 + padCount * 130 && rand() < 0.28) {\n var width = padCount === 0 ? 34 : 22;\n pads.push({ x0: x, x1: x + width, y: y, bonus: padCount === 0 ? 1 : 3 });\n ground.push([x, y], [x + width, y]);\n x += width; padCount++;\n continue;\n }\n x += 6 + rand() * 12;\n y = Math.max(12, Math.min(110, y + (rand() - 0.5) * 34));\n ground.push([x, y]);\n }\n if (pads.length === 0) { pads.push({ x0: 140, x1: 174, y: 30, bonus: 1 }); ground.push([140, 30], [174, 30]); ground.sort(function (a, b) { return a[0] - b[0]; }); }\n }\n\n function groundAt(px) {\n for (var i = 1; i < ground.length; i++) {\n var a = ground[i - 1], b = ground[i];\n if (px >= a[0] && px <= b[0]) return b[0] === a[0] ? a[1] : a[1] + (b[1] - a[1]) * (px - a[0]) / (b[0] - a[0]);\n }\n return 0;\n }\n\n function start(fresh) {\n if (fresh) { level = 1; total = 0; }\n seed = 7 + level * 101;\n makeGround();\n ship = { x: 40 + rand() * 60, y: 168, vx: 2 + rand() * 3, vy: 0, a: -0.2, fuel: 100 };\n state = 'flying'; sparks = []; say.textContent = level === 1 && fresh ? 'Land on a flat pad. Gently.' : 'Level ' + level + '.';\n }\n\n function end(ok, why, bonus) {\n state = ok ? 'landed' : 'crashed';\n if (ok) {\n var points = Math.round((50 + ship.fuel) * bonus * (why === 'hard' ? 0.5 : 1));\n total += points; level++;\n say.textContent = (why === 'hard' ? 'A hard landing, but down. ' : 'Landed. ') + points + ' points' + (bonus > 1 ? ', on the small pad' : '') + '. Space or Again for the next level.';\n } else {\n say.textContent = 'Crashed: ' + why + '. Press R or Again.';\n for (var i = 0; i < 18; i++) sparks.push({ x: ship.x, y: ship.y, vx: (rand() - 0.5) * 30, vy: rand() * 20, a: rand() * 6, t: 1.6 });\n }\n }\n\n function step(dt) {\n if (!active) return;\n if (state !== 'flying') { sparks.forEach(function (s) { s.vy -= G * 4 * dt; s.x += s.vx * dt; s.y += s.vy * dt; s.t -= dt; }); return; }\n if (keys.left) ship.a -= TURN * dt;\n if (keys.right) ship.a += TURN * dt;\n ship.a = Math.max(-1.6, Math.min(1.6, ship.a));\n var burning = keys.thrust && ship.fuel > 0;\n if (burning) { ship.vx += Math.sin(ship.a) * THRUST * dt; ship.vy += Math.cos(ship.a) * THRUST * dt; ship.fuel = Math.max(0, ship.fuel - BURN * dt); }\n ship.vy -= G * dt;\n ship.x += ship.vx * dt; ship.y += ship.vy * dt;\n if (ship.x < 0) ship.x += W; if (ship.x > W) ship.x -= W;\n ship.burning = burning;\n var floor = groundAt(ship.x);\n if (ship.y - 4 <= floor) {\n ship.y = floor + 4;\n var pad = pads.filter(function (p) { return ship.x >= p.x0 && ship.x <= p.x1; })[0];\n if (!pad) end(false, 'not on a pad');\n else if (Math.abs(ship.a) > SAFE_TILT) end(false, 'tilted ' + Math.round(Math.abs(ship.a) * 57.3) + ' degrees');\n else if (-ship.vy > HARD_VY) end(false, 'coming down at ' + (-ship.vy).toFixed(1) + ' m/s');\n else if (Math.abs(ship.vx) > SAFE_VX) end(false, 'sliding at ' + Math.abs(ship.vx).toFixed(1) + ' m/s');\n else end(true, -ship.vy > SAFE_VY ? 'hard' : '', pad.bonus);\n }\n }\n\n function hud() {\n var ok = function (bad) { return bad ? '<b>' : ''; }, close = function (bad) { return bad ? '</b>' : ''; };\n var vy = -ship.vy, vx = Math.abs(ship.vx);\n hudEls.alt.innerHTML = 'Height ' + Math.max(0, Math.round(ship.y - groundAt(ship.x) - 4)) + ' m';\n hudEls.vx.innerHTML = 'Across ' + ok(vx > SAFE_VX) + vx.toFixed(1) + ' m/s' + close(vx > SAFE_VX);\n hudEls.vy.innerHTML = 'Down ' + ok(vy > SAFE_VY) + vy.toFixed(1) + ' m/s' + close(vy > SAFE_VY);\n hudEls.fuel.innerHTML = 'Fuel ' + ok(ship.fuel < 20) + Math.round(ship.fuel) + '%' + close(ship.fuel < 20);\n hudEls.score.innerHTML = 'Score ' + total;\n }\n\n function draw() {\n var dpr = window.devicePixelRatio || 1, cw = canvas.clientWidth, ch = canvas.clientHeight;\n if (canvas.width !== Math.round(cw * dpr)) { canvas.width = Math.round(cw * dpr); canvas.height = Math.round(ch * dpr); }\n var s = canvas.width / W;\n var paper = ink('--paper', '#fbfbf9'), black = ink('--ink', '#000'), grey = ink('--ink-3', '#66665f');\n ctx.setTransform(1, 0, 0, 1, 0, 0);\n ctx.fillStyle = paper; ctx.fillRect(0, 0, canvas.width, canvas.height);\n ctx.setTransform(s, 0, 0, -s, 0, canvas.height);\n ctx.lineJoin = 'round'; ctx.lineCap = 'round';\n\n ctx.beginPath();\n ground.forEach(function (p, i) { if (i === 0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]); });\n ctx.lineTo(W, 0); ctx.lineTo(0, 0); ctx.closePath();\n ctx.fillStyle = paper; ctx.fill();\n ctx.strokeStyle = black; ctx.lineWidth = 1 / s * 1.2; ctx.stroke();\n pads.forEach(function (p) {\n ctx.lineWidth = 3 / s; ctx.beginPath(); ctx.moveTo(p.x0, p.y); ctx.lineTo(p.x1, p.y); ctx.stroke();\n ctx.save(); ctx.scale(1, -1); ctx.fillStyle = grey; ctx.font = (10 / s) + 'px sans-serif'; ctx.textAlign = 'center';\n ctx.fillText(p.bonus > 1 ? '\\u00d7' + p.bonus : '', (p.x0 + p.x1) / 2, -p.y + 11 / s); ctx.restore();\n });\n\n if (state !== 'crashed') {\n ctx.save(); ctx.translate(ship.x, ship.y); ctx.rotate(-ship.a); ctx.lineWidth = 1.2 / s; ctx.strokeStyle = black;\n ctx.beginPath(); ctx.moveTo(-3, -1); ctx.lineTo(-3, 3); ctx.lineTo(0, 5.5); ctx.lineTo(3, 3); ctx.lineTo(3, -1); ctx.closePath(); ctx.fillStyle = paper; ctx.fill(); ctx.stroke();\n ctx.beginPath(); ctx.moveTo(-2.5, -1); ctx.lineTo(-4.5, -4); ctx.moveTo(2.5, -1); ctx.lineTo(4.5, -4); ctx.moveTo(-5.5, -4); ctx.lineTo(-3.5, -4); ctx.moveTo(3.5, -4); ctx.lineTo(5.5, -4); ctx.stroke();\n if (ship.burning) { var f = 3 + Math.random() * 3; ctx.beginPath(); ctx.moveTo(-1.4, -1.5); ctx.lineTo(0, -1.5 - f); ctx.lineTo(1.4, -1.5); ctx.stroke(); }\n ctx.restore();\n }\n ctx.strokeStyle = black; ctx.lineWidth = 1 / s;\n sparks.forEach(function (p) { if (p.t <= 0) return; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x + Math.cos(p.a) * 2.5, p.y + Math.sin(p.a) * 2.5); ctx.stroke(); });\n }\n\n function frame(t) {\n var dt = Math.min(0.05, (t - last) / 1000 || 0); last = t;\n if (!document.hidden) { step(dt); draw(); hud(); }\n requestAnimationFrame(frame);\n }\n\n function next() { if (state === 'landed') start(false); else if (state === 'crashed') start(true); }\n var map = { ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'thrust', ' ': 'thrust' };\n root.addEventListener('keydown', function (e) {\n if (e.key === 'r' || e.key === 'R') { start(true); e.preventDefault(); return; }\n if (e.key === ' ' && state !== 'flying') { next(); e.preventDefault(); return; }\n if (map[e.key]) { keys[map[e.key]] = true; e.preventDefault(); }\n });\n root.addEventListener('keyup', function (e) { if (map[e.key]) keys[map[e.key]] = false; });\n root.addEventListener('blur', function () { keys.left = keys.right = keys.thrust = false; });\n canvas.addEventListener('pointerdown', function () { root.focus(); });\n root.querySelectorAll('[data-hold]').forEach(function (b) {\n var k = b.dataset.hold;\n var on = function (e) { e.preventDefault(); if (!active) play(); keys[k] = true; b.setAttribute('aria-pressed', 'true'); };\n var off = function () { keys[k] = false; b.setAttribute('aria-pressed', 'false'); };\n b.addEventListener('pointerdown', on); b.addEventListener('pointerup', off); b.addEventListener('pointercancel', off); b.addEventListener('pointerleave', off);\n });\n root.querySelector('[data-tap=\"again\"]').addEventListener('click', function () { if (state === 'flying') start(true); else next(); play(); });\n\n // Focus first, so hiding the cover does not drop it.\n function play() { root.focus(); active = true; cover.hidden = true; }\n function idle() {\n if (!active) return;\n active = false; keys.left = keys.right = keys.thrust = false;\n root.querySelectorAll('[data-hold]').forEach(function (b) { b.setAttribute('aria-pressed', 'false'); });\n cover.querySelector('.what').textContent = 'carry on';\n cover.hidden = false;\n }\n cover.addEventListener('click', play);\n root.addEventListener('focusout', function (e) { if (!root.contains(e.relatedTarget)) idle(); });\n window.addEventListener('blur', idle);\n\n start(true);\n requestAnimationFrame(frame);\n})();"
}
```Physics 1 — motionNo. 14
Landing on the Moongravity, thrust and a fuel budget
7 sections, 2,140 words, about 9 minutes, revised 18 September
Lab 4 of the motion unit. Predict every flight before you fly it; a crash you predicted is a result, and a crash you did not is a question for the Explain section.
Lab 4 record: the worked prediction, then what the class flew
| Question | Predicted, then flown (class mean) |
|---|---|
| Fall time, engine off | Ground 121 m below: t = √(2h/g) = 12.2 s. Flown: 12.9 s; it passed the pad and fell 134 m. |
| Speed at the ground | v = gt = 19.8 m/s. Flown: 20.8 m/s on the Down readout. |
| Where it lands | Drifting 4.1 m/s: 4.1 × 12.2 = 50 m on, past the wide pad. Flown: crashed past the pad, not on a pad. |
| Net acceleration | Engine on, upright: measure it. Flown: 5.2 m/s gained in 2 s, so 2.6 m/s². |
| Highest safe cut-off | To land under 4 m/s: h = v²/2g = 4.9 m. Flown: safe from 4 m, hard from 6 m, crash from 8 m. |
| A full tank’s worth | 4.2 m/s² × 14.3 s = 60 m/s of speed change. Flown: 14.3 s of burning, 100% to 0. |
Lunar gravity 1.62 m/s². The lander’s engine gives 4.2 m/s² along its axis and burns 7 per cent of the tank per second. Heights are read from the rounded readout, so expect a metre either way.
Lab 4 record: the worked prediction, then what the class flew
| Question | Predicted, then flown (class mean) |
|---|---|
| Fall time, engine off | Ground 121 m below: t = √(2h/g) = 12.2 s. Flown: 12.9 s; it passed the pad and fell 134 m. |
| Speed at the ground | v = gt = 19.8 m/s. Flown: 20.8 m/s on the Down readout. |
| Where it lands | Drifting 4.1 m/s: 4.1 × 12.2 = 50 m on, past the wide pad. Flown: crashed past the pad, not on a pad. |
| Net acceleration | Engine on, upright: measure it. Flown: 5.2 m/s gained in 2 s, so 2.6 m/s². |
| Highest safe cut-off | To land under 4 m/s: h = v²/2g = 4.9 m. Flown: safe from 4 m, hard from 6 m, crash from 8 m. |
| A full tank’s worth | 4.2 m/s² × 14.3 s = 60 m/s of speed change. Flown: 14.3 s of burning, 100% to 0. |
Lunar gravity 1.62 m/s². The lander’s engine gives 4.2 m/s² along its axis and burns 7 per cent of the tank per second. Heights are read from the rounded readout, so expect a metre either way.
```element table
{
"caption": "Lab 4 record: the worked prediction, then what the class flew",
"columns": [
{
"label": "Question",
"align": "left"
},
{
"label": "Predicted, then flown (class mean)",
"align": "left"
}
],
"rows": [
[
"Fall time, engine off",
"Ground 121 m below: *t* = √(2*h*/*g*) = 12.2 s. *Flown:* 12.9 s; it passed the pad and fell 134 m."
],
[
"Speed at the ground",
"*v* = *gt* = 19.8 m/s. *Flown:* 20.8 m/s on the Down readout."
],
[
"Where it lands",
"Drifting 4.1 m/s: 4.1 × 12.2 = 50 m on, past the wide pad. *Flown:* crashed past the pad, not on a pad."
],
[
"Net acceleration",
"Engine on, upright: measure it. *Flown:* 5.2 m/s gained in 2 s, so 2.6 m/s²."
],
[
"Highest safe cut-off",
"To land under 4 m/s: *h* = *v*²/2*g* = 4.9 m. *Flown:* safe from 4 m, hard from 6 m, crash from 8 m."
],
[
"A full tank’s worth",
"4.2 m/s² × 14.3 s = 60 m/s of speed change. *Flown:* 14.3 s of burning, 100% to 0."
]
],
"notes": "Lunar gravity 1.62 m/s². The lander’s engine gives 4.2 m/s² along its axis and burns 7 per cent of the tank per second. Heights are read from the rounded readout, so expect a metre either way."
}
```The first two rows disagree by most of a second and a metre a second, and the Explain section says why: where the lander comes down, the ground is 13 m lower than under the start. The last row is the one to talk about next: sixty metres a second of speed change, and gravity takes 1.62 of it back every second you spend in the air.
Sections
- 1Predict: the fall from the startQuestionTime, speed and landing point, with the working, before anyone presses a key.
- 2The engine, measured with a stopwatchEvidenceTwo-second burns, three pairs; 2.6 m/s² net, so 4.2 m/s² from the engine.
- 3Energy: what the fuel has to take awayInsight, key199 J per kilogram above the wide pad; 8 J left at a safe touchdown.
- 4What the model leaves outProblemA lander that gets no lighter as it burns, an engine with no throttle, and turns that cost no fuel.
and 3 more sections
How it works
Every student wants to land on the Moon, and nearly every one crashes the first time. Make that crash the lesson. Each lab has students predict before they fly, and the landing lab holds a working lander with lunar gravity, an engine and a fuel gauge, so every equation they write gets tested.