<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Raw Fun Gaming</title>
    <description>The latest articles on DEV Community by Raw Fun Gaming (rawfungaming).</description>
    <link>https://dev.to/rawfungaming</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F15354%2F67da5461-07de-4cc2-95df-0c17a998c2b8.jpg</url>
      <title>DEV Community: Raw Fun Gaming</title>
      <link>https://dev.to/rawfungaming</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rawfungaming"/>
    <language>en</language>
    <item>
      <title>Acting Out a Fight That's Already Over: How Spinblade X Moves Its Spintops</title>
      <dc:creator>Raw Fun Gamer</dc:creator>
      <pubDate>Thu, 15 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/rawfungaming/acting-out-a-fight-thats-already-over-how-spinblade-x-moves-its-spintops-fb7</link>
      <guid>https://dev.to/rawfungaming/acting-out-a-fight-thats-already-over-how-spinblade-x-moves-its-spintops-fb7</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq9pij3opgiiq1cy6bjsf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq9pij3opgiiq1cy6bjsf.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So the movement system has two jobs that pull against each other:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Look like real spinning tops.&lt;/strong&gt; Glancing blows, bounces off at odd angles, the occasional top that backs up the slope and comes screaming back down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hit its marks.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the script actually says
&lt;/h2&gt;

&lt;p&gt;A round in the book is a list of beats, not a list of positions. Trimmed, one looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"seed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"multiplier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.82&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"opponent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"events"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Clash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.196&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"playerTier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"opponentTier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"outcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Normal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"playerRpm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4902&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"opponentRpm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4870&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"multiplierAfter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Death"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;8.497&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"victimIsPlayer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"finisher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Spin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"playerRpm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;476&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"opponentRpm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"multiplierAfter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.82&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each &lt;code&gt;Clash&lt;/code&gt; says &lt;em&gt;when&lt;/em&gt; 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 &lt;code&gt;Death&lt;/code&gt; says who goes out and how: &lt;strong&gt;Spin&lt;/strong&gt; (ran out of power), &lt;strong&gt;Over&lt;/strong&gt; (knocked out of the bowl) or &lt;strong&gt;Burst&lt;/strong&gt; (shattered).&lt;/p&gt;

&lt;p&gt;What the script does &lt;em&gt;not&lt;/em&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: making tops behave like tops
&lt;/h2&gt;

&lt;p&gt;Before a top can hit its marks it has to move like a top, and the first version didn’t.&lt;/p&gt;

&lt;h3&gt;
  
  
  Chase where the other top will be, not where it is
&lt;/h3&gt;

&lt;p&gt;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 &lt;strong&gt;lead pursuit&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  A bowl that pulls
&lt;/h3&gt;

&lt;p&gt;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 &lt;strong&gt;soft wall&lt;/strong&gt; 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 &lt;strong&gt;speed cap&lt;/strong&gt; , 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bounce apart, and not in a straight line
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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%:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="n"&gt;Vector3&lt;/span&gt; &lt;span class="nf"&gt;Scatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Vector3&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;angle&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Mathf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Lerp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clashDeflectDegrees&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clashDeflectDegrees&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                             &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;clashScatter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NextDouble&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clashScatter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NextDouble&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;angle&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="n"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Quaternion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AngleAxis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two physics bugs that look like design problems
&lt;/h3&gt;

&lt;p&gt;Two bugs are worth knowing about, because both looked like “the AI is bad” for a while.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One hit, counted four times.&lt;/strong&gt; 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 &lt;em&gt;both&lt;/em&gt; tops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Equal speeds, no bounce.&lt;/strong&gt; To avoid counting a hit from both sides, only the faster top resolves it. Our first version used &lt;code&gt;&amp;lt;=&lt;/code&gt;, so two tops meeting at &lt;em&gt;exactly&lt;/em&gt; the same speed resolved on neither side. Unity doesn’t re-fire &lt;code&gt;OnCollisionEnter&lt;/code&gt; 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 &lt;em&gt;strictly&lt;/em&gt; slower, and a persisting contact re-resolves through the same debounced path.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Layer 2: an AI that knows the script
&lt;/h2&gt;

&lt;p&gt;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 &lt;em&gt;these&lt;/em&gt; two tops. Free-running physics doesn’t care.&lt;/p&gt;

