< Back 

Rod and Reef: Fishing Minigame

2026-09-26

Every fish in Rod and Reef is fought on a dial where a needle and a target spin in opposite directions. Difficulty comes from the rod against the fish's rarity and size, scoring is a tug of war, and the effects are drawn with Spray, my GUI particle library.

LuauRobloxGameplayUIVFX
Go to quick view

Two needles that meet head-on.

Warning

Rod and Reef hasn’t been released yet, and videos or GIFs of this system can’t be shown until it launches.

Abstract. In Rod and Reef, when a fish bites, a minigame starts: a circular dial where a needle and a target box spin in opposite directions, and the player clicks when they line up. What looks like a simple minigame has a fair amount of geometry and some probability behind it. Difficulty is a pure function of the rod and the fish: the rod sets the needle’s speed, the fish’s rarity and size set the box’s, and from how fast the two close in come the distance at which the box appears and the width of the scoring window, with a floor in seconds so it stays playable. The box always comes head-on, the distance between the two is measured with modular arithmetic, and a pass with no click counts as a miss. Scoring is a tug of war, and modelled as a Markov chain it turns out to be more skill-sensitive than a simple race. The dial engine is separate from the rules of each minigame that uses it, and its effects are drawn with Spray, my particle library for user interfaces.

1. The problem

The dial is used by two different games. One is the fight with a fish, which is won with a number of hits and lost with the same number of misses. The other is the friendly challenge between two players, which accumulates points and gets harder set by set. At first the two shared the visual frame (the needle, the arcs, the slots, the particle emitters) and nothing else, and everything that made that frame behave like a dial was written twice. The two copies disagreed on the things two copies always disagree on. The challenge never hid its arc when it finished, so the next fight with a legendary fish, which uses a narrower arc, drew it on top of the wide one still sitting there visible, and the player saw two arcs at two widths, one of which scored nothing. That was the “duplicated circle” report.

On top of that, difficulty had to come from the rod against the fish in a way that made sense, and it had to be fair to young players, who are a large part of Roblox’s audience.

2. Architecture

The code ended up in four pieces:

  • CircularDial, the engine: the two spinning elements, the hit test, the slots, the progress bar, which arc is visible, and all of the feedback. It doesn’t know how many hits win or what a hit is worth.
  • CircularMinigame, the rules of the fight with the fish.
  • ChallengeMinigame, the rules of the challenge between players.
  • BiteDifficulty, a pure function that turns the rod and the fish into the numbers of a round.

The engine receives a configuration of already-resolved numbers and three functions: OnHit(precision), OnMiss() and OnTimeout(). Whoever plays on the dial decides what each one means. That separation is what made the duplicated-circle bug impossible: the engine is the only thing that shows arcs, and showing one hides all the others.

3. Dial geometry

Angles are in degrees. Wrapping an angle into [−180,180) is done with a modulo,

wrap(θ)=((θ+180)mod360)−180

rather than by adding or subtracting 360 once, because that only corrects by one lap, and a long frame can carry a fast element past 540 degrees. The angular distance between two angles is

Δ(a,b)=min⁡(|a−b|mod360, 360−|a−b|mod360)

Each element carries a signed velocity instead of a speed plus a separate direction flag. That way reversing is negating a number, and there’s no way for the two halves of “which way and how fast” to disagree. To tween an element toward an angle, the code uses the equivalent of that angle reachable without crossing the ±180 seam, because rotation is a plain number and tweening straight onto a wrapped angle takes the long way round.

4. The difficulty model

4.1 The rod drives the needle

Every rod has a control value C between 10 (starter) and 100 (secret rods). Normalized,

σ=clamp(C−1090, 0, 1),vn=110+(60−110)σ

in degrees per second. A weak rod is hard to control and its needle is fast; a strong one is easy and its needle is slow, almost half as fast. That is the rod’s whole contribution to speed.

4.2 The fish drives the box

