Build a game backend with Valkey in Python
Nest Invaders is a tiny arcade game, and it needs a backend: save slots, a cache, a global leaderboard, player profiles, anti-cheat and a live feed. Each level builds one piece with one Valkey data structure. Run the program against your own instance, beat the challenge in the sandbox, and collect XP on the way.
Uses valkey-py · needs Python 3.9 or newer · progress is saved in this browser
Rank
Rookie
0 / 1350 XP
0 of 9 levels · 250 XP to Cache Cadet
LEVEL 1Connect and PING
Insert coin
- PING
Valkey is an in-memory key/value store: your data lives in RAM, so the server answers in microseconds. It speaks the Redis protocol, which means every Redis client and tool works with it unchanged.
Set up a project, put your instance's connection URL in VALKEY_URL, and say hello. The double s in rediss:// turns on TLS.
Set up
You need Python 3.9 or newer. Create a project:
mkdir valkey-arcade && cd valkey-arcadepython3 -m venv .venv && source .venv/bin/activatepip install valkeyGet an instance
Play it for real
Run every level against your own Valkey instance. Starter is free during the preview: no credit card, ready in under a minute, and you come straight back here.
Not ready yet? Every challenge also works in the sandbox on this page.
Then put its connection URL in an environment variable, in the terminal where you'll run the programs:
export VALKEY_URL='rediss://default:<password>@inst-<id>.eu.kvnest.com:6380' On Windows PowerShell: $env:VALKEY_URL = '…'
Say hello
import osimport time import valkey client = valkey.from_url(os.environ["VALKEY_URL"], decode_responses=True) print(client.ping()) # True # How long does a round trip from your machine take?start = time.perf_counter()for _ in range(100): client.ping()print(f"Average round trip: {(time.perf_counter() - start) * 10:.2f} ms")python level_1.pyAlmost all of that round trip is the network between you and the data centre. Valkey itself handles a PING in a few microseconds.
Challenge: Send your first command: PING. Valkey answers PONG.
Type a command, or pick one below. Up and down arrows recall history.
Did you know? Valkey is a Linux Foundation project. It was forked from Redis 7.2.4 in March 2024, when Redis moved away from an open source licence, and it is still BSD-licensed today.
Ran the program? Claim the level.
LEVEL 2Strings and expiry (TTL)
Save the game
- SET
- GET
- TTL
The simplest value is a string: one key, one value of up to 512 MB of text or bytes. SET writes it, GET reads it, and a JSON document fits right in.
Any key can also have a time to live. Add EX 5 and the key deletes itself after five seconds: perfect for power-ups, login codes, sessions and anything else that shouldn't outstay its welcome.
import jsonimport osimport time import valkey client = valkey.from_url(os.environ["VALKEY_URL"], decode_responses=True) # A save slot: a key holds any string, JSON included.client.set("save:ada", json.dumps({"level": 3, "lives": 2}))print(json.loads(client.get("save:ada"))) # A shield power-up that wears off by itself after 5 seconds.client.set("powerup:ada:shield", "on", ex=5)print("Shield TTL:", client.ttl("powerup:ada:shield"), "seconds") time.sleep(6)print("Shield after 6 seconds:", client.get("powerup:ada:shield")) # Nonepython level_2.pyName keys with colons, like powerup:ada:shield. Valkey doesn't care, but you will once there are millions of them.
Challenge: Give yourself a shield that wears off: set a key with EX, then ask for its TTL.
Type a command, or pick one below. Up and down arrows recall history.
Did you know? Valkey deletes expired keys when they are next touched, and a background sweep catches the rest. That's why millions of keys can each carry their own timer at almost no cost.
Ran the program? Claim the level.
LEVEL 3Caching with cache-aside
Turbo mode
- GET
- SET … EX
- DEL
This is the job most people hire Valkey for. Put it in front of something slow, like a database query or a third-party API, and keep the answer for a while.
The pattern is called cache-aside: look in the cache first. On a miss, load the data, store it with a TTL and return it. The TTL makes sure stale data refreshes itself eventually.
import jsonimport osimport time import valkey client = valkey.from_url(os.environ["VALKEY_URL"], decode_responses=True) def load_profile_from_database(player_id): """Stands in for a slow database query or API call.""" time.sleep(0.3) return {"id": player_id, "name": "Ada", "favourite_game": "Nest Invaders"} def get_profile(player_id): """Cache-aside: ask Valkey first; on a miss, load and keep it for 60 seconds.""" key = f"cache:profile:{player_id}" cached = client.get(key) if cached: return json.loads(cached) profile = load_profile_from_database(player_id) client.set(key, json.dumps(profile), ex=60) return profile client.delete("cache:profile:ada") # start with a cold cachefor call in ["1st", "2nd", "3rd"]: start = time.perf_counter() get_profile("ada") print(f"{call} call: {(time.perf_counter() - start) * 1000:.1f} ms")python level_3.pyThe first call pays the full 300 ms; the next ones come straight from memory. Your game just got a turbo button.
Challenge: Play the cache: GET a key that isn't there yet (a miss), SET it with a TTL, then GET it again (a hit).
Type a command, or pick one below. Up and down arrows recall history.
Did you know? Reading from RAM is hundreds of times faster than a random read from even a fast SSD. That gap is the whole reason caches exist.
Ran the program? Claim the level.
LEVEL 4Atomic counters
Count everything
- INCR
- INCRBY
- DECRBY
INCR adds one to a number and returns the new value. Sounds trivial, until a thousand players finish a game at the same moment.
If your app reads the value, adds one and writes it back, two servers can read the same number and one update gets lost. INCR happens inside Valkey in a single step, so every increment counts.
import osfrom concurrent.futures import ThreadPoolExecutor import valkey client = valkey.from_url(os.environ["VALKEY_URL"], decode_responses=True) # 1,000 players finish a game at the same moment, on 20 threads.with ThreadPoolExecutor(max_workers=20) as pool: list(pool.map(lambda _: client.incr("stats:games_played"), range(1000)))print("Games played:", client.get("stats:games_played")) # Coins go up and down by any amount.client.incrby("coins:ada", 50)client.decrby("coins:ada", 20)print("Ada's coins:", client.get("coins:ada"))python level_4.pyRun it again: the total grows by exactly 1,000 every time. Counters are just as handy for page views, likes, stock levels and unique IDs.
Challenge: INCR the same counter until it reaches at least 3.
Type a command, or pick one below. Up and down arrows recall history.
Did you know? Valkey runs commands one at a time, so two INCRs can never interleave. Recent versions use extra threads for network I/O, but the commands themselves still run in order.
Ran the program? Claim the level.
LEVEL 5Leaderboards with sorted sets
High scores
- ZADD
- ZRANGE
- ZREVRANK
A sorted set keeps its members ordered by score, all the time. Adding a score, finding a player's rank and reading the top 10 all stay fast with millions of players: exactly what a leaderboard needs.
ZADD with GT only updates a score if the new one is higher, so a player's best run sticks. ZRANGE … REV reads from the top, and ZREVRANK tells a player where they stand.
import os import valkey client = valkey.from_url(os.environ["VALKEY_URL"], decode_responses=True) client.zadd("leaderboard", {"ada": 4200, "linus": 3100, "grace": 5600}) # gt=True: a score only replaces a lower personal best.client.zadd("leaderboard", {"ada": 6100}, gt=True)client.zadd("leaderboard", {"grace": 1000}, gt=True) # ignored, 5600 is higher # The top 3, highest first, with their scores.top = client.zrange("leaderboard", 0, 2, desc=True, withscores=True)for i, (name, score) in enumerate(top, start=1): print(f"{i}. {name} {score:.0f}") # Ranks start at 0, so add 1 for humans.rank = client.zrevrank("leaderboard", "linus")print(f"Linus is #{rank + 1}")python level_5.pyNo sorting in your code, and no ORDER BY on every page view: the set is always in order.
Challenge: The sandbox leaderboard already has ada, grace and linus. Add yourself with ZADD, then find your place with ZREVRANK.
Type a command, or pick one below. Up and down arrows recall history.
Did you know? We put a million players in a sorted set on Valkey 9.1. It took about 70 MB of memory, and ZREVRANK answered in 4 microseconds. Starter has room for that twice over.
Ran the program? Claim the level.
LEVEL 6Hashes
Player profiles
- HSET
- HGETALL
- HINCRBY
A hash is a small map of fields under one key, much like a row in a table. You can read or change one field without touching the others.
HINCRBY is INCR for a single field, so levelling up is one atomic command.
import os import valkey client = valkey.from_url(os.environ["VALKEY_URL"], decode_responses=True) # One key per player, one field per attribute.client.hset("player:ada", mapping={"name": "Ada", "country": "DE", "level": 1, "coins": 30}) # Change single fields without reading the rest.client.hincrby("player:ada", "level", 1) # level up!client.hset("player:ada", "title", "Cache Cadet") print(client.hgetall("player:ada"))print("Level:", client.hget("player:ada", "level"))python level_6.pyHashes fit profiles, settings, shopping carts and anything else shaped like an object.
Challenge: Create your own profile with HSET, with at least two fields, then read it back with HGETALL.
Type a command, or pick one below. Up and down arrows recall history.
Did you know? Small hashes are stored in a compact encoding called a listpack, so a profile kept as one hash usually takes less memory than the same fields as separate keys.
Ran the program? Claim the level.
LEVEL 7Rate limiting
Anti-cheat
- MULTI
- INCR
- EXPIRE … NX
- EXEC
Mallory found the score endpoint and is hammering it. Time for a rate limit: at most 5 submissions per player every 10 seconds.
Count submissions with INCR on one key per player, and give that key a 10-second TTL the first time. When it expires, the count starts over. MULTI and EXEC send both commands as one transaction, and EXPIRE … NX only sets the timer if there isn't one yet.
import os import valkey client = valkey.from_url(os.environ["VALKEY_URL"], decode_responses=True) def allow_submit(player): """At most 5 score submissions per player in each 10-second window.""" key = f"ratelimit:{player}" # A pipeline sends both commands in one MULTI/EXEC. nx=True sets the # expiry only if there is none yet, so the window starts at the first one. pipe = client.pipeline() pipe.incr(key) pipe.expire(key, 10, nx=True) count, _ = pipe.execute() return count <= 5 for i in range(1, 8): print(f"Submission {i}:", "accepted" if allow_submit("mallory") else "slow down!")python level_7.pyThis is a fixed-window limiter, the simplest kind. The same two commands protect login forms, APIs and contact forms from abuse.
Challenge: Build the limiter by hand: INCR a ratelimit: key and give it an EXPIRE. Bonus points for doing both inside MULTI … EXEC.
Type a command, or pick one below. Up and down arrows recall history.
Did you know? For smoother limits, a sliding window stores the time of each request in a sorted set and counts the ones from the last 10 seconds.
Ran the program? Claim the level.
LEVEL 8Real-time messaging with pub/sub
Live feed
- SUBSCRIBE
- PUBLISH
Players love seeing what's happening right now. With pub/sub, any part of your backend can PUBLISH a message to a channel, and every client that has SUBSCRIBEd to it gets the message instantly.
A subscribed connection can only listen, so the program opens a second connection for the feed.
import os import valkey client = valkey.from_url(os.environ["VALKEY_URL"], decode_responses=True) # A subscription takes over a connection; pubsub() opens its own.feed = client.pubsub()feed.subscribe("arcade:events")feed.get_message(timeout=1) # wait for the subscription to be confirmed listeners = client.publish("arcade:events", "ada set a new high score: 6100")client.publish("arcade:events", "grace joined the game")print(f"Delivered to {listeners} listener(s)") for _ in range(2): message = feed.get_message(timeout=1) print(f"📣 [{message['channel']}] {message['data']}") feed.close()python level_8.pyChat, notifications, live dashboards and cache invalidation across servers all work this way.
Challenge: The sandbox feed is subscribed to arcade:events. PUBLISH something to it and watch it appear.
Type a command, or pick one below. Up and down arrows recall history.
SUBSCRIBE arcade:events
Waiting for messages…
Did you know? Pub/sub is fire-and-forget: a message published while nobody is listening is gone. When messages must not get lost, Valkey has Streams, which the advanced track covers.
Ran the program? Claim the level.
LEVEL 9Put it all together
Boss level
- MULTI
- HINCRBY
- ZADD … GT CH
- PUBLISH
Time to wire it all up. One submitScore function checks the rate limit, counts the game in the player's profile, keeps the best score on the leaderboard and announces new records on the live feed.
And of course, Mallory is back.
import osimport time import valkey client = valkey.from_url(os.environ["VALKEY_URL"], decode_responses=True) feed = client.pubsub()feed.subscribe(**{"arcade:events": lambda message: print(f" 📣 {message['data']}")})feed.get_message(timeout=1) # wait for the subscription to be confirmedlistener = feed.run_in_thread(sleep_time=0.01) def submit_score(player, score): """Every level so far, in one function.""" limit_key = f"ratelimit:{player}" pipe = client.pipeline() pipe.incr(limit_key) pipe.expire(limit_key, 10, nx=True) attempts, _ = pipe.execute() if attempts > 5: return f"{player}: rejected, too many submissions" client.hincrby(f"player:{player}", "games", 1) # ch=True makes ZADD count changed scores, so 1 means a new personal best. improved = client.zadd("leaderboard", {player: score}, gt=True, ch=True) if improved: client.publish("arcade:events", f"{player} reached {score}") return f"{player}: {score}" + (" (new best!)" if improved else "") for player, score in [("ada", 7300), ("grace", 6800), ("ada", 6900), ("grace", 7600)]: print(submit_score(player, score)) # Mallory spams submissions, hoping a lucky one sticks.for i in range(1, 8): print(submit_score("mallory", 5000 + i * 100)) print("\n🏆 Top 3")top = client.zrange("leaderboard", 0, 2, desc=True, withscores=True)for i, (name, score) in enumerate(top, start=1): print(f"{i}. {name} {score:.0f}") time.sleep(0.5) # let the feed print its last messageslistener.stop()python level_9.pyThe limiter lets Mallory's first five submissions through and turns the rest away. Every request costs a handful of in-memory operations, and that's a backend that holds up on launch day.
Challenge: Beat the boss in the sandbox: pass the rate limit with INCR ratelimit:you, post a best score with ZADD leaderboard GT, and announce it with PUBLISH arcade:events.
Type a command, or pick one below. Up and down arrows recall history.
SUBSCRIBE arcade:events
Waiting for messages…
Did you know? Every level used one data structure and a couple of commands. That's the trick of Valkey: simple building blocks, each fast enough to call on every request.
Ran the program? Claim the level.
The finish line
Complete all 9 levels to finish the track, 9 to go. Beat every challenge as well to become a Latency Legend.