&lt;p&gt;The answer we settled on we call &lt;strong&gt;beat-bound theatre&lt;/strong&gt;. The tops are still physical, AI-driven bodies, but the AI paces itself to the record:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;After a touch, a top backs off briefly, then drifts and holds a loose distance from its partner instead of chasing.&lt;/li&gt;
&lt;li&gt;Half a second before the pair’s next recorded clash, it starts the approach. In a melee it aims at that beat’s &lt;em&gt;partner&lt;/em&gt;, not the nearest top, because a touch with the wrong top binds nothing.&lt;/li&gt;
&lt;li&gt;A real touch within &lt;strong&gt;0.35 s before&lt;/strong&gt; the beat claims it on the spot: the spark, the damage numbers, the sound and the HUD update all land on that contact.&lt;/li&gt;
&lt;li&gt;If no touch has come by &lt;strong&gt;0.35 s after&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One rule keeps it consistent: a touch can’t claim a beat while an earlier beat of &lt;em&gt;either&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://rawfungaming.com/news/spinblade-x-spintop-movement/melee.webm" rel="noopener noreferrer"&gt;Watch the clip (video)&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: custom moves for the moments that matter
&lt;/h2&gt;

&lt;p&gt;Pursuit, bounce and pacing give you a plausible fight. They don’t give you a &lt;em&gt;climax&lt;/em&gt;. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The charge run
&lt;/h3&gt;

&lt;p&gt;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 &lt;strong&gt;charge run&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;t&lt;/em&gt; has a closed form, and a 24-step bisection inverts it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// distance covered by time t: exponential approach to vmax, plus the bowl's pull&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vmax&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vmax&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;v0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1f&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;Mathf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Exp&lt;/span&gt;&lt;span class="p"&gt;(-&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;
        &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="m"&gt;0.5f&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;bowlPull&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Then it read &lt;em&gt;wrong&lt;/em&gt;, 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Over: a flight that can’t fail
&lt;/h3&gt;

&lt;p&gt;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 &lt;em&gt;see&lt;/em&gt; the top leave.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Mathf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Clamp01&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;flightSeconds&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 0.5 s flight&lt;/span&gt;
&lt;span class="n"&gt;Vector3&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Lerp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arcHeight&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;4f&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1f&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// parabola, 0.5 high&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;em&gt;survives&lt;/em&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://rawfungaming.com/news/spinblade-x-spintop-movement/over.webm" rel="noopener noreferrer"&gt;Watch the clip (video)&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Burst, and the slow motion
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://rawfungaming.com/news/spinblade-x-spintop-movement/burst.webm" rel="noopener noreferrer"&gt;Watch the clip (video)&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The last four seconds of a Burst round, cropped to the bowl: the full-charge strike, the burst, and the slow motion that follows.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How it’s seeded, and what “the same” means
&lt;/h2&gt;

&lt;p&gt;The question we get most: &lt;em&gt;if it’s all random-looking, how is the same round guaranteed to give the same result?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The answer is that Spinblade X has three kinds of randomness, kept strictly apart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The seed stream decides things.&lt;/strong&gt; Unity’s global &lt;code&gt;Random&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Cosmetic randomness never touches the seed stream.&lt;/strong&gt; The clash scatter, the burst debris and the arena’s colour scheme each get their &lt;em&gt;own&lt;/em&gt; &lt;code&gt;System.Random&lt;/code&gt;, never &lt;code&gt;UnityEngine.Random&lt;/code&gt;. This rule has been broken twice in this project, and both times by something that looked harmless. The first burst effect made two cosmetic &lt;code&gt;Random.insideUnitSphere&lt;/code&gt; 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 &lt;code&gt;roundSeed ^ 0x5CA77E&lt;/code&gt;, the debris &lt;code&gt;roundSeed * 397 ^ actorIndex&lt;/code&gt;), so they repeat when a round is replayed. The charge-run planner is seeded from the clock on purpose.&lt;/p&gt;

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

&lt;p&gt;So the guarantee comes in two strengths:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exact:&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Similar:&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We decided the second one should &lt;em&gt;not&lt;/em&gt; be exact, and the next section is why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four designs we threw away
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Re-simulate from the seed.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Force the hits on the clock.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Let collisions consume the beats.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Ghost playback: record positions too.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Then we scanned what it produced. In &lt;strong&gt;44–61%&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;We fixed the bug and replayed the worst rounds &lt;em&gt;live&lt;/em&gt;, 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.&lt;/p&gt;

