Building a Ping Pong Physics Engine With No Physics Library Allowed
A university project with one rule that made everything harder and more interesting: a 3D renderer is fine, a physics engine is not. RK4 integration, Magnus force, energy-conserving collisions, a bot that runs its own forward simulation to predict where the ball is going, and a phone turned into a paddle controller over a WebSocket.
The assignment brief had one line in it that shaped everything else: you're allowed a 3D rendering library, and nothing else. No physics engine, no Cannon, no Rapier, no Ammo, none of the usual shortcuts. Three.js gets you a scene, a camera, and a way to draw things. Everything the ball does after that, how it flies, how it spins, how it bounces off a table, a net, or a paddle, had to be written from scratch.
That constraint is basically the whole story of this project. A physics library would have handed us rigid body dynamics, collision resolution, and a numerical integrator on day one. Without one, "make the ball bounce realistically" turns into a genuine physics and numerical methods problem, and getting it wrong doesn't look like a bug, it looks like a ball that gains energy on every bounce until it's ricocheting off the ceiling.

#At a Glance
- Project type: university assignment, physics simulation with a rendering-only library constraint
- Stack: Three.js, TypeScript, Vite, a small Express + ws server for the mobile controller
- What's hand-built: the numerical integrator, aerodynamic forces, every collision response (table, paddle, net, table legs), rest detection, a predictive bot AI, and real ITTF-style scoring rules
- Constraint: a 3D renderer is allowed, a physics or collision engine is not
- Live at: pinging-and-ponging.vercel.app
#Why RK4 Instead of Just Moving the Ball Each Frame
The laziest way to simulate a flying ball is Euler integration: take the current velocity, multiply by the frame's delta time, add it to position, repeat. It's one line of code and it's wrong in a way that gets worse the more interesting the forces get.
The ball in this sim isn't just falling under gravity. It's also getting pushed sideways by the Magnus force whenever it has spin, and slowed down by air drag whenever it has speed, and both of those forces depend on the ball's current velocity, which is exactly the thing being updated. Euler integration evaluates the force once at the start of the step and assumes it stays constant for the whole step, which is a bad assumption when the force itself is changing because of the motion it's producing.
RK4, fourth-order Runge-Kutta, fixes this by sampling the derivative four times across a step, once at the start, twice in the middle with slightly different trial states, once at the end, and blending them with weights of 1, 2, 2, 1. It's more math per frame, but it's the difference between a spin curve that actually curves correctly and one that visibly wobbles or drifts wrong at higher spin values.
const d1 = deriv(pos, vel);
const p2 = pos.clone().addScaledVector(d1.dpos, dt / 2);
const v2 = vel.clone().addScaledVector(d1.dvel, dt / 2);
const d2 = deriv(p2, v2);
const p3 = pos.clone().addScaledVector(d2.dpos, dt / 2);
const v3 = vel.clone().addScaledVector(d2.dvel, dt / 2);
const d3 = deriv(p3, v3);
const p4 = pos.clone().addScaledVector(d3.dpos, dt);
const v4 = vel.clone().addScaledVector(d3.dvel, dt);
const d4 = deriv(p4, v4);
// weighted average: (d1 + 2*d2 + 2*d3 + d4) / 6On top of that, the integrator doesn't run at a single fixed step regardless of speed. A ball moving under 4 units per second integrates in one step per frame. Past 4, 8, and 15, it switches to 2, then half of the configured max substeps, then the full max, up to 8 substeps in a single frame. A fast serve gets simulated in smaller, more accurate slices than a ball that's basically rolling to a stop, without paying that cost everywhere all the time.
#Forces: Drag and Magnus, Driven by One Spin Parameter
Two aerodynamic forces act on the ball in flight: drag, which always opposes velocity, and the Magnus force, which is what makes a spinning ball curve. Both of their strengths are driven by a single dimensionless number, the spin parameter:
export function spinParameter(omega, vel, p) {
const omegaMag = omega.length();
const velMag = vel.length();
return (p.ball.radius * omegaMag) / (velMag + EPS);
}That's spin rate times ball radius, divided by forward speed. A ball spinning fast but barely moving has a huge spin parameter. A ball moving fast with barely any spin has a tiny one. Drag and lift coefficients are both derived from that same number, clamped to a configured maximum so an unrealistic amount of spin doesn't produce an unrealistic amount of curve:
function dragCoeff(S, p) {
const Sc = Math.min(S, p.aero.spinClampMax);
return 0.4 + 0.6 * Sc;
}
function liftCoeff(S, p) {
const Sc = Math.min(S, p.aero.spinClampMax);
return Math.max(-0.05, Math.min(-0.05 + 0.65 * Sc, 0.95));
}The Magnus force itself is just a cross product, spin axis crossed with velocity, scaled by that lift coefficient and an air constant derived from the ball's real cross-sectional area and mass:
const omegaHat = omega.clone().normalize();
magnus = omegaHat.cross(vel).multiplyScalar(k * CL * vMag);The ball's own numbers are the real ITTF ones, 2.7 grams, 20mm radius, because plugging in made-up values here is exactly how you end up with a spin curve that looks like it belongs to a different sport.
#Collisions: Everything Has to Conserve Energy, or Explain Why It Doesn't

