< Back 

Rod and Reef: Seasons

2026-09-14

45-day seasons counted from a fixed epoch, four leaderboards per season on OrderedDataStores and permanent titles for the top 50. No server coordinates with any other: the season is a function of the clock and closing it is an idempotent job.

LuauRobloxBackendDataStore
Go to quick view

Time cut into 45-day seasons.

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 competitive seasons with four leaderboards: fish caught, coins earned, experience and best catch streak. When each season closes, the top 50 of every board receive a permanent title with their rank and the season’s number. The underlying problem is that a Roblox game runs on many servers at once, with no shared state and no central process, and all of them have to agree on which season it is, close each one effectively once, and do it without spending the request budget that player data loads also depend on. This article describes how I solved it. The current season is a pure function of the clock: an integer division from a fixed epoch. Each player’s counters are reset lazily, the first time they’re touched in the new season. Closing uses no locks: any server may compute it, the computation is idempotent, and two competing servers write the same thing. And every read goes through caches with in-flight request deduplication, so a thousand players opening the same board cost a single request every 30 seconds.

1. The problem

The first version used calendar months, with ids like 2026-08. It worked, but months are 28 to 31 days long, and what we wanted was a cadence of our own and seasons of equal length. The system also had to satisfy several things at once. Every server has to agree on the current season without talking to the others. Boards have to be stored per season, and past seasons can’t change. A player who was offline when a season closed still has to get their title. And none of this can compete with the one thing that really can’t fail, which is loading the player’s data when they join.

2. The clock

2.1 The season as a function of time

Let E be the epoch, August 11, 2026 at 00:00 UTC, and Δ=45⋅86400=3888000 seconds the length of a season. The season index at time t is

k(t)=max⁡(0, ⌊t−EΔ⌋)

The maximum with zero means a clock running behind can never produce a season before the first. Twenty seasons make a cycle, so

c=⌊k20⌋+1,s=(kmod20)+1

and the season’s id is C<c>S<s>. Players see the long name, “Cycle 1 Season 2”, and everything that’s stored or used to build a key uses the short id, which has no spaces. A cycle lasts 900 days, almost two and a half years. The inverse is k=20(c−1)+(s−1), with the check 1≤s≤20, and season k ends at E+(k+1)Δ, which is also when the next one begins. Today, October 2, 2026, k=1: it’s season C1S2, which runs from September 25 to November 9.

2.2 No coordination

Since k depends only on the clock, every server computes the same season without sending a single message, except for the few seconds around a boundary where their clocks may disagree [1]. No process announces the change of season, because none is needed: the change is an instant, and each server notices it the next time it asks.

Old month-style ids don’t parse as any season, and an id that names no season is treated just like a new profile’s empty id: it resolves to the current season. So migrating profiles to the new format didn’t need a script.

2.3 The number on titles

Titles show the season’s number, like “(S7)”. That number is k+1, counted from the epoch across every cycle, not the season’s number within its cycle. If it were the cycle’s, the first season of two different cycles would both stamp “(S1)”, and two titles earned in different seasons would look identical.

3. The counters

3.1 Lazy reset

Every profile keeps a season block: the id of the season its counters belong to, fish caught, coins earned, experience, current streak, best streak, and the list of seasons already processed for titles. When a new season starts, there is no job that walks the profiles to zero them, which with profiles stored in a DataStore would be impossible anyway. Every event that touches the counters first checks whether the profile’s id matches the current season, and if it doesn’t, it updates the id and zeroes everything:

idprofile≠id(k(t))⇒counters←0,  idprofile←id(k(t))

It’s a lazy reset at constant cost per event. The check comes before touching any counter, and that closed a bug: an event could land between the profile loading and the player’s join routine, when the profile still had the template’s empty id. The increment was attributed to a season that the join routine would reset a moment later, and whatever was earned in that window was lost.

3.2 Events, not polling

The service doesn’t check anything periodically. It listens to the game’s signals: a catch, a lost fish, coins earned and experience gained. A catch adds one to fish and to the current streak, and updates the best streak with a max. Losing a fish sets the current streak to zero. Every change is synced to the client and marks the player as having board writes pending.

4. The boards

4.1 One ordered store per season

Each board of each season is its own OrderedDataStore, named after the board type and the season id (RAR_Season_Fish_C1S2, for example), where the key is the UserId and the value is the counter. Keeping one store per season means closed seasons don’t change by construction, since nobody writes to the name of a season that has ended, except for one late write that section 7 discusses.

Writing on every event would burn through the budget in minutes. Players with changes are marked, and every 30 seconds their four values and display name are written. When a player leaves, one last write is attempted, but with a bounded wait, a race between the write and a 5 s timer, so a stuck request can’t hang the leave handler. When the server shuts down, it waits for every pending write for up to 25 s, inside the 30 Roblox gives a closing server.

