Acting Out a Fight That's Already Over: How Spinblade X Moves Its Spintops

· 14 min read · Raw Fun Gaming

Spinblade X key art: the Azure Dragon spintop spinning in the arena

In Spinblade X, two spinning tops (four in the melee modes) launch into a bowl and fight until one is left. It looks like a fight. It isn’t one. By the time the launcher’s trigger is pulled, the game already knows who wins, every hit that lands, how hard, when the finishing blow comes, and what it pays.

That is not a shortcut. It is a requirement. Spinblade X is a real-money game, and the outcome of a real-money round can’t depend on a physics engine. The result comes from a pre-computed book of rounds, picked server-side and provably fair, and the 3D battle is a performance of a script it isn’t allowed to change.

So the movement system has two jobs that pull against each other:

  1. Look like real spinning tops. Glancing blows, bounces off at odd angles, the occasional top that backs up the slope and comes screaming back down.
  2. Hit its marks. Every recorded clash has to land on screen, on time, between the right two tops, and the round has to end the way the book says.

This post is about how we hold both. It covers the three layers we ended up with: physics that reads as real, an AI that paces itself to a script, and a few hand-authored moves for the moments that matter most. It also covers how all of that is seeded, which part of the result is guaranteed to be identical, and which part we deliberately let vary. Along the way are four designs we built and threw out, because they are most of how we got here.

What the script actually says

A round in the book is a list of beats, not a list of positions. Trimmed, one looks like this:

{"seed": 302, "multiplier": 4.82, "opponent": 1, "mode": 0, "events": [
  {"type": "Clash", "time": 0.196, "playerTier": 0, "opponentTier": 0,
   "outcome": "Normal", "playerRpm": 4902, "opponentRpm": 4870,
   "multiplierAfter": 0.05},
  ...
  {"type": "Death", "time": 8.497, "victimIsPlayer": false,
   "finisher": "Spin", "playerRpm": 476, "opponentRpm": 0,
   "multiplierAfter": 4.82}
]}

Each Clash says when two tops meet, how each strike rolled (miss, normal or critical), what that did to each top’s Shoot Power, and where the multiplier stands afterwards. The Death says who goes out and how: Spin (ran out of power), Over (knocked out of the bowl) or Burst (shattered).

What the script does not say is where anyone is standing. Our books are generated by a math engine, not recorded in Unity. That is a decision we’ll come back to, because it’s the reason the rest of this post exists.

Layer 1: making tops behave like tops

Before a top can hit its marks it has to move like a top, and the first version didn’t.

Chase where the other top will be, not where it is

The obvious AI is “steer toward the opponent”. Give it to both tops and you get a stable orbit: each chases the other’s tail around the bowl, and after the opening collision they never touch again. Our tops use lead pursuit instead. They aim at where the opponent will be 0.4 seconds from now. Each archetype also hunts at its own speed, from 1.5 m/s for the Defense top to 2.25 m/s for the Attack top, which breaks the symmetry that let the orbit form in the first place.

A bowl that pulls

Real stadiums are curved, so a top left alone drifts to the middle. We don’t simulate that curvature. We fake it with a steady pull toward the centre, which keeps the fight mid-bowl where the camera wants it. Two guards sit on top. A soft wall starts pushing inward at 75% of the radius and ramps up steeply, so a pile-up in the melee can’t shove a top over the rim by accident. There’s also a speed cap, because a top that bounces off one opponent straight into another picks up speed from both. Leaving the bowl is the Over finisher’s job and nobody else’s.

Bounce apart, and not in a straight line

Real tops don’t grind against each other. They hit and fly apart. On every clash the struck top is pushed away at 1.1 m/s and the striker recoils at 70% of that. That alone fixed the “glued together” look, but it left a stranger one. A duel that started mirrored, with the tops on opposite sides of the centre, shuttled back and forth along one straight line, clash after clash, because the push ran exactly along the line between the two centres and the bowl’s pull sent them straight back along it. It looked staged, because it was.

The fix is a small piece of noise. Both pushes are swung off that line by a random 10 to 35 degrees, to a random side, and scaled by up to ±20%:

private Vector3 Scatter(Vector3 direction)
{
    float angle = Mathf.Lerp(clashDeflectDegrees.x, clashDeflectDegrees.y,
                             (float)clashScatter.NextDouble());
    if (clashScatter.NextDouble() < 0.5) angle = -angle;
    return Quaternion.AngleAxis(angle, Vector3.up) * direction;
}

On a mirrored start, the tops’ average distance off that line went from 0.023 to 0.079, in a bowl about 0.6 in radius. That’s small as a number and obvious on screen: they glance off each other now.

Two physics bugs that look like design problems

Two bugs are worth knowing about, because both looked like “the AI is bad” for a while.

  • One hit, counted four times. Each top is several colliders, so one visible impact fires the collision callback once per collider pair. Every clash was triple- or quadruple-counted until we debounced it: one clash per 0.4 s, shared across both tops.
  • Equal speeds, no bounce. To avoid counting a hit from both sides, only the faster top resolves it. Our first version used <=, so two tops meeting at exactly the same speed resolved on neither side. Unity doesn’t re-fire OnCollisionEnter on a contact that persists, so there was no bounce, and they ground along together for the rest of the round. Now the slower top yields only when it’s strictly slower, and a persisting contact re-resolves through the same debounced path.

Layer 2: an AI that knows the script

Physics that reads as real is the easy half. The hard half is that the book says the third clash happens at 2.84 seconds, between these two tops. Free-running physics doesn’t care.

The answer we settled on we call beat-bound theatre. The tops are still physical, AI-driven bodies, but the AI paces itself to the record:

  • After a touch, a top backs off briefly, then drifts and holds a loose distance from its partner instead of chasing.
  • Half a second before the pair’s next recorded clash, it starts the approach. In a melee it aims at that beat’s partner, not the nearest top, because a touch with the wrong top binds nothing.
  • A real touch within 0.35 s before the beat claims it on the spot: the spark, the damage numbers, the sound and the HUD update all land on that contact.
  • If no touch has come by 0.35 s after the beat, it fires anyway, on the clock. The visuals are gated on the tops actually being close, so nothing sparks across an empty bowl.
  • A top with no beats left holds off for good. A touch the record never made would be a hit the player sees and the payout doesn’t include.

One rule keeps it consistent: a touch can’t claim a beat while an earlier beat of either top is still pending. Energy and combo are computed from the order of a top’s strikes, so beats have to be consumed in order or the meters on screen drift from the book.

The result: the on-screen timeline stays within about a third of a second of the record, and most beats land on a real touch rather than a clock tick.

A GRAND MELEE round replayed from the book: four tops acting out a record they can't change. The feed on the left is that record, beat by beat.

Layer 3: custom moves for the moments that matter

Pursuit, bounce and pacing give you a plausible fight. They don’t give you a climax. Beat-bound theatre treats the killing blow like any other clash, so the round’s biggest moment looked like the round’s most ordinary one. Three things are hand-authored.

The charge run

Watch real Beyblade and the big hits come from distance: a top swings wide, climbs the stadium wall and comes back down with everything it has. So before a killing blow, the striker makes a charge run. It backs away from its partner up its own side of the bowl, swung about 35° off to one side so both the climb and the return are curves, then charges back down to land 0.1 s after the beat, inside the binding window.

The interesting part is the timing. The run has to start at exactly the moment that makes it arrive on the beat, wherever the wind-up happened to end. So the AI solves its own steering equations for the travel time. Under our steering, speed approaches its target exponentially, and the bowl’s pull adds a constant acceleration on the way down. The distance covered by time t has a closed form, and a 24-step bisection inverts it:

// distance covered by time t: exponential approach to vmax, plus the bowl's pull
float d = vmax * t - (vmax - v0) * (1f - Mathf.Exp(-g * t)) / g
        + 0.5f * bowlPull * t * t;

When the time remaining until the beat drops to that answer, the top commits, and once committed it doesn’t re-decide. Without that, a top that slowed on contact would drop back into its wind-up mid-strike.

