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 League") 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 component manages the placement process.
The Division component consists of Division entities: 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, score is counted for the participants.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 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 Season 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:
LeagueManager
actor.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,
});
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 must be initialized inside the player model and stored in the PlayerSubClientStates
dictionary with the key PlayerDivision
. This can be done inside GameInitializeNewPlayerModel
.
If adding Leagues to an existing game, the state can be initialized in GameOnSessionStarted
to make sure existing players also have a valid state.
...
protected override void GameInitializeNewPlayerModel(MetaTime now, ISharedGameConfig gameConfig, EntityId playerId, string name)
{
// Setup initial state for new player
PlayerId = playerId;
PlayerName = name;
Random = RandomPCG.CreateNew();
// Initialize division client state
PlayerSubClientStates[ClientSlotCore.PlayerDivision] = new IdlerDivisionClientState();
}
protected override void GameOnSessionStarted()
{
// Initialize player-specific state for the idler leagues
if (!PlayerSubClientStates.ContainsKey(ClientSlotCore.PlayerDivision))
PlayerSubClientStates[ClientSlotCore.PlayerDivision] = new IdlerDivisionClientState();
}
...
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) { }
}
}
In the 'resolving' stage of a season, when it's no longer active, you can distribute rewards to participants based on their placement and register their histories. You can do this by defining an actor type and adding the corresponding IDivisionRewards
and IDivisionHistoryEntry
.
namespace Game.Server.League
{
public class PersistedDivision : PersistedDivisionBase
{
}
[EntityConfig]
public class DivisionConfig : DivisionEntityConfigBase
{
public override Type EntityActorType => typeof(IdlerPlayerDivisionActor);
}
public sealed class IdlerPlayerDivisionActor : PlayerDivisionActorBase<IdlerPlayerDivisionModel, PersistedDivision>
{
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.
namespace Game.Server.League
{
[MetaSerializableDerived(1)]
[SupportedSchemaVersions(1, 1)]
public class IdlerLeagueManagerState : LeagueManagerActorStateBase { }
[PlayerLeaguesEnabledCondition]
[EntityConfig]
internal sealed class IdlerLeagueManagerConfig : PersistedEntityConfig
{
public override EntityKind EntityKind => EntityKindCloudCore.LeagueManager;
public override Type EntityActorType => typeof(IdlerLeagueManagerActor);
public override EntityShardGroup EntityShardGroup => EntityShardGroup.Workloads;
public override IShardingStrategy ShardingStrategy => ShardingStrategies.GlobalSingleton;
public override TimeSpan ShardShutdownTimeout => TimeSpan.FromSeconds(10);
}
public class IdlerLeagueManagerActor : LeagueManagerActorBase<IdlerLeagueManagerState, PersistedDivision>
{
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.");
case 1:
return new LeagueRankDetails("Silver", "For the best of the worst.");
case 2:
return new LeagueRankDetails("Gold", "Good idlers live here");
case 3:
return new LeagueRankDetails("Diamond", "The best idlers.");
case 4:
return new LeagueRankDetails("Legend", "Only the best of the best.");
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);
}
}
}
And if you are running non-singleton custom topologies, you should define Division and LeagueManager 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>(),
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>>(ClientSlotCore.PlayerDivision);
}
You can also create an accessor for the DivisionClient
state inside the player model.
public class PlayerModel
{
...
public IdlerDivisionClientState DivisionClientState => PlayerSubClientStates.GetValueOrDefault(ClientSlotCore.PlayerDivision) as IdlerDivisionClientState;
}
DANGER
Additional steps needed! There are still a couple of steps necessary before the Leagues can work properly. 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 can become active. Follow the steps in Customizing Leagues to complete your Leagues implementation.
Now we have a minimal League system running. On the client, we now may inspect a Division's state via LeagueUtil.Client.Division
and enqueue actions via LeagueUtil.Client.Context
. When running on a local server, the player is automatically placed into a Division and should be able to see themselves as a Division participant.
For the League to be complete, we need to add a way for players to increase 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.