The fish’s rarity is a tier τf from 1 (common) to 7 (secret). The rod also gets a tier τc on that same scale, read from its control by linear interpolation over a table (control 10 is tier 1, 45 is 2.75, 70 is 4, 80 is 5 and 100 is 6). Reading it from control rather than from the rod’s name has one advantage: paid rods, which for fairness roll as a rare rod, land on a rare rod’s tier with no special case. The box speed is

vb=55⋅(1+0.25max⁡(0, τf−τc)−0.05min⁡(3, max⁡(0, τc−τf)))⋅κ

Each tier the fish sits above the rod speeds the box up by 25%, linearly, so “one tier above” means the same thing anywhere on the scale: a common rod against an epic fish is three tiers under, and the box runs 75% faster. Each tier below slows it by 5%, up to three. It’s small, because a strong rod’s slow needle is already the reward, and it only keeps a legendary rod from feeling the same against a common fish as against a legendary one. The factor κ is size: 1.05 for a large fish, 1.10 for a giant one and 1.16 for a legendary-sized one. Size is seasoning, not the main course. Finally, vb≤225−vn, a rail that only a low-tier rod against a big legendary fish ever touches, and that exists so a future rarity or a typo can never produce a dial nobody can play.

4.3 The window

What the player is timing is the speed at which the needle and the box close in, vc=vn+vb. The scoring window has a width w in degrees, and the time the needle spends inside it is tw=w/vc. The width comes from the arc’s art, widens with the rod, has a floor in time and a ceiling in the art itself:

w=min⁡(max⁡(a(1+0.35σ), 0.15vc), ag)

There are two arcs. The wide one is a=24∘ and can be enlarged up to g=1.35 times, and the narrow one, used for legendary fish and above, is a=11∘ and can be enlarged up to g=1.8 times. The narrow one gets more room because it needs it: at 150 degrees per second, 11 degrees of art is a 0.07 s window, which is a coin flip, not a reflex.

The 0.15vc floor guarantees the needle spends at least 0.15 s inside the window. Clicking in time with a target you can see coming is an anticipation task, and the design assumes a young player’s error is somewhere around ±0.07 s. A 0.15 s window catches most of their clicks, and a much narrower one stops rewarding skill and starts rewarding luck. When the art’s ceiling binds, the floor gives way, and that’s what keeps a starter rod against a legendary fish genuinely hard.

4.4 The gap

Every time the box appears, it does so at a distance γ ahead of the needle. That distance comes from how much preparation time the round deserves:

P=0.85+(1.45−0.85)clamp(vc−11080, 0, 1),γ=clamp(vcP, 95, 270)

A fast round gets more time over a much larger arc, and a slow round gets less time over a small one, so a calm round is also a short round and five hits never turn into a chore. The 95 degree floor keeps the approach visible on the slowest round, and the 270 degree ceiling stops the box from appearing right behind the needle, where it would read as a pass that just happened.

With the game’s rods and fish, the function gives this:

Rod Fish vn vb vc w tw γ
Starter Common 110 55 165 24.8° 0.15 s 208°
Starter Epic 110 96 206 30.9° 0.15 s 270°
Rare 1 Rare 91 58 149 27.3° 0.18 s 170°
Epic Epic 77 55 132 29.6° 0.22 s 133°
Legendary Giant legendary 71 61 132 19.7° 0.15 s 133°
Secret Mythical 60 55 115 17.3° 0.15 s 102°
Starter Legendary-sized legendary 110 115 225 19.8° 0.09 s 270°

The last row is the only one where the art’s ceiling beats the time floor: 0.09 s inside the window, with the box appearing almost behind the needle.

5. The approach

5.1 Always head-on

The fish fight uses a mode of the engine I call approach mode. The needle and the box always spin in opposite directions, so they are always closing in on each other along one side of the dial. The round opens with the box at a distance γ ahead. A hit reverses both, leaves the needle where it is and slides the box back out to a distance γ ahead, in the new direction. A pass with no click counts as a miss and lays the box out again, without reversing, because reversing is what a hit earns. So the next alignment is always one approach away, never a full lap.

5.2 Measuring the distance

The box’s distance from the needle, measured in the direction the needle is moving, is

D=((θb−θn)s+B)mod360−B,B=70