The steering gain needed its own value. The normal chase closes about 63% of a speed gap per second, which never reaches charge speed between beats. A gain of 8 got there but snapped on like a remote-controlled car. 4.5 gets there and still looks like momentum.

Then it read wrong, which surprised us. With only killing blows charging, the charge was the one move in the round unlike every other move, so it looked remote-controlled for a different reason: it announced itself. The fix was to make it vocabulary. About 35% of ordinary clashes with at least 0.4 s of room now get a run as well, at a lower speed (2.8 m/s against 4.0) and by either top. Measured on real book rounds, plain clashes peak at 1.2–1.5 m/s, ordinary runs at 2.0–2.2 and killing blows at 2.65–2.86. The killing blow is still the hardest hit in the round, but it is no longer a different kind of hit.

Over: a flight that can’t fail

A real knockout is a physics impulse, and a bowl wall eats impulses. In free play a failed ejection is fine. In playback it isn’t: if the book says Over, the player has to see the top leave.

So the Over flight is scripted rather than thrown. The victim goes kinematic and rides a half-second parabola aimed well past the rim on the arena’s long axis. The frame it reads as outside the bowl, physics takes back over with the arc’s own momentum and the ring-out lands:

float u = Mathf.Clamp01(elapsed / flightSeconds);          // 0.5 s flight
Vector3 p = Vector3.Lerp(start, end, u)
          + Vector3.up * (arcHeight * 4f * u * (1f - u));   // parabola, 0.5 high

The victim is immune to damage for the whole flight, or a pursuing top would land a kill mid-air and turn an Over into a Spin. In the melee, the books also contain Over strikes that the victim survives. Only an Over followed by that top’s death within 1.2 s is a real knockout. The rest get a weak hop that peaks under the rim, staggers the top toward the wall and lets it roll back in, which is what a failed knockout looks like in real life.

A base-mode round from the book: glancing bounces, the striker's charge run into the killing blow, then the scripted Over flight out of the bowl in slow motion.

Burst, and the slow motion

A Burst splits the losing top into its three real parts: blade, ratchet and bit. Each part gets a seeded chance to fly out of the bowl (75%, 50% and 15% respectively) and velocities set directly rather than as forces, so they don’t depend on mass. The wreckage stays on the floor until the round ends.

Both round-ending finishers also play in slow motion: a 60 ms hit-stop at 5% speed, then 25% easing back to full over 0.75 s for an Over and 0.5 s for a Burst. The ring-out’s flight plays slowed and lands as speed returns. The physics timestep scales with it, so the debris doesn’t stutter.

The last four seconds of a Burst round, cropped to the bowl: the full-charge strike, the burst, and the slow motion that follows.

How it’s seeded, and what “the same” means

The question we get most: if it’s all random-looking, how is the same round guaranteed to give the same result?

The answer is that Spinblade X has three kinds of randomness, kept strictly apart.

1. The seed stream decides things. Unity’s global Random is initialised from the round’s seed, and the game draws from it in a fixed order: does the player win, which opponents, which spawn points, and in the melee which NPC is the protected champion. That order is a contract. Insert one draw anywhere and every later draw shifts, and old recordings stop reproducing. In playback the book is authoritative anyway (the payout, the finisher, the opponents and the mode all come from the JSON), but the draw order is kept so recordings and generated books line up.

2. Cosmetic randomness never touches the seed stream. The clash scatter, the burst debris and the arena’s colour scheme each get their own System.Random, never UnityEngine.Random. This rule has been broken twice in this project, and both times by something that looked harmless. The first burst effect made two cosmetic Random.insideUnitSphere calls. In a duel that’s invisible, because a Burst ends the round. In the melee the round continues after a Burst, those two draws shifted every later roll, and recorded melee rounds stopped reproducing. Some cosmetic generators are seeded from the round seed (the scatter uses roundSeed ^ 0x5CA77E, the debris roundSeed * 397 ^ actorIndex), so they repeat when a round is replayed. The charge-run planner is seeded from the clock on purpose.

3. Physics isn’t deterministic, and we don’t ask it to be. A rigidbody simulation varies with frame timing, and on WebGL that means it varies by device.

