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
- NumericalEarth/Breeze.jltheir own version, first seen Oct 17, 2026
- CliMA/Oceananigans.jltheir own version, first seen Oct 17, 2026
Other versions2 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 2
- CliMA/Oceananigans.jl★ 1.4ksame name
- 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.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- public/llms-full.txtllms.txt
- public/llms.txtllms.txt
- new-articleSkill
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
- Very specific: line 8
Read this before writing any file.
- Distinctive: line 22
Propose ideas as questions, never as statements of intent: Before writing any file, you must offer your ideas to the human explicitly as …
- Very specific: line 50
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.
- Very specific: line 220
Every catalogue entry must pick a level (the primary school level it is aimed at) and a difficulty (how demanding it is _within_ that …
- Very specific: line 259
If the formula you need has no card yet, add one to the right domain file.
- Very specific: line 301
buildre-runs on every resize, so it must be idempotent: Never mutate module-level state from it. - Very specific: line 306
Import everything from the barrel: ../app/(core)/engine/index.js.
- Distinctive: line 354
When a behaviour is genuinely specific to one simulation, write it inline rather than bending a general element or adding a narrow option …
- Very specific: line 392
Getters, not manual syncing: Every numeric option and body param accepts () => inputs.x.
- Distinctive: line 401
Derive, don't hardcode: If a quantity follows from others, let it.
- Very specific: line 426
Write the derivation in a comment: when a formula is not obvious (the Lagrange configuration in ThreeBody, the tension in Distance).
- Very specific: line 455
Never edit version in package.json (semantic-release owns it).
≈ “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.
Files
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).
simulations/<Name>.jsx—"use client", default-exportscreateSimulation({...}). Nothing else. Imports use relative paths (../app/(core)/...), not the@/alias.app/(core)/data/configs/<Name>.js—INITIAL_INPUTS,INPUT_FIELDS,SimInfoMapper. UI and readout only; never put forces here.app/(core)/data/chapters.js— the catalogue entry. Missing entry means a 404 in the static export (dynamicParams = false).- Optionally
app/(core)/data/articles/<slug>.js, registered inarticles/index.js, referenced byrelatedBlogSlug. 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 byutils/relatedArticles.jsfrom the simulation'stagsand name, so pick accurate topical tags). Writing that article? Load thenew-articleskill first (.claude/skills/new-article/SKILL.md) — it is the house style: answer-first structure, thetakeaways/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.
| key | required | purpose |
config | yes | { INITIAL_INPUTS, INPUT_FIELDS, SimInfoMapper } |
build(ctx) | yes | populate the world, return named handles |
update(ctx) | no | per-frame logic that is not a force |
draw(ctx) | no | extra rendering; prefer a render element |
info(ctx) | no | { state, context } fed to SimInfoMapper |
overlay({ inputs, state }) | no | DOM over the canvas |
world | no | { integrator, solverIterations, substeps, fade } |
simInfoRefs | no | factory for refs that accumulate across frames |
Hook context: { p, world, inputs, handles, refs, infoRefs, bounds, dt, steps,
setOverlay, setInput, rebuild }.
inputsis a live proxy. Safe to capture in a closure; always current.boundsis{ width, height }in metres, kept in sync with the canvas.refsis free-form per-simulation storage, reset on reset.setInput(name, value)writes an input back from the sketch: the slider/value box follows, andinputsreturns the new value from the next frame on. Use it only when a canvas gesture is a parameter change (dragging the light inRayTracingmoves its position sliders) — never to smuggle simulation state through the inputs; that belongs inrefsor on a body.rebuild()discards the world and re-runsbuild— use it when an input changes what exists (a body count), not merely a parameter.buildre-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 / Strut | the 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
- 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.jsorconstants/Utils.js. A raw pixel value in a physics expression is a bug. - 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. - Forces only call
body.applyForce(f, label). The label records the force inbody.appliedForces, which is exactly whatForceVectorsdraws. Never compute a force a second time for rendering — that is how the two silently drift apart. - Never touch time. No
p.deltaTime, no accumulators, noframeCountphysics. The runtime callscomputeStepsand hands you a fixeddt. - 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.
semiImplicitEulerconserves 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.
PiCollisionscounts collisions to produce the digits of π, so it solves for contact times instead of stepping.CircularMotionprescribes ω = v/r rather than integrating a centripetal force that would drift. - Raise
substepsfor stiff systems, don't shrink the global timestep.ThreeBodyuses 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 inDistance). A wrong constant that nobody can check is the failure mode here.
Precedents worth copying
SimplePendulum.jsx | constraint as the pendulum; drag projected onto the arc |
ParabolicMotion.jsx | analytic guide vs numerical flight; relaunch via update |
InclinedPlane.jsx | emergent normal force; element ordering matters |
BallGravity.jsx | DOM overlay driven from the sketch via setOverlay |
CollisionSimulation.jsx | LockAxis reduces the 2-D solver to exact 1-D |
ThreeBody.jsx | substeps, and initial conditions that are real solutions |
DoublePendulum.jsx | when to leave the pipeline: exact Lagrangian + rk4 |
PiCollisions.jsx | event-driven element, kinematic: true bodies |
RayTracing.jsx | no 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.
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
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.

