agentleFS
physicshubphysicshub/physicshub.github.io/.claude/skills/new-simulation/SKILL.md

new-simulation

Skill68 starsChanged 50 days agoMITView on GitHub

How to build a new PhysicsHub simulation on the engine in app/(core)/engine — the four files to touch, the declarative createSimulation spec, the full catalogue of forces/constraints/renderers, and the physics rules that keep simulations correct. Use whenever adding, porting or substantially rewriting anything under simulations/, or when asked how a simulation is put together.

2 other projects use a version of this.

Adopted by

Most recently taken up by

  • Other versions2 projects use a version of this.

    Word for word · 0

    None found.

    Edited · 0

    None found.

    Wrote their own version · 2

    1. CliMA/Oceananigans.jl★ 1.4ksame name
    2. NumericalEarth/Breeze.jl★ 90same name

    How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.

  • In this repository5 other files it gives its agents.

The file

We split this skill into the rules it gives an agent

12 rules. About 0 are common, 3 are distinctive, and 9 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 9

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Read this before writing any file.

    line 8
  2. Distinctive:

    Propose ideas as questions, never as statements of intent: Before writing any file, you must offer your ideas to the human explicitly as …

    line 22
  3. Very specific:

    If you find yourself writing a class that extends Body, or a p.draw loop that integrates motion by hand, stop: the answer is an element.

    line 50
  4. Very specific:

    Every catalogue entry must pick a level (the primary school level it is aimed at) and a difficulty (how demanding it is _within_ that …

    line 220
  5. Very specific:

    If the formula you need has no card yet, add one to the right domain file.

    line 259
  6. Very specific:

    build re-runs on every resize, so it must be idempotent: Never mutate module-level state from it.

    line 301
  7. Very specific:

    Import everything from the barrel: ../app/(core)/engine/index.js.

    line 306
  8. Distinctive:

    When a behaviour is genuinely specific to one simulation, write it inline rather than bending a general element or adding a narrow option …

    line 354
  9. Very specific:

    Getters, not manual syncing: Every numeric option and body param accepts () => inputs.x.

    line 392
  10. Distinctive:

    Derive, don't hardcode: If a quantity follows from others, let it.

    line 401
  11. Very specific:

    Write the derivation in a comment: when a formula is not obvious (the Lagrange configuration in ThreeBody, the tension in Distance).

    line 426
  12. Very specific:

    Never edit version in package.json (semantic-release owns it).

    line 455

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Building a PhysicsHub simulation

Read this before writing any file. Then read one existing simulation end to end — simulations/SimplePendulum.jsx is the shortest complete example.

The brief: topic in, questions out

The human gives you only a broad topic ("moto armonico", "circuiti", "forse sul piano inclinato", "gravity wells") and, sometimes, a school level. Everything else — what the simulation actually does, its features, its details — is your job to propose, never to assume.

  • Be creative, stay on theme. Invent features and aspects that make the simulation worth opening: unconventional setups, toggleable modes, surprise interactions, readouts that reveal something non-obvious. But every idea must serve the physics of the given topic — no unrelated gimmicks.
  • Propose ideas as questions, never as statements of intent. Before writing any file, you must offer your ideas to the human explicitly as questions and wait for the answers. One batch of 2–4 well-chosen questions is better than a ten-item checklist: pick the decisions that actually change the build, and phrase each one as a concrete "what if …?" you can execute once answered.
  • Anchor every proposal to a school level. The catalogue classifies every simulation by difficulty: EASY ≈ scuola media, MEDIUM ≈ biennio di scuola superiore (prima/seconda), ADVANCED ≈ triennio e università. If the human gave you a level, stay inside it and say so in your questions; if they didn't, ask for one up front. Your proposed features, the formulas in the readout, and any theory article must all be reachable at that level — do not secretly inflate the difficulty by adding harder numbers or formalism the level doesn't cover.

The mental model

A World owns Bodies and Elements.

  • A Body is state only: position, velocity, accumulated force, plus display params. It never decides how it moves.
  • An Element is every behaviour: forces, constraints, colliders, renderers, pointer handlers. A plain object with optional lifecycle hooks.

Nothing subclasses Body. Behaviours combine by adding another element, which is the property the whole engine exists to protect. A pendulum hanging off a flying projectile is one body with Gravity, a second body, and a Distance between them — no new class, no new file.

If you find yourself writing a class that extends Body, or a p.draw loop that integrates motion by hand, stop: the answer is an element.

The four files

Adding a simulation named <Name> touches four places that must agree on the name (plus its overview, below).

  1. simulations/<Name>.jsx — "use client", default-exports createSimulation({...}). Nothing else. Imports use relative paths (../app/(core)/...), not the @/ alias.
  2. app/(core)/data/configs/<Name>.js — INITIAL_INPUTS, INPUT_FIELDS, SimInfoMapper. UI and readout only; never put forces here.
  3. app/(core)/data/chapters.js — the catalogue entry. Missing entry means a 404 in the static export (dynamicParams = false).
  4. Optionally app/(core)/data/articles/<slug>.js, registered in articles/index.js, referenced by relatedBlogSlug. The article is not embedded on the simulation page: the page links to it and lists related articles (RelatedArticles, shown as the same cards as /blog, ranked by utils/relatedArticles.js from the simulation's tags and name, so pick accurate topical tags). Writing that article? Load the new-article skill first (.claude/skills/new-article/SKILL.md) — it is the house style: answer-first structure, the takeaways/faq/key-fact blocks, length targets, and the no-code rule for non-developer articles.

1. The config module

// app/(core)/data/configs/Example.js
import { gravityTypes, EARTH_G_SI } from "../../constants/Config.js";

// Every key here is a shareable URL parameter (`?mass=2`) and part of any
// community preset, and the *type* of each default decides how a URL or preset
// value is parsed (utils/simulationUrl.js — values of another type are dropped):
// keep numbers as numbers and booleans as booleans, never "1" or "true".
// Only flat values (number / boolean / string) round-trip through a link, a
// colour default must be a hex string ("#rrggbb"), a string that isn't a
// colour must be a select with `options` (or a short plain token ≤ 32 chars),
// and a preset stores at most 64 inputs.
export const INITIAL_INPUTS = {
  mass: 1, // SI units throughout: kg, m, s, N
  gravity: EARTH_G_SI,
  trailEnabled: true,
  ballColor: "#3b82f6",
};

export const INPUT_FIELDS = [
  {
    name: "mass",
    label: "Mass", // plain name only — no unit, no symbol, no trailing ":"
    symbol: "m", // physics symbol → accent pill in the control head
    unit: "kg", // unit of measure → standardized chip in the control head
    type: "number",
    min: 0.1,
    max: 20,
    step: 0.1,
  },
  {
    name: "gravity",
    label: "Gravity",
    symbol: "g",
    unit: "m/s²",
    type: "select",
    options: gravityTypes,
  },
  { name: "trailEnabled", label: "Enable trail", type: "checkbox" },
  { name: "ballColor", label: "Ball color", type: "color" },
];

// Receives whatever the simulation's `info` hook returns, plus long-lived refs.
export const SimInfoMapper = (state, context, refs) => ({
  "v (speed)": `${state.vel.mag().toFixed(2)} m/s`,
  "Eₖ (kinetic)": `${state.kineticEnergy.toFixed(2)} J`,
});

Field type is one of number (min/max/step/placeholder), checkbox, color, select (options: [{value, label}]), slider, text.

DynamicInputs renders every field through the shared .sim-field shell (styles/components/forms.css). Three cross-cutting props, all optional:

  • label — the human name only. No unit, no symbol prefix, no trailing : or . ("Launch speed", not "v₀ - Launch speed (m/s):").
  • symbol — the physics symbol ("v₀", "μₛ", "θ"), shown as a filled accent pill. Always add it for a named physical quantity — these are teaching materials.
  • unit — the unit of measure ("m/s", "kg", "N·s/m", "°"), shown as a standardized monospace chip. Omit it for dimensionless quantities (coefficients of friction/restitution, damping factors) and for simulation-unit quantities that have no SI unit.

A number field with both min and max renders as a slider + editable value box + min/max scale. The range bounds the slider only: a learner may type a value beyond it (the thumb pins to the nearest end), so the simulation must stay stable for out-of-range inputs — pick slider bounds a learner would actually want, and never rely on them for safety. Without a full range it renders as a value box with −/+ steppers, and a single min or max is a hard limit.

2. The simulation

"use client";

import {
  createSimulation,
  Gravity,
  Drag,
  Bounds,
  Dragging,
  ForceVectors,
} from "../app/(core)/engine/index.js";
import {
  INITIAL_INPUTS,
  INPUT_FIELDS,
  SimInfoMapper,
} from "../app/(core)/data/configs/Example.js";

export default createSimulation({
  config: { INITIAL_INPUTS, INPUT_FIELDS, SimInfoMapper },

  build({ world, inputs, bounds }) {
    const ball = world.addBody({
      label: "ball",
      mass: () => inputs.mass,
      size: () => inputs.size,
      color: () => inputs.ballColor,
      at: [bounds.width / 2, bounds.height * 0.8],
      trail: () => inputs.trailEnabled,
    });

    world.add(
      Gravity({ g: () => inputs.gravity }),
      Drag({ c: () => inputs.dragCoeff }),
      Bounds(),
      Dragging(),
      ForceVectors({ bodies: ball })
    );

    return { ball }; // becomes `handles` in every later hook
  },

  info: ({ handles, inputs }) => ({
    state: {
      vel: handles.ball.state.velocity,
      kineticEnergy: handles.ball.getKineticEnergy(),
    },
    context: { gravity: inputs.gravity },
  }),
});

3. The catalogue entry

{
  id: <next free integer>,
  name: "Human Readable Name",
  desc: "One or two sentences. This is the SEO description — say what the user
         can do and which concepts it teaches.",
  link: "/simulations/Example",          // must match the filename exactly
  level: "lowerSecondary",               // school level: one of LEVELS ids (see below)
  alsoFor: ["upperSecondary"],           // optional: other levels the sim still works for
  difficulty: "core",                    // core | extended | advanced (challenge within `level`)
  tags: [TAGS.DYNAMICS, TAGS.GRAVITY],   // topical tags only
  thumbnail: "/thumbnails/example.webp",
  relatedBlogSlug: "some-article-slug",  // optional
}

Every catalogue entry must pick a level (the primary school level it is aimed at) and a difficulty (how demanding it is within that level). School levels come from data/tags.js → LEVELS / LEVEL_ORDER: elementary, lowerSecondary, upperSecondary, undergraduate, tool (non-curricular demos). alsoFor lists extra levels the simulation is still useful for — many topics (Hooke's law vs. SHM, momentum, trigonometry) are taught across several bands. Topic tags are purely topical; never place the old EASY / MEDIUM / ADVANCED here (they no longer exist). Pick the difficulty of the physics content, not the target band: e.g. SHM is upperSecondary + extended, phase space or chaos is undergraduate + advanced.

LEVELS is the international default. Readers also see the level in their own country's system (UK "A-Level · Year 13", India "Class 11", Singapore "JC1"…), resolved from data/curriculumTopics.js. Add a "sim:<Name>": "<concept>" row to CONTENT_TOPICS there, reusing a concept from TOPIC_PLACEMENTS when one fits (two springs sims share Hooke's law, say) or adding one with a placement for all six curricula (us uk in au it sg). Place it where each country first teaches the depth the sim actually reaches, not where the concept is first mentioned — check the real syllabus, and use the note argument for quirks. If you skip this the sim still works (it falls back to its international level) but its per-country level is only approximate.

4. The overview and its formulas

app/(core)/data/simulationOverviews.js, keyed by <Name>, holds the server-rendered text under the stage: intro, controls, concepts and formulas. formulas lists Formulary ids, not LaTeX. Each formula is a card in app/(core)/data/formulas/<domain>.js. The simulation page shows a compact version of the card, and /formulas automatically lists the simulation under "Used in".

formulas: [
  "hookes-law",                                   // a card, shown as it is
  { ref: "elastic-potential-energy",              // the card, in this sim's own form
    label: "Total energy", latex: "E = \\tfrac{1}{2} k A^{2}" },
],

If the formula you need has no card yet, add one to the right domain file. It needs id (kebab-case, permanent: it is the URL anchor), name, latex, summary, variables (key, latex, name, SI unit, a typical value), validity, tags, level, difficulty and related. Add solve (one plain function per variable the calculator can solve for, taking SI values and radians; return NaN when there is no real solution) wherever the formula makes sense to compute. example, pitfalls, note and history are optional, so only write them when they teach something. An unknown id or a broken card fails the build (validateFormulas in utils/formulaUsage.js, run by generate:feeds).

The createSimulation spec

createSimulation already owns: input state and localStorage, URL params, the reset and load controls, canvas setup and resize, the fixed-timestep loop, pointer dispatch, the background, and the sim-info panel. Never re-implement any of it in a simulation.

keyrequiredpurpose
configyes{ INITIAL_INPUTS, INPUT_FIELDS, SimInfoMapper }
build(ctx)yespopulate the world, return named handles
update(ctx)noper-frame logic that is not a force
draw(ctx)noextra rendering; prefer a render element
info(ctx)no{ state, context } fed to SimInfoMapper
overlay({ inputs, state })noDOM over the canvas
worldno{ integrator, solverIterations, substeps, fade }
simInfoRefsnofactory for refs that accumulate across frames

Hook context: { p, world, inputs, handles, refs, infoRefs, bounds, dt, steps, setOverlay, setInput, rebuild }.

  • inputs is a live proxy. Safe to capture in a closure; always current.
  • bounds is { width, height } in metres, kept in sync with the canvas.
  • refs is free-form per-simulation storage, reset on reset.
  • setInput(name, value) writes an input back from the sketch: the slider/value box follows, and inputs returns the new value from the next frame on. Use it only when a canvas gesture is a parameter change (dragging the light in RayTracing moves its position sliders) — never to smuggle simulation state through the inputs; that belongs in refs or on a body.
  • rebuild() discards the world and re-runs build — use it when an input changes what exists (a body count), not merely a parameter.
  • build re-runs on every resize, so it must be idempotent. Never mutate module-level state from it.

Catalogue

Import everything from the barrel: ../app/(core)/engine/index.js.

Forces — applyForces phase, accumulate newtons.

Gravity({ g })uniform F = mg downward
Constant({ x, y, perMass })fixed force, or acceleration with perMass
Wind({ strength })horizontal Constant, conventional label
Drag({ c, linear })fluid drag, F = −cv² (or −cv)
Damping({ c, perMass })dashpot N·s/m; perMass makes it a rate s⁻¹
Buoyancy({ density, surfaceY, g })Archimedes, with partial submersion
MutualGravity({ G, softening })every pair, F = Gm₁m₂/r²
PointAttraction({ center, strength, falloff })inverseSquare | constant | linear
CustomForce(fn, { label })escape hatch: (body, ctx) => {x, y} | null

Constraints — positional, solved after integration.

Distance(a, b, len, { limit, stiffness })rod; limit: "max" rope, "min" strut
Rope / Strutthe two presets above
Spring(a, b, rest, { k, damping, oneWay })Hooke; oneWay: "push" | "pull"
Bounds({ sides, restitution, friction, ... })walls, energy-correct bounce
Ground({ y, friction })floor only, with a drawn line
SurfaceFriction({ mu, surfaceY, g })kinetic friction on a flat surface
Incline(bodies, { origin, angle, length, muStatic, muKinetic })ramp; N emerges
CircularPath(body, centre, r, { omega })prescribed uniform circular motion
Pin(body, point)hold at a point
LockAxis(bodies, { x | y })confine to one axis
SpeedLimit(bodies, max)kinematic speed cap

Collision: Collisions({ restitution }), plus collide1D and contactImpulse for closed-form work.

Rendering: ForceVectors({ bodies, only, exclude, net, scale, enabled }) draws recorded forces; Vectors({ show: ["velocity", "acceleration"] }) draws kinematic ones. Primitives: toScreen, drawSegment, drawAnchor, drawCoil, drawPath, drawGround, drawBody. Palette: FORCE_COLORS, SCENE_COLORS.

Interaction: Dragging({ project, throwOnRelease, onGrab, onRelease }). project constrains the grab (onto an arc, a ramp, an axis).

Maths: formulas.* — pure textbook expressions, no Body/World/p5. Use these for readouts and predicted paths instead of re-deriving anything.

Writing a one-off element

When a behaviour is genuinely specific to one simulation, write it inline rather than bending a general element or adding a narrow option to the engine:

world.add({
  zIndex: 11, // bodies are 0; negative draws behind them
  beforeStep(ctx) {
    /* … */
  },
  applyForces(ctx) {
    /* … */
  },
  solve(ctx) {
    /* … */
  }, // runs solverIterations times
  resolveCollisions(ctx) {
    /* … */
  },
  afterStep(ctx) {
    /* … */
  },
  render(ctx) {
    /* … */
  },
  onPointerDown(ctx) {
    /* … */
  }, // also Move / Up / onDoubleClick
});

Promote it into app/(core)/engine/ only once a second simulation needs it.

Rules that must not be broken

  1. Units and axes. Metres, kilograms, seconds, newtons. Y-up, origin bottom-left. Conversion to screen space happens only at render time, via engine/render/Shapes.js or constants/Utils.js. A raw pixel value in a physics expression is a bug.
  2. Getters, not manual syncing. Every numeric option and body param accepts () => inputs.x. Never re-assign parameters inside a loop, and never rebuild the world because a slider moved.
  3. Forces only call body.applyForce(f, label). The label records the force in body.appliedForces, which is exactly what ForceVectors draws. Never compute a force a second time for rendering — that is how the two silently drift apart.
  4. Never touch time. No p.deltaTime, no accumulators, no frameCount physics. The runtime calls computeSteps and hands you a fixed dt.
  5. Derive, don't hardcode. If a quantity follows from others, let it. The ramp does not assume N = mg·cos θ; it sums the applied forces and supplies what the surface must push back. That is what makes adding wind to a ramp work without touching the ramp.

Getting the physics right

This is a teaching site: a plausible-looking animation that is subtly wrong is worse than no simulation. Before you finish:

  • Check the conserved quantities. With no dissipation, does total energy stay flat? Does momentum survive a collision? If restitution is 1, does the ball return to its release height forever? These catch integration bugs that look fine for ten seconds.
  • Check the limits. Zero gravity, zero mass, zero radius, θ = 0 and θ = 90°, μ = 0. Anything dividing by an input needs a guard.
  • Prefer the symplectic default. semiImplicitEuler conserves energy in oscillators; explicit Euler pumps it in. Only change the integrator with a reason written in a comment.
  • Reach for exactness when the answer is exact. PiCollisions counts collisions to produce the digits of π, so it solves for contact times instead of stepping. CircularMotion prescribes ω = v/r rather than integrating a centripetal force that would drift.
  • Raise substeps for stiff systems, don't shrink the global timestep. ThreeBody uses 40 because close approaches are violent.
  • Write the derivation in a comment when a formula is not obvious (the Lagrange configuration in ThreeBody, the tension in Distance). A wrong constant that nobody can check is the failure mode here.

Precedents worth copying

SimplePendulum.jsxconstraint as the pendulum; drag projected onto the arc
ParabolicMotion.jsxanalytic guide vs numerical flight; relaunch via update
InclinedPlane.jsxemergent normal force; element ordering matters
BallGravity.jsxDOM overlay driven from the sketch via setOverlay
CollisionSimulation.jsxLockAxis reduces the 2-D solver to exact 1-D
ThreeBody.jsxsubsteps, and initial conditions that are real solutions
DoublePendulum.jsxwhen to leave the pipeline: exact Lagrangian + rk4
PiCollisions.jsxevent-driven element, kinematic: true bodies
RayTracing.jsxno forces: cached progressive render, setInput on drag

Before you call it done

npx prettier --write <changed files>
npx eslint <changed files>

CI runs prettier --check . and eslint . and nothing else — there is no test suite, so these plus your own reasoning about conservation laws are the only safety net.

Never edit version in package.json (semantic-release owns it). PR titles are conventional commits; the squashed title becomes the changelog entry. Significant UI changes want a screenshot under public/screenshots/<NEW_VERSION>/.

Keeping this file true

This guide is part of the engine's public surface. Whenever you add or change anything in app/(core)/engine/ — a new force, constraint, renderer, hook, or option; a renamed export; a different createSimulation contract — update this file in the same change, and CLAUDE.md too if the architecture moved. A contributor's Claude will follow this document literally, so a stale catalogue entry here becomes a broken simulation there.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.