MatchmakingExtPackage - Mini-IT/SnipeWiki GitHub Wiki

Extended matchmaking

Location: snipe/packages/matchmakingExt/

This package extends the basic matchmaking functionality to match any number of users against one another. Multiple independent queues are supported with the limitation that a single user can only be in a single queue at any time. Note that running both basic and extended matchmaking in the same project is supported, however, you will need to handle the client being in different queues at the same time yourself, the packages will not check the queues of one another.

You can include this package into the game server with:

  override function initModulesGame()
    {
      loadModules([
        snipe.packages.matchmakingExt.GameModule,
        ]);
    }

You can include this package into the cache server with:

      loadModules([
        snipe.packages.matchmakingExt.CacheModule,
        ]);

Cache server configuration variables:

  • "packages/matchmakingExt.time" - Timer delay in seconds between matchmaking checks. Equals 1 by default.

The implementation of extended matchmaking mainly differs from the basic one in the cache server part. Instead of having a pair check method we need to implement the full queue class on top of the default one. The check method of the class will be called periodically by the package timer and it's job is to match the users in the queue, start new games and manage the queue. The class also has to override two provided hooks that are called on adding and removing the user.

Let's take a look at the example code. Just like the basic matchmaking, we start by declaring the matchmaking user class in "MatchmakingExtUserClass.hx":

typedef MatchmakingExtUserClass = MatchmakingUser;

And the "MatchmakingUser.hx" contains the following declaration:

class MatchmakingUser
{
  public var serverID: Int; // global game server ID
  public var metaServerID: Int; // meta game server ID
  public var localServerID: Int; // local game server ID
  public var id: Int; // user ID
  public var seconds: Int; // seconds spent waiting in queue
  public var rating: Int; // user rating
  public var deleted: Bool; // user deleted from the pool?

  public function new()
    {
      serverID = 0;
      metaServerID = 0;
      localServerID = 0;
      id = 0;
      seconds = 0;
      rating = 0;
      deleted = false;
    }
}

The matchmaking user class requires only the "id" field but we supply the queue with all of the information about the server thread the client is currently in, plus the user rating (that is used as a base for matchmaking).

Now we will need to register the matchmaking queue type in one of the cache server modules and declare the associated class:

class MatchmakingModuleCache extends ModuleCache<CacheServer>
{
  public function new(s: CacheServer)
    {
      super(s);
      name = 'matchmaking';

      var module: CacheModule = server.getModule('packages/matchmakingExt');
      module.registerType(new MatchmakingListTest(server, 'basic'));
    }
}
class MatchmakingListTest extends MatchmakingList
{
  var list: Array<MatchmakingUser>;

  public function new(s: CacheServer, vid: String)
    {
      super(s, vid);

      list = [];
    }


// find matching users
  override function check()
    {
      var timePeriod = 10;
      var tmp = [];
      var poses = [];
      for (i in 0...list.length)
        {
          var user = list[i];
          if (user.deleted) // user already found a game in this tick
            continue;

          user.seconds++;

          // only treat as game starter after enough time has passed
          if (user.seconds < timePeriod)
            continue;

          // add to potential game starters list
          tmp.push(user);
          poses.push(i);
        }

      // sort potential starters by time spent waiting
      tmp.sort(sortBySeconds);

      // loop through users trying to create a game
      var gameFound = false;
      for (i in 0...tmp.length)
        {
          var user = tmp[i];
          var pos = poses[i];

          // user might have already joined another game
          if (user.deleted)
            continue;

          var ret = checkGame(user, pos);
          if (ret == true)
            gameFound = true;
        }

      // at least one game found in this tick, some users need to be deleted
      if (gameFound)
        for (u in list)
          if (u.deleted)
            list.remove(u);
    }


// sorting function
  function sortBySeconds(u1: MatchmakingUser, u2: MatchmakingUser)
    {
      if (u1.seconds > u2.seconds)
        return -1;
      else if (u1.seconds < u2.seconds)
        return 1;
      else return 0;
    }


// try to find a game for this user
  function checkGame(user: MatchmakingUser, pos: Int): Bool
    {
      // loop 10 users in both directions, adding them to the temp pool
      var tmp = new List();
      for (i in 1...10)
        {
          // -1
          if (pos - i >= 0 && !list[pos - i].deleted)
            {
              var u2 = list[pos - i];

              // we don't allow users on different meta servers in one game
              if (user.metaServerID != u2.metaServerID)
                return false;

              tmp.add(u2);
            }

          // +1
          if (pos + i < list.length && !list[pos + i].deleted)
            {
              var u2 = list[pos + i];

              // we don't allow users on different meta servers in one game
              if (user.metaServerID != u2.metaServerID)
                return false;

              tmp.add(u2);
            }
        }

      // not enough users for a full game and not enough time has passed to make an incomplete one
      if (tmp.length < 4 && user.seconds < 60)
        return false;

      // too many users, delete excess ones, starting from the edges
      while (tmp.length > 4)
        tmp.remove(tmp.last());

      // start the game
      server.log('match', 'success ' + tmp);

      var s = server.getClient(user.serverID);

      // start the room on first user's server and wait for all clients to join
      var ids = [];
      for (u in tmp)
        ids.push(u.id);
      ids.push(user.id);
      s.notify({
        _type: 'game.start',
        id: user.id,
        ids: ids
        });
      user.deleted = true;

      // loop through all users, notifying them to join or migrate
      for (u in tmp)
        {
          // user on a different server, migrate
          if (user.serverID != u.serverID)
            {
              var s2 = server.getClient(u.serverID);
              s2.notify({
                _type: 'game.migrate',
                id: u.id,
                id2: user.id,
                serverID: user.localServerID
                });
            }

          // user on the same server, join the game
          else s.notify({
            _type: 'game.join',
            id: u.id,
            id2: user.id
            });

          // mark as deleted
          u.deleted = true;
        }

      return true;
    }


// add user
  override function add(user: MatchmakingUser): String
    {
      // find a proper place so that the list remains ordered
      var pos = 0;
      var hasUser = false;
      for (i in 0...list.length)
        {
          // list already has this user
          if (list[i].id == user.id)
            {
              hasUser = true;
              pos = i;
              break;
            }

          if (list[i].rating < user.rating)
            continue;

          pos = i;
          break;
        }

      // replace old record
      if (hasUser)
        list[pos] = user;

      // insert a new one
      else list.insert(pos, user);

      return 'ok';
    }


// remove user
  override function remove(userID: Int): String
    {
      for (i in 0...list.length)
        if (list[i].id == userID)
          {
            list.remove(list[i]);
            break;
          }

      return 'ok';
    }
}