&lt;p&gt;The lesson cost us a six-day run: &lt;strong&gt;a recording is only as good as the take you recorded.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we’d tell another team
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Separate what must be identical from what must look right.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give cosmetic randomness its own generators,&lt;/strong&gt; and treat any new call to the shared seeded one as a bug until proven otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the special move ordinary.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure stillness, not just accuracy.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://rawfungaming.com/games/spinblade-x" rel="noopener noreferrer"&gt;Spinblade X&lt;/a&gt; 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.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>news</category>
    </item>
    <item>
      <title>How we built Farm Pop: a slot game where every symbol is a living 3D character</title>
      <dc:creator>Raw Fun Gamer</dc:creator>
      <pubDate>Tue, 18 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/rawfungaming/how-we-built-farm-pop-a-slot-game-where-every-symbol-is-a-living-3d-character-40kk</link>
      <guid>https://dev.to/rawfungaming/how-we-built-farm-pop-a-slot-game-where-every-symbol-is-a-living-3d-character-40kk</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxehyiwnegstqft6qus16.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxehyiwnegstqft6qus16.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Farm Pop is a game we built at Raw Fun Gaming: a 7×7 cluster-pays slot where the “symbols” are not spinning icons — they are fully animated 3D pets. A dog, a cat, a chick, a rabbit, each one idling, running as the reels move, jumping when they win, and burning away in a dissolve when a cluster pops. Match five or more touching symbols, they dissolve, everything above tumbles down, and the cascade continues. Trigger free spins and the whole farm sinks into night — lanterns on, fireflies out — until the bonus ends.&lt;/p&gt;

&lt;p&gt;It runs entirely in the browser, built for Stake, where most players are on mobile Safari. That combination — a boardful of skinned, animated characters, on WebGL, on the worst-case browser — is where all the interesting engineering lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The client is a choreographer, not a game
&lt;/h2&gt;

&lt;p&gt;Like every game we ship, the outcome is decided before the first frame renders. A math engine pre-generates the full result set — “books” of simulated rounds, one file for the base game and one for the bonus — and the web layer feeds Unity a script of typed events for the round it drew: reveal (here is the board, with per-column anticipation flags), win (these clusters, these amounts), tumble (remove these, drop in those), grid boosts, free spins.&lt;/p&gt;

&lt;p&gt;Unity’s job is to perform each event beautifully and then say “done.” That handshake is the entire contract: every event ends with a completion message back to the web layer, which only then sends the next one. Nothing advances on a timer; nothing races. It also makes the system forward-compatible in a way we like: if the web sends an event type the engine does not handle yet, the interface auto-completes it back immediately instead of deadlocking the round. A new mechanic can ship in the math and the webfront before the Unity presentation exists, and the game degrades gracefully instead of hanging.&lt;/p&gt;

&lt;p&gt;The deeper win is what this does to animation sequencing — the thing slot games live or die on. Because each event is a discrete, awaited performance, the tumble choreography (dissolve → despawn → compact → spawn → settle) can be genuinely asynchronous inside while still being strictly ordered outside. Column-by-column settles, staggered win animations, anticipation delays — all free to overlap internally, all guaranteed to finish before the next event starts. Even anticipation is authored by the math: the reveal event flags which columns should tease, and turbo mode’s settle-everything-at-once fast path is explicitly disabled when a flag is present. You never skip the tease.&lt;/p&gt;

&lt;p&gt;A side effect we use constantly: a debug controller can load a book straight from disk in the editor and replay it against the same handshake, so the entire game is testable — every mechanic, every cascade — without a webfront running at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  A state machine wearing a fur coat
&lt;/h2&gt;

&lt;p&gt;The heart of the engine is a small state machine on every symbol: Idle, Spinning, Settling, Landed, Anticipating, Win, Dissolving, Clicked. The engine drives states; a game-specific subclass translates them into whatever the art can do. Farm Pop’s translation layer maps states onto the pet animators: idle loops (picked at random so a row of seven cats does not breathe in unison), a run cycle while scrolling and falling, a jump for wins, a landing animation that plays only on a symbol’s &lt;em&gt;first&lt;/em&gt; settle — tumbles re-settle existing symbols and should not retrigger it.&lt;/p&gt;