So the guarantee comes in two strengths:

  • Exact: who wins, every clash’s strike tiers and damage, every Shoot Power and multiplier value along the way, the finisher and the payout. These come from the book, never from the simulation. The on-screen counter and power bars play back the recorded curves rather than computing their own. Once the battle ends, the book’s payout overrides whatever the live tracker computed.
  • Similar: where the tops are and how they get there. Every beat lands within about a third of a second of its recorded time, between the right two tops. Two viewings of the same round are two takes of the same script.

We decided the second one should not be exact, and the next section is why.

Four designs we threw away

1. Re-simulate from the seed. Replay re-runs the physics with the same seed. The same seed paid 2.37×, then 2.43×, then 2.31× on consecutive runs. A physics engine can’t carry money.

2. Force the hits on the clock. Fire each recorded clash at its time and pull the tops together for it. By each beat the physics had drifted, so the controller dragged tops across the bowl to meet. We called it the magnet snap, and it fought the bounce, so the tops rubber-banded apart and together forever.

3. Let collisions consume the beats. Let the AI fight freely and have each real contact claim the next recorded clash. It worked for duels. Then four tops in one bowl broke it. The pair that owed the next beat chased other tops, bounces compounded until tops slingshot over the rim, and idle tops ground together without clashing. Four heuristic layers each helped and none sufficed. This one survived in a narrower form: it’s the binding window in today’s design.

4. Ghost playback: record positions too. Store a 10 Hz position track for every top and play it back kinematically. It’s the classic racing-game ghost, and it worked beautifully for rounds recorded in Unity, because the tops are exactly where they were when each beat happened.

Our books weren’t recorded in Unity, though. A 100× maximum win needs a Burst on a round that has already built up 25× before the finisher. Organic Unity battles produce that roughly 0.5 times in 10,000 rounds, and weighting a pool can only redistribute payouts it already contains. So the math engine builds its rounds tail-first: it draws a payout band, then constructs a round that lands in it.

So we gave those generated rounds positions after the fact. A baker played each line once as theatre, recorded where everything went, time-warped each top’s track so its beats lined up with the recorded times, and wrote the tracks back into the book. It ran for six days over all eight melee books, 80,000 rounds, and every rebuilt book paid out exactly as before.

Then we scanned what it produced. In 44–61% of melee rounds, a top held perfectly still for half a second or more, up to 20 seconds. Some of that was the time-warp: a beat that fired late followed by one that bound early put two anchors almost on top of each other, and the warped track froze between them. The worst was a bug: an Over strike the victim survived had been played as a full knockout up to 18 seconds early, so that top’s recorded track froze outside the arena for the rest of the round.

We fixed the bug and replayed the worst rounds live, with beat-bound theatre and no tracks. They showed no stillness, ran their full length and landed 53–74% of beats on a real touch. So we retired the bake. The tracks are still in the books, and nothing plays them.

The lesson cost us a six-day run: a recording is only as good as the take you recorded. We had been treating “exactly reproducible” as the goal, when the actual goal was “always looks right”. A bad take played back perfectly is still a bad take. Theatre that’s re-performed every time gets a fresh chance to look good, and the outcome is protected either way, because the book never depended on the motion.

What we’d tell another team

  • Separate what must be identical from what must look right. Ours is a JSON file and a physics simulation, and only one of them carries money. Once that line is drawn, the motion can be as loose as it needs to be.
  • Give cosmetic randomness its own generators, and treat any new call to the shared seeded one as a bug until proven otherwise.
  • Make the special move ordinary. A killing blow that is the only move of its kind reads as scripted, however good it is. Sprinkling the same move, toned down, through the rest of the round is what made it read as a hit.
  • Measure stillness, not just accuracy. Our baked tracks hit every beat to the frame and still looked broken. The metric that caught it was “how long does anything stand still”, which no beat-accuracy check would ever have asked.

Spinblade X is live on Stake. The best way to see all of this is to watch a few rounds of the four-top melee and try to spot which hits were scripted. All of them were.

More from the studio