Appearance
Appearance
On the previous page, we created a basic implementation of the leagues system, including all of the basic functionality. It includes allowing players to join, configuring seasons start and end dates, and allowing players to score, as well as get placed and rewarded accordingly at the end of a season. This time around we'll expand especially on modifying the Rank-Up logic, but we'll also touch on having custom server-only logic and state on the leagues.
The simplest customizations available are done through overriding the GetRankUpStrategy
method, which provides some plug and play options. This guide also goes over using global Rank-Up, meaning players can compete between multiple ranks, instead of only their own. If you wish, you can also write a completely new season migration method, which will also be discussed on this page.
Lastly, we'll also go over server-only logic and state, which can be useful from preventing modified versions of the game's client to snoop on data to gain an unfair advantage. You can also use server-only logic to add bots indistinguishable from real players from the client's point of view, maybe to populate divisions that don't have enough players.
If the default local-only Rank-Up Strategy does not work for you, the league manager offers a few customization options for the Rank-Up behavior.
To customize the Rank-Up behavior of the league, override the GetRankUpStrategy
method inside the manager actor. This method returns a LeagueRankUpStrategy
for each rank, which you can customize based on rank and season.
Available options are:
isSingleDivision
sets the rank as only having a single division. This will prevent the league manager from creating new divisions even if the participant count exceeds the default or maximum values. Divisions with a very large number of participants can be very bad for performance, so be sure to limit the participant amount when deciding Rank-Ups for this rank.rankUpMethod
can change the default Local
behavior to a Global
one. The Global
Rank-Up method will gather all participants of all ranks with the Global
method and only then decide for all the participants simultaneously. You can use this, for example, when you want to have a single division at the top of the ranks, where only the best of the best participants will be placed. Note, however, that doing a global pass on all participants is rather expensive, so only use the Global
Rank-Up method if explicitly necessary.In the example below, we'll change the *Rank-Up Strategy for the top two ranks to be Global
, to make our "Legend" rank only contain the absolute best players. We need to use the Global
method for both the "Diamond" and "Legend" ranks since we cannot find the top players just by looking at the existing "Legend" rank and "Diamond" divisions separately. We`ll need to look at both at once:
protected override LeagueRankUpStrategy GetRankUpStrategy(int rank, int season)
{
switch (rank)
{
case 0:
return new LeagueRankUpStrategy();
case 1:
return new LeagueRankUpStrategy(preferNonEmptyDivisions: true);
case 2:
return new LeagueRankUpStrategy(preferNonEmptyDivisions: true);
case 3:
return new LeagueRankUpStrategy(preferNonEmptyDivisions: true, rankUpMethod: LeagueRankUpMethod.Global);
case 4:
return new LeagueRankUpStrategy(isSingleDivision: true, rankUpMethod: LeagueRankUpMethod.Global);
default:
throw new ArgumentOutOfRangeException(nameof(rank), "Given rank is out of range of known ranks");
}
}
When using the Global
Rank-Up method for any rank, we must also override the SolveGlobalSeasonPlacement
method in the league manager. The manager will assign new ranks to all participants of Global
Rank-Up methods at once, and the method should return a ParticipantSeasonPlacementResult
for all of them.
In the example below, we'll find and place the top participants in the "Legend" rank and the rest in "Diamond". As with the Local
method, we will demote the bottom 10 participants of each "Diamond" division.
protected override Dictionary<EntityId, ParticipantSeasonPlacementResult> SolveGlobalSeasonPlacement(int lastSeason, int nextSeason, Dictionary<EntityId, GlobalRankUpParticipantData> allParticipants)
{
Dictionary<EntityId, ParticipantSeasonPlacementResult> results = new Dictionary<EntityId, ParticipantSeasonPlacementResult>();
int legendRankSize = GetRankDetails(GetSeasonDetails(nextSeason).NumRanks - 1).DivisionParticipantCount;
// Find top players by producers upgraded
IEnumerable<(EntityId Id, GlobalRankUpParticipantData Data)> topParticipants = allParticipants.OrderByDescending(
(participant) => ((IdlerPlayerDivisionConclusionResult)participant.Value.ConclusionResult).PlayerScore.NumProducerUpgrades).Select((x) => (x.Key, x.Value)).Take(legendRankSize);
// Process top participants
foreach ((EntityId Id, GlobalRankUpParticipantData Data) topParticipant in topParticipants)
{
// Add top players to legend rank.
results.Add(topParticipant.Id, ParticipantSeasonPlacementResult.ForRank(numRanks - 1));
}
// Process the rest of the participants
foreach (KeyValuePair<EntityId, GlobalRankUpParticipantData> participant in allParticipants)
{
// Skip top participants
if(results.ContainsKey(participant.Key))
continue;
IdlerPlayerDivisionConclusionResult playerResult = participant.Value.ConclusionResult as IdlerPlayerDivisionConclusionResult;
if (playerResult == null)
throw new NullReferenceException($"Conclusion result was null or not castable to a {nameof(IdlerPlayerDivisionConclusionResult)}");
int leaderBoardPlacementFromBottom = playerResult.LeaderboardNumPlayers - playerResult.LeaderboardPlacementIndex - 1;
// Demote the bottom 10 players of diamond.
if (participant.Value.CurrentRank == numRanks - 2 && leaderBoardPlacementFromBottom < 10)
results.Add(participant.Key, ParticipantSeasonPlacementResult.ForRank(participant.Value.CurrentRank - 1));
else
results.Add(participant.Key, ParticipantSeasonPlacementResult.ForRank(numRanks - 2)); // else place to diamond
}
return results;
}
If the local and global Rank-Up Strategies don't fulfill your needs, it is possible to write a custom season migration method in the actor. To do this, override the MigrateParticipantsToCurrentSeason
method.
The custom migration method should:
SeasonMigrationResult
object returned to the base actor.The base actor offers a few helper methods to do that, such as:
EnumerateAllParticipants
returns an async enumerator that iterates over all the participant associations stored in the database.TryFetchConclusionResultsForDivisionParticipants
fetches conclusion results from a given division. You can then calculate the participants' new placements using the conclusion results.InitializeDivisionsAndTransferAvatars
initializes new divisions and transfers the participants' avatars to the next season's divisions. This method requires you to create and populate a SeasonMigrationDivisionsResult
object, which includes a list of any new divisions created during this initialization call, a mapping table of DivisionIndex
to a list of participants, and a list of all the participants and their Divisions in the form of ParticipantDivisionPair
s. This method does not write any association entries into the database.RemoveOldParticipantDivisionAssociations
removes any association entries that do not match the current season. You can use this after the new associations have been written to the database to remove any leftover associations of dropped participants.Below is an example migration that removes all participants from the league system between seasons. They will be treated as brand-new players upon their next login. This method does not decide any participant placements or create new divisions.
// Override the default migration behavior to drop all participants.
protected override async Task<SeasonMigrationResult> MigrateParticipantsToCurrentSeason()
{
int lastSeasonRanks = 0;
int lastSeasonId = -1;
int estimatedParticipantsToEnumerate = 0;
int participantsEnumerated = 0;
if (State.HistoricSeasons.Count > 0)
{
lastSeasonRanks = State.HistoricSeasons[^1].Ranks.Count;
lastSeasonId = State.HistoricSeasons[^1].SeasonId;
estimatedParticipantsToEnumerate = State.HistoricSeasons[^1].Ranks
.Sum(r => r.RankDetails.DivisionParticipantCount * r.NumDivisions);
}
SeasonMigrationResult migrationResult = new SeasonMigrationResult(lastSeasonRanks);
UpdateSeasonMigrationProgress(0, "Counting last season participants");
// Gather data on rank participant counts
await foreach (PersistedParticipantDivisionAssociation participant in EnumerateAllParticipants())
{
DivisionIndex division = DivisionIndex.FromEntityId(EntityId.ParseFromString(participant.DivisionId));
if (division.Season == lastSeasonId)
{
migrationResult.LastSeasonRankResults[division.Rank].NumParticipants++;
migrationResult.LastSeasonRankResults[division.Rank].NumDropped++;
migrationResult.LastSeasonTotalParticipants++;
participantsEnumerated++;
UpdateSeasonMigrationProgress(((float)participantsEnumerated / estimatedParticipantsToEnumerate) * 0.9f);
}
}
UpdateSeasonMigrationProgress(0.9f, "Clearing association table");
// Remove all associations from the current league.
int affected = await MetaDatabase.Get().RemoveAllLeagueParticipantDivisionAssociationsAsync(_entityId);
migrationResult.ParticipantsDropped = affected;
return migrationResult;
}
The leagues system supports running server-only logic in the divisions. This makes it possible to run logic hidden from the client so that even a modified client cannot snoop on the state to gain an advantage.
You can also use this to, for example, add placeholder or bot participants into divisions with not enough real players. You can make these placeholder bots indistinguishable from real players because the client can only see the state in the shared model.
Below is an example of adding placeholder participants to the division. The placeholder participants are added in the OnFastForwardModel
method if there are too few actual participants and removed in the OnModelServerTick
method when there are too many.
The OnFastForwardModel
method is called on the server when the actor wakes up after being persisted for a while. This happens before the model is sent to the client, so we can safely modify the model state here.
The OnModelServerTick
method is called every tick on the server but should only modify the server-only state of the model. If you modify the shared state of the model, clients will not be able to see the changes and may receive a checksum mismatch error. You can, however, use the IServerActionDispatcher
to execute actions that modify the state so the changes are visible both on the server and the client.
[MetaSerializableDerived(1)]
public class IdlerDivisionServerModel : DivisionServerModelBase<IdlerPlayerDivisionModel>
{
[MetaMember(1)] public List<int> PlaceholderParticipants = new List<int>();
/// <inheritdoc />
public override void OnModelServerTick(IdlerPlayerDivisionModel readOnlyModel, IServerActionDispatcher actionDispatcher)
{
// Delete placeholder participants if we have too many
if (readOnlyModel.Participants.Count > readOnlyModel.DesiredParticipantCount && PlaceholderParticipants.Count > 0)
{
int participantToRemove = PlaceholderParticipants[0];
PlaceholderParticipants.RemoveAt(0);
actionDispatcher.ExecuteAction(new DivisionParticipantRemove(participantToRemove));
}
}
/// <inheritdoc />
public override void OnFastForwardModel(IdlerPlayerDivisionModel model, MetaDuration elapsedTime)
{
// Add placeholder participants if we have too few
if (model.Participants.Count < model.DesiredParticipantCount)
{
int numToAdd = model.DesiredParticipantCount - model.Participants.Count;
for (int i = 0; i < numToAdd; i++)
{
int newParticipantIdx = model.NextParticipantIdx++;
model.Participants.Add(newParticipantIdx, new IdlerPlayerDivisionParticipantState());
((IPlayerDivisionParticipantState)model.Participants[newParticipantIdx]).InitializeForPlayer(newParticipantIdx,
new PlayerDivisionAvatarBase.Default(FormattableString.Invariant($"Placeholder Player {newParticipantIdx}")));
PlaceholderParticipants.Add(newParticipantIdx);
}
(model as IDivisionModel).RefreshScores();
}
}
}
The placeholder participants are excluded automatically from promotion and demotion in the league manager, but they can affect the Rank-Up logic by taking real players' spots in the division, which you should take into account when writing the Rank-Up logic.
The leagues system supports customizing the avatar of the participants. This can be useful for adding extra info to the avatar, such as an icon, player's Level, or any other information. If you want to have the players' EntityIds available for other players, you can also add it here.
To customize the avatar, you should create a new avatar class deriving from PlayerDivisionAvatarBase
and then override the GetPlayerLeaguesDivisionAvatar
method in the player actor.
Below is an example of a custom avatar that includes the player's Level and EntityId:
[MetaSerializableDerived(1)]
public sealed class MyDivisionAvatar : PlayerDivisionAvatarBase
{
[MetaMember(1)] public string DisplayName;
[MetaMember(2)] public int Level;
[MetaMember(3)] public EntityId PlayerId;
MyDivisionAvatar() { }
public MyDivisionAvatar(string displayName, int level, EntityId playerId)
{
DisplayName = displayName;
Level = level;
PlayerId = playerId;
}
}
And then returning the custom avatar in the player actor:
protected override PlayerDivisionAvatarBase GetPlayerLeaguesDivisionAvatar(ClientSlot leagueSlot)
{
return new MyDivisionAvatar(Model.PlayerName, Model.PlayerLevel, Model.PlayerId);
}
You'll also have to replace any references to PlayerDivisionAvatarBase.Default
with your custom avatar class in the type parameters in PlayerDivisionParticipantState
, PlayerDivisionModel
and PlayerDivisionConclusionResult
.
Additionally, you'll have to override the GetParticipantInfo
method in the division actor to make analytics events work correctly. This methods turns the avatar class into a format that is used in the analytics events for divisions.
If your game needs to have multiple Leagues, you can create any number of LeagueManager and Division actors. Each League needs to have its own unique LeagueId, which is a integer used to identify the League and its divisions. The LeagueId is extracted from the League's EntityId.Value
defined in the LeagueManagerRegistry
, and is also encoded in the EntityId of each division belonging to that League. Each league also needs to have its own unique ClientSlot
to work properly. Next, we'll go over the steps needed to add another League to the game.
SectionName
in the RuntimeOptions
attribute than the first options class.LeagueManagerRegistry
, along with all of the new types we just created. Give the new league unique LeagueId.PlayerActor
. Remember to use the new ClientSlot and the correct LeagueId.LeagueClient
in the client project and the BotClient for the new league.PlayerModel
.You can repeat these steps to add as many Leagues as you need to your game.