&lt;p&gt;Because symbols are characters, they can do character things. Tap one and it plays a reaction animation with its own sound. The scatter — a little farmer — spawns with randomized outfits, hair, and a 50/50 hoe-or-pitchfork prop, so no two look alike; during anticipation, when one more scatter would trigger the bonus, he &lt;em&gt;digs&lt;/em&gt;. That is the kind of storytelling a flat symbol atlas cannot do, and it costs nothing extra: it is the same anticipation state every game built on this engine gets.&lt;/p&gt;

&lt;p&gt;The price of living symbols is that everything must survive pooling. Forty-nine skinned characters churn through every tumble, so all of them come from object pools — and Unity’s Animator makes pooled characters expensive by default, because re-enabling one triggers a full avatar rebind. A one-line setting (keepAnimatorStateOnDisable) skips that. The pooling discipline runs deep: when the backend awards position multipliers, the winning symbol visually duplicates into a vertical stack of clones, and those clones are pooled too, with their animators rebound on dequeue and their materials restored on return. When a symbol dissolves, its clones share the &lt;em&gt;same&lt;/em&gt; dissolve material instance, so the whole stack burns away in perfect sync without any coordination code.&lt;/p&gt;

&lt;p&gt;That multiply stack is our favorite piece of pure juice in the game. It is not a loop — it is a recursive wave: the original squashes and pops, and at the peak of its overshoot the first clone starts rising out of it; when that clone arrives, &lt;em&gt;it&lt;/em&gt; pops and launches the next one, like a stadium wave traveling up the stack. The glow flash is derived from the same motion — rim-light intensity is computed from how far the scale overshoots 1.0 — so one animation curve drives both the bounce and the flash, through a shared material property block, with zero extra material instances. And because a tall stack clips the top of the frame, the effect requests a ref-counted camera pullback that scales with the stack’s depth in the scene — computed against a fixed reference distance, because computing it against the &lt;em&gt;current&lt;/em&gt; distance turns your camera into a positive feedback loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safari, again, but worse
&lt;/h2&gt;

&lt;p&gt;On &lt;a href="https://stake.com/casino/games/rawfungaming-highway-rush" rel="noopener noreferrer"&gt;Highway Rush&lt;/a&gt; we learned that Safari compiles and reflects shaders on first draw with no persistent cache — a multi-second main-thread stall the first time an effect appears. A slot game is the pathological case for this: almost every interesting visual is &lt;em&gt;conditionally&lt;/em&gt; spawned. Win bursts, dissolve variants, cluster highlights, floating win amounts, grid-boost overlays — none of it renders during the loading screen naturally, so all of it stalls mid-celebration on a player’s first win. The worst possible moment. Our dissolve shader alone — four passes, times shadow, light, fog, and dissolve keyword combinations — put roughly 194 shader programs through first-use uniform reflection at once, at about 3ms per uniform on WebKit versus about 0.1ms on Chrome. First cluster win on iOS: a three-second freeze.&lt;/p&gt;

&lt;p&gt;The fix grew into a proper engine service. A shader warmup system stages prefabs under a hidden transform, moves it in front of the real camera for three frames during boot, and holds the webfront’s splash screen until every scheduled pass completes. Two details turned out to matter far more than the basic idea:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The warmup must produce the same pipeline state the gameplay draw will.&lt;/strong&gt; Unity’s render pipeline picks shader variants from global lighting state at render time — how many additional lights, which cascades, fog on or off. Warm up too early, or in the wrong part of the scene, and you compile pristine variants of shaders the game will never use, then stall on the real ones anyway. So the service waits about a second for scene lighting to stabilize, and stages geometry at a &lt;em&gt;gameplay anchor&lt;/em&gt; — the grid root — so warmup symbols sit in the same light volume as real ones and the pipeline resolves the same lighting keywords.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each spawning system registers its own warmup.&lt;/strong&gt; The win handler warms its popups (actually showing one so the text-outline keyword toggles), the dissolve effect warms its burn variant half-dissolved so both branches of the fragment shader execute, the boost manager warms its overlays. New engine systems warm themselves on startup, so the next game gets the coverage for free instead of maintaining a fragile central list.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even that was not quite enough — per-prefab warmup does not cover the &lt;em&gt;combination&lt;/em&gt; of states a real win produces. So the boot sequence ends with a fake spin: three real grid symbols play a full win-then-dissolve cycle behind the splash screen, with a warmup flag that turns the sound and web-event calls into no-ops so the webfront never knows it happened. The single most hazardous frame of the session — the first cluster win — gets pre-played where nobody can see it.&lt;/p&gt;

