Tutorial
Building a game backend with Redis
June 02, 202634 minute read
#Building a game backend with Redis
TL;DRCache Quest is a browser RPG/roguelite whose backend uses Redis for functions: storing HTTP sessions viaconnect-redis, persisting per-player save data as JSON documents indexed with Redis Search, and tracking high scores with sorted-set leaderboards. This tutorial is a walkthrough of the running code. By the end you'll know how to wire all three services into your own game.Play the game at: https://redis.io/cache-quest/
#Why Redis for games?
Modern multiplayer games are some of the most demanding real-time applications in the world. Whether it's a battle royale with millions of concurrent players, an MMO with persistent worlds, or a fast-pased mobile game with live events, game backends must process enormous amounts of data with extremely low latency.
Every movement, action, score update, inventory change, message, matchmaking request generates data. But unlike traditional applications, games need to respond in milliseconds. Why? Because lag breaks immersion.
Redis has become a foundational technology for game developers because it delivers ultra-fast performance, massive scalability, and flexible data structures that are ideal for real-time game infrastructure.
#The top use cases for Redis are
- Player and game state real-time synchronization between players and game servers
- Matchmaking low-latency queueing and fast matching logic
- Sessions management instant access to frequently used session data
- Real-time messaging asynchronous, decoupled communication between backend services
- Leaderboards real-time score updates and efficient ranking queries
- Rate limiting protect servers without affecting game performance
#What you'll learn
- How
connect-redisplugs intoexpress-sessionto keep session data in Redis instead of in process memory. - How to model player and game state as a Redis JSON document and query it with Redis Search (
FT.CREATE,FT.SEARCH,JSON.SET,JSON.GET). - How to back a leaderboard with a Redis sorted set (
ZADD,ZRANGE … REV WITHSCORES). - The data save and load flow from the frontend game client.
NOTE: This tutorial uses the code from the following git repository:
#What you'll build (and run)
A Redis powered player data save and leaderboard service. When you run it you get:
-
Node/Express on
http://localhost:3000serving the KAPLAY.js game frompublic/and three JSON APIs:POST/PATCH/GET /api/playerstate— player save dataPOST/PATCH/GET /api/gamestate— quest/world progressionPOST/GET /api/leaderboard— scores per map
-
Redis on
redis://localhost:6379storing four kinds of data:Key Type Purpose sessions:{id}string Serialized Express session (managed by connect-redis)players:{id}JSON A player's stats, position, unlocked skills games:{id}JSON Chapter progression and boss-defeated flags leaderboards:survivor-{map}sorted set High scores by map (member = "{name}-{id}")All four are keyed off the same session ID in this demo game for simplicity since there is no user registration/character creation. If you were to add such features, be sure to implement player IDs for your users and a game IDs for each game playthrough.
#Prerequisites
- Node.js 20+ and npm
- Docker (recommended —
docker compose up -dboots both the game and a Redis instance) - A local clone of the repo
- Experience with JavaScript and Express. No prior Redis knowledge required — every Redis command is introduced inline.
#Quickstart using Docker
This game is meant to be hosted on a web server back by Redis Cloud. For local testing and development, use Docker.
Open
http://localhost:3000. You should see the title screen.#Step 1: Redis Cloud
If you don't already have a Redis Cloud account, you can sign up for free here. You can then create a free database and connect to Redis Cloud by changing the
REDIS_URL in the .env file.#1. Log into the Redis Cloud console and find the following
- The
public endpoint(looks likeredis-#####.####.us-region-2-#.ec2.redns.redis-cloud.com:#####) - Your
username(defaultis the default username, otherwise find the one you setup) - Your
password(either setup through Data Access Control, or available in theSecuritysection of the database page).
#2. Combine the above values into a connection string and put it in your .env. It should
look something like the following:
#3. Install dependencies and start the dev server
Open
http://localhost:3000. You should see the title screen.#Step 2: Redis Client
Every server-side file that needs Redis imports a single helper:
#Two things to mention
- Clients are memoized by URL. The
clientsobject caches one connection per URL. Everyawait getClient()call after the first reuses the same connection — no thrashing connections per request, no connection pool to manage by hand. - The
errorhandler self-heals. If the connection drops,refreshClient()evicts the stale entry fromclientsand callsgetClient()again, which rebuilds the connection on the next call.
The pattern to copy: don't
createClient() inline at the top of every module. Wrap it in a getter that returns a shared, lazily-connected client. It keeps boot time fast and recovers from transient network errors without restarting the process.#Step 3: Sessions with connect-redis + express-session
Express sessions need somewhere to store their data. By default that's process memory, which dies with the process and doesn't scale beyond one server.
connect-redis is a drop-in store backed by Redis. Each session becomes one string key in Redis containing the serialized session object.#Under the hood
Three configurations deserve attention:
-
prefix: "sessions:"namespaces every session key. The value is a JSON-stringified blob of the session object.connect-redishandles the serialization. -
disableTTL: truetellsconnect-redisnot to set a Redis TTL on each session. Why? Because Express already has its own expiration concept (the cookie'smaxAge). Setting both means two clocks fighting each other and sessions getting evicted server-side while the browser still thinks it's logged in. Pick one source of truth; this app picks the cookie. -
rolling: truewrites the session back on every response, which (together with the cookiemaxAge) extends the session every time the user does something. A player who plays for an hour stays logged in another 7 days, automatically.
#Why the session ID matters
NOTE: For simplicity, this demo does not implement user registration, authentication, or character creation. This demo uses the session ID as the game and player IDs. This enables users to play the demo without creating an account or logging in.
There's a
/session route the client hits on first load:The session ID gets returned to the browser and becomes the JSON document ID for player and game states. Simply put, this session ID is the unique identifier for both the
gamestate and playerstate in Redis. The session ID is also appended to the playerNames on the leaderboards to allow for multiple players with the same playerName.| Key | Type | Purpose |
|---|---|---|
players:{id} | JSON | One player's save |
games:{id} | JSON | One game's progression |
player-idx, game-idx | Search index | Indexing to find player and game states with Redis Search |
#Step 4: Player and game state as JSON documents with Redis Search
Player save data is a deeply nested object: stats, current map, position, unlocked skills, kill counts. Storing that in Redis used to mean either flattening it into a hash (painful) or serializing it to a JSON string (opaque to Redis). The Redis JSON capability solved this: each key holds an actual JSON value, and Redis can read/write subtrees of it. Redis Search layers on top so you can index and query those JSON fields.
The player store and the game store are near-identical files. We'll walk through
playerstate/store.js in full; the same pattern applies to gamestate/store.js.#1. Create the search index
Redis search indexes are secondary structures that sit alongside your data. When you create an index, you’re telling Redis: "Watch all keys that match this prefix, and build a searchable structure from these specific fields." You create it once at boot:
#Under the hood
The Redis command is
FT.CREATE index ON JSON PREFIX count prefix SCHEMA:ON: "JSON"tells the index that the documents it's indexing are stored with Redis JSON (rather than as plain hashes).PREFIX: "players:"is the key-pattern filter. Any key whose name starts withplayers:is automatically indexed. You don't have to tell the index about each new player —JSON.SET players:abc123 $ {...}is enough.$.idand$.nameare JSONPath expressions identifying which fields inside the document get indexed.$is the root.AS: "id"is the alias used in queries (@id:(...)).SCHEMA_FIELD_TYPE.TEXTdeclares each field as a full-text field. (Other types:TAGfor exact-match strings,NUMERICfor ranges,VECTORfor embeddings.)
The
haveIndex / createIndexIfNotExists pattern is the idempotent init trick: it's safe to call on every server boot, because it only does the FT.CREATE if FT._LIST doesn't already report the index. (Trying to create an existing index would throw.)gamestate/store.js uses the same shape with a different index name (game-idx), prefix (games:), and one fewer indexed field.#2. Write a player document
Create or update a player state in Redis using
JSON.SET#Game client
createPlayerData()savePlayerData()
#API routes
POSTtoapi/playerstate/callscreate()on initial player creation.PATCHtoapi/playerstate/callsupdate()on subsequent player data saves.
#Game backend server
- Player state
create()update()
#Under the hood
The Redis command
JSON.SET key path value:NOTE: Replacing the whole document on each save (which this demo does) is the simplest choice for save data. The player and game states are small enough so the JSON document fits neatly in one round trip. In production, if you want finer-grained updates you'd use partial-pathJSON.SETorJSON.MERGEinstead. Smaller, finer-grained updates will greatly reduce network bandwidth and latency once your player and game states grow in size.
$is the JSONPath of where to write new data. The dollar sign,$, is the root of the document. (You can write a partial update to a specific path, e.g.JSON.SET players:jpKRE2B80KRt56huN9 $.health.dungeon 100to update just one field.)valueis the JSON to store at that path.
The
update() function does one defensive thing the create() doesn't: it first calls one() to confirm the document exists, returning 404 if not. This is what lets the client's save flow distinguish "you've never saved before" from "you have, here's the update". More on this in Step 6. Tying it together on the client.#3. Read a player document
Retrieve a player state using
JSON.GET#Game client
loadPlayerData()
#API route
GETtoapi/playerstate/{playerId}callsone()on load player data
#Game backend server
- Player state
one()
#Under the hood
The Redis command
JSON.GET key:JSON.GETreturns the full JSON value at that key (ornullif it doesn't exist). The Node client deserializes it into a plain object.
#4. Search by field
This is not implemented in the game client but the backend functionality is there.
#Under the hood
The Redis command is
FT.SEARCH index query:-
@id:(playerId)— match documents where theidfield contains "playerId." The session ID is used in this demo for simplicity. You can generate a permanent playerId when you implement your own user registration and persist it in this field. -
@name:(playerName)— match documents where thenamefield contains "playerName" (full-text match, on aTEXTfield). -
Multiple clauses joined with a space are AND'd together.
For more advanced queries you can wrap groups in parentheses and use
| for OR (@name:(HeroLink32|Zeldah71)), - for NOT, [10 30] for numeric ranges, etc.Note: The game-state store atserver/components/gamestate/store.jsis structurally identical to this one withGamesubstituted forPlayerand only$.idindexed (no$.name).
#Step 5. Leaderboards as sorted sets
A sorted set in Redis is a collection of unique string members, each tagged with a numeric score. Redis keeps the set ordered by score automatically, and gives you O(log N) inserts and O(log N + M) range reads. That's the algorithmic shape of a leaderboard, baked into the data structure.
| Key | Type | Purpose |
|---|---|---|
leaderboards:survivor-Grassland | sorted set | Grassland scores |
leaderboards:survivor-Mountains | sorted set | Mountains scores |
leaderboards:survivor-Badlands | sorted set | Badlands scores |
#1. Add an entry
Add new highscore to a leaderboard using
ZADD#Game client
saveScore()
#API route
POSTto/api/leaderboard/callsaddLeaderboardEntry()on new highscore
#Game backend server
addLeaderboardEntry()
#Under the hood
The Redis command is
ZADD key GT score member:-
scoremust be numeric. The set is kept ordered by score.zAddreturns the number of new members added. If the member already existed and you only changed its score, the return is0. This demo game client will only calladdLeaderboardEntryon a player achieving a new personal high score.- The
ZADD … GToption to ensure the score only updates on the individual player new highscore.
-
memberis a unique string. If youZADDthe same member again with a different score, you update the existing entry rather than adding a new one.- That's why this demo encodes the session/player ID into the member, to allow for different players with the same playerName.
#2. Read the top N
Retrieve the top scores from a leaderboard using
ZRANGE#Game client
getLeaderboardEntries()
#API route
GETto/api/leaderboard/leaderboards:{leaderboardName}?count=10retrieves the top 10 scores.
#Game backend server
getLeaderboard()
#Under the hood
The command is
ZRANGE key start stop REV WITHSCORES:startandstopare 0-indexed, inclusive on both ends.0 9returns the first ten entries.REVreverses the sort order. By default sorted sets are returned low-to-high; for a leaderboard you want high-to-low, so you flip it.WITHSCORESasks for[member, score, member, score, …]instead of just members. The Node client returns it as an array of{ value, score }objects.
#3. Sorted set member-encoding
Note: This feature is implemented solely for this demo to allow users to play this game without creating a login. In production, you will want to implement proper user registration with a sorted set of IDs and a parallel JSON document or hash for the per-entry detail. The sorted set ranks, the document holds the data.
A sorted set has no schema beyond
(string, number). To prevent users with the same playerName from overwriting each other's scores, we concatenate their playerName with their playerId.And on read, splits on the first
- to pull the display name back out:#Step 6. Tying it all together
The client's
public/src/systems/saveload.js is where the Redis-backed APIs become the game function: "save my progress".#1. The save flow
PATCHthenPOSTon404
#Game client
The server will return a
404 if no player data is found. That single existence check on the server lets the client avoid one of its own and the same code handles "first save" and "every save after."- Always try
PATCHfirst. - If the server responds 404 (the document doesn't exist yet), fall back to
POSTto create it. - If you know a player save does not already exist (i.e., when a new player initially plays the game), call
createPlayerData()instead.
The same pattern repeats for game state via
saveGameData() / createGameData().#2. The load flow
#Game client
The main menu calls
getSessionId() first, then passes that ID to loadPlayerData(session.id) and loadGameData(session.id).If no data exist in Redis,
createPlayerData() and createGameData()#3. The highscore
#Game client
When a player dies in survivor mode, if they have a personal high score...
...call
saveScore() to POST new highscore to Redis.#FAQ
#Why disableTTL: true on the session store?
Because the cookie's
maxAge (set in app.js) already controls expiration. If you let connect-redis set its own TTL too, you have two clocks ticking at different rates and risk evicting a session server-side while the browser still believes it's valid. Pick one — this app picks the cookie, which is why rolling: true works smoothly.#What happens if Redis restarts?
The
redis:alpine image in compose.yml mounts a redis-data volume, so data survives container restarts. If Redis is wiped, every player loses their session (and therefore their save), since the session ID is the document key. For production you would host Redis on its own server and enable persistence or use Redis Cloud.#Why JSON documents instead of Redis hashes?
Hashes are flat — every field is a top-level string. Player save data is nested (
health.dungeon, skillUnlocked.swordBeam). You can either flatten everything into dotted keys (painful, error-prone) or use a JSON document and let Redis JSON walk the structure for you. With JSON you also get partial-path updates: JSON.SET players:jpKRE2B80KRt56huN9 $.health.dungeon 100 updates one field without sending the whole document.#Why use JSON.SET...$ (root) in both update() and create() player/game state?
Replacing the whole document on each save (which this demo does) is the simplest choice for save data. The player and game states are small enough so the JSON document fits neatly in one round trip. In production, if you want finer-grained updates you'd use partial-path
JSON.SET or JSON.MERGE instead. Smaller, finer-grained updates will greatly reduce network bandwidth and latency once your player and game states grow in size.#Why isn't there a TTL on player documents?
Save data is meant to outlive sessions. A user who returns 8 days later (after their session cookie expires) gets a new session ID, which means a new empty save — yes, in this demo, that's a feature. If you want returning-player support, swap the session ID out for a stable user ID.
#Can I run this against Redis Cloud?
Yes. Set
REDIS_URL in .env to the connection string from your Redis Cloud database's Public endpoint (the README has the exact format). Redis Cloud supports both JSON and Search by default. Skip the docker compose step and run npm run dev.#How do I reset a player's progress?
#Why does the leaderboard member include the player ID?
Sorted set members are unique strings. If two players were both named "Redis", their entries would collide and one would overwrite the other. Encoding the ID makes each member globally unique while still letting the client extract the display name on read.
Note: The sorted set member-encoding method works fine for this demo game with no user registration. In production, switch to a sorted set of IDs and a parallel JSON document or hash for the per-entry detail. The sorted set ranks, the document holds the data.
#Where is the search index defined?
Each server boot calls
playerstate.initialize() and gamestate.initialize() from server/app.js. Those call createIndexIfNotExists() which uses FT._LIST to check for the index before issuing FT.CREATE. The pattern is idempotent — it's safe on every boot.#Troubleshooting
REDIS_URL not set on startup.
Copy .env.example to .env and confirm REDIS_URL is present. If you're using Docker, also confirm .env.docker exists with REDIS_URL="redis://redis:6379".connect-redis can't reach Redis.
From the host machine, your REDIS_URL should be redis://localhost:6379. From inside the game container (Docker), it has to be redis://redis:6379 (the service name from compose.yml). The two env files give you both perspectives.Error: Unknown command 'FT.CREATE' or 'JSON.SET'.
If you're using an older version of Redis, it doesn't have the Search / JSON modules by default. Use the redis:alpine image from the included compose.yml (which is recent enough to bundle them) or Redis 8+, or use Redis Cloud (which supports them by default). Standalone Redis 7.x and older without modules won't work.Session cookie doesn't persist across reloads.
Confirm
cookie.maxAge is set and the browser isn't blocking third-party cookies for localhost. Also check that REDIS_SESSION_SECRET is set — express-session silently misbehaves without it in some Express versions.Leaderboard route returns
{ status: 404, message: "No leaderboard entries found" }.
You haven't saved a score for that map yet. Trigger an entry from the game, or insert one manually: redis-cli ZADD leaderboards:survivor-Grassland 100 "Tester-123".#Next steps
- Sign up for Redis Cloud and run the game against a free hosted database. This effectively implements cloud save for your save data and leaderboards.
- Build with another tutorial: How to build matchmaking and game session state with Redis
- Watch the Webinar: Building a Lag-Free Game Backend