4.2 Reading without draining the budget

DataStores have a request budget per minute per server, and when the queue fills up the engine drops requests, including the data loads of players who are joining. All of the season system’s background work waits, before each request, until there’s budget for that request type, checking every half second, with a cap of about 150 s so it never waits forever.

Reads are also shared. A resolved board (the top 50 with their names) is kept for 30 s and served to everyone who asks within that time. The raw board page, without names, is also shared with the profile card, which shows the player’s rank and is opened much more often than the board. And if two players ask for the same board while the first request is still in flight, the second one receives the same promise instead of firing another. With that, the number of reads of a board per server is bounded by

reads≤⌈time30 s⌉

no matter how many players open it. Display names are cached per server, because they rarely change.

An OrderedDataStore only hands out sorted pages from the top, so a player’s rank is only known if they’re in the top 50. Knowing that someone is 5,213th would mean walking every page of the store. And since writes go out every 30 s, the rank trails the live counters by up to half a minute, but it’s the same data the board shows, so the two always agree, which matters more than being instantaneous.

5. Closing a season

5.1 No locks

When a season ends, its top 50 per board have to be computed and saved. No server is in charge of that, and electing one would need coordination. Instead, every server tries. Each one, 60 s after booting and then every 300 s, looks at the three seasons before the current one, and for each it hasn’t handled yet:

  1. Reads that season’s saved results, through the shared cache.
  2. If they exist, does nothing.
  3. Otherwise, requests the sorted pages of the four boards, takes the top 50 of each, and writes all four results in a single update to that season’s key.

It used to be four writes to the same key, one per board. Combining them into one costs a quarter as much and keeps the key from being left half-written.

5.2 Why no lock is needed

The computation is a function of the season and the contents of its boards, f(k), and a closed season no longer receives writes. If two servers race, both read the same boards and compute the same list, and the write merges the list into whatever is there, so writing the same value twice leaves the same state as writing it once:

w(w(S,v),v)=w(S,v)

The operation is idempotent, and the result is the same with one, two or ten servers racing. It’s the same idea that lets eventually consistent systems retry without fear [2, 3]: if repeating changes nothing, there’s no need to make sure something happens only once. Each server also remembers which seasons it already handled, so it doesn’t retry on every pass, and if an attempt fails it clears the mark so the next pass retries.

Closing waits 60 s after boot for a practical reason. Computing it is a burst of ordered-store reads and one write per season, and doing that while everyone is joining competed with their profile loads and starved them of budget.

6. Titles

On joining, each player checks the three previous seasons they haven’t yet marked as processed. For each, it reads the results (from the cache, so it’s free after the first player on the server), and if they don’t exist yet it doesn’t mark the season, so it’s checked again the next time the player joins. If they do exist, it looks for the player’s UserId in each board’s list. The list is sorted from highest to lowest, so its position is the rank.

The title is saved with a compound key, board and season (for example Fish@C1S2), together with the rank. A simple key like the_sea_king would already be set the second time someone earned it, and the title has to be a permanent record that’s distinct per season. It shows up as “#3 SEA KING (S2)”, the player sees the reward screen, and the season is marked as processed, so processing it again grants nothing. There’s one title per board: SEA KING for fish, GOLD SOVEREIGN for coins, GRAND MARINER for experience and RELENTLESS KING for the streak.

7. Limitations

There is a window of up to 30 s at the end of each season. A player who catches a fish right before the boundary still has their profile on the old season, and their write goes out on the next 30 s pass, to the old season’s board. If another server closed that season in those few seconds, that last change doesn’t make it into the result. If reading one of the four boards fails during the close, that board is written as an empty list so the other three aren’t lost, but since the result then exists, nobody computes it again, and that board stays empty for that season. A player who doesn’t join for more than three seasons, 135 days, no longer gets the titles from the ones left behind. And ranks outside the top 50 can’t be known with an OrderedDataStore.

The first two have known fixes: wait a few minutes after the boundary before closing, and don’t save a failed board’s result so it gets retried. I haven’t done them yet.

8. What I took away

Treating time as a pure function removed all coordination between servers, and the lazy reset removed the only job that would have had to walk every profile. The most useful idea, though, was idempotent jobs: instead of guaranteeing that something happens exactly once, which is hard in a distributed system, design the job so doing it twice is the same as doing it once, and then let anyone do it. And the last lesson was about shared resources. Seasons are a secondary feature, and most of their code exists to avoid taking budget away from what actually is critical.

References

  1. L. Lamport. “Time, Clocks, and the Ordering of Events in a Distributed System”. Communications of the ACM, 21(7), 1978.
  2. W. Vogels. “Eventually Consistent”. Communications of the ACM, 52(1), 2009.
  3. M. Kleppmann. Designing Data-Intensive Applications. O’Reilly, 2017.

This text by Mastedore. All rights reserved.