&lt;p&gt;Safari had more lessons. WebGL caps fragment shaders at 16 texture units, and feature-rich toon shaders blow past it — Unity logs a link error but keeps rendering, &lt;em&gt;silently dropping the shadow-receiving variant&lt;/em&gt;. Objects look fine and simply never show shadows, which took a while to stop blaming on light settings. Everything that needs to receive shadows now uses a minimal custom toon shader that binds three textures.&lt;/p&gt;

&lt;p&gt;Fonts were their own saga. TextMesh Pro’s dynamic atlas mode — the convenient one, where glyphs rasterize on demand — calls FreeType, which is a null function pointer in a WebGL wasm build. Works perfectly in the editor, breaks in the browser. So every font in the game is a static SDF atlas with its glyphs pre-baked: currency symbols for every market baked into the Latin font, and the CJK fonts for localized bonus text baked down from 3–5 MB of raw font files to atlases of exactly the seventeen Chinese and fourteen Japanese characters the translation table actually uses, with the source font reference cut so the build drops the file entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free spins turn off the sun
&lt;/h2&gt;

&lt;p&gt;Farm Pop’s bonus does not cut to a different scene — the same farm transitions from day to night in front of you. One environment controller lerps everything at once: directional light color and intensity, the ambient spherical-harmonics gradient, fog, the toon shader’s shadow tint, even per-symbol emission and outline colors so the pets stay readable in the dark. Then the night lights come on: lanterns, window glow, fireflies.&lt;/p&gt;

&lt;p&gt;Nineteen point lights is a lot for WebGL, and the way it is affordable is an unusual renderer split: the project ships three render pipeline configurations — Forward for mobile, Forward+ for desktop, and &lt;em&gt;deferred&lt;/em&gt; for the WebGL build, where many small lights are close to free. But the lights still caused the best bug of the project: a visible flicker on the ground plane whenever the wild symbol appeared. The wild dog carries a real light for its glow, and on the big ground plane it competed with all the night lights for per-object light slots — as the sorted list reshuffled each frame, the ground popped. The fix was rendering layers: the ground got its own layer, the nineteen night lights were excluded from it entirely, and only the wild’s glow light opts in. The non-obvious part is that light layers are a &lt;em&gt;shader&lt;/em&gt; feature, not a free engine feature — our hand-written environment shader had to add the light-layers variant and test each light’s layer mask inside its own light loop before any of it worked.&lt;/p&gt;

&lt;p&gt;Shadows got the same kind of platform-specific care: shadow distance doubled to 100 (the portrait camera pullback had pushed the camera past the old range, silently killing shadows on phones), and the WebGL renderer runs three cascades split so the middle cascade covers exactly the band of world the grid sits in — better effective shadow resolution with no increase in shadowmap size.&lt;/p&gt;

&lt;h2&gt;
  
  
  The multiplier patch that grows
&lt;/h2&gt;

&lt;p&gt;One mechanic is pure Farm Pop: grid boosts. Cells accumulate “+N” multiplier levels as wins land on them, and each boosted cell grows a little ecosystem — a patch of grass that gains blades with every level (10 blades at +1, capped at 55) and butterflies whose color escalates through tiers as the number climbs. Blade positions are hashed from the cell’s world position, so every cell’s patch is unique but identical every time it regrows. The overlays are pooled like everything else, with an explicit teardown on return — an early bug had a level-9 overlay going back to the pool with fifty grass blades still attached, ready to reappear fully grown on some other cell’s +1.&lt;/p&gt;

&lt;p&gt;It is a small system, but it is the game’s personality in miniature: a math-engine integer arriving over the bridge and becoming something that feels &lt;em&gt;grown&lt;/em&gt; rather than displayed.&lt;/p&gt;

&lt;h2&gt;
  
  
  One engine, thinner games each time
&lt;/h2&gt;

&lt;p&gt;Farm Pop is the first of the “Pop” series, and it shares its engine with the “Rush” runners — same web bridge, same pooling, same warmup service, same build hardening. The whole slot layer (grid manager, tumble choreography, anticipation, win presentation, overlay UI, day/night system, boost manager) lives in the shared engine. What is actually Farm Pop? Ten scripts, about 1,300 lines: the animator adapter, the farmer’s costume randomizer, the butterflies and grass, the wild dog’s glow. The next game — different animals, different theme — reuses everything else.&lt;/p&gt;

