MatchmakingExtPackage - Mini-IT/SnipeWiki GitHub Wiki
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.