Three offers every server has to agree on.
Warning
Sword Masters was abandoned in October 2024, and most of its animations, sounds and images belong to the group that owned it, so the game can’t run as it did and this system can’t be shown on video.
Abstract. The Sword Masters ability shop doesn’t sell the whole catalog at once. It shows three offers drawn by rarity, and those three change on a fixed clock, with a countdown on screen. The draw and the clock loop came with the code I inherited, and the loop itself started from a DevForum tutorial for daily shops. I adapted it to the new game and added the part that keeps every server on the same offer: the current offer is saved in a DataStore, and when a server rotates it, it announces the new one through MessagingService. This article covers the draw and its real odds (the Legendary shows up as often as the Rare), how the servers agree and where they can disagree, the clock and a mismatch between how often the offer changes and what the countdown says, and what the shop never got to do.
1. The problem
Five abilities can be bought: Superjump, Stomp, Burger, Fireball and Laser beam, one per rarity from Common to Legendary. The shop shows three. A Roblox game runs on many servers at once that share no memory, and players move between them: if the shop shows one thing on one server and something else on another, the player notices right away. When the offer changes, it has to change on every server at the same time.
2. The draw
2.1 The rarity
The offer is drawn in two steps: first a rarity, then an ability of that rarity. The weights are in Settings.Abilities.SpecialOffersChances:
| Rarity | Weight |
|---|---|
| Common | 40 |
| Uncommon | 20 |
| Rare | 10 |
| Epic | 10 |
| Legendary | 10 |
The picker, Randomizer.Pick, draws an integer uniformly between 1 and (the factor of a million is there to allow weights with up to six decimals) and walks the items subtracting each weight until falls inside one. It assumes the weights add up to 100. These add up to 90, so in 10% of draws doesn’t land on any item and the function returns nil. The caller repeats until it gets a rarity:
repeat
rar = Randomizer.Pick(ServerSettings.Abilities.SpecialOffersChances)
until rar ~= nil
That’s rejection sampling [1], and it renormalizes the weights: the probability of each rarity in a valid draw is .
| Rarity | Ability | |
|---|---|---|
| Common | Superjump | 44.4% |
| Uncommon | Stomp | 22.2% |
| Rare | Burger | 11.1% |
| Epic | Fireball | 11.1% |
| Legendary | Laser beam | 11.1% |
The order in which pairs walks the table decides which interval each rarity gets, but not its size, so it doesn’t change the probabilities.
2.2 Three different ones
Each rarity has exactly one ability on the shop list, so the rarity decides the ability. To fill three slots, the code draws and throws away any ability that’s already in the offer, until it has three different ones. Rejecting duplicates is the same as drawing without replacement in proportion to the weights. Adding over the orders in which three different rarities can come out, the probability that each ability appears in an offer is
| Ability | |
|---|---|
| Superjump | |
| Stomp | |
| Burger | |
| Fireball | |
| Laser beam |
The interesting number is the last one. Laser beam, the Legendary and most expensive, shows up in almost half of the rotations, exactly as often as Burger, which is Rare. In this shop the rarity is only a weight, and Rare, Epic and Legendary weigh the same. Superjump and Stomp appear together in 64% of the offers, and the third slot is a fair draw among the other three.
The cost of the two rejections is small. An offer needs on average 4.725 valid draws (counting the duplicates thrown away), and with the 10% of nils, calls to the picker.
3. One offer for every server
3.1 Saved state
The current offer is a small table, {Time, abilities}, saved in the AbilitiesOffer DataStore under a single key. When a server boots, it reads it, with one retry if the first read fails. If there’s nothing saved (the very first time the game runs), it draws an offer and saves it. The client asks for the offer when it joins and again whenever its inventory changes, so the “owned” marks update.
3.2 Rotating
When a server crosses into a new period, it does this:
- Reads the saved offer.
- If it contains some ability that isn’t in its own offer, it assumes another server already rotated and adopts it.
- If not, it draws a new one, saves it with
SetAsyncand publishes it on theabsOffertopic withMessagingService.
Every server is subscribed to that topic, and when a message arrives it adopts the offer and sends it to its clients. The idea is that the first server to cross the boundary rotates, and the rest find the work done when they read.
3.3 Where they disagree
Step 1 and step 3 aren’t atomic. If two servers read before either one writes, both see the old offer, both draw, both save and both publish. Each server receives both messages, its own included, and keeps the last one that arrives. MessagingService doesn’t guarantee delivery order [3], and messages from different servers have no global order anyway [4], so two servers can be left with different offers until the next rotation.
What makes this worse is that it isn’t a rare case. Every server computes the boundary from the same synchronized clock (section 4), so they all cross it within about a second of each other, and with several servers running the collision is the normal case.
There are two fixes, and the second is the better one. The first is to do the read and the write in a single UpdateAsync [2], whose transform function only draws if the saved offer belongs to an older period. The DataStore re-runs the transform when another server wrote in between, so exactly one offer per period survives. The second is not to coordinate at all: if the offer is drawn with a Random seeded by the period’s index, every server computes the same offer on its own and nothing needs to be saved or announced. The earlier version of the game did exactly that in its sword and skin shop, with Random.new(day).
The adoption check has a quirk too. It compares by membership, so if a new draw happens to produce the same three abilities as the previous offer, the server can’t tell “someone rotated to the same thing” from “nobody rotated” and rotates again. It happens more often than it looks: the probability that two consecutive offers are the same set is .
4. The clock
4.1 Where time comes from
Servers don’t trust their own clock. TimeSync makes a request to google.com, reads the Date header, and corrects for latency by assuming the response took half of the round trip to arrive. It’s Cristian’s algorithm [5], with a resolution of one second, because that’s the header’s precision. If the request fails, it falls back to os.time().
4.2 The period
The loop runs every second and computes two things from the time : the index of the current period and the countdown.
local day = math.floor((syncedtime.time() + offset) / (3600)) * 4
local t = (math.floor(syncedtime.time()) / 4) - offset
local daypass = (t) % (3600)
local timeleft = (3600 * 4) - (daypass * 4)
With offset = 0, the countdown simplifies to
a four-hour countdown, which matches the configuration (ResetInterval = 4 and the list of UTC refresh hours 0, 4, 8, 12, 16, 20). But the index is
which changes every hour. The offer therefore rotates every hour, while the countdown on screen says it’ll rotate in up to four, and only reaches zero once every four rotations. The factor 4 ended up multiplying the index instead of dividing the time. The correct index is . The configured ResetTimeUTC = -2 isn’t read either, so the periods start on UTC hours.
4.3 The countdown on screen
The server writes the countdown in HH:MM:SS form to a replicated StringValue, and the client copies it to the label every second, showing “Refreshing…” when it reads 00:00:00. It’s a cheap way to keep every player on the server with the same countdown, without a clock on each client.
5. What the shop never got to do
Buying doesn’t work on the server. The client asks for confirmation, compares the price against a coin stat from the old data layout, fires the Buy remote and shows “bought” or “not enough coins” according to its own comparison, but the server’s Buy handler is empty, so nothing is charged or added to the inventory. The sword and skin shop from the earlier version wasn’t ported either: the rewrite’s ShopService has an empty RandomSkin. And there’s a small leak: each rotation creates three StringValues in one folder and clears a different one, so the first folder grows by three entries every hour.
6. What I took away
What I learned here applies to any system with many servers: before coordinating, check whether the result can be derived. An offer that’s a pure function of the period’s index doesn’t need a DataStore, messages or a “who rotated first” check, and it can’t disagree with itself. When coordination is unavoidable, the read and the write have to be one operation. And the clock bug taught me to write periods with their units in the name, PERIOD_SECONDS = 4 * 3600, so the 4 can’t end up on the wrong side of a division.
References
- J. von Neumann. “Various Techniques Used in Connection with Random Digits”. National Bureau of Standards Applied Mathematics Series, 12, 1951.
- Roblox Creator Documentation. “Data stores” (
GlobalDataStore:UpdateAsync). - Roblox Creator Documentation. “Cross-server messaging” (
MessagingService). - L. Lamport. “Time, Clocks, and the Ordering of Events in a Distributed System”. Communications of the ACM, 21(7), 1978.
- F. Cristian. “Probabilistic Clock Synchronization”. Distributed Computing, 3(3), 1989.
This text by Mastedore. All rights reserved.