This is the part a physics library usually hides completely, and the part where getting it wrong is the most obvious. A ball that bounces and comes back faster than it landed doesn't look like a rounding error, it looks broken.
Every collision in this sim, table bounce, paddle hit, net hit, table leg hit, follows the same shape: split the velocity into a component along the collision normal and a component tangential to it, apply restitution to the normal component, apply friction to the tangential one, and reassemble. Table bounces and net hits share a tangential-and-spin solver that couples linear tangential velocity with spin at the contact point, because that coupling is exactly what real backspin and topspin do at contact: a ball can lose linear speed and gain spin, or vice versa, capped at whatever impulse friction can actually deliver before it stops the relative slip:
function resolveTangentialSpinPair(vTan, omegaSpin, contactSign, muNormalImpulseLin, m, r, I) {
const vContact = vTan + contactSign * omegaSpin * r;
const sgn = Math.sign(vContact) || 0;
if (sgn === 0 || muNormalImpulseLin <= 0) return { vTan, omegaSpin, impulseMag: 0 };
const jRequested = muNormalImpulseLin * m;
const slipStiffness = 1 / m + (r * r) / I;
const jMax = Math.abs(vContact) / slipStiffness;
const jApplied = Math.min(jRequested, jMax);
// ...
}That jMax clamp matters more than it looks like it should. Without it, a high-friction, low-mass collision can request more impulse than physically exists to stop the slip, which is exactly the kind of thing that quietly injects energy into a system instead of removing it.
The net gets its own restitution value depending on what kind of net it's modeled as, let, cord, or mesh, each with a different bounciness, because a ball catching the cord behaves differently than one brushing the mesh.
#Off-Center Paddle Hits Actually Deflect the Ball
A flat paddle hit just reflects the ball off the paddle's normal. A real paddle hit off-center adds deflection, the kind of glancing touch that sends the ball sideways instead of straight back. The paddle collision handles this by projecting where on the paddle's face the ball actually made contact, normalizing that offset against the paddle's radius, and adding a deflection impulse in the opposite direction of that offset, scaled by how far off-center the hit was:
if (hitOffset && p.paddle.offCenterDeflection > 0) {
const offsetTangential = hitOffset.clone().sub(n.clone().multiplyScalar(hitOffset.dot(n)));
const relOffset = offsetTangential.divideScalar(Math.max(p.paddle.radius, 1e-6));
const relMag = relOffset.length();
if (relMag > 1e-6) {
const clampedMag = Math.min(relMag, 1);
const deflectionDir = relOffset.multiplyScalar(-1 / relMag);
newVTangential.add(deflectionDir.multiplyScalar(clampedMag * p.paddle.offCenterDeflection));
}
}A hit dead center gets none of this. A hit near the edge of the paddle gets a real sideways push, which is the difference between a paddle that feels like a flat wall and one that feels like an actual piece of equipment you can mishit.
#Proving It: an Energy Conservation Sweep, Not Just a Vibe Check
Rather than trusting the collision math by eye, there's a standalone test that sweeps a wide range of incoming velocities and spins through every collision function and checks one invariant: energy after a bounce should never exceed energy before it.
function check(label, before, after) {
checks++;
if (after > before + 1e-9) {
failures++;
console.error(`${label}: before=${before} after=${after} diff=${after - before}`);
}
}
for (const vx of [-15, -8, -3, -0.1, 0, 0.1, 3, 8, 15]) {
for (const vy of [-0.1, -1, -3, -6, -10, -15]) {
for (const omegaZ of [-1000, -300, -50, 0, 50, 300, 1000]) {
// run applyTableBounce, compare energy before and after
}
}
}It does the same sweep for the net collision across velocity, spin, and net type combinations. It's not a test that checks the ball "looks right." It's a test that would fail loudly the moment any collision function started manufacturing energy out of nowhere, which is exactly the failure mode that's easy to introduce by accident and easy to miss by eye until the ball is bouncing higher than it started.
A ball that visibly gains height on every bounce is an obvious bug. A ball that gains a tiny, consistent amount of energy on every bounce isn't obvious at all, it just makes the whole simulation feel subtly "off" in a way that's hard to diagnose without exactly this kind of sweep test.
#Knowing When the Ball Has Actually Stopped
Deciding when a ball is "at rest" sounds trivial and isn't. A ball that's basically settled but still has a tiny bit of vertical velocity will keep triggering bounce events forever if the rest check is too strict, chattering in place instead of visibly coming to a stop.
The rest detector handles this with two tiers. If the ball is genuinely slow on both axes, it settles immediately. If it's in a looser "chatter" range, small bounces that are decaying in a consistent, predictable ratio from one bounce to the next, it tracks a streak of consecutive bounces that look like decay rather than real motion, and only settles once that streak crosses a threshold:
const ratio = state.lastBounceVy > 1e-6 ? verticalSpeed / state.lastBounceVy : null;
const ratioLooksStuck = ratio !== null && ratio >= ratioMin && ratio <= ratioMax;
if (withinChatterEnvelope && ratioLooksStuck) {
state.rapidBounceStreak += 1;
} else {
state.rapidBounceStreak = 0;
}That ratio band is the key idea. A ball genuinely still bouncing with real energy doesn't decay in a tight, consistent ratio bounce to bounce, it does something messier. A ball that's basically done does decay predictably. Watching for that pattern rather than just watching for a low absolute speed is what keeps the ball from visibly vibrating in place for a second before the game logic notices it's over.
#A Bot That Runs Its Own Physics Simulation to Decide Where to Stand
The bot doesn't track the ball's current position and react. It runs a forward simulation of the ball's future trajectory, using the exact same RK4 stepper the real game uses, and picks the point along that predicted path that looks like the best interception spot:
while (t < maxTime) {
const step = rk4SubStep(pos, vel, omega, simDt, p);
pos = step.pos;
vel = step.vel;
t += simDt;
if (pos.y - p.ball.radius < p.table.height && vel.y < 0) {
pos.y = p.table.height + p.ball.radius;
vel.y = Math.abs(vel.y) * p.table.restitution;
}
const heightScore = Math.abs(pos.y - idealY);
const xReach = Math.abs(pos.x - this.endX(p));
const score = heightScore + xReach * 0.3;
// keep the best-scoring point along the simulated path
}It simulates up to three seconds of future flight, including a simplified version of the table bounce, and scores each point along that path on how close it is to a comfortable hitting height and how far the bot would have to reach. The paddle then eases toward whatever point wins, rather than snapping to it, with the ease speed and aim angle both driven by simple lerps against a per-point target.
The bot also isn't perfect on purpose. A configurable miss chance rolled once per incoming shot decides whether it's even going to try to reach the ball at all, which matters more than it sounds like it should. A bot that returns literally everything doesn't feel like an opponent, it feels like a wall, and a wall isn't fun to play against.
#Real Scoring Rules, Not Just "First to 11"

