10 · Project — Game Leaderboard & Activity Feed¶
This project combines Level 2 into one feature set a real game backend needs:
- a weekly leaderboard that keeps each player's best score, where ties go to whoever got there first;
- "players around me" and top-N queries;
- an activity stream of every submission, trimmed automatically;
- an approximate count of distinct players this week;
- a Pub/Sub announcement when someone enters the top 10;
- a badge worker that reads the stream through a consumer group and never double-awards, even after a crash.
The write path, a score submission, touches four structures. It has to be atomic and decide things ("did this beat your best?", "what's your rank now?") based on current data. That's a job for a Redis Function.
The score encoding¶
Sorted sets break ties alphabetically (Level 1 · 08). To make equal points rank by submission time, pack both into one score:
A week is 604,800 seconds, which is less than 2^20 (1,048,576). So the time part never overflows into the points
part. With equal points, an earlier submission leaves a larger remainder and ranks higher. Doubles hold integers
exactly up to 2^53, so points can go up to 2^33 (about 8.6 billion) before precision is lost. ZADD GT keeps the
higher composite, which gives "personal best" semantics. A later submission with equal points doesn't replace the
earlier one, because its composite is smaller.
The function library¶
leaderboard.lua:
#!lua name=leaderboard
-- score layout: points * 2^20 + (2^20 - 1 - seconds since week start)
-- a week is 604,800 s < 2^20, so equal points rank the EARLIER submission higher
local SHIFT = 1048576
local function submit(keys, args)
local board, activity, players = keys[1], keys[2], keys[3]
local player, points = args[1], tonumber(args[2])
local now_ms, week_start_ms = tonumber(args[3]), tonumber(args[4])
local maxlen = args[5]
if not points or points < 0 or points ~= math.floor(points) then
return redis.error_reply('ERR points must be a non-negative integer')
end
local secs = math.floor((now_ms - week_start_ms) / 1000)
if secs < 0 or secs >= SHIFT then
return redis.error_reply('ERR timestamp outside this week')
end
local composite = points * SHIFT + (SHIFT - 1 - secs)
local improved = redis.call('ZADD', board, 'GT', 'CH', composite, player)
local rank = redis.call('ZREVRANK', board, player)
redis.call('PFADD', players, player)
redis.call('XADD', activity, 'MAXLEN', '~', maxlen, '*',
'player', player, 'points', points, 'best', tostring(improved == 1), 'rank', rank + 1)
if improved == 1 and rank < 10 then
redis.call('PUBLISH', board .. ':top10', player .. ' is now #' .. (rank + 1))
end
return {improved, rank + 1}
end
redis.register_function('lb_submit', submit)
Notes:
- All three keys come in through
KEYSand share the hash tag{<game>}, so in Cluster they live in one slot and the function can run there (Level 4 · 05). - Validation comes first, and the writes come after, so a bad input can't leave a partial update (Level 2 · 09).
ZADD GT CHreturns 1 only if the score was changed, which is exactly "improved your best".- The activity entry records whether it was a best and the rank at the time. The stream is trimmed with
MAXLEN ~, so it costs nothing extra to keep bounded. PUBLISHinside a function is fine. Like any write, it's propagated as an effect.
The Python side¶
game.py:
"""Weekly game leaderboard + activity feed (Level 2 project)."""
import datetime as dt
import time
from pathlib import Path
import redis
SHIFT = 1 << 20
LIB = Path(__file__).with_name("leaderboard.lua").read_text()
def week_start(now: float) -> dt.datetime:
d = dt.datetime.fromtimestamp(now, tz=dt.timezone.utc)
monday = d - dt.timedelta(days=d.weekday())
return monday.replace(hour=0, minute=0, second=0, microsecond=0)
def decode(composite: float) -> int:
return int(composite) // SHIFT
class Leaderboard:
def __init__(self, r: redis.Redis, game: str = "arena", feed_len: int = 1000):
self.r, self.game, self.feed_len = r, game, feed_len
r.function_load(LIB, replace=True)
def _keys(self, now):
ws = week_start(now)
tag = ws.strftime("%G-W%V") # ISO week, e.g. 2026-W42
base = f"lb:{{{self.game}}}" # hash tag keeps all keys in one slot
return ws, (f"{base}:board:{tag}", f"{base}:activity", f"{base}:players:{tag}")
def submit(self, player: str, points: int, now: float | None = None):
now = time.time() if now is None else now
ws, keys = self._keys(now)
improved, rank = self.r.fcall("lb_submit", 3, *keys, player, points,
int(now * 1000), int(ws.timestamp() * 1000), self.feed_len)
board = keys[0]
self.r.expire(board, 35 * 24 * 3600, nx=True) # keep ~5 weeks of boards
self.r.expire(keys[2], 35 * 24 * 3600, nx=True)
return bool(improved), rank
def top(self, n=10, now=None):
_, (board, _, _) = self._keys(time.time() if now is None else now)
rows = self.r.zrange(board, 0, n - 1, desc=True, withscores=True)
return [(i + 1, p, decode(s)) for i, (p, s) in enumerate(rows)]
def around(self, player, k=2, now=None):
_, (board, _, _) = self._keys(time.time() if now is None else now)
rank = self.r.zrevrank(board, player)
if rank is None:
return []
start = max(0, rank - k)
rows = self.r.zrange(board, start, rank + k, desc=True, withscores=True)
return [(start + i + 1, p, decode(s)) for i, (p, s) in enumerate(rows)]
def players_this_week(self, now=None):
_, (_, _, players) = self._keys(time.time() if now is None else now)
return self.r.pfcount(players)
def feed(self, n=5):
_, (_, activity, _) = self._keys(time.time())
return [f for _, f in self.r.xrevrange(activity, count=n)]
Boards are per ISO week (2026-W42), so a "weekly reset" is just a new key. Old boards keep a 35-day TTL, set once
with EXPIRE … NX, so history is available for a few weeks and then disappears without a cleanup job.
achievements.py, the badge worker:
"""Consumer-group worker: awards badges from the activity stream, at-least-once, idempotently."""
import redis
BADGES = [(1000, "bronze"), (5000, "silver"), (10000, "gold")]
def ensure_group(r, stream, group="badges"):
try:
r.xgroup_create(stream, group, id="0", mkstream=True)
except redis.ResponseError as e:
if "BUSYGROUP" not in str(e):
raise
def process_batch(r, stream, consumer, group="badges", count=50, min_idle_ms=30000):
_, msgs, _ = r.xautoclaim(stream, group, consumer, min_idle_time=min_idle_ms, start_id="0-0", count=count)
if not msgs:
res = r.xreadgroup(group, consumer, {stream: ">"}, count=count, block=100)
msgs = res[0][1] if res else []
awarded = 0
for msg_id, f in msgs:
pts = int(f["points"])
for threshold, badge in BADGES:
if pts >= threshold:
# SADD returns 1 only the first time: re-delivery never double-awards
awarded += r.sadd(f"badges:{f['player']}", badge)
r.xack(stream, group, msg_id)
return len(msgs), awarded
The worker follows the recovery loop from Level 2 · 06: claim stale entries first, then read new ones. Badges are
stored in a set, so SADD's return value tells you whether this delivery actually awarded anything new. A message
delivered twice (after a crash) awards nothing the second time. At-least-once delivery plus an idempotent
handler gives effectively-once results.
Tests¶
test_game.py pins time to a fixed instant (Tuesday 2026-10-13 02:00 UTC, ISO week 2026-W42) so ranks and week keys
are deterministic:
import threading, time
import pytest, redis
from game import Leaderboard, week_start
from achievements import ensure_group, process_batch
NOW = 1791856800.0 # a fixed instant (Tue 2026-10-13 UTC) so tests are deterministic
@pytest.fixture
def lb():
r = redis.Redis(db=0, decode_responses=True)
for k in r.scan_iter("lb:{test}*"): r.unlink(k)
for k in r.scan_iter("badges:*"): r.unlink(k)
yield Leaderboard(r, game="test", feed_len=100)
for k in r.scan_iter("lb:{test}*"): r.unlink(k)
def test_personal_best_only_goes_up(lb):
assert lb.submit("ana", 500, NOW) == (True, 1)
assert lb.submit("ana", 300, NOW + 1) == (False, 1)
assert lb.submit("ana", 900, NOW + 2) == (True, 1)
assert lb.top(1, NOW) == [(1, "ana", 900)]
def test_ties_go_to_whoever_got_there_first(lb):
lb.submit("late", 700, NOW + 50)
lb.submit("early", 700, NOW + 10)
lb.submit("ben", 650, NOW)
assert [p for _, p, _ in lb.top(3, NOW)] == ["early", "late", "ben"]
def test_equal_score_later_does_not_replace_earlier(lb):
lb.submit("ana", 700, NOW)
improved, _ = lb.submit("ana", 700, NOW + 100)
assert improved is False
def test_rejects_bad_points(lb):
with pytest.raises(redis.ResponseError, match="non-negative integer"):
lb.submit("ana", -5, NOW)
def test_around_me(lb):
for i in range(20):
lb.submit(f"p{i:02}", i * 10, NOW)
around = lb.around("p10", k=2, now=NOW)
assert [(rank, p) for rank, p, _ in around] == [(8, "p12"), (9, "p11"), (10, "p10"), (11, "p09"), (12, "p08")]
assert lb.around("nobody", now=NOW) == []
def test_new_week_new_board(lb):
lb.submit("ana", 100, NOW)
next_week = week_start(NOW).timestamp() + 7 * 24 * 3600 + 5
lb.submit("ben", 50, next_week)
assert [p for _, p, _ in lb.top(5, NOW)] == ["ana"]
assert [p for _, p, _ in lb.top(5, next_week)] == ["ben"]
def test_concurrent_submissions_keep_max(lb):
def player(i):
for s in range(0, 2000, 7):
lb.submit(f"c{i}", s, NOW)
ts = [threading.Thread(target=player, args=(i,)) for i in range(8)]
[t.start() for t in ts]; [t.join() for t in ts]
top = lb.top(8, NOW)
assert all(score == 1995 for _, _, score in top) and len(top) == 8
def test_feed_and_uniques(lb):
lb.submit("ana", 10, NOW); lb.submit("ben", 20, NOW); lb.submit("ana", 5, NOW)
feed = lb.feed(3)
assert [(f["player"], f["points"], f["best"]) for f in feed] == [("ana", "5", "false"), ("ben", "20", "true"), ("ana", "10", "true")]
assert lb.players_this_week(NOW) == 2
def test_feed_is_trimmed(lb):
for i in range(1000):
lb.submit(f"x{i}", i, NOW)
assert lb.r.xlen("lb:{test}:activity") < 1000
def test_top10_announcements(lb):
p = lb.r.pubsub(); p.subscribe("lb:{test}:board:2026-W42:top10"); time.sleep(0.1)
lb.submit("ana", 100, NOW)
msgs = []
end = time.time() + 1
while time.time() < end:
m = p.get_message(timeout=0.1)
if m and m["type"] == "message": msgs.append(m["data"])
assert msgs == ["ana is now #1"]
def test_badges_awarded_once_even_if_redelivered(lb):
stream = "lb:{test}:activity"
lb.submit("ana", 1200, NOW); lb.submit("ana", 6000, NOW + 1)
ensure_group(lb.r, stream)
# consumer "crashy" reads but never acks
lb.r.xreadgroup("badges", "crashy", {stream: ">"}, count=10)
n, awarded = process_batch(lb.r, stream, "w1", min_idle_ms=0) # reclaims crashy's entries
n2, awarded2 = process_batch(lb.r, stream, "w1", min_idle_ms=0)
assert (n, awarded) == (2, 2) and awarded2 == 0
assert lb.r.smembers("badges:ana") == {"bronze", "silver"}
assert lb.r.xpending(stream, "badges")["pending"] == 0
test_game.py::test_personal_best_only_goes_up PASSED [ 9%]
test_game.py::test_ties_go_to_whoever_got_there_first PASSED [ 18%]
test_game.py::test_equal_score_later_does_not_replace_earlier PASSED [ 27%]
test_game.py::test_rejects_bad_points PASSED [ 36%]
test_game.py::test_around_me PASSED [ 45%]
test_game.py::test_new_week_new_board PASSED [ 54%]
test_game.py::test_concurrent_submissions_keep_max PASSED [ 63%]
test_game.py::test_feed_and_uniques PASSED [ 72%]
test_game.py::test_feed_is_trimmed PASSED [ 81%]
test_game.py::test_top10_announcements PASSED [ 90%]
test_game.py::test_badges_awarded_once_even_if_redelivered PASSED [100%]
============================== 11 passed in 2.22s ==============================
The concurrency test has 8 threads each submitting rising scores up to 1,995 for its own player, and the board ends
with all eight at exactly 1,995. The badge test leaves two entries pending under a "crashy" consumer, reclaims them
with XAUTOCLAIM, processes them twice, and checks that the second pass awards nothing and nothing is left pending.
Running it with load¶
demo.py submits 40,000 scores for 50,000 possible players from 8 threads, with long-tailed point values:
import random, threading, time
import redis
from game import Leaderboard
r = redis.Redis(decode_responses=True)
for k in r.scan_iter("lb:{demo}*"): r.unlink(k)
lb = Leaderboard(r, game="demo")
random.seed(5)
players = [f"player{i}" for i in range(50_000)]
def worker(seed):
rnd = random.Random(seed); c = Leaderboard(redis.Redis(decode_responses=True), game="demo")
for _ in range(5000):
c.submit(rnd.choice(players), int(rnd.paretovariate(1.5) * 100))
t = time.perf_counter()
ts = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
[x.start() for x in ts]; [x.join() for x in ts]
el = time.perf_counter() - t
print(f"40,000 submissions from 8 threads in {el:.2f}s ({40000/el:,.0f}/s)")
for row in lb.top(5): print("top", row)
me = lb.top(500)[-1][1]
for row in lb.around(me, k=1): print("around", row)
print("players this week ~", lb.players_this_week())
print("feed:", lb.feed(2))
board = [k for k in r.scan_iter("lb:{demo}:board:*")][0]
print(board, "members", r.zcard(board), "memory", r.memory_usage(board), "| activity XLEN", r.xlen("lb:{demo}:activity"))
40,000 submissions from 8 threads in 6.20s (6,447/s)
top (1, 'player31559', 80816)
top (2, 'player16280', 64898)
top (3, 'player25408', 55269)
top (4, 'player45704', 49827)
top (5, 'player44481', 40786)
around (499, 'player19634', 1930)
around (500, 'player18416', 1928)
around (501, 'player13286', 1927)
players this week ~ 27562
feed: [{'player': 'player25101', 'points': '108', 'best': 'false', 'rank': '2514'}, {'player': 'player18754', 'points': '102', 'best': 'false', 'rank': '8834'}]
lb:{demo}:board:2026-W42 members 27559 memory 2173259 | activity XLEN 1000
- About 6,500 submissions per second from one Python process with 8 threads. Each submission is three round trips
(the
FCALLand twoEXPIRE … NX), and Python's thread overhead dominates. The server time per call is far smaller. Folding theEXPIREs into the function would make it one round trip (Exercise 2). - The HyperLogLog estimated 27,562 players this week. The board, which is exact, has 27,559 members.
- The week's board for about 27,600 players uses 2.2 MB. The activity stream stayed at its 1,000-entry target.
How It Actually Works¶
Each submission is one FCALL, executed atomically on the server. Inside it: ZADD GT CH (O(log N)), ZREVRANK
(O(log N), using the skip-list spans from Level 1 · 08), PFADD (O(1)), XADD with approximate trimming (amortized
O(1)) and, rarely, a PUBLISH. No other client can see the board updated without the activity entry, or read a
rank that doesn't match the score just written.
Ranks are read after the ZADD, inside the same function, so the rank in the activity entry and in the return
value is exactly the rank the submission produced. A client doing ZADD and then ZREVRANK in two round trips
could report a rank that someone else's submission had already changed.
The week tag is computed by the client from its clock, and the secs bound check in the function rejects
timestamps outside the week. If app servers' clocks disagree near midnight on Sunday, a submission can land on the
"wrong" week. Using the Redis server's time (redis.call('TIME')) inside the function would remove that
dependency, at the cost of making tests harder to pin. That's a trade-off worth knowing you made.
Common mistakes¶
- Separate
ZADDandZREVRANKround trips to report "your new rank". It's racy. Compute it in the same atomic unit. - Alphabetical tie-breaking by accident. Encode the tie-breaker into the score, and check the arithmetic against 2^53.
- A forever-growing activity log. Trim on every
XADD. - Non-idempotent consumers. A redelivered "award gold badge" must not award twice.
- Weekly resets by deleting the board with a cron job. Rotate key names, and let TTLs clean up.
- Keys without a shared hash tag in a multi-key function. It works on one node and fails in Cluster.
Exercise¶
- Add per-country boards (
board:<week>:<country>) updated in the sameFCALL, and atop_in_countrymethod. What changes about the keys you pass, and what does that mean for Cluster? - Move the two
EXPIRE … NXcalls into the function and measure the throughput change withdemo.py. - Use the server clock (
redis.call('TIME')) inside the function instead of client timestamps. How do you keep the tests deterministic now? - Add an all-time board that tracks the best score ever, updated with the same function. What's the size of the all-time board after a year if 2 million distinct players have played? Estimate it from this lesson's bytes-per-member, then measure it.