&lt;p&gt;Even the 2D art pipeline leans on this. An editor tool renders the actual 3D symbol prefabs to transparent PNGs for the paytable and marketing — posing them mid-jump by sampling animation clips in edit mode, on an isolated layer with its own throwaway camera, no Play button involved. The paytable cannot drift from the game because the paytable &lt;em&gt;is&lt;/em&gt; the game, photographed.&lt;/p&gt;

&lt;p&gt;That was the bet with this architecture, and Farm Pop is the proof: the second game in a genre should mostly be art.&lt;/p&gt;

&lt;p&gt;If you want to see the result, it is live: &lt;a href="https://stake.com/casino/games/rawfungaming-farm-pop-3d" rel="noopener noreferrer"&gt;&lt;strong&gt;Farm Pop on Stake&lt;/strong&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built with Unity 6 (WebGL) on the Raw Fun Gaming engine, for Stake Engine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>gamedev</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>How we built Highway Rush: turning a server seed into a browser runner that feels alive</title>
      <dc:creator>Raw Fun Gamer</dc:creator>
      <pubDate>Sun, 09 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/rawfungaming/how-we-built-highway-rush-turning-a-server-seed-into-a-browser-runner-that-feels-alive-3oa</link>
      <guid>https://dev.to/rawfungaming/how-we-built-highway-rush-turning-a-server-seed-into-a-browser-runner-that-feels-alive-3oa</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhxqjxg0jyx5auz0d6pbw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhxqjxg0jyx5auz0d6pbw.png" alt=" " width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://stake.com/casino/games/rawfungaming-highway-rush" rel="noopener noreferrer"&gt;Highway Rush&lt;/a&gt; is a game we built at Raw Fun Gaming: a single character sprints down a highway, its head swelling as it passes through multiplier gates and shrinking when it clips a cone, a stop sign, or a roadblock. At the finish line the head size is cashed out against a column of stop signs. It looks and plays like a hyper-casual mobile runner. It is actually a provably-fair casino game, running entirely in the browser, on Stake.&lt;/p&gt;

&lt;p&gt;That one sentence hides the whole engineering story, so let us pull it apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint that shapes everything: the run is already decided
&lt;/h2&gt;

&lt;p&gt;On a normal runner, the game &lt;em&gt;is&lt;/em&gt; the source of truth — you steer, the physics respond, the score is whatever you earned. On a provably-fair casino game it is the opposite. The moment a bet is placed, a server-seeded math engine has already decided the entire outcome: which gates you will hit, what your head size will be, what you pay out. That result is signed and verifiable — the definition of “provably fair.”&lt;/p&gt;

&lt;p&gt;So Unity is not playing the game. &lt;strong&gt;Unity is replaying a result it was handed.&lt;/strong&gt; The whole craft of the client is making a predetermined multiplier feel like a live, breathing run that you are watching unfold for the first time.&lt;/p&gt;

&lt;p&gt;That reframes everything downstream:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No gameplay input may change the outcome.&lt;/strong&gt; The client takes the seed, derives the exact sequence of events, and renders it. Player “control” is presentation, never causation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Determinism is non-negotiable.&lt;/strong&gt; The same seed has to produce a byte-identical run on our laptops, on your phone, and on the server’s own verification pass. One stray random call in the gameplay path, one frame-rate-dependent physics step, and the replay diverges from the proof. So gameplay randomness comes only from the seeded source; anything cosmetic is kept on a separate private RNG so it can never perturb the deterministic run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The client is a pure replayer.&lt;/strong&gt; A lot of the hardening work was exactly this: hunting down every path where live input, timing, or engine state could leak into gameplay, and cutting it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The engineering problem is not “build a runner.” It is “make a fixed, server-authored outcome feel earned, tense, and alive — every single time, identically, everywhere.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping it to the browser — including mobile Safari
&lt;/h2&gt;

&lt;p&gt;Stake games run as WebGL. No native app, no install; the game streams into a browser tab, and roughly two-thirds of players are on mobile Safari / iOS. That platform is the real boss fight.&lt;/p&gt;

