Here’s the complete, self-contained ball physics implementation — everything needed for someone to actually drop this in and have it work correctly.
1. In build.js — ground detection, pivot centering, and ball setup
Add this method (unfiltered ground check, needed because the player-oriented getGroundHeight has a step-up filter that breaks fast-moving objects):
javascript
// Unfiltered ground check for physics objects (balls) — always returns the
// true topmost surface, even if the object has tunneled far below it in one frame.
getGroundHeightRaw(pos) {
if (!this.buildGroup.visible || this.collidableMeshes.length === 0) return 0;
this.downRay.set(new THREE.Vector3(pos.x, 200, pos.z), new THREE.Vector3(0, -1, 0));
const hits = this.downRay.intersectObjects(this.collidableMeshes);
if (hits.length === 0) return 0;
return hits[0].point.y;
}
Update normalizeModel to support a centered pivot (rolling objects need their origin at the true center, not the base — otherwise rotation swings the mesh around its edge instead of spinning in place):
javascript
normalizeModel(gltfScene, targetMaxSize = 2.5, centerPivot = false) {
const box = new THREE.Box3().setFromObject(gltfScene);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
if (maxDim > targetMaxSize) {
const autoScale = targetMaxSize / maxDim;
gltfScene.scale.setScalar(autoScale);
}
const scaledBox = new THREE.Box3().setFromObject(gltfScene);
if (centerPivot) {
const center = scaledBox.getCenter(new THREE.Vector3());
const sx = gltfScene.scale.x || 1, sy = gltfScene.scale.y || 1, sz = gltfScene.scale.z || 1;
gltfScene.children.forEach(child => {
child.position.x -= center.x / sx;
child.position.y -= center.y / sy;
child.position.z -= center.z / sz;
});
} else {
const minY = scaledBox.min.y;
if (Math.abs(minY) > 0.001) {
const currentScaleY = gltfScene.scale.y || 1;
gltfScene.children.forEach(child => {
child.position.y -= (minY / currentScaleY);
});
}
}
}
Update createInScene to detect balls, use the centered pivot, record the real radius, and exclude ball meshes from the collidable/ground list:
javascript
createInScene(data) {
this.loader.load('assets/' + data.file, (gltf) => {
const obj = gltf.scene;
const nameLower = data.file.toLowerCase();
const isBall = nameLower.includes('soccer') || nameLower.includes('ball');
this.normalizeModel(obj, 2.5, isBall);
obj.position.set(data.pos.x, data.pos.y, data.pos.z);
obj.rotation.y = data.rot;
if (data.scale !== undefined && data.scale !== null) obj.scale.setScalar(data.scale);
obj.userData.id = data.id || ('obj_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7));
obj.userData.file = data.file;
const isCar = nameLower.includes('car') || nameLower.includes('auto') || nameLower.includes('truck') || nameLower.includes('vehicle');
const isPlane = nameLower.includes('plane') || nameLower.includes('jet') || nameLower.includes('aircraft') || nameLower.includes('flight');
if (isCar || isPlane) {
obj.userData.isVehicle = true;
if (isPlane) obj.userData.isPlane = true;
}
if (isBall) {
obj.userData.isBall = true;
obj.userData.velocity = new THREE.Vector3(0, 0, 0);
const box = new THREE.Box3().setFromObject(obj);
const size = box.getSize(new THREE.Vector3());
obj.userData.radius = Math.max(size.x, size.y, size.z) / 2;
}
this.buildGroup.add(obj);
obj.traverse(c => {
if (c.isMesh) {
if (!isBall) this.collidableMeshes.push(c); // balls must never act as ground/walls
if (obj.userData.isVehicle) c.userData.isVehiclePart = true;
}
});
});
}
2. In your main game file — the full physics loop
javascript
const BALL = {
GRAVITY: -22,
RESTITUTION: 0.55, // energy kept per bounce — always < 1, so it settles eventually
GROUND_FRICTION: 0.985, // rolling resistance — closer to 1 = rolls farther
AIR_DRAG: 0.998,
MAX_SPEED: 35,
RADIUS: 0.5, // fallback if a ball's real radius wasn't computed
KICK_DIST: 5.0,
KICK_FORCE: 30,
KICK_LIFT_RATIO: 0.22, // vertical launch = horizontal force * this ratio, keeps a visible arc
KICK_COOLDOWN: 0.4,
SYNC_RATE: 0.05
};
let lastBallSync = 0;
function updateBalls(delta) {
if (!localPlayer) return;
const now = performance.now();
const doSync = (now - lastBallSync) > BALL.SYNC_RATE * 1000;
for (const obj of build.buildGroup.children) {
if (!obj.userData || !obj.userData.isBall) continue;
stepBall(obj, delta, doSync);
}
if (doSync) lastBallSync = now;
}
function stepBall(ball, delta, doSync) {
const d = ball.userData;
if (!d.vel) d.vel = new THREE.Vector3();
if (d.kickCd === undefined) d.kickCd = 0;
if (d.wasNear === undefined) d.wasNear = false;
const vel = d.vel;
const radius = ball.userData.radius || BALL.RADIUS;
d.kickCd = Math.max(0, d.kickCd - delta);
// --- KICK: fires once when entering range, not every frame ---
const toBall = new THREE.Vector3().subVectors(ball.position, localPlayer.position);
const flatDist = Math.hypot(toBall.x, toBall.z);
const isNear = flatDist < BALL.KICK_DIST;
if (isNear && !d.wasNear && d.kickCd <= 0) {
const running = !!(keys['shift'] && (keys['w'] || keys['s']));
const dir = flatDist > 0.001
? new THREE.Vector3(toBall.x / flatDist, 0, toBall.z / flatDist)
: new THREE.Vector3(0, 0, 1).applyQuaternion(localPlayer.quaternion);
const force = running ? BALL.KICK_FORCE * 1.8 : BALL.KICK_FORCE;
vel.x = dir.x * force;
vel.z = dir.z * force;
vel.y = force * BALL.KICK_LIFT_RATIO; // scales with kick strength — keeps the bounce visible
d.kickCd = BALL.KICK_COOLDOWN;
}
d.wasNear = isNear;
// --- GRAVITY: unconditional, every frame ---
vel.y += BALL.GRAVITY * delta;
// --- SPEED CAP: nothing can ever runaway ---
const speed = vel.length();
if (speed > BALL.MAX_SPEED) vel.multiplyScalar(BALL.MAX_SPEED / speed);
// --- MOVE ---
ball.position.addScaledVector(vel, delta);
// --- GROUND: recomputed AFTER moving, using the ball's real radius ---
const groundY = build.getGroundHeightRaw(ball.position) + radius;
if (ball.position.y <= groundY) {
ball.position.y = groundY;
if (vel.y < -0.6) {
vel.y = -vel.y * BALL.RESTITUTION; // bounce, always loses energy
} else {
vel.y = 0; // too slow to bounce — settle
}
vel.x *= BALL.GROUND_FRICTION;
vel.z *= BALL.GROUND_FRICTION;
} else {
vel.x *= BALL.AIR_DRAG;
vel.z *= BALL.AIR_DRAG;
}
// --- FULL STOP once slow enough (kills leftover jitter) ---
const horizSpeed = Math.hypot(vel.x, vel.z);
const onGround = ball.position.y <= groundY + 0.001;
if (onGround && horizSpeed < 0.05 && Math.abs(vel.y) < 0.05) {
vel.set(0, 0, 0);
}
// --- ROLL: pivot-centered, so it spins instead of swinging ---
if (horizSpeed > 0.02) {
const axis = new THREE.Vector3(0, 1, 0).cross(vel).normalize();
if (!isNaN(axis.x)) ball.rotateOnWorldAxis(axis, (horizSpeed * delta) / radius);
}
// --- NETWORK SYNC (skip once at rest, saves bandwidth) ---
if (doSync && (horizSpeed > 0.05 || Math.abs(vel.y) > 0.05)) {
build.syncObjectUpdate(ball);
}
}
3. Wire it into your render loop
javascript
function animate() {
requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.05);
if (localPlayer) {
updateBalls(delta); // call once per frame, before other player logic
// ...rest of your animate loop
}
renderer.render(scene, camera);
}
That’s the whole thing — self-contained, no missing pieces. Requires build.getGroundHeightRaw, build.syncObjectUpdate, and any placed object with userData.isBall = true and userData.radius set (both handled by the build.js changes above).
Six Bugs I Had to Fix Before a Ball Would Just… Roll
I recently added a kickable soccer ball to a small multiplayer 3D world. Gravity, a bounce, some friction — how hard could it be?
Famous last words. Here’s what actually happened, in order, and what each bug taught me about writing physics code that has to survive contact with a real player mashing keys in a browser.
Bug #1: The ball that never stopped flying
The first version worked… for about three seconds. Then the ball would drift upward, slowly at first, then faster, until it disappeared into the sky and never came back down.
The cause: my “kick” logic checked “is the player close to the ball?” and fired every single frame that condition was true — not just once. So if a player stood near the ball, it got re-kicked 60 times a second, each kick nudging in a slightly upward direction. Gravity got one frame to pull it back down before the next kick overwrote its velocity again. Net result: a slow, steady climb into orbit.
The fix was to edge-detect the moment a player enters range, rather than checking “are they in range right now”:
javascript
const isNear = flatDist < KICK_DIST;
if (isNear && !wasNear && kickCooldown <= 0) {
vel.x = dir.x * force;
vel.z = dir.z * force;
kickCooldown = 0.4; // seconds before this ball can be kicked again
}
wasNear = isNear;
One flag (wasNear) and a short cooldown turned a runaway feedback loop into a single, clean impulse.
Bug #2: The ball that thought the sky was the ground
Next problem: the ball would bounce, then suddenly hover in mid-air, refusing to fall.
This one took longer to spot because it wasn’t really a physics bug — it was a raycasting bug. Every object placed in the world (buildings, props, the ball itself) got added to the same list of “things you can stand on.” When the ball needed to find the ground beneath it, it cast a ray straight down through that list — and hit itself. The ball was, quite literally, treating its own surface as solid ground.
javascript
obj.traverse(c => {
if (c.isMesh) {
if (!isBall) collidableMeshes.push(c); // balls must never act as their own floor
}
});
One if statement. The lesson generalizes though: any time an object needs to query the world about itself, make sure it’s excluded from its own query.
Bug #3: The ball buried thirty percent in the ground
With the self-collision bug fixed, bounces looked mostly right — except every so often the ball would settle noticeably below the visible floor, like it was standing in a shallow hole.
The culprit was a different ground-detection function that already existed in the codebase — one built for a walking character, with a “step-up” filter: it deliberately ignores any ground hit that’s more than half a unit above the character’s current position, so a player doesn’t teleport up through a low ceiling.
That filter is exactly right for a person walking up a curb. It’s exactly wrong for a fast-bouncing ball. A hard bounce can move the ball several units in a single frame, tunneling it well below the real floor — at which point the actual ground is now above the filter’s cutoff, and the function throws that correct answer away and falls back to a default height instead.
The fix was to add a second, unfiltered ground check specifically for physics objects:
javascript
function getGroundHeightRaw(pos) {
downRay.set(new THREE.Vector3(pos.x, 200, pos.z), new THREE.Vector3(0, -1, 0));
const hits = downRay.intersectObjects(collidableMeshes);
return hits.length ? hits[0].point.y : 0;
}
No step-up gating — just “what’s the nearest surface directly below me, right now.” Characters still use the filtered version; physics objects use the raw one. Same world, two different questions being asked of it.
Bug #4: The ball that dipped mid-roll for no reason
Even with correct ground detection, the ball would occasionally dip a few inches into the floor while rolling — not falling through, just… sagging, rhythmically, as it moved.
This turned out to be a pivot problem, not a physics problem. The model-normalization code (shared across every asset type in the world) shifted each object’s origin to sit at the bottom of its bounding box — sensible for a building or a lamppost, which should sit flush on the ground. But for a sphere, that means the rotation pivot is on its surface, not its center. Spinning it doesn’t make it roll — it makes it swing, arcing up and down around that off-center point as it turns.
javascript
if (centerPivot) {
const center = box.getCenter(new THREE.Vector3());
children.forEach(child => {
child.position.x -= center.x / scale.x;
child.position.y -= center.y / scale.y;
child.position.z -= center.z / scale.z;
});
}
Flagging rolling objects to use a centered pivot instead of a base-aligned one fixed it immediately. It’s a good reminder that “normalize this model” isn’t a one-size-fits-all operation — different shapes need different assumptions about where their origin should live.
Bug #5: A duplicate variable that quietly broke everything
At one point, a refactor left two const groundY = ... declarations inside the same function — one computed too early (before the ball had even moved that frame), one computed correctly afterward. In JavaScript, that’s not a silent bug: it’s a SyntaxError, and the whole module fails to load. No error dialog, no partial functionality — just nothing happening at all.
The lesson here isn’t really about physics: it’s about how easy it is, mid-iteration, to leave a stale calculation sitting above a newer one that was supposed to replace it. Worth a second read-through any time you patch a function in place rather than rewriting it clean.
Bug #6: Kicks that slid instead of bounced
Last one was more of a tuning problem than a bug. After making kicks noticeably more powerful (further reach, faster travel), the satisfying bounce disappeared — the ball just skimmed flat and fast across the ground.
The vertical “hop” applied on each kick had been a fixed number, left over from when kicks were much weaker. Once horizontal force scaled up, that fixed vertical component became proportionally tiny — barely enough to leave the ground, so gravity brought it back down before it built up any real bounce.
javascript
const force = running ? KICK_FORCE * 1.8 : KICK_FORCE;
vel.x = dir.x * force;
vel.z = dir.z * force;
vel.y = force * KICK_LIFT_RATIO; // vertical launch now scales with kick strength
Tying the vertical launch to a ratio of the horizontal force, instead of a flat constant, meant the bounce-to-distance feel stayed consistent no matter how hard the kick was tuned later.
What it all adds up to
None of these bugs were exotic. No collision-response math, no rigid-body solvers, nothing you’d find in a physics textbook. Every single one came from a small mismatch between what a piece of code was written to assume and what actually happened once real, fast-changing values ran through it repeatedly:
- State that fires every frame instead of once (the flying ball)
- An object querying a system that includes itself (the floating ball)
- A filter built for one use case silently breaking another (the buried ball)
- A shared function with assumptions that don’t hold for every shape (the swinging roll)
- A leftover calculation nobody deleted (the crash)
- A constant that didn’t scale with the value next to it (the flat kick)
If there’s a single takeaway, it’s this: physics code doesn’t usually break because the formula is wrong. It breaks because the code around the formula — the state tracking, the filtering, the assumptions borrowed from a different object type — wasn’t built to survive the specific, repetitive, fast-moving conditions that physics actually runs under. The gravity equation was one line. Getting a ball to just roll normally took six separate, unglamorous fixes.