As the gaming industry continues to grow in size, the need to create a unique and dynamic user experience has become even more mandatory. Because of its fandom, businesses have to maximize the multiplayer gaming experience to drive customer acquisition and retention. However, companies are faced with a number of obstacles when trying to scale multiplayer games, all of which can be solved through Redis.
Personalized interactions and high-speed reactions are at the heart of creating a unique user experience. Redis provides game publishers with a powerful database that can support low-latency gaming use cases. Recently a Launchpad App built a unique application that could have only been deployed by Redis due to its unmatched speed in transmitting data.
This is crucial because participants play from all around the world. Interactions between players must be executed in real-time to support gameplay, requiring the latency to be less than one millisecond.
Let’s take a deep dive into how this was done. But before we do so, make sure to have a browse through the exciting range of different applications that we have on the Launchpad.
You’ll build a real-time Geo-distributed multiplayer top-down arcade shooting game using Redis. The backbone of the application is Redis, for it acts as an efficient real-time database that enables you to store and distribute data.
As we progress through each stage chronologically, we’ll unpack important terminology as well as each Redis function.
Let’s identify the different components you’ll need to create this game. The application consists of 3 main components:
The main idea of this multiplayer game is to keep it real-time and distribute it geographically. That means that all the instances in your cluster should be updated so that you don’t run out of synchronization. Now let’s take a look at the architecture.
Now let’s take a look at the flow of the architecture.
git clone https://github.com/redis-developer/online_game/
Under the root of the repository, you will find a Docker compose YAML file:
version: '3.8'
services:
redis:
build:
dockerfile: ./dockerfiles/Dockerfile_redis
context: .
environment:
- ALLOW_EMPTY_PASSWORD=yes
- DISABLE_COMMANDS=FLUSHDB,FLUSHALL,CONFIG,HSCAN
volumes:
- ./redis/redis_data:/data
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf
ports:
- 6379:6379
restart: always
backend:
build:
dockerfile: ./dockerfiles/Dockerfile_node_backend
context: .
environment:
- NODE_ENV=development
volumes:
- ./game_conf.json:/game_config/game_conf.json
ports:
- 8080:8080
- 8082:8082
restart: always
Under this YAML file, there are two major services that are defined – redis and backend.
Below is how the Dockerfile for Redis looks like:
FROM redislabs/redismod:latest
COPY ./redis/redis_functions /functionsCOPY ./redis/start_redis.sh /start_redis.sh
RUN chmod +x /start_redis.sh
ENTRYPOINT ["bash"]CMD ["/start_redis.sh"]
Below is the Dockerfile for NodeJS backend:
FROM node:15.14WORKDIR /home/node/app
COPY ./app/package*.json ./RUN npm install --only=production
COPY ./app .CMD [ "npm", "start" ]
Bringing up the services
Ruin the following commands from the online_game directory
docker-compose up
You access the online WebServer via http://127.0.0.1:8080
There are three RedisGears functions that each have their own set of sub-functions:
Once a user starts the game, the first thing the user will do is search for a game using RediSearch. If the game is present then that person will try and join the game. If not then RedisGears is triggered to create a new game. See the function below
def find_game(user_id):
game = query()
if game != [@] and type(game) == list:
return game[1].split(":") [1]
# CREATE A NEW GAME IF THERE ARE NO GAMES
game = execute("RG.TRIGGER", "create_new_game", f"USER:{user_id}")
if game:
return game[@]
(
GB( 'CommandReader ' )
.map(lambda x: find_game(*x[1:]))
.register(trigger=self.command_name
)
Once the user has created a new game, then other players will join and everyone will be able to play.
class CreateNewGameFunctionBuilder(BaseFunctionBuilder):
def __init__(self):
super().__init__(command_name='create_new_game')
def register_command(self):
"""
Registers create_new_game redis gears fucntion to redis.
For each generate_new_game call creates a new HASH under game namespace:
GAME:[game_id] owner [user_id], secret [hash], private [bool], playercount [int]
Returns:
redis key [GAME:game_id]
Trigger example:
RG.TRIGGER create_new_game USER:123 1 secret123
"""
def subcall(user, private=0, secret=""):
game_id = uuid.uuid4().hex
key = f"GAME:{game_id}"
execute("HSET", key, "owner", user, "secret", str(secret), "private", int(private), "playercount", 0)
execute("EXPIRE", key, SECONDS_IN_DAY)
return game_id
(
GB('CommandReader')
.map(lambda x: subcall(*x[1:]))
.register(trigger=self.command_name, mode='sync')
)
If no game is present, then RedisGears is triggered to create a new one.
Once the user has created a new game, then other players will join and everyone will be able to play.
class CreateUserFunctionBuilder(BaseFunctionBuilder):
def __init__(self):
super().__init__(command_name='create_new_user')
def register_command(self):
"""
Registers create_new_user redis gears fucntion to redis.
For each create_new_user call creates a new HASH under user namespace:
USER:[u_id] name [str], settings [str], secret [str]
Returns:
redis key [USER:u_id]
Trigger example:
RG.TRIGGER create_new_user hhaa Player1 '' aahh
"""
def subcall(user_id, name, settings='{}', secret=""):
key = f"USER:{user_id}"
execute("HSET", key, "name", name, "setttings", settings, "secret", str(secret))
execute("EXPIRE", key, SECONDS_IN_DAY * 30)
return key
(
GB('CommandReader')
.map(lambda x: subcall(*x[1:]))
.register(trigger=self.command_name)
)
This function adds a new player to the game and it has the same approach as Create_new_game function. Again, RedisGears is triggered which then creates a new user
class JoinGameFunctionBuilder(BaseFunctionBuilder):
def __init__(self):
super().__init__(command_name='join_game')
def register_command(self):
"""
Determines best public server to join to.
- Assings User to the Game.
- Increments playercount
Arguments:
user, game, secret (optional)
Returns:
redis key [GAME:game_id]
Trigger example:
RG.TRIGGER join_game user1 game1
RG.TRIGGER join_game user1 game1 secret123
"""
When a user joins the game, RedisGears is triggered to enable the Join_game function. This also increments the player count of the game_instance (HINCRBY).
class LeaveGameFunctionBuilder(BaseFunctionBuilder):
def __init__(self):
super().__init__(command_name='leave_game')
def register_command(self):
"""
Determines best public server to join to.
- Removes USER to the ROOM.
- Decrements playercount
- Publishes a notification
Arguments:
user, game
Returns:
None
Trigger example:
RG.TRIGGER leave_game user1 game1
"""
def subcall(user_id, game_id, secret=None):
execute("HDEL", f"GAME:{game_id}", f"USER:{user_id}")
execute("HINCRBY", f"GAME:{game_id}", "playercount", -1)
(
GB('CommandReader')
.map(lambda x: subcall(*x[1:]))
.register(trigger=self.command_name, mode='sync')
)
When a user is eliminated from the game or chooses to leave, RedisGears is triggered to facilitate the process. This also automatically reduces the player count and automatically creates a notification to confirm that this action has been completed.
During gameplay, players will fire missiles to eliminate other competitors. When a player fires a missile, the below sub-function is then triggered:
def click(self, game_id, user_id, x, y, o):
"""
Handle player main key pressed event.
"""
player = self.games_states[game_id]["players"][user_id]
self.games_states[game_id]["projectiles"].append({
"timestamp": self.ts, # server time
"x": player["x"] if player['x'] is not None else 9999,
"y": player["y"] if player['y'] is not None else 9999,
"orientation": o, # radians
"ttl": 2000, # ms
"speed": 1, # px/ms
"user_id": user_id
})
return True
If a missile hits another player, then that user will be eliminated from the game. The below code determines whether a player has been hit by a missile.
def hit(self, game_id, user_id, enemy_user_id):
"""
Determines if the projectile has hit a user [user_id]
Extrapolates projectile position based on when projectile has spawned, and the time now.
Publishes a hit even if target is hit.
"""
projectiles = self.games_states[game_id]["projectiles"]
player = self.games_states[game_id]["players"][enemy_user_id]
for projectile in projectiles:
time_diff = self.ts - projectile['timestamp']
orientation = float(projectile["orientation"])
x = projectile['x'] + ( math.cos(orientation) * (projectile['speed'] * time_diff) )
y = projectile['y'] + ( math.sin(orientation) * (projectile['speed'] * time_diff) )
if abs(player['x'] - x < 50) and abs(player['y'] - y < 50):
self.games_states[game_id]['players'][projectile['user_id']]['score'] += 1
execute('PUBLISH', game_id, f"hit;{enemy_user_id}")
return False
return False
def respawn(self, game_id, user_id, x, y):
player = self.games_states[game_id]["players"][user_id]
player["respawns"] = player["respawns"] + 1
player["x"] = x
player["y"] = y
return True
MESSAGE_EVENT_HANDLERS | Explanation |
p (pose) args: [user_id, x, y, orientation]; | A client receives a user_id position update |
c(click) args: [user_id, x (where it was clicked at), y (where it was clicked at), angle (from the player position to click position)]; | A client receives user_id click event |
r (respawn) args: [user_id, x, y]; | A client receives user_id has respawned |
l (leave) args: [user_id]; | A client receives user_id has left the game |
j (join) args: [user_id, x, y] | A client receives user_id has joined the game, and user_id has spawned in the (x, y) position |
uid (user id) args: [is_valid]; | A client receives the response whether it is possible to find ‘log the user in |
gid (game id) args: [is_valid]; | A client receives if the user is part of the game (is user authorized) |
hit args: [user_id]; | A client receives a message that user_id has been hit / client can remove user_id from rendering it |
RediSearch indexes are registered on container startup in the redis/start_redis.sh
Created Redis Search indexes:
FT.CREATE GAME ON HASH PREFIX 1 GAME: SCHEMA owner TEXT secret TEXT private NUMERIC SORTABLE playercount NUMERIC SORTABLE
FT.CREATE USER ON HASH PREFIX 1 USER: SCHEMA name TEXT settings TEXT secret TEXT
FT.SEARCH "GAME" "(@playercount:[0 1000])" SORTBY playercount DESC LIMIT 0 1
As with any Redis database, one of the most adored assets is its ability to transmit data between components with unrivalled efficiency. Yet in this application, without the exceptional latency speed that Active-Active provides, the game would simply not be able to function.
Gameplay is purely interactive, where users from all around the world react and fire missiles at other players. From start to finish, RedisGears deploys a sequence of functions based on the chronological order of the application set-up.
This is done with ease due to the efficiency of RedisGears which enables an active-active geo-distributed top-down arcade shooter application to be deployed.
If you want to find out more about this exciting application you can see the full app on the Redis Launchpad. Also make sure to check out all of the other exciting applications we have available for you.
Jānis Vilks
Jānis is a Big Data engineer who works at Shipping Technology.
If you want to discover more about his work and the projects he’s been involved in, then make sure to visit his GitHub profile here.