Repository: physicshub/physicshub.github.io
Stack: Next.js 16 · React 19 · TypeScript/JavaScript · p5.js · Planck.js · Tailwind CSS v4
Analysis Date: 2026-03-03
Severity Legend: 🔴 Critical · 🟠 High · 🟡 Medium · 🟢 Low
The PhysicsHub codebase is a well-structured interactive physics simulation platform. The core architecture (centralized PhysicsBody, ForceCalculator, ForceRenderer, DragController) is thoughtfully designed. However, the audit identified 23 confirmed bugs and quality issues across 4 severity tiers.
| Severity | Count | Key Area |
|---|---|---|
| 🔴 Critical | 3 | Physics correctness, broken physics engine |
| 🟠 High | 5 | Integration bugs, null crashes, broken reset |
| 🟡 Medium | 9 | Code quality, dead code, unit errors |
| 🟢 Low | 6 | Performance, UX, naming |
physicshub.github.io-main/
├── app/(core)/
│ ├── constants/ Config.js, Time.js, Utils.js
│ ├── physics/ PhysicsBody.js, ForceCalculator.js, ForceRenderer.js,
│ │ InclinedPlaneBody.js, DragController.js, Spring.ts
│ ├── data/configs/ Per-simulation INITIAL_INPUTS, INPUT_FIELDS, SimInfoMapper
│ └── hooks/ useSimulationState, useSimInfo, useMobile, useTheme
└── simulations/ 9 simulation components (BallGravity, BouncingBall, etc.)
Core coordinate convention (critical context for all bugs):
All physics runs in Y-UP (standard physics: y increases upward). Rendering converts to screen Y-DOWN only at draw time via physicsToScreen(). Most bugs arise where this conversion is missed, doubled, or reversed.
File: app/(core)/physics/PhysicsBody.js after line ~225
Category: Build Correctness
The file contains the full source of Spring.ts (TypeScript interface declarations, class definition, methods) appended directly after export default PhysicsBody. This creates a .js file with TypeScript syntax, which will crash in any environment that does not transpile .js files as TypeScript. Two separate modules should not share one file.
Fix:
// PhysicsBody.js — everything after this line must be removed:
export default PhysicsBody;
// ← file ends here. Spring.ts content below must be deleted.Ensure Spring.ts is the sole location for the Spring class.
File: app/(core)/physics/ForceRenderer.js line 140, simulations/SpringConnection.jsx
Category: Physics Correctness
ForceCalculator.gravity() returns { y: -mass*g } (Y-up convention, downward = negative). When this is passed directly to drawVector in SpringConnection.jsx, the vector renders with negative Y in screen space — pointing upward on screen instead of downward.
// SpringConnection.jsx — BUGGY direct call:
renderer.drawVector(
p,
screenPos.x,
screenPos.y,
gravityForce.x,
gravityForce.y, // gravityForce.y = -mass*g → draws UP on screen
"#ef4444",
"Weight"
);
// FIX — use the dedicated helper which handles direction correctly:
renderer.drawWeight(
p,
screenPos.x,
screenPos.y,
bodyRef.current.params.mass,
inputsRef.current.gravity
);File: simulations/VectorsOperations.jsx ~line 116
Category: Functional — Physics Never Runs
const scale = 0; //getTimeScale(); ← DEBUG LEFTOVER, never removed
if (!isPaused()) {
accumulator += dt * Math.max(0, scale); // accumulator always stays 0
}
// worldRef.current.step() is never called → Planck.js body never movesThe getTimeScale() call was commented out. The physics body is created and displayed but completely frozen regardless of user interaction.
Fix:
import { getTimeScale } from "../app/(core)/constants/Time.js";
// ...
const scale = getTimeScale(); // restore this lineFile: app/(core)/physics/InclinedPlaneBody.js line 27
Category: Physics Correctness
The method signature stepAlongPlane(dt, netForceParallel) ignores the angleRad argument passed by the caller. The 2D state.velocity is set as (velAlongPlane, 0) instead of being projected onto the plane's actual direction. This makes state.velocity incorrect for energy calculations.
Fix:
stepAlongPlane(dt, netForceParallel, angleRad = 0) {
if (dt <= 0) return;
this.planeState.accAlongPlane = netForceParallel / this.params.mass;
this.planeState.velAlongPlane += this.planeState.accAlongPlane * dt;
this.planeState.posAlongPlane += this.planeState.velAlongPlane * dt;
// Project onto 2D axes correctly
const v = this.planeState.velAlongPlane;
const a = this.planeState.accAlongPlane;
this.state.velocity.set(v * Math.cos(angleRad), v * Math.sin(angleRad));
this.state.acceleration.set(a * Math.cos(angleRad), a * Math.sin(angleRad));
this.isMoving = Math.abs(v) > 0.001;
}File: simulations/SimplePendulum.jsx ~line 95 (SimInfoMapper)
Category: Physics Correctness — Wrong Display Values
// BUGGY — measures angle from (0,0), not from the anchor pivot
const angle =
(Math.atan2(bodyState.position.x, -bodyState.position.y) * 180) / Math.PI;The anchor is at (w/2 meters, h*0.2 meters) in physics space — not at origin. This formula produces completely wrong angle values. The PendulumBody.getAngle() method is already correct and should be used instead.
Fix: Pass body.getAngle() directly into SimInfoMapper via the state object:
// In sketch p.draw():
updateSimInfo(p, {
...
angle: bodyRef.current.getAngle(), // already correct: atan2(dx,dy) from anchor
...
}, ...);
// In SimInfoMapper:
Angle: `${(state.angle * 180 / Math.PI).toFixed(1)}°`,File: simulations/BallGravity.jsx ~lines 200–215
Category: Functional Bug
const w = 800; // default width ← ignores actual canvas size
const h = 600; // default height
bodyRef.current.reset({
position: bodyRef.current.p.createVector(toMeters(w / 2), toMeters(h / 4)),
});On any canvas other than 800×600 (every real device), the ball resets to the wrong position. Since setResetVersion triggers a full re-mount that calls setupSimulation() with correct p.width/p.height, this explicit reset is both wrong and redundant.
Fix: Remove the explicit position reset from onReset. Let setupSimulation() handle initial placement.
File: simulations/BouncingBall.jsx, BallGravity.jsx
Category: Physics Correctness — Wrong Energy Values
// In BouncingBall.jsx and BallGravity.jsx — BUGGY:
potentialEnergy: bodyRef.current.getPotentialEnergy(gravity, toMeters(p.height)),In Y-up coordinates, the ground is at y = 0. toMeters(p.height) is the canvas top in meters (a large positive number). So PE = mass * g * (position.y - large_number) is almost always large and negative — incorrect.
Fix:
// Ground reference in Y-up space is y = 0 (or ball radius for center-of-mass)
potentialEnergy: bodyRef.current.getPotentialEnergy(gravity, 0),File: app/(core)/physics/Spring.ts lines 44–55
Category: Physics Correctness
const force = p5.Vector.sub(this.anchor, body.state.position); // points TOWARD anchor
const displacement = currentLength - this.restLength;
const springForceMag = -this.k * displacement; // negative when extended
force.normalize().mult(springForceMag); // flips to point AWAY from anchor
body.applyForce(force);When the spring is extended (displacement > 0), springForceMag is negative, which flips the direction vector to point away from the anchor. An extended spring should pull the body toward the anchor.
Fix:
public connect(body: PhysicsBody): void {
const toAnchor = p5.Vector.sub(this.anchor, body.state.position);
const currentLength = toAnchor.mag();
if (currentLength < 0.0001) return;
const displacement = currentLength - this.restLength;
// Positive displacement = extended = pull toward anchor (positive direction)
toAnchor.normalize().mult(this.k * displacement); // no negative sign
body.applyForce(toAnchor);
}File: simulations/InclinedPlane.jsx ~line 65
length: planeLength / 100, // Manual /100 instead of toMeters(planeLength)If SCALE is ever changed from 100, this silently breaks. Use toMeters() consistently.
File: simulations/ParabolicMotion.jsx, configs/ParabolicMotion.js
The input field is labeled "c_d - Linear drag (1/s):" but the simulation calls ForceCalculator.airResistance(speed, coeff, false) where false means quadratic drag. Units of quadratic drag are kg/m, not 1/s. This mislabels the physical quantity and will confuse students.
Fix: Change the call to true for linear drag (matching the label), or fix the label to say "Quadratic drag (kg/m)".
File: simulations/SimplePendulum.jsx
INITIAL_INPUTS, INPUT_FIELDS, and SimInfoMapper are inline in the simulation file. Every other simulation uses app/(core)/data/configs/[Name].js. This inconsistency breaks the project's own architecture.
Fix: Create app/(core)/data/configs/SimplePendulum.js and move all three exports there.
File: app/(core)/physics/ForceRenderer.js line 254
this.drawWeight(p, x, y, forces.weight.magnitude / 9.81, 9.81, { label: "mg" });forces.weight.magnitude is already in Newtons. Dividing by 9.81 to get kg, then multiplying back by 9.81 works numerically only when gravity = 9.81. With Moon gravity (1.62 m/s²) selected, this displays the wrong force magnitude.
Fix: Pass the actual gravity value from the caller context, not the hardcoded 9.81.
File: app/(core)/constants/Time.js
The module-level Map accumulates entries for every simulation instance ever visited. No simulation component calls cleanupInstance() on unmount. Over multiple SPA navigations, this grows indefinitely.
Fix: In P5Wrapper.jsx, call cleanupInstance(p) in the p5 instance cleanup/remove handler.
File: app/(core)/physics/ForceCalculator.js ~line 198
const vel = body.state.velocity?.x || body.state.vel || 0; // uses x component
friction = ForceCalculator.kineticFriction(normal, frictionKinetic, vel);Should use body.planeState.velAlongPlane (the scalar velocity along the plane). state.velocity.x is only the horizontal projection, underestimating friction when the angle is non-zero.
Fix:
const vel = body.planeState?.velAlongPlane ?? body.state.velocity?.x ?? 0;File: app/(core)/constants/Utils.js
Both functions are fully implemented but never called anywhere in the codebase. collideBoundary() is actually more physically accurate than the current constrainToBounds() (energy-conserving bounces). This dead code adds confusion about which system is canonical.
Fix: Either remove both unused functions, or migrate PhysicsBody.constrainToBounds() to use collideBoundary() for better bounce physics.
File: simulations/BallAcceleration.jsx
The vector renderer's drawLabel appends "(N)" to all magnitudes. The acceleration vector is correctly computed as F/m = a, but the label reads "Acceleration (X.XN)" — showing acceleration in Newtons, which is physically wrong and confusing for students.
Fix: Pass a unitLabel: "m/s²" option to suppress the default "N" suffix for acceleration vectors, or create a dedicated drawAcceleration() method.
File: simulations/VectorsOperations.jsx case "+"
The + operation draws from absolute screen coordinates (origin = top-left corner) without p.translate(center), while operations "x" and "normalize" correctly call p.translate(p.width/2, p.height/2). Vector A (center.copy()) represents the half-diagonal of the canvas rather than a meaningful fixed vector from origin. This is visually inconsistent with the other operations.
"engines": { "node": "24.8.0" } // should be ">=24.0.0"{ value: 0.379 * earthG, label: "Mars (3.71 m/s²)" }
// 0.379 * 9.81 = 3.719 ≈ 3.72, label should read "Mars (3.72 m/s²)"Both cases draw the exact same three lines. The cross product should show a shaded parallelogram (area = |A×B|), the dot product should show a projection onto A.
if (velAlongNormal < 0) continue; // "don't resolve if separating"
// relVel · normal > 0 means separating → should be: if (velAlongNormal > 0) continuesetIsBlowing(true/false) updates state and toggles class wind-overlay blowing, but no CSS for this class was found in the uploaded styles. The wind animation has no visual effect.
The DragController.js file content contains appended source from other files (similar to BUG-001). Each physics module file should contain only its own source.
| # | Bug | File | Effort |
|---|---|---|---|
| 1 | BUG-001, BUG-023 | Separate concatenated files | 30 min |
| 2 | BUG-003 | Restore getTimeScale() | 5 min |
| 3 | BUG-008 | Fix Spring.connect() sign | 15 min |
| 4 | BUG-002 | Fix weight vector direction | 1 hr |
| 5 | BUG-007 | Fix PE reference height | 30 min |
| 6 | BUG-004 | Fix stepAlongPlane 2D projection | 1 hr |
| 7 | BUG-006 | Remove hardcoded reset dimensions | 15 min |
| 8 | BUG-005 | Fix pendulum angle display | 1 hr |
| 9 | BUG-012 | Fix gravity hardcoding in renderer | 30 min |
| 10 | BUG-014 | Fix kinetic friction velocity source | 15 min |
| 11 | BUG-011 | Move SimplePendulum config | 30 min |
| 12 | BUG-021 | Fix collision sign | 5 min |
| 13 | BUG-013 | Add cleanupInstance on unmount | 30 min |
| 14 | BUG-010 | Fix drag label unit | 5 min |
| 15 | BUG-015 | Remove/migrate dead utils | 15 min |
| 16 | All others | Remaining low-priority | ~2 hr |
Estimated total fix time: ~10 hours
-
Create
PHYSICS_CONVENTIONS.md— document the Y-up coordinate system, meters/pixels boundary, and which functions perform conversion. This prevents the whole class of coordinate bugs. -
Standardize SimInfoMapper contract — all mappers should receive the same typed
{ pos, vel, mass }state object. Currently some pass rawposition/velocityand some passpos/vel. -
Migrate constrainToBounds → collideBoundary — the
collideBoundary()function already implements energy-conserving bounces. Using it inPhysicsBodywould improve bounce realism in all simulations. -
Add
drawAcceleration()to ForceRenderer — distinct fromdrawVector, so labels automatically show correct units (m/s² vs N). -
Extract PendulumBody to its own file — currently defined inline in
SimplePendulum.jsx. It's a proper physics class and should live inphysics/PendulumBody.js.
Report generated by static analysis of repository snapshot physicshub.github.io-main.