A Stardew-Style Fishing Minigame in a Server-Authoritative ARPG
How to add a twitchy, client-side minigame to a game where the server decides everything, and test it without a human.
Why fishing? #
Grind games need a breather. Between Shadow runs it's nice to stand at a lake at night and try for the Golden Carp. Fishing sat on the backlog early on, and lakes were placed with it in mind.1 The backlog names Mirror Pond, Fisher's Pool and the Moonlake. The model was Stardew Valley's reeling minigame: a vertical meter, a fish that darts up and down, and a bar you lift by holding a button. Keep the fish inside the bar until the catch meter fills.
The interesting part is not the minigame itself. It's how a reflex game fits into an architecture where the server decides everything, and where a client should never be able to say "I caught a Golden Carp" and be believed.The screenshots in this post are from the day fishing landed, when the player was still a placeholder capsule. The character is a pixel sprite now; the fishing code hasn't needed to change, because the rod, line and bobber are their own visual node.
The loop #
Equip a Fishing Rod on the hotbar (or right-click it in the bag) and use it near water:
- Cast: the bobber lands on the nearest fishable water within 9 m of where you aim.
- Wait: after 2.5 to 7 seconds, a fish bites. A "!" appears.
- Hook: press attack within 1.1 seconds.
- Reel: win the minigame, and the fish goes into your bag.
Moving more than a metre, getting hit or dying reels the line in.
Fish are data #
Which fish bites depends on the liquid, the map and the time of day, all in data/fish.json:
{ "item": "silver_eel", "liquids": ["water", "swamp"], "time": "night", "weight": 14, "difficulty": 55, "behavior": "dart" },
{ "item": "golden_carp", "liquids": ["water"], "time": "night", "weight": 2, "difficulty": 80, "behavior": "floater" },
{ "item": "ghostfin", "liquids": ["swamp"], "time": "night", "weight": 3, "difficulty": 85, "behavior": "dart" },
{ "item": "oasis_koi", "liquids": ["water"], "maps": ["sunscar_desert"], "time": "any", "weight": 12, "difficulty": 65, "behavior": "floater" },
There are 14 fish, from the Old Boot (difficulty 5, it just sinks) to the Ghostfin that only bites in the
Marsh at night. Time of day comes from the shared WorldClock, a 36-minute day read from the wall clock,
so everyone on a server agrees whether it's night without any network traffic. Fish are ordinary items
and can be sold.
The minigame is pure logic #
FishingMinigame is a RefCounted with no nodes, no input and no drawing. One method advances it:
## One step; holding = the reel key is down. Returns 1 when caught, -1 when it got away, else 0.
func step(delta: float, holding: bool) -> int:
elapsed += delta
bar_speed = clampf(bar_speed + (LIFT if holding else -GRAVITY) * delta, -MAX_BAR_SPEED, MAX_BAR_SPEED)
bar_pos += bar_speed * delta
var top := 1.0 - bar_size
if bar_pos < 0.0:
bar_pos = 0.0
bar_speed = -bar_speed * BOUNCE if bar_speed < -0.3 else 0.0
elif bar_pos > top:
bar_pos = top
bar_speed = 0.0
_move_fish(delta)
if fish_inside():
progress += FILL * delta
else:
progress -= (DRAIN + difficulty * DRAIN_PER_DIFFICULTY) * delta
Holding the key accelerates the bar up; letting go lets gravity pull it down, and it bounces a little off
the bottom. The fish picks a new target every so often and swims toward it. Difficulty (0-100) sets
how far and how fast it moves, and behaviour shapes the moves: smooth, dart (bigger, quicker
jumps), sinker (drifts down), floater (drifts up) or mixed.
Keeping it pure pays off twice. The UI (FishingWindow) just draws its state each frame. And it can be
tested in a tight loop with no scene at all, which matters below.
Who decides what #
Here is how the work splits between server and client:
- The server runs the state machine in the
Fishingnode: cast, bite timer, hook window, and the result. It picks the fish (with its item, difficulty, behaviour and a seed), and it never tells the client which item it is until the catch. - The client plays the minigame locally with that difficulty, behaviour and seed. Reeling is a reflex game, and a round trip per frame would make it unplayable online.
- The server checks the result for plausibility. The client reports a win or a loss. The server knows when reeling started, and a win that came too fast is rejected:
## Reeling can't be won faster than this (the minigame needs ~3.2 s at best).
const MIN_REEL := 1.6
if not won or _since < MIN_REEL:
_send("escaped", ["It got away..."])
return
var item := ItemDb.create(str(bite["item"]))
if item == null or actor.inventory == null or not actor.inventory.add_item(item):
_send("escaped", ["Your bag is full: you let it go"])
return
Is this cheat-proof? No. A modified client could always "win" after two seconds. But it can't choose the fish, can't catch faster than the rules allow, can't fish without a rod or away from water, and can't skip the bite timer. For fishing in a co-op game, that's the right amount of server authority. The hook window gets a 0.4 s grace for network delay, so playing online isn't harsher than playing alone.
Every step (cast, bite, reel, caught, escaped, cancel) goes out as an event to the peers who can see the
player, so other players see your rod, the line and the bobber dipping. FishingFx draws all that: the
rod in hand, a line, a bobber that bobs, dips hard on a bite and shakes while reeling, and splashes. It's
purely cosmetic, like every visual node in the game.
Testing a reflex game without a human #
Because the minigame is pure logic, a test can play it. FishingMinigame even ships an autopilot(), "a
decent player" that holds while the fish is above the bar's middle and looks a little ahead:
func autopilot() -> bool:
return fish_pos - (bar_pos + bar_size * 0.5) - bar_speed * 0.3 > 0.0
The test then checks the properties that matter:
var easy := FishingMinigame.new(12.0, "smooth", 7)
var result := 0
while result == 0 and easy.elapsed < 60.0: # keep the bar's middle on the fish
result = easy.step(1.0 / 60.0, easy.autopilot())
_check(result == 1, "following the fish catches it")
_check(easy.elapsed >= Fishing.MIN_REEL, "no catch faster than the server allows (%.2f s)" % easy.elapsed)
The second check is important. It proves that an honest, good player can never be rejected by the
server's plausibility rule. If someone later tunes FILL up and makes catches faster than MIN_REEL, this
test fails. Other checks in the same test: doing nothing against a hard darting fish loses it within 10
seconds, every fish in the data is a real item, the Ghostfin bites only at night, regional fish stay in
their region, and there's no fishing in lava.
A second test runs the full flow on the Proving Grounds test map: no rod, no fishing; a cast onto the pond; the bite; the hook; the catch arriving in the bag.
Gamepad #
Fishing arrived on the evening of day three. Later that night the game got gamepad support, which
touched fishing too: on a pad, the reel is the attack button like everything else. The GamePad node
remembers whether the last input came from a pad, and hints then name pad buttons (A/B or Cross/Circle
depending on the controller). While a window is open, the pad drives its buttons: the d-pad moves focus,
A presses, LB/RB switch tabs, and B closes it. Bindings live in a rebindable pad column in the Controls
settings.
Takeaways #
- Split reflex from authority. Let the client run the twitchy part with server-chosen parameters and seed; let the server own the outcome and check it for plausibility.
- Keep minigames pure. A
step(delta, input)function is trivial to draw, network and test. - Give tests an autopilot. A bot that plays "decently" lets you prove that honest players never trip your anti-cheat rules.
- Time of day from the wall clock gives every player the same world without any network traffic.
What's next #
The next post is about how the work itself is organized now: a coordinator session with parallel subagents, approval gates, before/after reviews, and what happens when four agents edit one repository.