Build a game backend with Valkey in Node.js
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 iovalkey · needs Node.js 18 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 Node.js 18 or newer. Create a project:
mkdir valkey-arcade && cd valkey-arcadenpm init -ynpm install iovalkeyGet 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 Valkey from "iovalkey"; const client = new Valkey(process.env.VALKEY_URL); console.log(await client.ping()); // PONG // How long does a round trip from your machine take?const start = performance.now();for (let i = 0; i < 100; i++) await client.ping();console.log(`Average round trip: ${((performance.now() - start) / 100).toFixed(2)} ms`); await client.quit();node level-1.mjsAlmost 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 Valkey from "iovalkey"; const client = new Valkey(process.env.VALKEY_URL); // A save slot: a key holds any string, JSON included.await client.set("save:ada", JSON.stringify({ level: 3, lives: 2 }));console.log(JSON.parse(await client.get("save:ada"))); // A shield power-up that wears off by itself after 5 seconds.await client.set("powerup:ada:shield", "on", "EX", 5);console.log("Shield TTL:", await client.ttl("powerup:ada:shield"), "seconds"); await new Promise((resolve) => setTimeout(resolve, 6000));console.log("Shield after 6 seconds:", await client.get("powerup:ada:shield")); // null await client.quit();node level-2.mjsName 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 Valkey from "iovalkey"; const client = new Valkey(process.env.VALKEY_URL); // Stands in for a slow database query or API call.async function loadProfileFromDatabase(id) { await new Promise((resolve) => setTimeout(resolve, 300)); return { id, name: "Ada", favouriteGame: "Nest Invaders" };} // Cache-aside: ask Valkey first; on a miss, load and keep it for 60 seconds.async function getProfile(id) { const key = `cache:profile:${id}`; const cached = await client.get(key); if (cached) return JSON.parse(cached); const profile = await loadProfileFromDatabase(id); await client.set(key, JSON.stringify(profile), "EX", 60); return profile;} await client.del("cache:profile:ada"); // start with a cold cachefor (const call of ["1st", "2nd", "3rd"]) { const start = performance.now(); await getProfile("ada"); console.log(`${call} call: ${(performance.now() - start).toFixed(1)} ms`);} await client.quit();node level-3.mjsThe 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 Valkey from "iovalkey"; const client = new Valkey(process.env.VALKEY_URL); // 1,000 players finish a game at the same moment.await Promise.all(Array.from({ length: 1000 }, () => client.incr("stats:games_played")));console.log("Games played:", await client.get("stats:games_played")); // Coins go up and down by any amount.await client.incrby("coins:ada", 50);await client.decrby("coins:ada", 20);console.log("Ada's coins:", await client.get("coins:ada")); await client.quit();node level-4.mjsRun 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 Valkey from "iovalkey"; const client = new Valkey(process.env.VALKEY_URL); await client.zadd("leaderboard", 4200, "ada", 3100, "linus", 5600, "grace"); // GT: a score only replaces a lower personal best.await client.zadd("leaderboard", "GT", 6100, "ada");await client.zadd("leaderboard", "GT", 1000, "grace"); // ignored, 5600 is higher // The top 3, highest first, with their scores.const top = await client.zrange("leaderboard", 0, 2, "REV", "WITHSCORES");for (let i = 0; i < top.length; i += 2) console.log(`${i / 2 + 1}. ${top[i]} ${top[i + 1]}`); // Ranks start at 0, so add 1 for humans.const rank = await client.zrevrank("leaderboard", "linus");console.log(`Linus is #${rank + 1}`); await client.quit();node level-5.mjsNo 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 Valkey from "iovalkey"; const client = new Valkey(process.env.VALKEY_URL); // One key per player, one field per attribute.await client.hset("player:ada", { name: "Ada", country: "DE", level: 1, coins: 30 }); // Change single fields without reading the rest.await client.hincrby("player:ada", "level", 1); // level up!await client.hset("player:ada", "title", "Cache Cadet"); console.log(await client.hgetall("player:ada"));console.log("Level:", await client.hget("player:ada", "level")); await client.quit();node level-6.mjsHashes 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 Valkey from "iovalkey"; const client = new Valkey(process.env.VALKEY_URL); // At most 5 score submissions per player in each 10-second window.async function allowSubmit(player) { const key = `ratelimit:${player}`; // MULTI/EXEC runs both commands as one unit. NX sets the expiry only if // there is none yet, so the window starts at the first submission. const [[, count]] = await client.multi().incr(key).expire(key, 10, "NX").exec(); return count <= 5;} for (let i = 1; i <= 7; i++) { const ok = await allowSubmit("mallory"); console.log(`Submission ${i}: ${ok ? "accepted" : "slow down!"}`);} await client.quit();node level-7.mjsThis 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 Valkey from "iovalkey"; const client = new Valkey(process.env.VALKEY_URL);// A subscribed connection can only listen, so the feed gets its own.const feed = client.duplicate(); feed.on("message", (channel, message) => console.log(`📣 [${channel}] ${message}`));await feed.subscribe("arcade:events"); const listeners = await client.publish("arcade:events", "ada set a new high score: 6100");await client.publish("arcade:events", "grace joined the game");console.log(`Delivered to ${listeners} listener(s)`); // Give the messages a moment to arrive, then hang up.await new Promise((resolve) => setTimeout(resolve, 500));await feed.quit();await client.quit();node level-8.mjsChat, 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 Valkey from "iovalkey"; const client = new Valkey(process.env.VALKEY_URL);const feed = client.duplicate();feed.on("message", (channel, message) => console.log(` 📣 ${message}`));await feed.subscribe("arcade:events"); // Every level so far, in one function.async function submitScore(player, score) { const limitKey = `ratelimit:${player}`; const [[, attempts]] = await client.multi().incr(limitKey).expire(limitKey, 10, "NX").exec(); if (attempts > 5) return `${player}: rejected, too many submissions`; await client.hincrby(`player:${player}`, "games", 1); // CH makes ZADD count changed scores, so 1 means a new personal best. const improved = await client.zadd("leaderboard", "GT", "CH", score, player); if (improved) await client.publish("arcade:events", `${player} reached ${score}`); return `${player}: ${score}${improved ? " (new best!)" : ""}`;} const games = [["ada", 7300], ["grace", 6800], ["ada", 6900], ["grace", 7600]];for (const [player, score] of games) console.log(await submitScore(player, score)); // Mallory spams submissions, hoping a lucky one sticks.for (let i = 1; i <= 7; i++) console.log(await submitScore("mallory", 5000 + i * 100)); const top = await client.zrange("leaderboard", 0, 2, "REV", "WITHSCORES");console.log("\n🏆 Top 3");for (let i = 0; i < top.length; i += 2) console.log(`${i / 2 + 1}. ${top[i]} ${top[i + 1]}`); await feed.quit();await client.quit();node level-9.mjsThe 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.