Item 603 in rAthena’s renewal database is the Old Blue Box, and its script is a single line: getgroupitem(IG_BlueBox);. The BLUEBOX group it calls in item_group_db.yml lists more than a thousand possible items, each with a rate, under Algorithm: Random. When a player opens one, get_random_itemsubgroup in itemdb.cpp rolls a value between 1 and the group’s total rate, then walks the list adding rates until the running sum passes it.

Hercules handles its version more bluntly, with group->nameid[rnd()%group->qty] and an item’s weight expressed by listing it several times.

Server-Side Rolls and Their Quirks

On both emulators the map server runs the box’s script and just tells the client what dropped. That’s still how anyone sensible builds a community server, and it’s the approach OPEN//77’s dedicated server platform for Cyberpunk 2077 takes from the start.

rAthena even keeps a bias on purpose. rnd_chance_official in random.hpp generates a value between 0 and 20000 and takes it modulo the range, and the comment on it admits “there’s always an increased chance that the result is 0.” It’s there to match the official servers.

SharedPool, rAthena’s default algorithm for groups that don’t specify one, draws from a pool that empties as picks are made and refills only when it runs dry or the server restarts. Inside a single map-server process that’s simple bookkeeping. Put the pool in a database shared by several servers and every draw has to take a lock.

On skin-unboxing sites, the roll in modern case opening technology still happens on the server against a weighted table, much as rAthena does it for a Blue Box, except that each open is paid for out of a wallet. A well-built backend also lets the player check that roll afterward, and keeps the item ledger consistent with the wallet while a lot of opens land at once.

Verifying a Roll

The usual way to make a roll checkable is commit-reveal. The server publishes a hash of its seed before any rolls happen, the player adds a seed of their own, and each roll is an HMAC keyed with the server seed over the player’s seed and a nonce that counts up. Once the server reveals its seed, anyone can rerun the whole sequence and check it.

Bias creeps in when that output becomes a table index. Hercules’ rnd()%group->qty is the simplest example of the pattern, and a 32-bit HMAC slice taken modulo a total weight that doesn’t divide 2^32 has a milder version of the problem: the lower results come up slightly more often. With a total weight in the thousands you won’t catch it with a few million test rolls, so fix it in the code. Java’s Random.nextInt(bound) does rejection sampling for this reason. Do the same, or pull enough bits that the leftover bias doesn’t matter, in whatever language you’re using.

If two open requests read the current nonce before either one increments it, both compute the same HMAC and get the same roll. A transaction on its own won’t stop that under Read Committed, so do the increment and the read in one statement:

UPDATE seeds SET nonce = nonce + 1
WHERE seed_id = $1
RETURNING nonce;

If each player has their own seed, locking the wallet row first and reading the nonce after it works too.

Duplication and the Wallet

Big publishers deal with dupes the way anyone running a server would: stop the trading first and work out the bug later. Blizzard took Diablo III’s auction houses offline in May 2013 over a bug that let players “duplicate gold through the Auction House,” and Amazon temporarily shut off wealth transfers in New World after a gold exploit in November 2021. Neither said how the bug worked.

Where the details did come out, the classic is Egor Homakov’s 2015 Starbucks write-up. By firing gift-card transfers in parallel he got two $5 transfers out of a card holding $5. A case-opening backend has the same exposure, because one open touches a wallet row and an inventory row.

The naive version reads the balance, checks it in application code, then writes, so two parallel requests can both pass the check and both debit. Make the check part of the write instead:

UPDATE wallets
SET balance = balance - $1
WHERE user_id = $2 AND balance >= $1;

If that affects zero rows, the open is refused. Under PostgreSQL’s default isolation level, Read Committed, a second transaction trying to update the same row “will wait for the first updating transaction to commit or roll back,” then re-checks the WHERE clause against the updated row, so the second debit fails cleanly instead of overdrawing. The item grant and the ledger entries go in that transaction too.

If you want stricter isolation you can run opens at Serializable, but then you have to handle retries: PostgreSQL’s documentation on transaction isolation says applications at that level “must be prepared to retry transactions due to serialization failures,” which come back as SQLSTATE 40001. Deadlocks come back separately as 40P01, and the usual fix is to lock rows in the same order everywhere, wallet first and stock counter second.

A limited-stock case works like a shared pool at web scale. Every open of that case points at one counter row, and waiting transactions hold wallet locks and pool connections while they queue. Splitting the stock across several rows, or reserving it in batches, keeps a popular case from backing up everything behind it.

The Live Feed

A pub/sub layer usually fans drop events out to clients over WebSockets. Redis Pub/Sub is fire-and-forget: if a subscriber is disconnected when a message goes out, that message is gone. That’s fine for a ticker, as long as nothing that touches a balance depends on it and clients re-read state when they reconnect. If the feed has to survive reconnects, Redis Streams, which keep a log consumers can read back from a given ID, are the better fit.

Ordering matters too. If the service publishes a drop event and the transaction behind it then rolls back, the feed has broadcast a grant that was never committed. The fix is a transactional outbox: write the event to an outbox table in the same transaction as the grant, and have a relay publish it only after commit. The relay can be a simple loop that polls the outbox table and marks rows as sent, and since it may send an event twice after a crash, clients should skip a drop they’ve already shown.