Appearance
Appearance
Leagues are a system for a sharded, leaderboard-like competition where participants compete with a fixed subset of opponents for a limited time by collecting scores.
Upon joining a League, all participants are allocated to a Rank (for example, "Bronze Rank") that represents their progression in the League. You can customize how many Ranks your game has, how participants move between them and how a participant's current Rank affects things like multiplayer matchmaking.
To make the League system massively scalable, all participants of a given Rank are further divided into small, fixed groups called Divisions. At their simplest, Divisions are a temporary leaderboard for their participants. Divisions are typically disbanded as a Season ends, with new Divisions created at the beginning of the next Season.
The League system is an open canvas for you to implement your ideal multiplayer progression experience. For example, by adding matchmaking logic to Divisions and allowing that logic to reference past Season performance, you can create a game mode where players compete to get to higher and higher Placements in their current Division until finally progressing to a higher Rank. Giving rewards at the end of a Season based on Division performance is also a great way to motivate competitive players.
This is an example of what the Rank and Divisions could look like on a given sequence of Seasons:
The Division component manages the day-to-day operations such as tracking the score in the local leaderboard, and the League Manager manages the placement process.
The Division component consists of multiple Division entity actors: Each Division is a disposable single-use entity that tracks the score of its participants. It is only active for the duration of a particular Season but its lifetime exceeds the Season. During this lifetime, it goes through the following phases:
Preview
, when the current Season has not started yet. This happens when between Seasons as participants are placed into Divisions ahead of Season starting.Ongoing
, when the Season has started. During this phase, participants can send score updates to the division.Resolving
, when the Season has ended, but the final score and reward count have not been performed yet.Concluded
, when the Season has ended and the final score has been counted and rewards become available. Note that a new Season may not become immediately available after the previous has concluded, as the placement process may take some time.A Division can conclude itself early, but a new season will not start until the league manager's configured season has ended.
A participant inside a division is identified with a ParticipantIndex
, which is a integer given to the participant when they join a division. The actual EntityId
s of the participants are not available to the client by default. The participant index is only unique for the participant withing a division, so multiple divisions could include a participant with the same index.
The Leagues component consists of a single League manager entity, whose job is to place players into the different Divisions within a Season. The League manager handles assigning new players into existing Divisions during a Season and migrating participants to a new season after the previous one has ended. Decisions of promotion and demotion between ranks also happen in the LeagueManager
Here are the steps necessary to create a minimal League system:
PlayerActor
Integration.LeagueManager
actor.LeagueManagerRegistry
integration.There are quite a few steps but they are mostly quick and will get you up and running with a Leagues system complete with Divisions and participants.
PlayerLeagues
Metaplay Feature To enable PlayerLeagues
add the corresponding flag to GlobalOptions
:
public class GlobalOptions : IMetaplayCoreOptionsProvider
{
public MetaplayCoreOptions Options { get; } = new MetaplayCoreOptions(
// ...
featureFlags: new MetaplayFeatureFlags
{
// ...
EnablePlayerLeagues = true,
});
Then, add a ClientSlot for the Leagues client:
namespace Game.Logic.TypeCodes
{
[MetaSerializable]
public class ClientSlotGame : ClientSlot
{
public ClientSlotGame(int id, string name) : base(id, name) { }
// Added for the Idler League
public static readonly ClientSlot IdlerLeague = new ClientSlotGame(11, nameof(IdlerLeague));
}
}
In this example, the score is a single integer tracking the number of performed upgrades, and a timestamp. The timestamp is used to break ties, favoring the earlier participant.
/// <summary>
/// Example score is the number of producer upgrades. There is no special score
/// computations logic, so let's use the same type as the Contribution.
/// </summary>
[MetaSerializableDerived(1)]
public class IdlerPlayerDivisionScore : IDivisionScore, IDivisionContribution
{
[MetaMember(1)] public int NumProducerUpgrades;
[MetaMember(2)] public MetaTime LastActionAt;
int IDivisionScore.CompareTo(IDivisionScore untypedOther)
{
IdlerPlayerDivisionScore other = (IdlerPlayerDivisionScore)untypedOther;
if (NumProducerUpgrades < other.NumProducerUpgrades)
return -1;
if (NumProducerUpgrades > other.NumProducerUpgrades)
return +1;
if (LastActionAt < other.LastActionAt)
return +1;
if (LastActionAt > other.LastActionAt)
return -1;
return 0;
}
}
The participant state is stored inside the Division model and includes all relevant data about a single participant. This includes their score and avatar (player name and picture), and you should add any other information relevant to your Leagues. The Division model has, in turn, a dictionary of all the currently participating players and their respective participant states.
In this implementation, we don’t have any custom additions, and we are using the default player avatar type PlayerDivisionAvatarBase.Default
:
[MetaSerializableDerived(1)]
public class IdlerPlayerDivisionParticipantState
: PlayerDivisionParticipantStateBase<
IdlerPlayerDivisionScore,
IdlerPlayerDivisionScore,
PlayerDivisionAvatarBase.Default>
{
// Visible in the dashboard
public override string ParticipantInfo => Invariant($"Score: {PlayerContribution.NumProducerUpgrades}");
}
[MetaSerializableDerived(3)]
[SupportedSchemaVersions(1,1)]
public class IdlerPlayerDivisionModel
: PlayerDivisionModelBase<
IdlerPlayerDivisionModel,
IdlerPlayerDivisionParticipantState,
IdlerPlayerDivisionScore,
PlayerDivisionAvatarBase.Default>
{
public override int TicksPerSecond => 1;
public override void OnTick()
{
// Nothing
}
public override void OnFastForwardTime(MetaDuration elapsedTime)
{
// Nothing
}
public override IdlerPlayerDivisionScore ComputeScore(int participantIndex)
{
// No special score computation logic.
return Participants[participantIndex].PlayerContribution;
}
}
This will contain all data about a past Division, including its rewards. The history is stored in each player's PlayerModel
. A minimum version will contain only the player's placement, but the history can hold more information, up to the whole leaderboard.
/// <summary>
/// An example historical entry of a division for the idler leagues.
/// </summary>
[MetaSerializableDerived(1)]
public class IdlerPlayerDivisionHistoryEntry : PlayerDivisionHistoryEntryBase
{
/// <summary>
/// The player's score in the division.
/// </summary>
[MetaMember(1)] public IdlerPlayerDivisionScore PlayerScore { get; private set; }
/// <summary>
/// The player's placement in the final division leaderboard. 0 is 1st .
/// </summary>
[MetaMember(2)] public int LeaderboardPlacementIndex { get; private set; }
public IdlerPlayerDivisionHistoryEntry(EntityId divisionId, DivisionIndex divisionIndex, IDivisionRewards rewards, IdlerPlayerDivisionScore playerScore,
int leaderboardPlacementIndex) : base(divisionId, divisionIndex, rewards)
{
PlayerScore = playerScore;
LeaderboardPlacementIndex = leaderboardPlacementIndex;
}
IdlerPlayerDivisionHistoryEntry() : base(EntityId.None, default, null) { }
}
This contains the player's current Division as well as a history of the Divisions they participated in.
/// <summary>
/// Example state for the leagues that is stored within the PlayerModel.
/// This contains the current division and a history.
/// </summary>
[MetaSerializableDerived(1)]
public class IdlerDivisionClientState : DivisionClientStateBase<IdlerPlayerDivisionHistoryEntry> { }
The state is stored in the PlayerModel.PlayerSubClientStates
dictionary with the ClietSlot defined before.
Next, we'll add some integration code to the PlayerActor
. We'll have to override the LeagueComponentBase
class and a create a LeagueIntegration
for our new League.
Both the LeagueComponent
and the LeagueIntegration
types are overrideable, so that you can add custom logic to them. In this example, we are using the default implementations.
public class PlayerActor ... {
DefaultPlayerLeagueIntegrationHandler<IdlerDivisionClientState> _idlerLeagueIntegration;
public PlayerActor(EntityId playerId) : base(playerId)
{
// Add to contructor
// The number zero has to be the same as the "Value" of the league's EntityId.
_idlerLeagueIntegration =
Leagues.CreateLeagueIntegrationHandler<DefaultPlayerLeagueIntegrationHandler<IdlerDivisionClientState>>(
ClientSlotGame.IdlerLeague, 0,
DefaultPlayerLeagueIntegrationHandler<IdlerDivisionClientState>.Create);
}
...
sealed class LeagueComponent : LeagueComponentBase
{
public LeagueComponent(PlayerActorBase<PlayerModel, PersistedPlayerBase> playerActor) : base(playerActor) { }
}
protected override LeagueComponentBase CreateLeagueComponent()
{
return new LeagueComponent(this);
}
}
The conclusion results are used in the rank-up algorithm to decide whether participants should move up or down in ranks between Seasons. We'll leave this class mostly empty for now and get back to it later. Define this class inside the server project only.
namespace Game.Server.League
{
[MetaSerializableDerived(1)]
public class IdlerPlayerDivisionConclusionResult : PlayerDivisionParticipantConclusionResultBase<PlayerDivisionAvatarBase.Default>
{
IdlerPlayerDivisionConclusionResult(EntityId participantId, PlayerDivisionAvatarBase.Default avatar) : base(participantId, avatar) { }
IdlerPlayerDivisionConclusionResult() : base(default, default) { }
}
}
We'll need to define an actor type for the Division entity. This actor will handle the logic for the Division, such as calculating rewards and running the Model logic. In addition to the actor, we'll need to define the entity configuration class for it, and a PersistedDivision
class. We can leave these classes empty, since the base classes have all the things we need for a minimal setup.
namespace Game.Server.League
{
public class PersistedDivision : PersistedDivisionBase { }
[EntityConfig]
public class DivisionConfig : DivisionEntityConfigBase { }
public sealed class IdlerPlayerDivisionActor : PlayerDivisionActorBase<IdlerPlayerDivisionModel, PersistedDivision, IdlerLeagueManagerOptions>
{
public IdlerPlayerDivisionActor(EntityId entityId) : base(entityId) { }
// No rewards for now
protected override IDivisionRewards CalculateDivisionRewardsForParticipant(int participantIdx) => null;
protected override IDivisionHistoryEntry GetDivisionHistoryEntryForPlayer(int participantIdx, IDivisionRewards resolvedRewards)
{
if (!Model.Participants.TryGetValue(participantIdx, out IdlerPlayerDivisionParticipantState state))
return null;
return new IdlerPlayerDivisionHistoryEntry(
_entityId,
Model.DivisionIndex,
resolvedRewards,
state.DivisionScore,
state.SortOrderIndex);
}
protected override IDivisionParticipantConclusionResult GetParticipantResult(int participantIdx)
{
if (!Model.Participants.TryGetValue(participantIdx, out IdlerPlayerDivisionParticipantState state))
return null;
// Placeholder default conclusion result for now.
return new IdlerPlayerDivisionConclusionResult(state.ParticipantId, state.PlayerAvatar);
}
}
}
The LeagueManager
is an actor in charge of keeping track of season progression, as well as placing new players into Divisions and migrating participants between Seasons. In addition to the manager actor, we also need to define some additional classes for it such as the LeagueManagerOptions
, LeagueManagerState
and LeagueManagerConfig
. Each league can have their own actor, options, state and divisions, so you can have multiple leagues with different rules and participants.
namespace Game.Server.League
{
[RuntimeOptions("Leagues", true, "Options for the leagues manager service.")]
public class IdlerLeagueManagerOptions : LeagueManagerOptionsBase { }
[MetaSerializableDerived(1)]
[SupportedSchemaVersions(1, 1)]
public class IdlerLeagueManagerState : LeagueManagerActorStateBase { }
[EntityConfig]
// Base config handles everything.
public class IdlerLeagueManagerConfig : LeagueManagerConfigBase { }
public class IdlerLeagueManagerActor : LeagueManagerActorBase<IdlerLeagueManagerState, PersistedDivision, IdlerLeagueManagerOptions>
{
protected override EntityKind ParticipantEntityKind => EntityKindCore.Player;
// 5 ranks: Bronze, Silver, Gold, Diamond and Legend
readonly int NumRanks = 5;
public IdlerLeagueManagerActor(EntityId entityId) : base(entityId) { }
protected override Task<IdlerLeagueManagerState> InitializeNew()
{
IdlerLeagueManagerState state = new IdlerLeagueManagerState();
return Task.FromResult(state);
}
protected override Task<ParticipantJoinRequestResult> SolveParticipantInitialPlacement(EntityId participant, LeagueJoinRequestPayloadBase payload)
{
// Any participant is allowed to join, and will be placed in rank 0
return Task.FromResult(new ParticipantJoinRequestResult(true, 0));
}
// The details methods dictate how the league is described in the dashboard, and the number of ranks for each season.
protected override LeagueDetails GetLeagueDetails()
{
return new LeagueDetails("The idling league.", "Players get points based on how many producers they upgrade per season.");
}
protected override LeagueSeasonDetails GetSeasonDetails(int season)
{
return new LeagueSeasonDetails(Invariant($"Season {season}"), "A normal idling season.", NumRanks);
}
protected override LeagueRankDetails GetRankDetails(int rank, int season)
{
switch (rank)
{
case 0:
return new LeagueRankDetails("Bronze", "The lowest tier.", 10);
case 1:
return new LeagueRankDetails("Silver", "For the best of the worst.", 10);
case 2:
return new LeagueRankDetails("Gold", "Good idlers live here", 10);
case 3:
return new LeagueRankDetails("Diamond", "The best idlers.", 10);
case 4:
return new LeagueRankDetails("Legend", "Only the best of the best.", 10);
default:
throw new ArgumentOutOfRangeException(nameof(rank), "Given rank is out of range of known ranks");
}
}
// Rank-up method. Will be filled in later.
protected override ParticipantSeasonPlacementResult SolveLocalSeasonPlacement(int lastSeason, int nextSeason, int currentRank, IDivisionParticipantConclusionResult conclusionResult)
{
// Stay in the same rank.
return ParticipantSeasonPlacementResult.ForRank(currentRank);
}
}
}
LeagueManagerRegistry
Integration The LeagueManagerRegistry
is a singleton that keeps track of all the Leagues in the game. We'll have to create a registry class and override the LeagueInfos
property to return a list of all the Leagues in the game. In this example, we only have one League, but you can add more by adding more LeagueInfo
objects.
public class IdlerLeagueManagerRegistry : LeagueManagerRegistry
{
public override IReadOnlyList<LeagueInfo> LeagueInfos { get; } = new LeagueInfo[]
{
new LeagueInfo(
leagueManagerId: EntityId.Create(EntityKindCloudCore.LeagueManager, 0), // Value here should be the same as leagueId in the PlayerActor league integration. The ids must start from 0 and increase by 1 for each league.
clientSlot: ClientSlotGame.IdlerLeague,
participantKind: EntityKindCore.Player,
managerActorType: typeof(IdlerLeagueManagerActor),
optionsType: typeof(IdlerLeagueManagerOptions),
divisionActorType: typeof(IdlerPlayerDivisionActor))
};
}
And if you are running non-singleton custom topologies, you should define Division and LeagueManager in your options:
ShardingTopologies:
// Add Division and LeagueManager in their appropriate topology groups
To add the Leagues system to your migrations, run the following command in your server folder:
> dotnet ef migrations add AddLeagues
In the MetaplayClient.Initialize
method, add the LeagueClient
:
MetaplayClient.Initialize(
...
AdditionalClients = new IMetaplaySubClient[]
{
...
new LeagueClient<IdlerPlayerDivisionModel>(ClientSlotGame.IdlerLeague),
}
You can then add a convenience accessor for the League. In a convenient place, put:
public static class LeagueUtil
{
public static LeagueClient<IdlerPlayerDivisionModel> Client => MetaplayClient.ClientStore.TryGetClient<LeagueClient<IdlerPlayerDivisionModel>>(ClientSlotGame.IdlerLeague);
}
You can also create an accessor for the DivisionClient
state inside the player model.
public class PlayerModel
{
...
public IdlerDivisionClientState DivisionClientState => PlayerSubClientStates.GetValueOrDefault(ClientSlotGame.IdlerLeague) as IdlerDivisionClientState;
}
If you want to access the leagues inside the bot client, you can add the LeagueClient
to the bot client's AdditionalSubClients
:
protected override IMetaplaySubClient[] AdditionalSubClients => new IMetaplaySubClient[]
{
_idlerLeagueClient = new LeagueClient<IdlerPlayerDivisionModel>(ClientSlotGame.IdlerLeague),
};
You can now start up the server and examine the league system in the dashboard.
🚨 Additional steps needed!
There are still a couple of steps necessary before the Leagues can work properly. Joining the leagues, the Division's sizing, rank-up logic, scoring system and rewards, as well as the League start and end dates, need to be configured before the League is fully operational. Follow the steps in Customizing Leagues to complete your Leagues implementation.
For the League to be complete, we need to add a way for players to join the league and to increase their score. Additionally, we'll add end-of-Season rewards and properly configure start and end dates for the Seasons. We'll do all of this and more in Customizing Leagues, up next.