Appearance
Appearance
Metaplay provides a base actor class on top of which you can build your own asynchronous matchmaker, and this guide will walk you through exactly that!
The Metaplay AsyncMatchmaker is designed to match players against each other in multiplayer gameplay where there is one active participant (attacker) and one passive participant (defender). The AsyncMatchmaker is not intended for matchmaking between online players for real-time PvP but is ideal for use-cases like:
The matchmaker works by continuously scanning the database for players to put in the matchmaking cache, which is held in memory. The cache is divided into several Buckets that each span a range of MMR values.
When a query is received, the matchmaker will look at a few Buckets close to the query's MMR value and select a list of players for the matchmaking algorithm. The game-defined matchmaking algorithm will then determine for each candidate if they are eligible to be matched against this query and calculates a quality score for each candidate. Finally, the matchmaker will send back the best candidate (deemed eligible) based on the quality score.
This quick guide will walk you through the basic steps of setting up an asynchronous matchmaker for your game. All of the new classes here should go into your game's Server folder.
The first class you need to create is a MatchmakerPlayerModel.
The MatchmakerPlayerModel is a representation of a player that contains all the properties of a defending player required for matchmaking. They include at the very least an EntityId and an MMR but can also include things like team roster, guild id, and resource amounts. This model is refreshed every time the player is scanned from the database or any time the player wants to update the matchmaker on its status. The matchmaker player model has to be a struct
and implement the IAsyncMatchmakerPlayerModel
interface.
Below is an example of a simple MatchmakerPlayerModel with the minimum required members. The model class also includes a static method for converting a normal PlayerModel
to a MatchmakerPlayerModel, so you don't have to duplicate this code in multiple places.
[MetaSerializable]
public struct MyMatchmakerPlayerModel : IAsyncMatchmakerPlayerModel
{
[MetaMember(1)] public EntityId PlayerId { get; set; }
[MetaMember(2)] public int DefenseMmr { get; set; }
public MyMatchmakerPlayerModel(EntityId playerId, int defenseMmr)
{
PlayerId = playerId;
DefenseMmr = defenseMmr;
}
// Return null if the player should not be included in matchmaking.
public static MyMatchmakerPlayerModel? TryCreateModel(PlayerModel player)
{
if(player.IsOnline) // Don't include online players.
return null;
int mmr = player.CalculateMMr(); // Your custom mmr logic here.
return new MyMatchmakerPlayerModel(player.PlayerId, mmr);
}
}
📝 Pro tip
When choosing what properties and data to include in the model, you should take into account the storage space requirements of what you’re planning to include. The smaller your matchmaking player model is, the more players you can fit in the matchmaking cache!
The MatchmakerQuery is similar to the MatchmakerPlayerModel, as it includes any data required by the matchmaker to represent the attacking player. Like with the model, this data is composed of at the very least an EntityId and an attacking MMR, but can also include any other game-specific data needed to fulfill the query.
[MetaSerializableDerived(1)]
public class MyMatchmakerQuery : AsyncMatchmakerQueryBase
{
public MyMatchmakerQuery() : base() { }
public MyMatchmakerQuery(EntityId attackerId, int attackMmr) : base(attackerId, attackMmr) { }
}
One other class you'll need to derive is AsyncMatchmakerOptionsBase
, which contains all the runtime options the base matchmaker actor will need. You can also add any custom options you need to this derived class.
[RuntimeOptions("AsyncMatchmaker", isStatic: true, "Options for the asynchronous matchmaker.")]
public class MyAsyncMatchmakerOptions : AsyncMatchmakerOptionsBase
{
// Add the configuration options you want for your matchmaker here
}
Now, it's time to create our own asynchronous matchmaker actor. Do so by creating a class deriving from AsyncMatchmakerActorBase
.
public class MyAsyncMatchmakerActor : AsyncMatchmakerActorBase<
PlayerModel,
MyMatchmakerPlayerModel,
MyMatchmakerQuery,
MyAsyncMatchmakerActor.State,
MyAsyncMatchmakerOptions
>
{
[MetaSerializableDerived(1)]
[SupportedSchemaVersions(1, 1)]
public class State : MatchmakerStateBase { }
public MyAsyncMatchmakerActor(EntityId entityId) : base(entityId) { }
protected override string MatchmakerName => "My matchmaker";
protected override string MatchmakerDescription => "An example matchmaker integration.";
// Leave this true if you want to use the matchmaker's database scanning feature.
protected override bool EnableDatabaseScan => true;
protected override MyMatchmakerPlayerModel? TryCreateModel(PlayerModel model)
=> MyMatchmakerPlayerModel.TryCreateModel(model);
protected override bool CheckPossibleMatchForQuery(MyMatchmakerQuery query, in MyMatchmakerPlayerModel player, int numRetries, out float quality)
{
// Your custom matchmaking logic here.
// Higher quality means a better match.
quality = 1000 - Math.Abs(player.DefenseMmr - query.AttackMmr);
return true; // return false if match should not be allowed
}
}
The matchmaker class needs to include a nested class deriving from MatchmakerStateBase
to store the state of the matchmaker. If your matchmaker needs to persist some state in the database, add it here.
The matchmaker TryCreateModel()
method should call the static method MyMatchmakerPlayerModel.TryCreateModel()
to convert a normal PlayerModel
to a MatchmakerPlayerModel.
CheckPossibleMatchForQuery()
is the primary method of the matchmaker that the actor calls to check if a match is possible for a given query. The matchmaker actor will call this method for every candidate it considers for a match, picking the best one afterward based on the returned quality
value.
Getting your matchmaker actor to run in the server requires an entity configuration and an EntityKind
.
Add a new EntityKind
to your game's EntityKindGame
registry.
[EntityKindRegistry(100, 300)]
public static class EntityKindGame
{
public static readonly EntityKind AsyncMatchmaker = EntityKind.FromValue(100);
}
Next, create a class deriving from AsyncMatchmakerConfigBase
and set the matchmaker EntityKind
and the EntityActorType
to the ones you made previously.
[EntityConfig]
public class MyAsyncMatchmakerConfig : AsyncMatchmakerConfigBase
{
public override EntityKind EntityKind => EntityKindGame.AsyncMatchmaker;
public override Type EntityActorType => typeof(MyAsyncMatchmakerActor);
}
Finally, you'll need to add an EFCore migration to be able to add the new matchmaker table to the database.
In your server project directory, run the following command in the terminal:
dotnet-ef migrations add AddAsyncMatchmakerActor
And we're done! Your matchmaker should now be running when you start the server. The LiveOps Dashboard will also automatically show the matchmaker's status.
Now that your matchmaker is running, you can start sending queries to it. This usually happens in the SessionActor
in response to a message from the client. The implementation should ask the PlayerActor
for the player's attack MMR and any other required matchmaking parameters and send them in the MatchmakerQuery
we implemented.
Below is an example of an attack request handler, which sends an AsyncMatchmakingRequest
to the matchmaker actor. The matchmaker responds to the request with an AsyncMatchmakingResponse
.
async Task HandleBattleMatchingRequest(BattleMatchingRequest request)
{
InternalPlayerGetBattleAttackParamsResponse attackParams =
await EntityAskAsync<InternalPlayerGetBattleAttackParamsResponse>(
PlayerId, InternalPlayerGetBattleAttackParamsRequest.Instance);
MyMatchmakerQuery query = new MyMatchmakerQuery(PlayerId, attackParams.Mmr);
// This is a set of already tried players.
// The matchmaker will not offer any players from this set.
OrderedSet<EntityId> triedDefenders = new OrderedSet<EntityId>();
for (int i = 0; i < 10; i++) // Retry logic if matchmaking fails.
{
EntityId matchmakerId = MyAsyncMatchmakerActor.Entities.GetQueryableMatchmakersRandom().First();
AsyncMatchmakingResponse response = await EntityAskAsync<AsyncMatchmakingResponse>(
matchmakerId, new AsyncMatchmakingRequest(
numRetries: i,
matchmakerQuery: query,
excludedPlayerIds: triedDefenders));
// Ask defender player if they're available to be attacked. (No shields active etc..)
...
// If defender is unavailable, add them to the tried defenders list.
if (!defenderAvailable)
{
triedDefenders.Add(defenderId);
continue;
}
// Start battle
...
//Inform the client
SendOutgoingPayloadMessage(new BattleMatchingResponse(...));
return;
}
// Here if all retries failed
SendOutgoingPayloadMessage(BattleMatchingResponse.ForFailure());
}
While usually, the matchmaker operates by scanning the database to find players for the matchmaking cache, the game may additionally decide to send AsyncMatchmakingPlayerStateUpdate
s to the matchmaker at any time. You can use this message to update the matchmaker's cache with new player data. You can do this, for example, when an attacking player has asked a defending player if they're available, but the defending player found out they have a shield up. The defending player can then notify the matchmaker that they should not be offered for matchmaking until the shield has worn out. In games where only offline players should be eligible for matchmaking, the PlayerActor
can update the matchmaker state whenever it comes online or offline.
Below is an example code that sends a state update to the matchmaker.
void UpdateMatchmaker()
{
MyMatchmakerPlayerModel? model = MyMatchmakerPlayerModel.TryCreateModel(Model);
// Sending a null model to the matchmaker means that the player is not available for matchmaking
AsyncMatchmakingPlayerStateUpdate playerStateUpdate = AsyncMatchmakingPlayerStateUpdate.Create(PlayerId, model);
EntityId matchmakerId = MyAsyncMatchmakerActor.Entities.GetMatchmakerForDefenderPlayer(Model.PlayerId);
CastMessage(matchmakerId, playerStateUpdate);
}
The matchmaker comes fairly well configured out of the box, but there are a few important values that you might want to take a look at:
BucketCount
- The number of Buckets of MMR to divide the playerbase into. If the matchmaker defines any additional bucketing strategies, you should tune this to a lower number.MatchingCandidateAmount
- The number of candidates to collect before deciding on the best match. Tuning this higher might result in better matches in games with more complex matchmaking rules.InitialMinMmr
and InitialMaxMmr
- The initial minimum and maximum MMR. The matchmaker will automatically tune its MMR range to whatever it encounters in the playerbase, but setting the initial minimum and maximum values to reasonable estimates of the game's MMR range will result in better performance before the matchmaker has time to readjust.