Goosethropic Physics · The Classroom · authoring

Writing a lesson

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.

0 · Setup

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

1 · The anatomy

FileContainsWho reads it
lessons/<slug>/crate/src/lib.rsthe 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.jsoneverything else as data: title, lede, views and world boxes, sliders, readouts, styles, legend, claims list, try-this, dissection steps, hub cardthe shared runtime, which builds the whole page from it
.../notes.htmlthe prosefetched into the page
.../index.htmla 21-line stub — copy it, change the title tagsnobody, twice

2 · The physics: one function

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));
            }
        }
    }
}

3 · The page: all data

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>

4 · Build, see, ship

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.

5 · Exercises are claims students earn

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 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.

6 · Taste, learned the hard way

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.