where s=±1 is the spin direction. D is positive while they are closing, zero at alignment and negative once the needle has gone past. The modulo keeps D in [−70,290): “280 ahead” and “80 behind” are the same pair of angles, and something has to decide which one it means. Up to 70 degrees behind reads as behind and the rest as ahead, and that’s only unambiguous because no layout ever goes past 280 degrees. The 10 degrees left between the two numbers are room for a slide that lands a frame late.

Each layout varies the distance by a random ±10% and clamps it between w+12 (so a layout is never a free hit on the alignment that was just scored) and 280. The box’s speed also varies, up to 8% faster on each approach, and stays constant during that approach, so the player watches the fish instead of counting beats.

5.3 Sliding to where the needle will be

While the box slides to its new position, which takes 0.22 s, the needle keeps turning. If the slide aimed at the rolled distance measured from where the needle is now, the distance the player sees at the end would be shorter. So the target is

Dtarget=γ′+|vn|⋅0.22

and the distance the player sees is the one that was rolled.

The direction of the slide matters too. From on top of the needle, after a hit, the short way is always right, because either side moves away from it. From behind the needle, after a pass, the box keeps going backwards even if that’s the long way round. The short way would drag it across the needle, which reads as a hit that didn’t count, and at exactly the moment the player is clicking.

5.4 Passes and clicks

A pass is detected when the needle goes beyond the far edge of the window by more than a 0.06 s grace margin:

D<−(w2+vc⋅0.06)

or when a frame was so long that the needle crossed the fold, which shows up as a distance jumping from under 90 to over 180 between one frame and the next. A late click inside the grace is scored as a miss and ends the pass right there, so a late click costs one miss, never the click plus the pass.

After a hit, the engine disarms and doesn’t rearm until the two elements are a full window width apart, not half, and until the box has finished sliding. Without that, the alignment the player just scored would still be an alignment on the next frame, and clicking repeatedly through one pass would bank every hit at once. Misses, on the other hand, are never gated. Each one already costs the player something, so wild clicking stays punished.

5.5 The other mode

The friendly challenge uses a second mode, scatter, where after every hit three independent dials decide whether the pair reverses, whether one of the two elements stops or starts again, and whether one of the two jumps somewhere else on the dial. Originally it was a single roll that redrew the whole shape out of six combinations, including the one it was already in, so a reversal chance of 1 meant “always re-rolls”, not “always reverses”. With three dials, each one does exactly what its name says. One state can never be reached: both elements spinning the same way. Two elements spinning together hold their offset forever and the round becomes unwinnable. When one of the two is parked, the one moving carries the whole closing speed, so a round with a parked element is exactly as hard as one with both spinning.

6. Scoring

6.1 Precision

Each hit is graded by its angular distance to the center, relative to half the window h=w/2: excellent if Δ≤0.1h, good up to 0.4h, ok up to 0.7h and bad up to h. Beyond that is a miss. The levels are walked in order, strictest first, because any distance that qualifies for one also qualifies for every looser one, and the answer is the first match. They used to be copied into a dictionary keyed by name and walked with a generic for, which in Luau has no defined order, so a dead-center click came out “bad” about as often as “excellent”. The thresholds were always right; only the walk was wrong.

6.2 Tug of war

Common fish are won with 3 hits and everything else with 5, and the same number of misses loses. But a hit first removes a miss and then adds a hit, and a miss first removes a hit and then adds a miss. The slots read as [red][green][empty], and the round is a tug of war, not a race.

If each attempt succeeds with probability q, independently of the others, the round is an absorbing Markov chain [2] over (hits, misses) pairs. With a target of A, the transient states are the pairs (h,m) with h+m≤A−1, which is 15 of them for A=5. From (h,m), a hit goes to (h+1, max⁡(0,m−1)) and a miss to (max⁡(0,h−1), m+1). Solving the system gives the probability of winning, which can be compared with a simple race to A hits against A misses, where nothing cancels:

q Tug of war, A=5 Race, A=5 Expected attempts
0.3 0.036 0.099 10.3
0.4 0.181 0.267 13.4
0.5 0.500 0.500 15.0
0.6 0.819 0.733 13.4
0.7 0.964 0.901 10.3
0.8 0.997 0.980 7.8

