Building a Real-time Game Character Selection System using Socket.io

A validation for character selection which enables us to see character selected by other player in our device in real time.

This is a guide to building the back-end of a simple game character selection system that lets us see the character selected by other players in our device in real-time. Let us explore how we can implement a character selection system for a real-time multiplayer game.

Overview

The main goal of this module is to allow our players to pick a character from a set of available characters and make it visible to other players in real-time. If there is no real-time validation of the selected characters, multiple players might select the same character. We need a system where the different player can select their desired character on a first-come, first-serve basis. That means, if one player selects a specific character, then other players should not be able to select that same character. In this article, we will learn how we can make such a system for character selection.

Prerequisites

Before diving into the details, we assume you have already set up a new project with some sort of socket connection and already know the basics of the event system used by Socket.io. If not, it's a good idea to learn the basics of Socket.io in Unity and set up a dummy server before moving forward.

The Architecture

This guide is simply an overview of the architecture, unlike a tutorial where you follow step-by-step guides. Instead, I'll share how you can build a few different pieces and put them together, so let's dive right into the details.

Database

We need to make a simple database that will hold information about all the players and their selected characters. Personally, I'd pick MongoDB because of its simplicity over a relational database. A Redis server would suffice too. For the sake of this article, I'm using MongoDB with Mongoose.

I have a Users model that contains basic information about the player. A player can only be present in only one room, so we have a roomName field to keep track of the game the player is currently playing.

const mongoose = require("mongoose");
const Schema = mongoose.Schema;

let UserSchema = new Schema({
    userId: { type: String, unique: true },
    socketId: { type: String, unique: true },
    roomName: { type: String, default: null },
    isPlaying: { type: String, default: false },
    ...
});

module.exports = mongoose.model("Users", UserSchema);
Users model

I also have a Rooms model, which basically represents a game. It contains basic information about the game, the list of players, and more. The property that we are interested in is the characters property. It contains the list of characters selected by the players. Both the players and characters are ordered lists of String. The first item of the characters list represents the character of the first item of the players list.

const mongoose = require("mongoose");
const Schema = mongoose.Schema;

let roomSchema = new Schema({
    roomName: {
        type: String,
        default: () => {
            ...
            return randomRoomName();
        },
    },
    gameName: { type: String, default: 'Example' },
    entryFee: { type: Number, default: 100 },
    players: { type: [String], default: [] },
    gameStarted: { type: Boolean, default:false },
    ...
    ...
    characters:{type:[String],default : []}
    
});

module.exports = mongoose.model("Rooms", roomSchema);
Rooms model

These two models are the only essential collections we need to get started. The Rooms model also contains a property gameStarted that keeps track of whether the game has started.

Events

Next, we need to write a controller that listens for events emitted by the client when one of the players picks a character. Let's call the event CharacterChange . The event will be fired by a game client, such as a Socket.io client in Unity, whenever a player changes a character.

Here's a sample code to do just that.

const redis = require("socket.io-redis");
const User = require("../Model/User");
const Room = require("../Model/Room"); 
const config = require("../Config/Config")

// socket manager
const SocketManager = function (http) {
    io = require("socket.io").listen(http, config.socketConfig);
    io.adapter(redis(config.redisConfig));
    SocketListeners(io);
};


// socket listeners
function SocketListeners(io) {
    
        socket.on("CharacterChange", async function (characterName) {
            
            // find the current game
            await User.findOne({socketId: socket.id})
                .then(async user => {
                    let roomName = user.roomName;

                await Room.findOne({roomName: user.roomName}).then(async (room) => {
                        // ensure game exists and has not started
                        if (room && !room.gameStarted) {
                            
                            // get the user's index
                            let userIndex = room.players.indexOf(user.userId);
                            
                            // ensure the same character is not selected
                            let allowedCharacterSelection = !arrayContains(room.characters, characterName);
                            
                            if (allowedCharacterSelection) {
                                
                                // set the character
                                room.characters[userIndex] = characterName;
                                
                                // commit
                                room.markModified('characters');
                                await room.save();
                                
                                // notify 
                                io.in(room.roomName).emit("RoomUpdated", JSON.stringify(room));
                            }
                        }
                    }).catch((err) => {
                        ...<handle error>
                    })
                }).catch((err) => {
                    ...<handle error>
                });
        });

}
Sample code for Character Change Controller

Let's break down the controller's job into smaller pieces so we can understand better.

  1. When the client sends the selected character for the given player, we look at the characters list in the user's current room to see if the character is already in use. If it's already there, we reject the request to change by ignoring it or sending an appropriate message.
  2. If the character is not in use, we set the new character to the characters list for the player. Then we emit an event to all the connected clients in the room to send the characters list.

That's all. After receiving the event, it's the clients' job to properly update the UI to mark the unavailable characters.

Race Conditions

What about Race conditions that might occur from multiple requests?

Different databases have different ways of dealing with race conditions when reading or inserting data into or from the database. In the case of MongoDB, we need to use some sort of validation inside the findOne callback. Redis has its own way of handling race conditions by using transactions. Based on your choice of database, you might have to look by yourself at how race conditions can be handled. In the end, the goal is to ensure that two players don't select the same character.

That's it. I hope you at least know a basic concept about how character selection can be updated in real-time for different players in the same lobby.