21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/marketplace.json:
--------------------------------------------------------------------------------
1 | {
2 | "app_name": "Redis Kaboom RPG Game",
3 | "description": "A Role playing maze game with Redis, the Kaboom.js game framework, Express.js and Bulma using Redis JSON",
4 | "rank": "280",
5 | "type": "Full App",
6 | "contributed_by": "Redis",
7 | "repo_url": "https://github.com/redis-developer/redis-kaboom-rpg",
8 | "preview_image_url": "https://raw.githubusercontent.com/redis-developer/redis-kaboom-rpg/master/images/app_preview_image.png",
9 | "download_url": "https://github.com/redis-developer/redis-kaboom-rpg/archive/refs/heads/main.zip",
10 | "hosted_url": "",
11 | "quick_deploy": "false",
12 | "deploy_buttons": [
13 | {
14 | "heroku": "https://heroku.com/deploy?template=https://github.com/redis-developer/redis-kaboom-rpg"
15 | },
16 |
17 | {
18 | "Google": "https://deploy.cloud.run/?git_repo=https://github.com/redis-developer/redis-kaboom-rpg"
19 | }
20 | ],
21 |
22 | "language": ["JavaScript", "Express"],
23 | "redis_commands": ["JSON.SET", "JSON.GET", "JSON.ARRLEN", "SADD", "SISMEMBER", "SCARD", "SMEMBERS", "XADD", "XRANGE", "XREVRANGE"],
24 | "redis_use_cases": [],
25 | "redis_features": ["JSON"],
26 | "app_image_urls": ["https://raw.githubusercontent.com/redis-developer/redis-kaboom-rpg/main/screenshots/screenshot1.png", "https://raw.githubusercontent.com/redis-developer/redis-kaboom-rpg/main/screenshots/screenshot2.png"],
27 | "youtube_url": "",
28 | "special_tags": [],
29 | "verticals": ["Gaming"],
30 | "markdown": "https://raw.githubusercontent.com/redis-developer/redis-kaboom-rpg/main/README.md"
31 | }
32 |
--------------------------------------------------------------------------------
/src/server.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const path = require('path');
3 | const Redis = require('ioredis');
4 |
5 | // Redis configuration.
6 | const PORT = process.env.PORT||8080;
7 | const REDIS_HOST = process.env.REDIS_HOST || '127.0.0.1';
8 | const REDIS_PORT = process.env.REDIS_PORT || 6379;
9 | const REDIS_PASSWORD = process.env.REDIS_PASSWORD;
10 |
11 | const app = express();
12 |
13 | // Connect to Redis.
14 | const redis = new Redis({
15 | host: REDIS_HOST,
16 | port: REDIS_PORT,
17 | password: REDIS_PASSWORD
18 | });
19 |
20 | // Keep our Redis keys in a namespace.
21 | const getRedisKeyName = n => `kaboom:${n}`;
22 |
23 | // We'll use this key a lot to get data about rooms, stored
24 | // in Redis as a JSON document.
25 | const ROOM_KEY_NAME = getRedisKeyName('rooms');
26 |
27 | // Serve the front end statically from the 'public' folder.
28 | app.use(express.static(path.join(__dirname, '../public')));
29 |
30 | // Start a new game.
31 | app.get('/api/newgame', async (req, res) => {
32 | const gameId = Date.now();
33 | const gameMovesKey = getRedisKeyName(`moves:${gameId}`);
34 |
35 | // Start a new stream for this game and set a long expiry in case
36 | // the user abandons it.
37 | await redis.xadd(gameMovesKey, '*', 'event', 'start');
38 | redis.expire(gameMovesKey, 86400);
39 |
40 | // Pick 3 random room numbers to place the keys in for this game.
41 | let keysPlaced = 0;
42 |
43 | // We'll store these in a Redis set, so we'll need a key for that...
44 | const keyLocationsKey = getRedisKeyName(`keylocations:${gameId}`);
45 |
46 | // Figure out how many rooms are available so we know what the range
47 | // of room numbers to pick from is.
48 | const numRooms = await redis.call('JSON.ARRLEN', ROOM_KEY_NAME, '.');
49 |
50 | do {
51 | const roomNumber = Math.floor(Math.random() * (numRooms - 1));
52 | await redis.sadd(keyLocationsKey, roomNumber);
53 | keysPlaced = await redis.scard(keyLocationsKey);
54 | } while (keysPlaced < 3);
55 |
56 | // Set a long expiry on the key locations key in case the user
57 | // abandons the game.
58 | redis.expire(keyLocationsKey, 86400);
59 |
60 | console.log(`Started game ${gameId}.`);
61 |
62 | res.json({ gameId: gameId });
63 | });
64 |
65 | // Get JSON array of all currently active game IDs.
66 | app.get('/api/activegames', async (req, res) => {
67 |
68 | // Scan through all keys in the stream starting with "kaboom:moves".
69 | const stream = redis.scanStream({
70 | match: 'kaboom:moves:*'
71 | });
72 |
73 | const gameIds = [];
74 |
75 | stream.on('data', (keys) => {
76 | // Extract the gameId from the key and append to gameIds array.
77 | keys.forEach((key) => gameIds.push(key.split(':')[2]));
78 | });
79 |
80 | stream.on('end', () => {
81 | res.status(200).json({
82 | data: {
83 | gameIds,
84 | length: gameIds.length
85 | },
86 | status: 'success',
87 | })
88 | })
89 | });
90 |
91 | // Get details for a specified room number from Redis.
92 | app.get('/api/room/:gameId/:roomNumber', async (req, res) => {
93 | const { gameId, roomNumber } = req.params;
94 |
95 | const minRoomNumber = 0;
96 | const roomCount = JSON.parse(await redis.call('JSON.ARRLEN', ROOM_KEY_NAME, '.'));
97 | const maxRoomNumber = roomCount - 1;
98 | const roomNumberInteger = parseInt(roomNumber);
99 |
100 | if (roomNumberInteger < minRoomNumber || roomNumberInteger > maxRoomNumber) {
101 | console.log(`/api/room/:gameId/:roomNumber called with invalid room number of ${roomNumber}`)
102 | return res.status(400).send('Invalid room number')
103 | }
104 |
105 | // Store this movement in Redis.
106 | redis.xadd(getRedisKeyName(`moves:${gameId}`), '*', 'roomEntry', roomNumber);
107 |
108 | // Get the room details for this room.
109 | const roomDetails = JSON.parse(await redis.call('JSON.GET', ROOM_KEY_NAME, `[${roomNumber}]`));
110 |
111 | // Does this room have a key in it for this specific game?
112 | const roomHasKey = await redis.sismember(getRedisKeyName(`keylocations:${gameId}`), roomNumber);
113 |
114 | if (roomHasKey === 0) {
115 | // No key here, so remove the 'k' placeholder from the room map.
116 | // String.replaceAll not available until Node 15...
117 | roomDetails.layout = roomDetails.layout.map(row => row.split('k').join(' '));
118 | }
119 |
120 | res.json(roomDetails);
121 | });
122 |
123 | // Get details for a specified room number.
124 | app.get('/api/randomroom/', async (req, res) => {
125 | // Figure out how many rooms are available.
126 | const numRooms = await redis.call('JSON.ARRLEN', ROOM_KEY_NAME, '.');
127 |
128 | // Get a random number from room 0 to room (numRooms - 1).
129 | res.json({ room: Math.floor(Math.random() * (numRooms - 1)) });
130 | });
131 |
132 | // End the current game and get the stats.
133 | app.get('/api/endgame/:gameId', async (req, res) => {
134 | const { gameId } = req.params;
135 |
136 | // Check the format of the gameId in the request
137 | if (isNaN(gameId) || !/^[1-9]+[0-9]*$/.test(gameId)) {
138 | console.log(`/api/endgame/:gameId called with invalid gameId: ${gameId}.`);
139 | return res.status(400).send('Invalid gameId specified, should be a whole number greater than zero');
140 | }
141 |
142 | // Check for gameIds that could only exist in the future
143 | if (gameId > Date.now()) {
144 | console.log(`/api/endgame/:gameId called with future gameId: ${gameId}.`);
145 | return res.status(400).send('Time travelling not allowed, this game hasn\'t started yet!');
146 | }
147 |
148 | const gameMovesKey = getRedisKeyName(`moves:${gameId}`);
149 |
150 | // Does this gameMovesKey (still) exist?
151 | const gameMovesKeyExists = await redis.exists(gameMovesKey);
152 | if (!gameMovesKeyExists) {
153 | console.log(`Request for invalid or completed gameId: ${gameId}.`);
154 | return res.status(400).send('Game not found, invalid gameId or game has ended');
155 | }
156 |
157 | // How many times did they enter a room (length of stream minus 1 for
158 | // the start event).
159 | const roomEntries = await redis.xlen(gameMovesKey) - 1;
160 |
161 | // Get the first and last entries in the stream, and the overall
162 | // elapsed game time will be the difference between the timestamp
163 | // components of their IDs.
164 | const streamStartAndEnd = await Promise.all([
165 | redis.xrange(gameMovesKey, '-', '+', 'COUNT', 1),
166 | redis.xrevrange(gameMovesKey, '+', '-', 'COUNT', 1),
167 | ]);
168 |
169 | // Parse out the timestamps from the Redis return values.
170 | const startTimeStamp = parseInt(streamStartAndEnd[0][0][0].split('-')[0], 10);
171 | const endTimeStamp = parseInt(streamStartAndEnd[1][0][0].split('-')[0], 10);
172 | const elapsedTime = Math.floor((endTimeStamp - startTimeStamp) / 1000);
173 |
174 | // Tidy up, delete the stream and key locations keys as
175 | // we don't need them any more.
176 | redis.del(gameMovesKey);
177 | redis.del(getRedisKeyName(`keylocations:${gameId}`));
178 |
179 | console.log(`Game ${gameId} has ended.`);
180 |
181 | res.json({ roomEntries, elapsedTime });
182 | });
183 |
184 | // Start the server.
185 | app.listen(PORT, () => {
186 | console.log(`Redis Kaboom RPG server listening on port ${PORT}, Redis at ${REDIS_HOST}:${REDIS_PORT}.`);
187 | });
188 |
--------------------------------------------------------------------------------
/public/static/js/game.js:
--------------------------------------------------------------------------------
1 | window.onload = function () {
2 | // Initialize Kaboom...
3 | const k = kaboom({
4 | global: true,
5 | scale: 3,
6 | clearColor: [0, 0, 0, 1],
7 | canvas: document.getElementById('game'),
8 | width: 180,
9 | height: 180
10 | });
11 |
12 | // Load the various sprite graphics.
13 | loadRoot('/');
14 | loadSprite('player', 'sprites/player.png');
15 | loadSprite('wall', 'sprites/wall.png');
16 | loadSprite('key', 'sprites/key.png');
17 | loadSprite('flag', 'sprites/flag.png');
18 | loadSprite('door', 'sprites/door.png');
19 | loadSprite('lockeddoor', 'sprites/lockeddoor.png');
20 |
21 | // Load the various sound effects
22 | loadSound("key", "sounds/key");
23 | loadSound("wall", "sounds/wall");
24 | loadSound("flag", "sounds/flag");
25 | loadSound("door","sounds/door");
26 |
27 | // Globals to remember which rooms the player found
28 | // keys in and the ID of the game they're playing.
29 | let keysHeld = [];
30 | let gameId;
31 |
32 | // Render a particular room...
33 | scene('play', async (roomNumber) => {
34 | // Get the room details from the server.
35 | const res = await fetch(`/api/room/${gameId}/${roomNumber}`);
36 | const roomDetails = await res.json();
37 |
38 | let popupMsg = null;
39 | let keysHeldMsg = null;
40 |
41 | // Show a message e.g. one to tell the player how many
42 | // keys they need to open a locked door.
43 | const showMsg = (msg) => {
44 | popupMsg = add([
45 | text(msg, 6),
46 | pos(width() / 2, 10),
47 | origin('center')
48 | ]);
49 | };
50 |
51 | // Update the keys held message at the bottom of the screen.
52 | const updateKeysHeld = () => {
53 | if (keysHeldMsg) {
54 | destroy(keysHeldMsg);
55 | }
56 |
57 | keysHeldMsg = add([
58 | text(`Room ${roomNumber}. Keys held: ${keysHeld.length}`, 6),
59 | pos(80, 150),
60 | origin('center')
61 | ]);
62 | };
63 |
64 | // Mapping between characters in the room layout and sprites.
65 | const roomConf = {
66 | width: roomDetails.layout[0].length,
67 | height: roomDetails.layout.length,
68 | pos: vec2(20, 20),
69 | '@': [
70 | sprite('player'),
71 | 'player'
72 | ],
73 | '=': [
74 | sprite('wall'),
75 | "wall",
76 | solid()
77 | ],
78 | 'k': [
79 | sprite('key'),
80 | 'key',
81 | solid()
82 | ],
83 | 'f': [
84 | sprite('flag'),
85 | 'flag',
86 | solid()
87 | ]
88 | };
89 |
90 | // Mapping for each door, determines whether to show a locked
91 | // or unlocked door...
92 | for (const doorId in roomDetails.doors) {
93 | const door = roomDetails.doors[doorId];
94 |
95 | roomConf[doorId] = [
96 | sprite(door.keysRequired > 0 ? 'lockeddoor' : 'door'),
97 | 'door',
98 | // Extra properties to store about this door - need
99 | // these when the player touches it to determine what
100 | // to do then.
101 | {
102 | leadsTo: door.leadsTo,
103 | keysRequired: door.keysRequired,
104 | isEnd: door.isEnd || false
105 | },
106 | solid()
107 | ];
108 | }
109 |
110 | addLevel(roomDetails.layout, roomConf);
111 | updateKeysHeld();
112 |
113 | // Delete any key in this room if the player already collected it.
114 | const keys = get('key');
115 | if (keys.length > 0 && keysHeld.includes(roomNumber)) {
116 | destroy(keys[0]);
117 | }
118 |
119 | const player = get('player')[0];
120 |
121 | const directions = {
122 | 'left': vec2(-1, 0),
123 | 'right': vec2(1, 0),
124 | 'up': vec2(0, -1),
125 | 'down': vec2(0, 1)
126 | };
127 |
128 | // Map key presses to player movement actions.
129 | for (const direction in directions) {
130 | keyPress(direction, () => {
131 | // Destroy any popup message 1/2 a second after
132 | // the player starts to move again.
133 | if (popupMsg) {
134 | wait(0.5, () => {
135 | if (popupMsg) {
136 | destroy(popupMsg);
137 | popupMsg = null;
138 | }
139 | });
140 | }
141 | });
142 | keyDown(direction, () => {
143 | // Move the player.
144 | player.move(directions[direction].scale(60));
145 | });
146 | }
147 |
148 |
149 | // What to do when the player touches a door.
150 | player.overlaps('door', (d) => {
151 | wait(0.3, () => {
152 | // Does opening this door require more keys than the player holds?
153 | if (d.keysRequired && d.keysRequired > keysHeld.length) {
154 | showMsg(`You need ${d.keysRequired - keysHeld.length} more keys!`);
155 | camShake(10);
156 | } else {
157 | // Does this door lead to the end state, or another room?
158 | play('door');
159 | if (d.isEnd) {
160 | go('winner');
161 | } else {
162 | go('play', d.leadsTo);
163 | }
164 | }
165 | });
166 | });
167 |
168 | player.overlaps("wall", (e) => {
169 | play("wall");
170 | });
171 |
172 | // What to do when the player touches a key.
173 | player.overlaps('key', (k) => {
174 | play("key");
175 | destroy(k);
176 | showMsg('You got a key!');
177 | // Remember the player has this key, so we don't
178 | // render it next time they enter this room and
179 | // so we know they can unlock some doors now.
180 | keysHeld.push(roomNumber);
181 | updateKeysHeld();
182 | });
183 |
184 | // What to do when the player touches a flag.
185 | player.overlaps('flag', async () => {
186 | play('flag');
187 | // Go to a random room number and spin the
188 | // camera around and around.
189 | let angle = 0.1;
190 | const timer = setInterval(async () => {
191 | camRot(angle);
192 | angle += 0.1;
193 |
194 | if (angle >= 6.0) {
195 | // Stop spinning and go to the new room.
196 | camRot(0);
197 | clearInterval(timer);
198 |
199 | const res = await fetch('/api/randomroom');
200 | const roomDetails = await res.json();
201 |
202 | go('play', roomDetails.room);
203 | }
204 | }, 10);
205 | });
206 |
207 | // Update the player position etc - run every frame.
208 | player.action(() => {
209 | player.resolve();
210 | });
211 | });
212 |
213 | // Get a new game ID and start a new game.
214 | const newGame = async () => {
215 | const res = await fetch('/api/newgame');
216 | const newGameResponse = await res.json();
217 |
218 | gameId = newGameResponse.gameId;
219 |
220 | // New game always starts in room 0.
221 | go('play', 0);
222 | }
223 |
224 | // Display a message telling the player how to start
225 | // a new game.
226 | scene('start', () => {
227 | keysHeld = [];
228 |
229 | add([
230 | text('press space to begin!', 6),
231 | pos(width() / 2, height() / 2),
232 | origin('center'),
233 | ]);
234 |
235 | keyPress('space', () => {
236 | newGame();
237 | });
238 | });
239 |
240 | // This is the scene for when the player solves the
241 | // puzzle and escapes the maze with all the keys.
242 | scene('winner', async () => {
243 | // Reset for next game.
244 | keysHeld = [];
245 |
246 | // Get the number of times a room was entered and the
247 | // overall elapsed time for this game.
248 | const res = await fetch(`/api/endgame/${gameId}`);
249 | const { roomEntries, elapsedTime } = await res.json();
250 |
251 | add([
252 | text(`you escaped in:\n\n${roomEntries} moves.\n\n${elapsedTime} seconds.\n\nspace restarts!`, 6),
253 | pos(width() / 2, height() / 2),
254 | origin('center'),
255 | ]);
256 |
257 | keyPress('space', () => {
258 | newGame();
259 | });
260 | });
261 |
262 | start('start');
263 | };
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Redis Kaboom RPG Game
2 |
3 | This is an RPG maze type game built with [Kaboom.js](https://kaboomjs.com/), [Node.js](https://nodejs.org/) and [Redis](https://redis.io). It makes use of the [Redis JSON](https://redisjson.io) module from [Redis Inc](https://redis.com).
4 |
5 | 
6 |
7 | [Watch the video on YouTube!](https://www.youtube.com/watch?v=cowIZWASJNs)
8 |
9 | Since making the video and GIF above, we changed out the sprite images as part of our Hacktoberfest initiative. If you're looking for the originals that you see in the video, they're in this repo in `public/sprites/original`.
10 |
11 | ## Setup
12 |
13 | To run this game, you'll need [Docker](https://www.docker.com/) (or a local Redis instance, version 5 or higher with Redis JSON installed) and [Node.js](https://nodejs.org/) (use the current LTS version). First, clone the repo and install the dependencies:
14 |
15 | ```bash
16 | $ git clone https://github.com/redis-developer/redis-kaboom-rpg.git
17 | $ cd redis-kaboom-rpg
18 | $ npm install
19 | ```
20 |
21 | ### Docker setup
22 |
23 | With Docker - you need to have Docker installed and there are no other requirements. You can use Docker to get a Redis instance with Redis JSON:
24 |
25 | ```bash
26 | $ docker-compose up -d
27 | ⠿ Network redis-kaboom-rpg_default Created
28 | ⠿ Container redis_kaboom Started
29 | ⠿ Container node_kaboom Started
30 | $
31 | ```
32 |
33 | Redis creates a folder named `redisdata` (inside the `redis-kaboom-rpg` folder that you cloned the GitHub repo to) and writes its append-only file there. This ensures that your data is persisted periodically and will still be there if you stop and restart the Docker container.
34 |
35 | Note that when using Docker, there is no need to load the game data as this is done for you. Once the containers are running you should be able to start a game simply by pointing the browser at http://localhost:8080/.
36 |
37 | ### Stopping Redis (Docker)
38 |
39 | If you started Redis using `docker-compose`, stop it as follows when you are done playing the game:
40 |
41 | ```bash
42 | $ docker-compose down
43 | Container node_kaboom Removed
44 | Container redis_kaboom Removed
45 | Network redis-kaboom-rpg_default Removed
46 | $
47 | ```
48 |
49 | ### Redis Setup (without Docker)
50 |
51 | Without Docker - you will need Redis 5 or higher, Redis JSON and Node.js (current LTS version recommended)
52 |
53 | This game uses Redis as a data store. The code assumes that Redis is running on localhost port 6379. You can configure an alternative Redis host and port by setting the `REDIS_HOST` and `REDIS_PORT` environment variables. If your Redis instance requires a password, supply that in the `REDIS_PASSWORD` environment variable. You'll need to have Redis JSON installed.
54 |
55 | ### Loading the Game Data
56 |
57 | Next, load the game map into Redis. This stores the map data from the `game_map.json` file in Redis, using Redis JSON:
58 |
59 | ```bash
60 | $ npm run load
61 |
62 | > redis-kaboom-rpg@1.0.0 load
63 | > node src/data_loader.js
64 |
65 | Data loaded!
66 | $
67 | ```
68 |
69 | You only need to do this once. Verify that the data loaded by ensuring that the key `kaboom:rooms` exists in Redis and is a Redis JSON document:
70 |
71 | ```bash
72 | 127.0.0.1:6379> type kaboom:rooms
73 | ReJSON-RL
74 | ```
75 |
76 | ### Starting the Server
77 |
78 | To start the game server:
79 |
80 | ```bash
81 | $ npm run dev
82 | ```
83 |
84 | Once the server is running, point your browser at `http://localhost:8080`.
85 |
86 | This starts the server using [nodemon](https://www.npmjs.com/package/nodemon), so saving changes to the source code files restarts the server for you automatically.
87 |
88 | If the server logs an error similar to this one, then Redis isn't running on the expected / configured host / port:
89 |
90 | ```
91 | [ioredis] Unhandled error event: Error: connect ECONNREFUSED 127.0.0.1:6379
92 | at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1146:16)
93 | ```
94 |
95 | ### Stopping the Server
96 |
97 | To stop the Node.js server, press Ctrl-C.
98 |
99 |
100 | ### Playing the Game
101 |
102 | Press space to start, then use the arrow keys to move your character. Red doors are locked until you have found the appropriate number of keys. Touch a red door to find out how many keys are required, or pass through it if you have enough keys. Green doors are unlocked and don't require keys.
103 |
104 | Find all 3 keys and unlock the door in the room you started in (room 0) to escape. Touching a flag teleports you to a random other room.
105 |
106 | At the end of the game, you'll see how long you took to complete the challenge, and how many times you moved between rooms.
107 |
108 | ## How it Works
109 |
110 | Let's take a look at how the different components of the game architecture fit together.
111 |
112 | ### Project Structure
113 |
114 | The project consists of a Node.js back end that has API routes for some of the game logic and a static server for the front end.
115 |
116 | The back end code lives in the `src` folder, along with the data loader code, used to load the game room map into Redis. It uses the [Express framework](https://expressjs.com/) both to serve the front end HTML / JavaScript / CSS and image files, and also to implement a small API for starting and tracking game events. Redis connectivity is handled using the [ioredis client](https://www.npmjs.com/package/ioredis).
117 |
118 | The front end is written in JavaScript using [Kaboom.js](https://kaboomjs.com/) as the game engine, and the [Bulma CSS framework](https://bulma.io/) for some basic layout. It lives in the `public` folder.
119 |
120 | ### Working with Kaboom.js
121 |
122 | [Kaboom.js](https://kaboomjs.com/) describes itself as "...a JavaScript library that helps you make games fast and fun!". It renders games as a set of scenes in a HTML `