The tug-of-war curve is steeper. A player who hits 70% of the time wins 96% of fights instead of 90%, and one who hits 40% wins 18% instead of 27%. The fight rewards skill more and punishes its absence more, and a mistake can be recovered, because the next hit erases it. The cost is length: with q=0.5, a fight to 5 lasts 15 attempts on average, against a maximum of 9 in the race. That’s why common fish, which are most casts, only ask for 3.

7. The frontend

7.1 Particles with Spray

The particles on each hit are regular Roblox particle emitters, authored in the properties panel inside the UI. Roblox only renders particles in the 3D world, and to show them in the interface I use Spray, a library I wrote and that the game vendors as its own dependency. Spray reads the emitter and plays it with ImageLabels, computing every particle as a closed-form function of the effect’s clock. How it works inside is in its own article. Three integration decisions matter here.

All effects are built once when the module loads, not every round, because building the pool of labels is the expensive part, and doing it on the first hit of a cast is exactly when a hitch shows most. That’s also why they’re built in the engine and not in each minigame: two pools drawing the same emitters would double every burst on screen and pay the cost twice.

The whole effect lives in a container drawn behind the dial, and that’s what gives it its shape. The dial face stays clean and readable, and what you see is the halo overrunning the rim and the sparks raking outwards. Whether it ends up behind depends on which container the emitter lives in, not on its Z-index, because with ZIndexBehavior.Sibling a particle never outranks its parent’s siblings however high its own index goes.

Each hit emits a number of particles according to its precision (30 for excellent, 15 for good, 3 for ok and 2 for bad), except for layers that are a single flash or shockwave, which declare their own count through an attribute. When the round ends, the effects keep running for 1.2 s so the last hit’s sparks burn out on their own instead of freezing mid-air, and then they’re paused so nobody’s computing a timeline no one is looking at.

7.2 Feedback

A hit plays one of two sounds at random, buzzes the controller for 0.1 s, emits particles, shakes the screen slightly and tints it with its precision’s color. A miss has its own sound, shakes the dial, tints it red and shakes the screen harder. The buzz is for hits only. A miss already has a shake, a flash and a sound, and a buzz on top would make missing the louder of the two events. It’s what Swink calls game feel [1]: much of whether a click feels good or bad isn’t in the rule but in what happens on screen during the next tenth of a second.

The progress bar is a transparency gradient with two keypoints placed very close together, at f and f+0.001, which draw a sharp edge. To animate it, a NumberValue is tweened and the gradient is rebuilt whenever it changes.

7.3 Details

The tutorial freezes the first bite. The needle stops, the timeout stops, the target arc pulses yellow, and the click that releases the freeze is consumed rather than scored, because it’s the player saying “got it”, not an attempt. A function, WouldHit(), answers without touching anything whether a click right now would hit, and the contextual hint row uses it to tell the player when to click without the dial knowing hints exist. And the function that opens a round is called OpenRound and never Start, because the client bootstrap calls Start on every module that has one, and under that name every boot opened a round with the module table itself as its configuration.

8. Limitations

The Markov model assumes independent attempts with a fixed probability, and a real player improves during the fight and can also miss several times on the same approach by clicking early. The 0.07 s figure for a young player’s anticipation error is a design assumption, not something I measured. And the numbers in the difficulty model are hand-tuned, so a new rod or rarity means revisiting the table in section 4.4.

9. What I took away

Separating the engine from the rules fixed bugs that weren’t logic bugs but duplication bugs, and it also made it possible to write difficulty as a pure function that can be tested without opening the game, which is how I built the table in this article. The other lesson came from the tug of war: a scoring rule that looks like a detail changes the shape of the win curve a lot, and it’s worth computing before deciding how many hits to ask for.

References

  1. S. Swink. Game Feel: A Game Designer’s Guide to Virtual Sensation. Morgan Kaufmann, 2009.
  2. J. G. Kemeny and J. L. Snell. Finite Markov Chains. Van Nostrand, 1960.

This text by Mastedore. All rights reserved.