&lt;p&gt;A few things we learned the hard way:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VFX Graph is out.&lt;/strong&gt; WebGL has no compute support, so GPU-driven particle systems simply do not render. Everything visual ships as plain CPU particle systems with standard URP particle shaders — old-school, but they build and render reliably on every browser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bloom needs a different path.&lt;/strong&gt; URP’s built-in bloom is unreliable in our WebGL builds, so the glow you see runs through a dedicated renderer feature instead. The editor lies to you here: it renders at the desktop quality tier by default, a different renderer than the tier a WebGL build actually ships with. More than once an effect looked perfect in the editor and vanished in the build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Safari shader stall.&lt;/strong&gt; The first time a browser renders a given shader program, it caches uniform locations on the main thread. On Chrome that is about 0.1ms and invisible. On Safari it is about 3ms per program, with no persistent cache — and a single complex prefab can carry a hundred-plus uniforms across its passes. So the first time a fancy effect appears mid-game, Safari can freeze the main thread for up to three seconds. The fix is a warmup pass: during the loading splash we stage every runtime-spawned prefab off-screen, render it for a few frames so the browser compiles and caches its shaders, then despawn it. The hitch happens where nobody is looking.&lt;/p&gt;

&lt;p&gt;The through-line for WebGL is humility: assume nothing transfers from the editor, budget for the worst browser, and pay your costs during the loading screen rather than mid-run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the world feel alive without spending the frame budget
&lt;/h2&gt;

&lt;p&gt;A runner needs a world going past, not just a track. Highway Rush has a low-poly city, birds, a day/night atmosphere, and lanes of background traffic. None of it is gameplay, and all of it has to be nearly free.&lt;/p&gt;

&lt;p&gt;The background traffic is a good example. It is a stop-and-go jam of around 80 cars that move like real traffic, play engine audio, and can even physically block the character when it dies — but during the normal run it costs almost nothing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Movement&lt;/strong&gt; is one controller over a flat list of lightweight structs — no per-car scripts. The cars follow a car-following model tuned so a packed lane still creeps instead of gridlocking, with each move committed to a distance rather than a timer so the stop-and-go reads convincingly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audio&lt;/strong&gt; is a looping engine bed per car whose volume is driven in code — a car is only audible when it is actually moving and within earshot, so out of ~80 cars only a handful ever hold a live voice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Physics&lt;/strong&gt; is deferred entirely: colliders start disabled and only wake up near the character at the one moment they are needed, on their own layer so they never trip the gameplay triggers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The player can only see, hear, and touch a sliver of the world at any instant, so build every system to spend effort only on that sliver.&lt;/p&gt;

&lt;p&gt;On top of that sits the polish that sells a casual game: haptic feedback, colour-matched bursts when the head passes a gate, camera work, and per-car colour variety. Small touches, but they are what make a deterministic replay feel like a game instead of a cutscene.&lt;/p&gt;

&lt;h2&gt;
  
  
  One engine, two very different games
&lt;/h2&gt;

&lt;p&gt;Highway Rush is not a one-off. It is built on the Raw Fun Gaming engine, a shared Unity codebase that ships two streams of games onto Stake: a slots stream of cluster/tumble “Pop” games, and a burst/casual stream of “Rush” runners, of which Highway Rush is one. The web-to-Unity bridge, the seed and replay plumbing, the WebGL hardening, the shader warmup, the ambient systems — all of it lives in the engine so the next game gets it for free.&lt;/p&gt;

&lt;p&gt;A slot game and a highway runner look nothing alike to a player, but underneath they are the same problem: take a signed, server-authored outcome and make it a joy to watch, in a browser, on a phone, provably fairly. Solve that once, well, and you can ship a lot of games.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is actually special here
&lt;/h2&gt;

&lt;p&gt;Plenty of studios make runners. Plenty make casino games. What made Highway Rush a fun build was the collision of the two: the discipline of provably-fair determinism, wrapped in the presentation language of a hyper-casual mobile game, delivered through the browser to the worst-case device — and made to feel effortless.&lt;/p&gt;

&lt;p&gt;If you want to see the result, it is live: &lt;a href="https://stake.com/casino/games/rawfungaming-highway-rush" rel="noopener noreferrer"&gt;&lt;strong&gt;Highway Rush on Stake&lt;/strong&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built with Unity 6 (WebGL) on the Raw Fun Gaming engine, for Stake Engine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>gamedev</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