In this specific algorithm we try to find a game for four users of the closest rating starting with the users that spent the most time waiting. If the potential game starter has waited for more than a minute, we try to start an incomplete game. We use the overriden function "add()" to put the new user into the list ordered by user rating. Overriden "remove()" function is used by the package to remove the user from the list of users when the game has started or the user has left the queue. Each separate queue has its own mutex to synchronize add/remove/check operations.

Notice the three separate game server notifications on success. The first one, "game.start", goes to the game server where the game starter is logged on, it creates the game room (game rooms are described in the Room Manager article) and gives it the list of client IDs that can join that game. The next one, "game.migrate", goes to the clients that reside in other game server threads. They will need to migrate to the game starter thread and join the game there. Client migration is described in detail in the Client Migration article. The third one, "game.join", will be used to notify game clients that are in the correct game server thread about the game start.

The game server client matchmaking part is not much different from the basic matchmaking:

class MatchmakingModule extends Module<Client, ServerTest>
{
  public function new(srv: ServerTest)
    {
      super(srv);
      name = "matchmaking";
    }


  public override function call(c: Client, type: String, params: Params): Dynamic
    {
      var response = null;

      if (type == "matchmaking.add")
        response = add(c, params);
      else if (type == "matchmaking.remove")
        response = remove(c, params);
      return response;
    }


// CALL: add user to matchmaking list
  function add(c: Client, params: Params)
    {
      if (c.state != '')
        throw 'wrong state: ' + c.state;

      var user = new MatchmakingUser();
      user.id = c.id;
      user.rating = c.rating;
      user.serverID = server.slaveID;
      user.localServerID = server.id;
      user.metaServerID = server.meta.id;

      var module: GameModule = server.getModule('packages/matchmakingExt');
      module.add('basic', user);

      c.state = 'match';

      return { errorCode: 'ok' };
    }


// CALL: remove user from matchmaking list
  function remove(c: Client, params: Params)
    {
      if (c.state != 'match')
        throw 'wrong state: ' + c.state;

      var module: GameModule = server.getModule('packages/matchmakingExt');
      module.remove('basic', c.id);

      c.state = '';

      return { errorCode: 'ok' };
    }
}

In this example we use client state variable to disable parts of project functionality when the client is in the matchmaking queue or during the running game.

The remaining example code part deals with the game start notifications described earlier:

class GameModule extends Module<Client, ServerProject>
{
  // [game module contents omitted for brevity]

// NOTIFY: start a new game
// creates a room, adds the first user and waits until the room fills up
  function start(msg: { id: Int, ids: Array<Int> })
    {
      server.log('game', 'start, msg: ' + msg);

      var c = server.getClient(msg.id, true);

      // client disconnected
      if (c == null)
        {
          server.log('game', 'cannot start, client disconnected');
          return;
        }

      // the client has the wrong state
      if (c.state != 'match')
        throw 'game.start(): wrong state: ' + c.state;

      var room: TestRoom = cast server.roomModule.create('game');
      room.playerIDs = msg.ids;
      room.join(c);
      c.state = 'game';
    }


// NOTIFY: start the client migration
  function migrate(msg: { id: Int, id2: Int, serverID: Int })
    {
      var c = server.getClient(msg.id, true);
      if (c == null)
        return;

      // start the client migration
      server.migrateClient(c, msg.serverID, msg, "game.migratePost");
    }


// HOOK: post-client migration, find the game and join it
  function migratePost(c: Client, msg: { id2: Int })
    {
      var c2 = server.getClient(msg.id2, true);
      if (c2 == null || c2.state != 'game')
        return;

      // get client room
      var room: TestRoom = cast server.roomModule.getClientRoom(c2);
      if (room == null)
        throw 'game.migratePost(): no such game room';

      c.state = 'game';
      room.join(c);
    }


// NOTIFY: join the room
  function join(msg: { id: Int, id2: Int })
    {
      var c = server.getClient(msg.id, true);
      if (c == null || c.state != 'match')
        return;

      var c2 = server.getClient(msg.id2, true);
      if (c2 == null || c2.state != 'game')
        return;

      // get client room
      var room: TestRoom = cast server.roomModule.getClientRoom(c2);
      if (room == null)
        throw 'game.migratePost(): no game room';

      c.state = 'game';
      room.join(c);
    }

  // [game module contents omitted for brevity]
}

The "TestRoom" class contents and room type registration are not provided for brevity. You can find the example room type in the Room Manager article. Note the additional "migratePost" function. After the client successfully migrates to the correct game server thread it will be used to join the room.

While the extended matchmaking brings the additional hassle of managing the queue contents and the necessity of the client migration (it's not related to the matchmaking itself, though), it is a much more powerful tool than the basic matchmaking.

⚠️ **GitHub.com Fallback** ⚠️