← All blogs

Creating a physics engine from scratch (Battlebeyz: Part 3)

Written from the code as it stood in October 2024. Where things changed afterwards, I say when. Last updated 2026-09-17.

Game engines can simulate a spinning top. They don’t simulate a Beyblade: a top whose tip friction drives it around the bowl, that bleeds spin every time it hits something, and that gets knocked around by an amount that depends on how aggressive its layer is. None of the physics libraries from Part 1 survived anyway, so all of this is hand-written in roughly 250 lines of Physics.cpp. Here is what’s in them.

One tick

Every frame, PhysicsWorld::update runs the same sequence for every bey:

Forces on a bey in the stadium, and one physics tick: each force accumulates into the bey's linear and angular deltas, then everything is applied at once

  1. Air resistance, always.
  2. If the tip is more than 5 mm above the stadium surface, gravity and nothing else. Otherwise, friction and slope from the contact.
  3. For every pair of beys whose layers overlap, an impact.
  4. Only now: apply everything that was accumulated, move the bey, and push it back out of the floor if it sank in.

I realized after a while that updating physics sequentially in PhysicsWorld::update could cause some unintended effects if one update affected the state of another. So, I had to figure out the best way to accumulate these changes until the end of the update scope. After some deliberation I settled on a pair<vec3, vec3> return type that would group linear vs angular neatly, and a custom struct to handle these additions cleanly.

But it was only after I had gotten most way through that I realized I could just store the accumulated changes in the Beyblade class! They are obviously tied to each Beyblade instance, and functions could easily be made and called within the scope of any place where beyblade physics is calculated.

The other thing I got wrong was the axis of the split. Linear vs angular is the obvious one, but the split that actually matters is instantaneous vs continuous. A collision changes velocity right now; friction is a force and has to be multiplied by the timestep. So the bey ended up owning four vectors, not two:

void BeybladeBody::applyAccumulatedChanges(double deltaTime) {
    velocity        += accumulatedVelocity;                       // impulses (collisions)
    angularVelocity += accumulatedAngularVelocity;
    velocity        += accumulatedAcceleration * deltaTime;       // forces (friction, slope, drag)
    angularVelocity += accumulatedAngularAcceleration * deltaTime;
    // ...then reset all four
}

Every accumulate* function in Physics writes into one of these, and nothing touches velocity until the loop above. (Almost nothing. See the last section.)

Why a bey isn’t a ball

Put a ball on the side of a bowl and friction opposes its sliding. Put a spinning top there and something different happens: the contact point is a tiny circle spinning at ω·r, so friction acts sideways, perpendicular to both the spin axis and the floor. The direction is −(ω̂ × n̂). On a flat floor the axis and the normal line up, the cross product is zero, and the bey just sits there spinning down. On a slope you get a sin θ factor and the bey is driven tangentially around the bowl. That is where the flower-petal paths come from.

The magnitude is two terms with tunable weights, because I could not find a model for the second one and made it up:

|a| = c_l · (μ g cos θ) + c_v · (μ ω)

The first is textbook friction. The second says a faster-spinning bey gets flung harder, which matches how real beys behave and was the whole point of the project. μ is the average of the stadium’s and the driver’s coefficients, and the result is scaled by an “efficiency” fudge factor before it’s accumulated. The same friction is then charged back as spin loss: the angular deceleration is |a| · m r / I against the spin axis, so a bey that’s being flung around hard is also dying faster.

Two details from that October code that I’m less proud of. If the linear acceleration came out as almost zero, meaning the bey was perfectly upright on a flat spot, I nudged it by 0.001 in x so that nothing was ever perfectly frozen. And the spin-dependent term had this line:

double angularComponent = glm::length(w) * combinedCOF * FRICTIONAL_VELOCITY_CONSTANT * alignment > 0 ? 1 : -1;

C++ precedence makes the whole product the condition of the ternary, so the “spin term” was always exactly 1 or -1. Nobody noticed because the constants had been tuned around it. It surfaced when the units port in December 2024 refused to compile a bare 1.

The slope force is simpler: g sin θ · μ pointed at the stadium center. Scaling a slope force by a friction coefficient is not physics, but it is what stops a launched bey from sailing over the rim, so it stayed.

Collisions

Detection treats each bey as a cylinder. If the two layers don’t overlap vertically there’s no contact; otherwise compare the xz distance with the sum of the layer radii and return the overlap depth. Resolution first pushes each bey out by half that depth so they never interpenetrate for more than a frame.

Then the impulse. Along the line between centers this is a one-dimensional restitution problem, and I used the standard result:

J = e · ((v₂ − v₁) · n̂) / (1/m₁ + 1/m₂)

with e the average of the two layers’ coefficients of restitution. That part I trusted.

The Beyblade part is recoil, and it’s where the game lives. Every bey has an attack stat, and on contact each one draws a random sample from it. The sum, scaled by the closing speed and by a curve of the combined spin, becomes two things: an angular impulse that drains spin from both beys, and a linear impulse that knocks both back. Faster spin means a harder hit, up to a point. The October curve was log(x + 1) − x/501, chosen because it rises to about 500 rad/s and then gently falls, and the comment next to it reads “modeling empirical data (AKA I just made it up)”. In the December 2024 physics fixes that became √(closing speed) · √(spin sum), which is easier to reason about with units attached.

All of this is for two beys spinning the same direction. Opposite-spin collisions, where the contact points move together instead of against each other, have been a cerr << "not implemented yet" since day one, and as of 2026 they still are.

Air

Both velocities get a v² drag. Linear: a = −(½ C_d A ρ) v² / m along the velocity. Angular: the same shape with the moment of inertia and a drag constant that grows with the radius, along the spin axis. Both accumulate as accelerations, so they get multiplied by the timestep with everything else. Angular drag is the thing that ends a match when nobody hits anybody. The version with proper units also has a comment about manually correcting a “radian overcount”, which is a whole story for Part 5.

The hacks

Things that are not physics but keep the game running:

  • preventStadiumClipping. After the move, if the tip is below the stadium surface, teleport it up to the surface and zero the vertical velocity. It’s a floor, not a force.
  • The 5 mm airborne rule. A bey is either on the ground getting friction and slope, or in the air getting only gravity. There’s no in-between.
  • Collision cooldown. Two beys that overlap by a lot would trade impulses every single frame until they separated. Since December 2024 a pair that collided within the last few milliseconds is skipped.
  • The impact sets velocity instead of adding to it. accumulateImpact computes Δv for each bey and then calls setVelocity(deltaV). The comment says collisions must set velocity directly because they’re instantaneous, which is true, but this replaces the bey’s velocity with the delta rather than adding it. I only noticed writing this up, and it’s still in the code in 2026. Either it’s an intentional full replacement that happens to feel right, or a two-year-old bug. I’ll find out.

Amidst all the work, I distinctly remember a time where I was rearranging all the functions of my class. Ooh, these are simple getters, these are specialized getters, these are accumulators, these are collision-related calculators…you get the idea. Amidst all the chaos of implementing custom physics, it was surprisingly relaxing to be able to organize all your hard work into neat little abstractable categories.