This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
PhysicsHub — a free, open-source educational site of interactive physics simulations plus written theory. Next.js 15 App Router, React 19, p5.js for rendering, Tailwind v4, statically exported (output: "export") and deployed to GitHub Pages. Physics is our own engine (below), not a library; planck is still in package.json but no longer imported anywhere.
npm run dev # next dev + nodemon regenerating the sitemap on routes.js changes
npm run build # generate:sitemap, then next build (static export to out/)
npm run preview # build + serve out/
npm run lint # eslint (lint:fix to autofix)
npm run format # prettier --write . (format:check for CI parity)
npm run generate:sitemap
npm run contributors # regenerate the all-contributors table (needs GH_TOKEN)
npm run deploy # gh-pages -d outThere is no test suite. CI (.github/workflows/prchecks.yml) only runs prettier --check . and eslint . — run both before proposing a PR. A husky pre-commit hook runs scripts/check-package-lock.js and lint-staged. Node >= 24.
Versioning is fully automatic via semantic-release; never edit version in package.json. PR titles must be conventional commits (feat:, fix:, ci: …) — the squashed title becomes the changelog entry.
app/(core)/— everything shared:engine/,components/,constants/,data/,hooks/,utils/,locales/,styles/. Not a route segment.app/(pages)/— the actual pages (about,blog,contribute,simulations).app/api/publish/route.ts— a POST handler that opens a GitHub PR with a proposed blog JSON via Octokit. Note this cannot run on the statically exported GitHub Pages deploy; it only works innext dev/ a Node host.- Import alias:
@/*→ repo root (e.g.@/app/(core)/data/chapters). Simulations undersimulations/use relative paths instead.
Writing or porting a simulation? Load the
new-simulationskill first (.claude/skills/new-simulation/SKILL.md). It is the step-by-step guide: the four files, the fullcreateSimulationcontract, the catalogue of every force, constraint and renderer, and the physics checks to run before calling it done. This section is the summary; the skill is the reference, and it must be updated whenever the engine changes.
Adding a simulation touches four places that must agree on the same name:
simulations/<Name>.jsx— the"use client"component. Loaded dynamically withssr: falsebyapp/(pages)/simulations/[id]/_components/SimulationWrapper.tsxviaimport("@/simulations/${id}").app/(core)/data/configs/<Name>.js— exportsINITIAL_INPUTS,INPUT_FIELDS(declarative form schema rendered bycomponents/inputs/DynamicInputs) andSimInfoMapper(state, context, refs). Configs describe the UI and the readout only: forces belong in the world, never here.app/(core)/data/chapters.js— the catalog entry:link: "/simulations/<Name>", tags fromdata/tags.js, thumbnail,relatedBlogSlug.[id]/page.tsxderivesgenerateStaticParamsand metadata from this file, withdynamicParams = false— a simulation missing fromchapters.jswill 404 in the export.- Optionally
app/(core)/data/articles/<slug>.js, registered inarticles/index.js.
Every simulation is built on it; there is no second way to do physics in this repo.
A World owns Bodies (state only — position, velocity, accumulated force) and Elements (all behaviour). Nothing subclasses a body: forces, constraints, colliders, renderers and pointer handlers are all elements, so behaviours compose by addition. A pendulum hanging off a projectile is one body with Gravity, one more body, and a Distance constraint between them.
engine/
World.js step order: beforeStep → applyForces → integrate → solve ×N → resolveCollisions → afterStep
Body.js state + params; `inverseMass` is 0 for fixed bodies, so anchors need no special case
integrators.js semiImplicitEuler (default), verlet, rk4 (for ODE systems like the double pendulum)
formulas.js pure textbook expressions — no Body, no World, no p5
forces/ Gravity Constant Wind Drag Buoyancy MutualGravity PointAttraction Damping Custom
constraints/ Distance Rope Strut Spring Bounds Ground SurfaceFriction Incline
Pin CircularPath LockAxis SpeedLimit
collision/ Collisions (impulse solver), collide1D, contactImpulse
render/ Shapes, ForceRenderer + ForceVectors + Vectors, Backdrop, colors
interaction/ Dragging
runtime/ createSimulation.jsx
Import everything from the barrel: import { createSimulation, Gravity, Distance } from "../app/(core)/engine/index.js".
Rules that keep it composable — breaking any of them reintroduces the duplication this replaced:
- An element is a plain object with optional hooks, never a class hierarchy. Adding
renderputs it in the draw order (zIndex; bodies are 0, so negative draws behind them). AddingonPointerDown/Move/Up/onDoubleClickgets pointer events from the runtime. Write one inline in a simulation when the behaviour is genuinely one-off. - Every numeric option accepts a getter, resolved each frame via
utils/params.js:Gravity({ g: () => inputs.gravity }). Body params (mass,size,color,restitution,trail, …) accept getters too. Never re-sync parameters by hand in a draw loop, and never rebuild the world because a slider moved. - Forces only ever call
body.applyForce(f, label); the integrator converts to motion. The label records the force inbody.appliedForces, which is whatForceVectorsdraws — so a force can never be drawn differently from how it was integrated. Never compute a force a second time for rendering. - Constraints are positional:
solveprojects positions (repeatedsolverIterationstimes), thenafterStepcancels the constraint-violating velocity.Distancealso publishes its tension as a pseudo-force so it appears in free-body diagrams.
createSimulation(spec) owns all the boilerplate — input state and localStorage, reset/load controls, canvas setup and resize, the fixed-timestep loop, pointer dispatch, background, sim-info panel. A simulation supplies only config and build({ world, p, inputs, bounds, refs, infoRefs, setOverlay }), plus optional update, draw, info, overlay, world and simInfoRefs. build re-runs on every resize, so it must be idempotent. inputs is a live proxy: safe to capture in a closure, always current.
SimplePendulum.jsx (constraint as pendulum), ParabolicMotion.jsx (analytic guide vs numerical flight) and InclinedPlane.jsx (emergent normal force) are the clearest references — read one before writing a new simulation.
Three simulations deliberately bypass the force/integrate pipeline, and the reason is always physical rather than convenience:
DoublePendulum.jsx— two rigid rods leave two degrees of freedom, so it integrates the exact Lagrangian equations for (θ₁, θ₂) withrk4. A constraint solver on free point masses would be far less accurate, and the system is chaotic enough to amplify that.PiCollisions.jsx— the collision count is the answer (the digits of π), so it must be exact. An inline element advances the blocks event by event, solving for each contact time, so no collision can be missed.ThreeBody.jsx— uses the normal pipeline but withworld: { substeps: 40 }, because close approaches are too stiff for a single 1/120 s step.
Bodies those elements position themselves are marked kinematic: true, which tells World to skip integration while still recording forces.
- Coordinates: all physics is in meters, Y-up, origin bottom-left. Conversion to screen space (Y-down, pixels) happens only at render time, through
constants/Utils.js(toPixels/toMeters/physicsToScreen,SCALEfromconstants/Config.js) orengine/render/Shapes.js.Utils.jsholds a module-globalCANVAS_HEIGHTset bysetCanvasHeight(). - Time:
constants/Time.jsis a module-level singleton owning timeScale, pause, manual stepping, and per-p5-instance fixed-timestep accumulators (FIXED_DT = 1/120).computeSteps(p)→{ dt, steps }is the only scheduler;createSimulationalready calls it, so a simulation never touches time directly.
scripts/sitemap-generator.js reads routes.js + data/articles/index.js + data/chapters.js, writes public/sitemap.xml and rewrites routes.js in place — so routes.js diffs (lastmod churn) are expected build output, not hand edits.
content/blogs/*.json holds community-submitted blog proposals created by the publish API; curated articles live as JS modules in app/(core)/data/articles/.
Custom, not a library: hooks/useTranslation.ts reads the Google Translate googtrans cookie to pick a language, loads app/(core)/locales/<lang>.json, and locales/meta.json marks which languages are completed. Incomplete languages fall back to Google Translate widget behaviour (notranslate is applied when a locale is complete). Extract new keys with npm run i18n:extract.
- Mixed JS/TS by design; new shared code trends toward
.tsx/.ts, simulations stay.jsx. The engine is.jswith JSDoc types, so it stays readable to contributors writing plain-JS simulations. - When you change the project's structure or add a subsystem, update this file in the same change — it is the only architecture documentation contributors (and their Claude) get.
- When you change anything in
app/(core)/engine/or the way simulations are written, update.claude/skills/new-simulation/SKILL.mdin the same change too. It is checked in, so every contributor's Claude loads it, and a stale catalogue there produces broken simulations elsewhere. - Some comments are in Italian — fine to keep, write new ones in English.
- Significant UI changes: add screenshots under
public/screenshots/<NEW_VERSION>/, homepage shot namedmain.png.