The scoring logic follows actual ITTF rules rather than a simplified version of them: games to 11, win by 2, service alternates every 2 points normally but every single point once the score reaches 10-10.
matchWinner(): TableSide | null {
const { leftScore, rightScore } = this;
if (leftScore >= 11 && leftScore - rightScore >= 2) return "left";
if (rightScore >= 11 && rightScore - leftScore >= 2) return "right";
return null;
}The service alternation is driven by counting completed "service blocks" rather than tracking a serve counter directly, which made deuce a lot less annoying to implement than it initially looked. Once both sides hit 10, the block size just drops from 2 to 1, and the same block-counting logic keeps working without a separate deuce-specific branch.
#Turning a Phone Into a Paddle
The gyroscope controller is the one piece of this project that isn't physics at all, it's a phone's DeviceOrientation data streamed over a WebSocket to whatever's running the actual 3D scene. A small Express and ws server sits in the middle, holding exactly one "host" connection (the desktop or laptop running the game) and relaying orientation events from a "phone" connection to it.
if (msg.type === "gyro") {
if (host) {
host.send(JSON.stringify({ type: "gyro", beta, gamma, alpha }));
}
}On the receiving end, each incoming orientation update is turned into a small delta rotation applied around a fixed anchor point near the base of the paddle handle, rather than the paddle's own origin, so the paddle rotates the way a hand actually rotates a paddle it's holding, pivoting from the wrist, not spinning around its own geometric center:
const paddleAnchor = new THREE.Vector3(0, -(handleLength * 1.5), 0);
this.rotateAroundAnchor(
this.paddle,
paddleAnchor,
(msg.alpha - old.alpha) * sensitivity,
(msg.beta - old.beta) * sensitivity,
(msg.gamma - old.gamma) * sensitivity,
);WASD keys move the paddle around the table on top of that rotation, so the phone handles orientation and the keyboard handles position, running side by side rather than trying to cram both into gyro data alone.
#What Shipping This Actually Looked Like
None of the individual pieces here are exotic on their own. RK4 is a documented method. Splitting a collision into normal and tangential components is standard rigid body stuff. A forward-simulating bot is a reasonable idea once you have an integrator fast enough to run it many times a second. What made this project genuinely hard was that a physics library normally bundles all of it together, tested, tuned, and consistent with itself, and here every one of those pieces had to agree with every other piece by hand: the same restitution values the collision code uses have to be the same ones the bot's forward simulation assumes, the same substep scaling that keeps a fast serve accurate has to run inside the bot's prediction loop too, or the bot ends up aiming at a slightly wrong future.
The energy sweep test is really the whole philosophy of the project in one file. Not "does this look like table tennis," but "does this obey the one law that isn't allowed to break no matter how everything else is tuned." Everything else, the exact restitution numbers, the aim wobble on the bot, the deflection strength on an off-center hit, is a dial that can be turned to taste. Energy conservation isn't a dial. It's the thing that tells you whether the physics underneath all those dials is actually correct.