Talking to yourself at the right moment.
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 the protagonist, X, comments on what the player is doing: casting, waiting for a bite, losing a fish, sailing, or walking to an errand. These short lines are called barks, and the game has 74 of them recorded. Making each one play at the right moment required being able to ask “what is the player doing right now”, and the game had no single place that answered it: the state was spread across private variables inside each controller. This article describes the two pieces I built to fix that. The first is PlayerStateMachine, a set of finite state machines running in parallel, one per owning system, with weights that pick an effective state and that detect transitions no controller ever writes. The second is the BarkDirector, which turns game moments into lines using a data catalog, per-moment probabilities, priorities, a global spacing rule and a shuffled deck instead of a die. On top of the same machine sits PlayerAvailability, a single definition of “the player is busy” shared by barks, menus, prompts and the rendering of other players.
1. The problem
The voice actor recorded 74 lines from a script of about 94, grouped by situation: standing around, walking, driving the boat, fishing, standalone jokes, reactions after handing a quest to each NPC, rare moments (a legendary fish, a glowing one) and a change of tone for the last part of the story. Each line only works at its moment. “Come on, come on. Bite already.” makes sense with the line in the water, not on a dock.
The player’s state existed, but nothing outside could read it. FishingController kept it in a local variable, currentState, assigned in 19 different places. BoatController knew when the player sat at the helm. Quests had their own signals. None of that was visible from the outside, so any new system had to infer it again, and inferring fails. The game already had an example: LocomotionStateMachine derives the state from the Humanoid every frame and returns Idle for someone driving a boat, because technically they are seated and not moving. Barks built on top of that would have fired “standing here doing nothing” lines in the middle of a voyage.
One constraint mattered more than the rest. The fishing state is load-bearing, not informational: FishingController has guards like “if I am in COOLDOWN, don’t cast”, and they are the only thing preventing a double cast or a cast during the minigame. If that state moved to a global object anyone could write, a bug in the boat code could break casting, with the symptom showing up far away from the cause.
2. Several machines in parallel
2.1 One machine per owner
A player is in several states at once. Fishing from a boat means fishing and being on a boat, and neither is false. A single state machine cannot represent that without inventing a state for every combination, so PlayerStateMachine is a set of small machines, one per owner.
Let be the set of owners (Foot, Boat, Fishing, Steal, Dialogue, Story and others). Each owner has a finite set of states , and its machine holds at most one of them:
where means that owner has nothing active. Entering a state of replaces whatever held, and that is what makes each owner a real machine rather than a bag of booleans: Fishing cannot be in Fishing.Casted and Fishing.Minigame at the same time, by construction. The idea is the one behind orthogonal regions in Harel’s statecharts [1], where a composite state is split into several machines that advance independently.
Composition avoids enumerating the product. With the owners that hold states today (Foot with 1, Boat with 1, Fishing with 4, Story with 2, Dialogue with 1 and Steal with 2), the number of configurations is
and each of the 9 hand-written tags (section 2.4) doubles that count, so a flat machine would have to name more than combinations. Here each controller writes only its own region and nobody has to think about the others.
2.2 The effective state
There still has to be one answer to “what is he doing”, because that is what a bark wants. Every state has a weight and the effective state is the heaviest active one:
The others stay active and can still be queried with IsStateActive; they just aren’t the headline. The weight belongs to the owner, not to the state: two states of the same owner are mutually exclusive, so their weights are never compared. The tiers are Foot 10, Boat 20, Fishing 30, Steal 40, Dialogue 50 and Story 60, with gaps so a new owner can be slotted between two existing ones without renumbering anything.
The order follows one rule: the headline is the most specific thing the player is doing. Fishing outranks the boat you fish from, and the boat outranks walking. A theft outranks fishing because the chase is what you are doing now, and the cast you abandoned is not. A conversation outranks the theft because the server never starts a theft against someone who is talking, so the only way to hold both is a chase that was already running when an NPC started talking, and at that point the NPC is what’s on screen. A cutscene outranks everything, because the player controls nothing.
Ties are broken by name, in lexicographic order. With the current vocabulary they can’t happen, since weights are per owner and every owner has a different one, but the code does not rely on that: Luau doesn’t define table iteration order, and two states of equal weight must resolve the same way on every machine and every frame.
2.3 The transition nobody writes
This is the subtle part of the design. StateChanged doesn’t fire from Enter or from Exit. It fires from one place, refreshEffective, which recomputes after every write and announces it only if it moved:
An example shows why. The player is fishing from the boat, with and . They stop fishing, and FishingController calls Clear("Fishing"). No controller ever said “enter Boat.Driving”, but the effective state becomes the boat, because something heavier went away. That moment, “I stopped fishing and I’m still sailing”, is real, and it’s exactly the kind of thing X would comment on. An implementation that announces from the raw writes never sees it, because every individual write is correct.
Redundant writes do nothing. Entering the state you already hold returns immediately, and leaving a state the owner no longer holds isn’t an error either, because teardown paths fire without knowing which phase they are cleaning up from.
2.4 Tags
The other half of the system is tags: free-floating booleans for things that are true about the player but could never be their main activity. There are nine hand-written ones today (Sprinting, FishEquipped, UsingBait, HasObjective, Night, PostReveal, MenuOpen, UsingMicrophone and Afk), plus one per kind of sub-zone on the map, generated from the sub-zone list so the vocabulary and its writer come out of the same code.
The test for state versus tag is whether the thing could answer “what is he doing right now”. Driving a boat could, so it’s a state. Carrying bait couldn’t, so it’s a tag. There is also no InBoat tag: Boat.Driving stays active while you fish from the boat, so IsStateActive already answers that, and a tag next to it would be a second thing to keep in sync with the first.
Whoever asks shouldn’t have to remember which was which, so there is one unified predicate:
The bark catalog uses it for its conditions (section 4.2).
2.5 Owned writes and a closed vocabulary
The owner is written into the name: Fishing.Casted belongs to Fishing, and only Fishing may enter or leave it. A write from any other owner is rejected with a warning. That way FishingController keeps its guards, because controllers keep their own logic and only publish to the machine. Reads are global and writes are private, and the migration could be done one controller at a time.
The whole vocabulary (states, weights, tags and their owners) is registered when the machine is required, not when some controller boots. ClientStart walks the modules in an order nobody controls, and a controller that published a state before it was defined got a warning and nothing happened. The symptom was “barks don’t work sometimes”, which is about as bad as symptoms get. With registration at require time, holding a reference to the machine guarantees the names exist. For the same reason, every name is used through a constant and never typed as a string, because a misspelled name is a write that silently does nothing.
3. One definition of “busy”
3.1 Sets and predicates
Two systems needed the same answer and had it written twice: the bark director, so X doesn’t mutter about the weather over an NPC’s line, and the menu library, so the inventory can’t be opened in the middle of a cutscene. Written twice, those lists drift. Someone adds a sixth interruption to the bark list, nobody remembers the menu list, and the game ends up with an inventory you can open during a cutscene that X politely refuses to talk over.
PlayerAvailability defines that answer once, as sets of states and predicates over the machine:
holds the states where the player is being shown something, the interruptions and the states where something takes over the whole screen. With them:
- Barks may play if no state in is held, no menu is open and the microphone isn’t live.
- Menus may open if no state in is held.
- World prompts are drawn if no state in is held, because during the minigame their key would compete with the dial’s.
- Other players are drawn if no state in is held.
The differences between the lists are decisions. A theft doesn’t hide other players, because hiding them would hide the person you are chasing. The minigame isn’t an interruption, since the player chose it and is playing it, but it does take over the screen. A live microphone silences X because the player is in a real conversation, and a subtitle appearing on top would be the game talking over a person, but it doesn’t stop anyone from opening their inventory.
The module reads the machine and nothing else. It doesn’t know what a cutscene is, only that Story.Cutscene is held, so adding a new interruption means declaring a state and listing it, with no new coupling.
3.2 Level-triggered bridges
The game’s menu library, Pretty, is a general UI library and has no business knowing the game has cutscenes or thefts. The machine, for its part, doesn’t know who consumes it. The only module that knows both is PrettyStateBridge, which connects them in both directions. A stacked menu on screen becomes the MenuOpen tag, and a held interruption becomes a named hold on menus, with a message for the player (*Not during a cutscene.).
What matters is how it computes the hold. Electronics distinguishes between edge-triggered logic (reacting to each transition) and level-triggered logic (reacting to the current value). A hold driven by edges only survives if every enter and every exit is seen exactly once and in order, and cutscenes, dialogues and thefts overlap and have teardown paths that fire in any order. So the bridge recomputes the hold from scratch on every change:
If an edge is missed, the next one corrects it, instead of leaving menus locked for the rest of the session. And since depends only on the state, hearing the same change twice (once through StateChanged and once through TagChanged) costs nothing, because the second call finds nothing to change.
4. The director
4.1 Named moments
The only contract between the system that knows something happened and the line that comments on it is a name, the cue. A controller calls Barks.Fire(Barks.Cue.FishingCatchFail) and forgets about it, as in the observer pattern [2]. Whether anything is said, and what, is up to the catalog and the director, which is why adding new lines never means touching a controller.
There are two kinds of cue, and the difference matters. Derived cues (State.Fishing.Casted.Enter, State.Idle.Dwell and so on) are fired by nobody: the director subscribes to StateChanged and raises them itself, including the transitions from section 2.3. Instant cues (Fishing.CatchSuccess, Catch.Legendary, Quest.HandedIn and others) are fired by whoever knows the moment, with an optional payload such as the rarity or the NPC. The rule is to use the machine for things that last and a loose cue for things that happen. “Just caught a legendary” isn’t a state. Modelling it as one would require somebody to clear it afterwards, and you would end up with a stale JustCaughtLegendary hanging around. There are 8 derived cues and 6 instant ones.
4.2 The catalog
BarkCatalog is a data table with one row per recorded line. Each row says which cues it can answer, which names must be true (Require), which must not be (Forbid), what the payload must contain (Match), how long before it can repeat (Cooldown) and its priority: 0 for ambient lines, 1 for lines about the current action and 2 for rare moments. It is the editorial judgement of “this line makes sense here”, written as a table instead of as ifs scattered across controllers.
A few rows show what each field is for. “Breaking news! Local fisherman still hasn’t figured out how bait works!” answers a lost fish, but requires UsingBait, because the joke only lands if there is bait to have misused. “Yeah, I could do this forever. Just point me at the horizon.” answers a long stretch of sailing, but forbids Sprinting, because it is contemplative. “This one’s a 2. Rude little guy.” only answers a catch with Rarity = Common. And the late-story lines require PostReveal and join the same pools as the regular ones, competing with them. The tone shift the script asked for was solved with one tag and zero new states.
Lines were assigned by what the actor actually said in the take, not by the script section they came from, because he improvised and some ended up closer to a different moment. 73 of the 74 are in the catalog. The missing one is about rain, and the game has no weather: there is not a single reference to rain or storms in the code, so there is no honest moment to hang it on. Two others were written for pulling a boot or a rock out of the water, which the game also doesn’t have, so they moved to the lost fish, the closest real beat. The “biggest catch of my life” lines needed a personal weight record that isn’t stored anywhere, so they hang on the legendary catch.
4.3 Eligibility
For a cue with payload , a line from the pool of is eligible at time if
where is the last time it played and its cooldown, 300 s by default and 600 to 900 s for rare moments. The default is long on purpose: hearing the same joke twice in one session is what makes a bark system feel cheap.
Picking lines with rules over the game state isn’t a new idea. The dynamic dialog system Valve used in Left 4 Dead takes it much further, with queries over hundreds of facts and the most specific rule winning [3]. With 73 lines that wasn’t needed, and a table of explicit conditions is easier to review by hand.
4.4 A deck, not a die
Picking independently at random among lines sounds broken very quickly. It is the birthday problem [4]: the probability that the first picks are all distinct is
and the expected position of the first repeat is , where is Ramanujan’s -function [5]. For the “waiting for a bite” pool, which has 6 lines before the end of the story, that comes to 3.8: on average, the fourth line you hear is already a repeat, and the chance of hearing a repeat within the first four casts is . With the 9 lost-fish lines it’s 0.54, and with the 13 idle lines it’s 0.40. Players read that as a bug, not as bad luck.
So lines are dealt from a shuffled deck, using the Fisher and Yates algorithm in Durstenfeld’s version [6], and the deck is only reshuffled once it runs out. That leaves one seam: the last card of one deck can be the first of the next, the only place a back-to-back repeat survives. When reshuffling, if the first card matches the last one dealt, it is swapped with the last card of the new deck. With that, for a pool of lines, no line is dealt twice before every line has been dealt, and the same line never comes out twice in a row.
Cards that aren’t eligible at that moment (on cooldown, or with a false Require) are discarded for that pass. draw walks at most two passes, what is left of the deck and a freshly shuffled one, so a line skipped in both isn’t badly positioned, it’s just not eligible right now. The deck’s initial order is sorted alphabetically so it’s deterministic, and the shuffle randomizes it.
4.5 The order of the filters
Fire(c, π) rejects in this order:
- The system hasn’t started, the player turned barks off in the settings, or some system is holding X quiet (section 4.8).
AllowsAmbientBarks()is false.drawfinds no eligible line.- A bark played less than 8 s ago and this line isn’t priority 2.
- A bark is speaking with equal or higher priority. If the new one has higher priority, it cuts the old one off with
StopSessionand takes the screen. - The cue’s probability roll fails.
If everything passes, the line plays through the SubtitleController, and the time of the last bark are stamped, and a “speaking” lock is taken with a 30 s expiry, in case the session never resolves (cancelled halfway, UI torn down on respawn).
The order matters in two ways. The cheap rejections come before draw, because draw spends a card from the deck: if interruptions were checked afterwards, every cue fired during a cutscene would eat the line X would have said, and a Forbid list on each of the ~60 entries would have that same problem on top of needing to be kept up to date. And the probability roll comes last, because rolling first and then finding nothing eligible would silently make every probability in the catalog lower than it reads. There is no queue either. An ambient line is about the moment it fired in, and saved for after a cutscene it would be a comment on nothing.
The global spacing exists because of one specific sequence. Casting, getting a bite and landing the fish are three cues in about 4 seconds. Without spacing X would talk over himself, and worse, the SubtitleController stacks concurrent sessions in separate rows, so you would also see two subtitle lines at once.
4.6 Probability per moment
The probability isn’t one global number. A flat 50% on moments that happen constantly, like setting off on foot or casting, makes X narrate half of what you do, and that grates within an hour. The same 50% on moments that happen once a session throws away half of what was recorded for them. Common moments stay quiet and rare ones almost always land:
| Cue | Kind | Probability |
|---|---|---|
| Start walking | derived | 0.15 |
| Walking for a while | derived | 0.25 |
| Take the helm | derived | 0.40 |
| Sailing for a while | derived | 0.30 |
| Cast | derived | 0.25 |
| Waiting for a bite | derived | 0.30 |
| Finish a catch | derived | 0.15 |
| Standing still | derived | 0.35 |
| Land a fish | instant | 0.30 |
| Lose a fish | instant | 0.45 |
| Boat speed burst | instant | 0.35 |
| Legendary fish | instant | 0.90 |
| Mutated fish | instant | 0.90 |
| Hand in a quest | instant | 1.00 |
Putting all the filters together, the probability that a cue produces a line is
where is the priority of the line drawn. With the indicators at 1, the probability in the table is the one you get.
4.7 Staying a while
Dwell cues (“has been doing this for a while”) come from the director’s own timers: 25 s standing still, 45 s walking, 30 s sailing and 12 s with the line in the water. Any change of effective state restarts the timer. They are checked by a once-per-second heartbeat rather than task.delay, because the timer has to be cancellable by a state change, and a delay that already fired can’t be taken back. A check per second costs far less than tagging every callback with a generation, and the latency is one second at most.
The timer re-arms after every window, whether or not anything was said. Standing still for five minutes can produce more than one line, but never more than one attempt per window, so the pace has a ceiling:
With 13 lines in that pool and a 300 s cooldown on each, the cooldown is almost never what limits it.
4.8 Going quiet
Fire only decides whether a line starts. A line that is already playing when a cutscene opens is a separate case, because nothing asks again after it has started. So the director listens to StateChanged and TagChanged, and if AllowsAmbientBarks() turns false it cuts the line with StopSession, which removes the text together with the audio. Cutting only the audio would leave the subtitle on screen.
There are two switches, and they don’t mix. The player’s one (Graphics > Ambient Barks) is a boolean. The systems’ one is a set of named sources, and X is held quiet if that set isn’t empty. The first user is the combat tutorial, a stretch of several minutes with the combat master talking. If the tutorial wrote the same boolean as the player, it would hand the preference back as “on” when it finished, whatever the player had chosen. With two separate states, releasing a hold restores what the player picked, and releasing a hold that was never taken does nothing, so callers can release unconditionally on their way out.
All of this runs on the client only. X is talking to himself, nobody else on the server hears it, so there is nothing to replicate.
5. Limitations
The deck’s guarantee is about what gets dealt, not about what gets heard. draw pulls the card before the spacing, the lock and the roll, so a line that loses the roll is discarded from that pass without playing. The deck keeps rotating even when X stays quiet, which isn’t serious, but it means not every line is heard before one repeats.
The content depends on which systems exist. With no weather, no junk catches and no personal records, three moments from the script have nowhere to play. The catalog notes where to move each line the day one of them exists.
The dwell timer restarts on any change of effective state, including derived transitions, so entering and leaving something heavier restarts the count for whatever was underneath. And availability is computed on the client: the server has its own checks for anything authoritative, and the only tag it reads is Afk, which the client reports through a packet.
6. What I took away
Separating what is true (the machine), what gets said about it (the director) and who may interrupt (availability) kept each piece small and let me change any of the three without touching the others. The second lesson was to prefer values derived from state over values maintained by events. The derived transition in section 2.3, the menu hold in 3.2 and the line cut-off in 4.8 are the same pattern: instead of trusting that every event is seen once and in order, recompute the answer from the current state, and a missed event stops being a permanent bug.
References
- D. Harel. “Statecharts: A Visual Formalism for Complex Systems”. Science of Computer Programming, 8(3), 1987.
- R. Nystrom. Game Programming Patterns. Genever Benning, 2014.
- E. Ruskin. “AI-driven Dynamic Dialog through Fuzzy Pattern Matching”. Game Developers Conference, 2012.
- W. Feller. An Introduction to Probability Theory and Its Applications, Vol. 1, 3rd ed. Wiley, 1968.
- P. Flajolet and R. Sedgewick. Analytic Combinatorics. Cambridge University Press, 2009.
- R. Durstenfeld. “Algorithm 235: Random Permutation”. Communications of the ACM, 7(7), 1964.
This text by Mastedore. All rights reserved.