A lesson here is four small files and zero JavaScript: the physics as one Rust function, the page as data, the prose as a fragment, and the claims as tests that run on the exact code the browser runs. This guide walks through the live Projectile Range lesson — kept minimal on purpose so its complete source fits below — and ends with you shipping your own.
The house rules, before the code. Models are pure functions of their parameters — no accumulated state, no integrators in the browser (what needs stepping belongs to the motoreel film studio, embedded as an asset). Closed form when it exists. Angles measure against τ: a full turn is one τ, and pi appears only where a genuine half-turn is meant. Geometry views scale both axes alike (the runtime throws if not); a phase portrait or graph may declare "uniform": false as a statement that its axes carry different quantities. And every quantitative sentence a lesson speaks must exist as a claim with a test — that is not bureaucracy, it is the product.
Two clones, side by side (the lesson crates path-depend on garust as a sibling), then confirm the world is green before you touch it:
git clone https://github.com/westerngazoo/garust git clone https://github.com/westerngazoo/physics-lab cd physics-lab cargo test --workspace # every existing claim, on your machine python3 checks/run.py # the independent second implementation
| File | Contains | Who reads it |
|---|---|---|
lessons/<slug>/crate/src/lib.rs | the model, ONE draw(params, prims, readouts) function, lessons_common::lesson!(draw), and the claims as #[cfg(test)] | cargo test natively; the browser as wasm — same bits |
public/lessons/<slug>/lesson.json | everything else as data: title, lede, views and world boxes, sliders, readouts, styles, legend, claims list, try-this, dissection steps, hub card | the shared runtime, which builds the whole page from it |
.../notes.html | the prose | fetched into the page |
.../index.html | a 21-line stub — copy it, change the title tags | nobody, twice |
The complete lib.rs of the template — this is the file that is live on the site right now, not a simplified listing. Note the shape: closed-form helpers, then draw emitting primitives (segment/arrow/point/curve) and readout slots, then the macro, then the claims. draw receives parameters in manifest order, already scaled (the θ slider is in turns; the manifest's scale: τ delivers radians).
//! Projectile Range — the deliberately minimal lesson, and the
//! authoring template the classroom guide walks through line by line.
//!
//! A launch at speed `v`, angle `θ` (measured in turns of τ), gravity
//! `g`. Everything is closed form, and the trajectory is drawn in
//! NATURAL UNITS `v²/g`, which is why changing `v` or `g` never changes
//! the drawn shape — only the numbers. That scale invariance is itself
//! a claim (P5), not an accident.
use std::f64::consts::TAU;
/// Range, apex height, and flight time — the real, unit-carrying numbers.
pub fn numbers(theta: f64, v: f64, g: f64) -> (f64, f64, f64) {
let range = v * v * (2.0 * theta).sin() / g;
let apex = v * v * theta.sin().powi(2) / (2.0 * g);
let time = 2.0 * v * theta.sin() / g;
(range, apex, time)
}
/// The trajectory in natural units x' = x·g/v²: shape depends on θ alone.
pub fn shape(theta: f64, xp: f64) -> f64 {
xp * theta.tan() - xp * xp / (2.0 * theta.cos().powi(2))
}
// ---- the wasm boundary --------------------------------------------------
use lessons_common::{Prims, Readouts};
/// The primary entry point for drawing the lesson.
///
/// # Interfacing & Framework Abstractions
///
/// This framework does not use Rust traits (e.g. there is no `Draw` trait to implement).
/// Instead, the interface is purely data-driven: the lesson crate registers this `draw`
/// function with the `lessons_common::lesson!` macro, which generates the uniform WebAssembly
/// C-ABI exports (`params_ptr`, `prims_ptr`, `readouts_ptr`, `state_at`) expected by the
/// JavaScript runtime.
///
/// # Parameters
///
/// * `p`: A slice of `f64` representing the input parameters, passed **in manifest order**
/// as declared under `params` in `lesson.json`. Values are pre-multiplied by their
/// manifest `scale` factor. Here, `p[0]` is `theta` (scaled by `TAU` to deliver radians),
/// `p[1]` is `v` (launch speed), and `p[2]` is `g` (gravity).
/// * `out`: A mutable reference to a `Prims` buffer writer, which provides helper methods
/// (`view`, `segment`, `arrow`, `curve`, `point`) to write drawing records into the flat
/// primitive buffer shared with the JS runtime.
/// * `read`: A mutable reference to a `Readouts` writer used to set numerical readout slots
/// by index (0 to 7) to be displayed on the page as defined in `lesson.json`.
///
/// # The Purity Contract
///
/// This function must be completely pure and stateless: no random number generation, no clock
/// access, and no internal state. It is run on every frame update from the current parameters.
fn draw(p: &[f64], out: &mut Prims, read: &mut Readouts) {
let (theta, v, g) = (p[0], p[1], p[2]);
let (range, apex, time) = numbers(theta, v, g);
let rp = (2.0 * theta).sin(); // range in natural units
out.view(0);
out.segment(-0.05, 0.0, 1.15, 0.0, 0); // ground
out.arrow(0.0, 0.0, 0.16 * theta.cos(), 0.16 * theta.sin(), 3); // launch
out.curve(0.0, rp, 48, 1, |xp| (xp, shape(theta, xp))); // the flight
out.point(rp / 2.0, shape(theta, rp / 2.0), 2); // apex
out.segment(rp, -0.02, rp, 0.02, 3); // range tick
read.set(0, theta / TAU);
read.set(1, range);
read.set(2, apex);
read.set(3, time);
read.set(4, (2.0 * theta).sin() * 100.0); // % of the best possible range
}
lessons_common::lesson!(draw);
// ---- the claims ---------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn thetas() -> impl Iterator<Item = f64> {
(1..24).map(|i| i as f64 * TAU / 100.0) // 0.01τ .. 0.23τ
}
/// P1: the drawn curve lands exactly where the range formula says.
#[test]
fn p1_curve_lands_on_the_range() {
for theta in thetas() {
let rp = (2.0 * theta).sin();
assert!(shape(theta, rp).abs() < 1e-12, "theta={theta}");
}
}
/// P2: range is maximized at θ = τ/8, and nowhere else on the grid.
#[test]
fn p2_range_maxes_at_tau_over_eight() {
let best = numbers(TAU / 8.0, 2.0, 9.81).0;
for theta in thetas() {
let r = numbers(theta, 2.0, 9.81).0;
assert!(r <= best + 1e-12);
if (theta - TAU / 8.0).abs() > 1e-9 {
assert!(r < best, "theta={theta} must be strictly worse");
}
}
}
/// P3: complementary angles share a range — R(θ) = R(τ/4 − θ).
#[test]
fn p3_complementary_angles_share_a_range() {
for theta in thetas().filter(|t| *t < TAU / 8.0) {
let a = numbers(theta, 3.0, 1.62).0;
let b = numbers(TAU / 4.0 - theta, 3.0, 1.62).0;
assert!((a - b).abs() < 1e-12 * a.max(1e-12), "theta={theta}");
}
}
/// P4: the drawn apex sits at the apex formula's height.
#[test]
fn p4_apex_matches() {
for theta in thetas() {
let (_, apex, _) = numbers(theta, 2.5, 9.81);
let natural = shape(theta, (2.0 * theta).sin() / 2.0);
// convert natural apex back to meters: × v²/g
assert!((natural * 2.5 * 2.5 / 9.81 - apex).abs() < 1e-12);
}
}
/// P5: scale invariance — the drawn shape is bit-identical across
/// (v, g), which is the whole point of natural units.
#[test]
fn p5_shape_ignores_v_and_g() {
for theta in thetas() {
for xp in [0.1, 0.3, 0.55, 0.8] {
// shape() takes no v or g at all: the invariance is
// structural. Assert the numbers still differ, so the
// claim is not vacuous.
let a = numbers(theta, 2.0, 9.81);
let b = numbers(theta, 4.0, 1.62);
assert!(a.0 != b.0 && shape(theta, xp) == shape(theta, xp));
}
}
}
}
The manifest. Things to notice: params order defines draw's argument order; sweep names which parameter the Play button animates; claims[].test ties each on-page claim to the test that enforces it; views[].world must match its viewBox aspect or the runtime refuses to run.
{
"slug": "projectile",
"title": "Projectile Range",
"topic": "mechanics",
"eyebrow": "Goosethropic Physics · Mechanics · the authoring template",
"lede": "The smallest lesson in the lab, on purpose: a launch at speed v and angle θ, everything closed form. It exists twice — once as physics, and once as the worked example the <a href='../../classroom/authoring.html'>authoring guide</a> walks through line by line. The trajectory is drawn in natural units v²/g, which is why changing v or g never changes the shape — only the numbers.",
"views": [
{
"world": {
"x0": -0.075,
"x1": 1.125,
"y0": -0.06,
"y1": 0.54
},
"viewBox": {
"w": 1000,
"h": 500
},
"wide": true,
"title": "The flight, in natural units",
"law": "y = x·tan θ − g x²/(2 v² cos² θ)"
}
],
"params": {
"theta": {
"label": "Launch angle θ",
"min": 0.01,
"max": 0.24,
"step": 0.001,
"value": 0.1,
"unit": "τ",
"digits": 3,
"scale": 6.283185307179586
},
"v": {
"label": "Launch speed v",
"min": 1,
"max": 4,
"step": 0.05,
"value": 2.0,
"unit": "m/s",
"digits": 2
},
"g": {
"label": "Gravity g",
"min": 1.62,
"max": 24.79,
"step": 0.01,
"value": 9.81,
"unit": "m/s²",
"digits": 2
}
},
"sweep": {
"param": "theta",
"rate": 0.02,
"label": "Sweep the angle"
},
"readouts": [
{
"slot": 0,
"label": "θ",
"fmt": "turns3"
},
{
"slot": 1,
"label": "Range",
"fmt": "fix3",
"hero": true
},
{
"slot": 2,
"label": "Apex height",
"fmt": "fix3"
},
{
"slot": 3,
"label": "Flight time",
"fmt": "fix3"
},
{
"slot": 4,
"label": "% of best range",
"fmt": "fix3",
"hero": true
}
],
"styles": [
{
"var": "--ash-dim",
"width": 1.5
},
{
"var": "--steel",
"width": 2.5
},
{
"var": "--gold",
"width": 3
},
{
"var": "--red-hot",
"width": 3
}
],
"legend": [
{
"style": 1,
"label": "the flight"
},
{
"style": 2,
"label": "apex"
},
{
"style": 3,
"label": "launch direction / range tick"
}
],
"claims": [
{
"id": "P1",
"text": "The drawn curve lands exactly where the range formula says.",
"test": "p1_curve_lands_on_the_range"
},
{
"id": "P2",
"text": "Range is maximized at θ = τ/8 and nowhere else.",
"test": "p2_range_maxes_at_tau_over_eight"
},
{
"id": "P3",
"text": "Complementary angles share a range: R(θ) = R(τ/4 − θ).",
"test": "p3_complementary_angles_share_a_range"
},
{
"id": "P4",
"text": "The drawn apex sits at v²sin²θ/2g.",
"test": "p4_apex_matches"
},
{
"id": "P5",
"text": "The drawn shape is structurally independent of v and g.",
"test": "p5_shape_ignores_v_and_g"
}
],
"tryThis": [
"Sweep the angle and watch % of best range: it touches 100 exactly once. Where, and why is it τ/8 and not τ/4?",
"Find two angles with the same range readout — then check their sum. P3 says it is always τ/4.",
"Slide v and g around while the sweep runs: the numbers churn, the shape never moves. What combination of v and g IS the shape? (It isn't any — that is P5.)",
"Moon vs Jupiter at fixed θ: how do range and flight time each scale with g?"
],
"notes": "notes.html",
"footer": "the authoring template — its full source is walked through in the classroom guide · angles in turns of τ",
"card": "The smallest lesson in the lab, on purpose — and the worked example the authoring guide dissects line by line. Range peaks at τ/8; complementary angles tie.",
"cardClaim": "R = v²·sin(2θ)/g, maximized at θ = τ/8; the drawn shape is independent of v and g by construction."
}
The stub, for completeness — you will copy it and edit two lines:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Projectile Range — Goosethropic Physics</title> <meta name="description" content="The smallest lesson in the lab: launch angle, range, and the τ/8 maximum — also the authoring template." /> <meta property="og:title" content="Projectile Range — Goosethropic Physics" /> <meta property="og:description" content="Range peaks at τ/8; the drawn shape is independent of v and g by construction." /> <meta property="og:type" content="article" /> <meta name="twitter:card" content="summary" /> <link rel="icon" href="../../assets/goose-glyph.svg" type="image/svg+xml" /> <link rel="stylesheet" href="../../css/tokens.css" /> <link rel="stylesheet" href="../../css/fonts.css" /> <link rel="stylesheet" href="../../css/lesson.css" /> </head> <body> <div class="ls" id="lesson"></div> <script data-lesson="." src="../../js/runtime.js"></script> </body> </html>
sh tools/build-wasm.sh # compiles every lesson crate to wasm, stages it python3 tools/gen-index.py # the hub regenerates; it cannot forget you cd public && python3 -m http.server 8000 # open localhost:8000/lessons/<slug>/
Then cargo test -p lesson-<slug> until every claim is green — CI runs the same on every push, in public.
You rarely need a whole new lesson. The lightest exercise is a tryThis entry phrased as predict, then check — it costs one line of JSON. The next weight up is a new claim: state a quantitative sentence, write its test, watch it fail, fix your statement or your understanding until it passes. The classroom's homework pattern inverts that: break an existing claim (change e² to e, flip a sign) and predict exactly which test fails and what it prints. A student who can predict the failure has understood the physics; the diff plus the test output is the deliverable.
Everything in this list cost us a real bug once. Draw in natural units where the physics offers them (the template's shape needs no v or g — and that became claim P5). Never let a label go undefined — a slider the student cannot define is a slider they cannot learn from (Restitution e (1 elastic · 0 beanbag)). Make degenerate cases first-class: the at-focal-point "no image", the ε = 0 pendulum that never flips. If your lesson needs an approximation, put its error on screen and give the student a slider that breaks it — the wave lesson's sin-vs-tan dissector is the pattern. And verify by driving the page, not by reading your own code: a screenshot cannot catch a dead animation loop, and we know because ours didn't.