530 quests, one graph and three slots.
Warning
Rod and Reef hasn’t been released yet, and videos or GIFs of this system can’t be shown until it launches.
Abstract. Rod and Reef has 530 quest definitions. 478 of them are pure fishing (“catch 5 Perch for Morris”) and the rest are errands: deliver something, find a lost item, ferry someone by boat, tail somebody or go and talk to a character. NPCs hand them out through 89 questline files organized in three tracks: fishing, side quests and story. This article describes the technical decisions behind the system. Every quest is a piece of data built with a chained interface, with three kinds of objective and three states. A single profile class runs on the server, where it writes into the player’s saved data, and on the client, where it is a read-only copy, so both sides ask the same questions with the same code. Prerequisites form a directed graph, and when a quest is disabled it is removed from the graph while keeping the constraints that sat behind it. Active quest slots are classified from the objectives instead of being written by hand into 530 files. And the journal sorts quests by an estimate of how many casts they take to finish.
1. The problem
Quests are the thread that holds Rod and Reef together. They give the player something to catch, someone to sell it to and a story to follow across four islands. That makes for a system with a lot of content and a lot of crossing rules. An NPC offers their quests in order and only once the player has the level and has finished the earlier ones, but a story quest can depend on another one handed out by a different NPC on another island. Some quests are handed in to a different character from the one who gave them. The player can carry several at once, but not any combination. And during testing, quests whose content wasn’t on the map yet had to be disabled without breaking the story chain that ran through them.
2. A quest as data
Every quest is a module returning a definition built with a chained interface:
return QuestDefinition.new("ChonkyTunaQuest")
:SetData({
Name = "Chonky Tuna Quest",
Description = "Bring 55kg of Tuna total to Paul.",
})
:AddObjective(ObjectiveDefinition.new("total_weight_tuna", 5500, {
Description = "Bring 55kg of Tuna total",
Kind = "TotalWeight",
}))
:AddReward(RewardDefinition.new("Coins", 9560, {}))
:AddReward(RewardDefinition.new("XP", 8200, {}))
There are three kinds of fishing objective. Count counts catches. SingleWeight completes with a single catch that weighs at least the target. TotalWeight adds up the weight of several. Weights are stored in hundredths of a kilogram, , so 55 kg is 5500 and comparisons are between integers, with no floating-point error at the boundary. Progress is clamped to the target, , so one huge catch doesn’t leave the counter above what the quest asks for.
The objective’s name follows a convention the rest of the game reads. catch_perch, weight_lunar shiny serpentfin and total_weight_tuna tell the fishing system which fish counts, with the modifiers (lunar, shiny, glowing) written as words in front of the name. Of the 554 objectives in the game, 482 are fishing objectives in those three shapes, 36 are boat rides and the rest are split between talking, finding, delivering, collecting, tailing and a few others.
A quest moves through three states, Locked, Active and Completed, plus a separate flag for whether its reward was claimed. Cancelling sends it back to Locked with its progress at zero. Every state change fires signals (QuestActivated, QuestCompleted, ObjectiveProgressChanged), and everything else, from the UI to the barks, reacts to those signals.
3. One profile, two stores
QuestProfile is the class that answers questions like “is this quest active?” or “are all of its objectives done?”. It stores nothing itself: it reads and writes through a store, and there are two.
On the server, the store writes into the player data managed by ProfileStore, under paths like Quests.<id>.Objectives.<objective>. On the client, the store is a copy of that same data, fed by the general player-data replication, and the profile is created read-only, so any write on the client does nothing. The code that decides whether a quest is ready to hand in is the same on both sides, and the client UI can never reach a different conclusion than the server by having reimplemented a rule.
Quest events travel separately, as binary ByteNet packets. The server wires the profile’s signals to packets (QuestStateChanged, ObjectiveProgressChanged and others) and, when the player joins, sends a full snapshot of all their quests. There is one distinction that took a bug to understand. A QuestStateChanged is an event: the client treats it as a live transition and reacts, for example with the “quest cancelled” banner. The snapshot, on the other hand, means “this is how things stand”. When the client moved its UI ahead of time and the server refused the request, nothing changed on the server and therefore no signal fires, and the client would sit in a state the server never agreed to. That’s what SyncQuest is for: it sends a single quest’s snapshot down the “this is how things stand” path rather than the “this just happened” one.
The client copy can be filled two ways: the server’s initial push, or an explicit request if the push was missed. At first only one of the two announced that the data had arrived, and every system waiting for that announcement (the quest diamonds over NPCs, the compass, the journal) was left with the empty answer it had read a moment earlier. Now both routes announce it, and the announcement is deferred by a frame, because the lazy fill happens inside another system’s read, and firing there would re-enter every listener halfway through that read.
4. Questlines
Each NPC has an ordered list of entries. An entry says which quest it is, which dialogue node offers it and which one checks it, the required level, the quests that must be completed first, and two optional flags: whether the entry only exists to take back a quest another NPC handed out, and whether the quest is a cutscene instead of a conversation. An NPC’s island comes from the folder its file lives in (Questlines/Island4/Albert.luau is island 4), which puts a per-island level floor in place without writing it into every entry.
4.1 Merging tracks
An NPC’s fishing quests and side quests compete for the same spot in their queue, so they are merged into a single list ordered by level. Since both lists already come sorted, the merge is the merge step of mergesort [3], in time, and it’s stable: on a level tie the fishing entry wins. Story quests are not merged. If they were, a single story quest at the head of the queue would hide every fishing offer behind it, and the player is allowed to carry both. So the story is a second track with higher priority, checked first at every step.
4.2 What an NPC offers
When a conversation opens, the NPC starts with the check node of one of their active quests if there is one, then with the quest the player is following in the journal if this NPC offers it and it’s already available, then with the first available quest in their queue, and if nothing is left, with a goodbye node. A followed quest can only jump the queue if it was already open to the player. A client naming a quest it hasn’t reached gets exactly the queue it would have got without naming it.
The journal uses the same logic to show each NPC’s next offer. That came from a concrete case: all of Brody’s general quests open at level 1, and the journal listed all of them as available. A player who followed the fifth one walked up to Brody and was offered the first, because that’s the order he hands them out in.
5. Prerequisites as a graph
5.1 The graph
Prerequisites form a directed graph with an edge when must be completed before can be offered. Every quest also has a minimum level . If , an entry is available when
where is the player’s level; ride quests additionally require owning a boat with room for a passenger. The graph has to be acyclic, because a cycle would leave its quests waiting on each other, and an acyclic graph always admits a topological order [1], which in practice is the order in which the player unlocks them. The story is almost a chain of single prerequisites, so the graph is long and narrow.
5.2 Removing a vertex without cutting the chain
A disabled quest can never be completed, and any entry that lists it as a prerequisite would wait forever. In a chain, that cuts off everything after it. Disabling TheLostBracelet alone would have ended the game at Finn, because the man-on-the-island quest, the Old Man’s whole track, the Steiner knife and the Chairman’s mission all depend on it, one after the other.
The obvious way out, treating the disabled quest as satisfied, is the opposite mistake. It would immediately open whatever came behind it and let a level 26 player walk into content that was gated three quests deep. What I did was replace each disabled prerequisite with its own prerequisites, recursively, and keep its level as a floor. With the set of disabled quests:
It’s the elimination of a vertex while preserving reachability among the vertices that remain, the same idea that shows up when computing transitive closures and reductions [2]. The chain keeps every constraint that still has a real quest behind it, inherits the pacing of the ones it lost, and only drops the links that no longer exist. An example with LostOnWinterspire disabled:
which is the same place in the game it always sat, minus a quest nobody can do.
The result is memoized per quest, so the whole graph costs the first time and a table lookup afterwards. A cycle can only come from a content mistake, but a prerequisite cycle must not be what takes the server down at boot, so a set of vertices being visited is kept, and a cycle is reported and cut. Results computed inside that guard aren’t cached, because they would be partial. And the list handed back to callers is a copy, because if someone sorted or trimmed it they would be rewriting the constraint for everyone.
5.3 One quest, two entries
Some quests are offered by one NPC and handed in to another, so they have two entries with the same id: the offer, with its level and prerequisites, and the hand-in, with neither, since it constrains nothing. At first both were registered in the same table keyed by quest, and which one won depended on table iteration order. The gate on FinnOnCorvane came out sometimes as “level 26 and TheZone completed” and sometimes as “nothing”, and it could change between two boots of the same server. Now only the entry that offers a quest says what the quest demands, and it’s the only one registered.
6. Three slots
A player can hold three active quests: two general and one unique. General quests are the ones you finish by fishing, without going anywhere in particular, which is why two can run in parallel. Unique quests have a place to be and one thing to do there (a delivery, a lost item, someone to ferry), and you carry one of those.
The classification isn’t written into the 530 quests. It’s derived from the objectives: a quest is general if every objective starts with catch_, weight_ or total_weight_, which are exactly the three shapes the fishing system knows how to advance. Reading a convention that is already load-bearing is safer than adding a field that 530 files would all have to agree on. A quest can declare its category by hand and that always wins, for the fishing quest that is really an errand or the other way round. Today there are 478 general quests and 52 unique ones.
The result is cached, but only after the definitions have loaded. On the client that happens a few seconds after joining, and a question asked earlier would have cached “general, because it has no objectives” for a delivery quest for the whole session. When every slot of a category is full and the player accepts another quest of that category, nothing is cancelled and nothing is refused: the request is parked on the server and applies itself as soon as a slot frees up, and the client opens the journal on the quests standing in the way.
7. Sorting by difficulty
The journal sorts by level first, but level is coarse: a single level on Verdantis, the first island, can hold up to seventeen quests, and Khalid alone has eleven at level 41. “Catch a Scottfish” and “Catch a Lunar Shiny Serpentfin over 35 kg” can share a level, and that is exactly when the journal most needs to tell them apart. So every quest gets an effort estimate in casts.
The cost of a species comes from its rarity, using ranking weights spaced like the rarity ladder: and casts from common to secret. Each modifier in the name multiplies that cost by the inverse of its probability, for shiny, for glowing and for lunar, where the 4 accounts for lunar fish only existing during a slice of the game’s night. With the product of the modifiers, each objective shape costs
with catches, kilograms and , the species’ typical weight on the gear players hold when those quests show up. The last case is the geometric distribution [4]: if each attempt succeeds with probability , the expected number of attempts is . uses the reference gear for that rarity and the same size distribution as the luck system. Within each size category the weight is uniform, so
where is the probability of category . The 0.003 floor keeps a trophy that’s impossible on the reference gear from coming out as infinitely hard, because better gear makes it possible, and “very hard” is the right answer for sorting. Talking to someone costs 1 and any other task costs 40, about ten common catches. A quest’s effort is the sum over its objectives.
These numbers don’t predict anyone’s session, and no reward reads them. They only have to put quests in the right order relative to each other.
8. Generated content
Almost none of the 530 quests was written by hand. A Python tool reads per-NPC JSON transcriptions taken from the design documents and generates, in one run, each quest’s definition, the NPC’s base dialogue and their questline, so the three pieces can’t fall out of sync. The 20 story quests from the original design document come from another tool that copies the values out of the document’s table and, at the end, checks that the rewards add up to what the document says they add up to, 242,750 coins and 207,500 experience, so a copying mistake can’t reach the game unnoticed.
9. Limitations
The core of the framework (definition, objective, profile, stores) is among the oldest code in the game and is written in Luau without strict types, unlike almost everything that came after it. Slot classification depends on objective prefixes: if the fishing system ever starts advancing a fourth objective shape, it has to be added to the list or its quests silently become unique. Merging tracks assumes each questline already comes sorted by level, and nothing checks that. And the effort estimator uses ranking weights that aren’t derived from the luck tables, so if the economy changes a lot they would need to be revisited by hand.
10. What I took away
The two decisions that helped me most have the same shape: derive instead of declare. Slots come from objectives, effective prerequisites come from the graph and the disabled list, and the journal’s next offer comes from the same function the NPC uses. Every time something was declared in two places, it ended in a bug where the two places disagreed. The other one was treating the prerequisite graph as a graph and not as a list of conditions, because that turned disabling content from a dangerous operation into one line in a table.
References
- A. B. Kahn. “Topological Sorting of Large Networks”. Communications of the ACM, 5(11), 1962.
- A. V. Aho, M. R. Garey and J. D. Ullman. “The Transitive Reduction of a Directed Graph”. SIAM Journal on Computing, 1(2), 1972.
- D. E. Knuth. The Art of Computer Programming, Volume 3: Sorting and Searching, 2nd ed. Addison-Wesley, 1998.
- W. Feller. An Introduction to Probability Theory and Its Applications, Vol. 1, 3rd ed. Wiley, 1968.
This text by Mastedore. All rights reserved.