Appearance
Appearance
Samples/Idler
inside the SDK.In this chapter's previous guide, we set up a minimal version of the leagues system, but it still needs to be configured before a season can commence. Namely, we must define:
In this example, we're taking the Idler project and looking at producers as the main contributors to a player's Score.
Joining the league happens inside the PlayerActor
by calling the TryJoinPlayerLeague
method on a league integration.
In Idler, this is done by sending a message from the client indicating that the player would like to join the leagues. However, in your implementation you can do this in response to any server activity. A good alternative solution would be to use a server listener called from some player-initiated action. Find out more on server listeners from Client and Server Listeners.
First, add the join request and response messages to PlayerMessages.cs
as below:
/// <summary>
/// Client requests to join the idle leagues.
/// Server responds with <see cref="PlayerJoinIdleLeagueResponse"/>
/// </summary>
[MetaMessage(MessageCodes.PlayerJoinIdleLeagueRequest, MessageDirection.ClientToServer), MessageRoutingRuleOwnedPlayer]
public class PlayerJoinIdleLeagueRequest : MetaMessage
{
public static PlayerJoinIdleLeagueRequest Instance { get; } = new PlayerJoinIdleLeagueRequest();
PlayerJoinIdleLeagueRequest() { }
}
/// <summary>
/// Server's response to <see cref="PlayerJoinIdleLeagueRequest"/>.
/// If the join was successful <see cref="JoinedDivision"/> is set.
/// Otherwise <see cref="FailureReason"/> will be set.
/// </summary>
[MetaMessage(MessageCodes.PlayerJoinIdleLeagueResponse, MessageDirection.ServerToClient)]
public class PlayerJoinIdleLeagueResponse : MetaMessage
{
public bool Success { get; private set; }
public LeagueJoinRefuseReason FailureReason { get; private set; }
public DivisionIndex JoinedDivision { get; private set; }
PlayerJoinIdleLeagueResponse() { }
public PlayerJoinIdleLeagueResponse(bool success, LeagueJoinRefuseReason failureReason, DivisionIndex joinedDivision)
{
Success = success;
FailureReason = failureReason;
JoinedDivision = joinedDivision;
}
public static PlayerJoinIdleLeagueResponse ForSuccess(DivisionIndex joinedDivision)
=> new PlayerJoinIdleLeagueResponse(true, default, joinedDivision);
public static PlayerJoinIdleLeagueResponse ForFailure(LeagueJoinRefuseReason failureReason)
=> new PlayerJoinIdleLeagueResponse(false, failureReason, default);
}
Then, add a handler for them in PlayerActor.cs
as below:
[MessageHandler]
async Task HandlePlayerJoinIdleLeagueRequest(PlayerJoinIdleLeagueRequest _)
{
(DivisionIndex? divisionId, LeagueJoinRefuseReason? refuseReason) = await _idlerLeagueIntegration.TryJoinPlayerLeague();
if (divisionId.HasValue)
PublishMessage(EntityTopic.Owner, PlayerJoinIdleLeagueResponse.ForSuccess(divisionId.Value));
else
PublishMessage(EntityTopic.Owner, PlayerJoinIdleLeagueResponse.ForFailure(refuseReason.GetValueOrDefault(LeagueJoinRefuseReason.UnknownReason)));
}
The client can then send the PlayerJoinIdleLeagueRequest
to the server when it wants the player to join the leagues system. This is done in Idler by extending the league client to add a new TryJoinLeagues
method:
public class IdlerLeagueClient : LeagueClient<IdlerPlayerDivisionModel>
{
IMessageDispatcher _messageDispatcher;
public override void Initialize(IMetaplaySubClientServices clientServices)
{
base.Initialize(clientServices);
_messageDispatcher = clientServices.MessageDispatcher;
_messageDispatcher.AddListener<PlayerJoinIdleLeagueResponse>(HandleLeagueJoinResponse);
}
public override void Dispose()
{
base.Dispose();
_messageDispatcher.RemoveListener<PlayerJoinIdleLeagueResponse>(HandleLeagueJoinResponse);
}
public void TryJoinLeagues()
{
// Send message to join the league
_messageDispatcher.SendMessage(PlayerJoinIdleLeagueRequest.Instance);
}
void HandleLeagueJoinResponse(PlayerJoinIdleLeagueResponse response)
{
// Handle response here
}
}
Joining the league is possible during the preview and active phases of the season, but not during the season migration process. The league manager actor will refuse the join request with the LeagueJoinRefuseReason.SeasonMigrationInProgress
reason if that is the case.
If you want to have all players try to join a league without player input, you can do this in the PlayerActor's OnSessionStartAsync
by calling the TryJoinPlayerLeague
if no current division is set.
The producer upgrades will count as a player's Contribution, and we'll also look at the timestamp for the event of ties in Score. In this example, the final Score will be the same as each player's Contribution.
First, we'll define a Score Event that represents a single upgrade:
/// <summary>
/// Player score event example for when player upgrades a producer.
/// </summary>
[MetaSerializableDerived(1)]
public class IdlerPlayerDivisionProducerScoreEvent : DivisionScoreEventBase<IdlerPlayerDivisionScore>
{
[MetaMember(1)] public MetaTime EventAt { get; set; }
[MetaMember(2)] public ProducerTypeId Producer { get; set; }
[MetaMember(3)] public int NewLevel { get; set; }
IdlerPlayerDivisionProducerScoreEvent() { }
public IdlerPlayerDivisionProducerScoreEvent(MetaTime eventAt, ProducerTypeId producer, int newLevel)
{
EventAt = eventAt;
Producer = producer;
NewLevel = newLevel;
}
public override void AccumulateToContribution(IdlerPlayerDivisionScore contribution)
{
contribution.NumProducerUpgrades++;
contribution.LastActionAt = EventAt;
}
}
The event increments the NumProducerUpgrades
and updates the LastActionAt
. Note that the event also tracks the producer type and the level the producer reached. We're not using these for now.
Next, we emit the Score Event when the producer is updated. First, we add a new OnProducerUpgraded
method in the player’s ServerListener
:
public interface IPlayerModelServerListener
{
void OnProducerUpgraded(ProducerTypeId producer, int newLevel);
}
// Also to the empty listener
public class EmptyPlayerModelServerListener : IPlayerModelServerListener
{
public void OnProducerUpgraded(ProducerTypeId producer, int newLevel) { }
}
Then, we call the new listener from the corresponding action:
public class PlayerUpgradeProducer : PlayerAction
{
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
...
if (commit)
{
...
// Callbacks
player.ServerListener.OnProducerUpgraded(ProducerType, producer.Level);
}
return ActionResult.Success;
}
}
And finally, emit the Score Event from the callback:
void IPlayerModelServerListener.OnProducerUpgraded(ProducerTypeId producer, int newLevel)
{
// Emit score event to league integration.
_idlerLeagueIntegration.EmitDivisionScoreEvent(
new IdlerPlayerDivisionProducerScoreEvent(MetaTime.Now, producer, newLevel));
}
We now have a minimally functional leaderboard. If we start the Unity client and upgrade any of our producers, our player Score increases. We can verify this Score increase by accessing LeagueUtil.Client.Division
, using Menu → Metaplay → Model Inspector.
Or check the LiveOps Dashboard to inspect our participant state:
Rewarding your players after a season has ended is done inside the division actor’s CalculateDivisionRewardsForParticipant
method. The SDK calls this method for each participant when the division transitions into the concluded state. The resulting reward is stored only on the server, so other players cannot snoop on what someone else has gotten as a reward.
In this method, you can add custom logic to pick what kind of reward each player should get. You could give every player the same reward or base them on the player’s ranking on the division leaderboard. You can use the SDK-provided DivisionPlayerRewardsBase.Default
class to store your rewards or implement your own by extending the abstract DivisionPlayerRewardsBase
class.
protected override IDivisionRewards CalculateDivisionRewardsForParticipant(EntityId participantId)
{
if (!Model.Participants.TryGetValue(participantId, out IdlerPlayerDivisionParticipantState state))
return null;
// Calculate rewards here..
return new DivisionPlayerRewardsBase.Default(
new List<MetaPlayerRewardBase>
{
new RewardGems(100),
});
}
This reward is then passed into the GetDivisionHistoryEntryForPlayer
method when a player logs in and requests their results from the division. You should then store their reward in their history entry:
protected override IDivisionHistoryEntry GetDivisionHistoryEntryForPlayer(EntityId playerId, IDivisionRewards resolvedRewards)
{
if (!Model.Participants.TryGetValue(playerId, out IdlerPlayerDivisionParticipantState state))
return null;
return new IdlerPlayerDivisionHistoryEntry(
_entityId,
Model.DivisionIndex,
resolvedRewards,
state.DivisionScore,
state.SortOrderIndex);
}
Once the player has fetched a history entry, the rewards are marked as claimed in the server-side division model since the player will not initiate any more communication with the division after this point. However, the actual rewards stored in the player’s division history have not yet been claimed and applied.
To claim the rewards, you can write a custom player action or use the SDK-provided PlayerClaimHistoricalPlayerDivisionRewards
action. Below is a simple example of how to claim a reward from the first encountered unclaimed division. In an actual implementation, you would probably want to tie this to a UI with a button to claim the rewards.
void ClaimFirstUnclaimedLeagueReward()
{
IdlerDivisionClientState divisionClientState = MetaplayClient.PlayerModel.DivisionClientState;
if (divisionClientState == null)
return; // No division client state set
// Find an unclaimed history entry.
IdlerPlayerDivisionHistoryEntry divisionToClaim = divisionClientState.HistoricalDivisions
.FirstOrDefault(x => x.Rewards != null && !x.Rewards.IsClaimed);
if (divisionToClaim == null)
return; // No unclaimed rewards
// Pass in divisionId of the unclaimed history entry.
PlayerClaimHistoricalPlayerDivisionRewards action = new PlayerClaimHistoricalPlayerDivisionRewards(ClientSlotGame.IdlerLeague, divisionToClaim.DivisionId);
// Execute action!
MetaplayClient.PlayerContext.ExecuteAction(action);
}
After claiming, the list of rewards is applied to the player, and the IsClaimed
property on the reward is set to true
.
The SDK does not automatically clear the historical entry for the division, so if you wish to delete divisions that players have already claimed from the list, you can create a custom player action to do that.
Promotions and demotions for participants are decided by the league manager actor, based on the conclusion result received from the division actor.
The actor writes the participant-to-division mapping straight in the database, without informing the participants or the division, to make the migration as light on resources as possible. The mapping is then stored in a table of PersistedParticipantDivisionAssociation
items. Writing to this table effectively moves a participant from one division to another. If participants are moved during a season, you should inform the old division separately to delete the participant from the participant table.
During a season migration, the actor asks all previous season's divisions for their participants' conclusion results, decides to either promote, demote, drop, or keep them in the same rank, and then writes new association data into the database for the next season. Additionally, the default rank-up method will send the previous season's participant avatars to the newly created divisions so the divisions won't seem empty before all the participants have logged in.
Below, we'll create an example rank-up behavior promoting the top 10 participants of each rank and demoting the bottom 10. To do this, we need to know the participants' placement in the division leaderboard and the number of players in their division.
First, we'll fill in the conclusion result class with the values we'll need in the decision-making.
[MetaSerializableDerived(1)]
public class IdlerPlayerDivisionConclusionResult : PlayerDivisionParticipantConclusionResultBase<PlayerDivisionAvatarBase.Default>
{
[MetaMember(1)] public int LeaderboardPlacementIndex { get; private set; }
[MetaMember(2)] public int LeaderboardNumPlayers { get; private set; }
[MetaMember(3)] public IdlerPlayerDivisionScore PlayerScore { get; private set; }
public IdlerPlayerDivisionConclusionResult(EntityId participantId, PlayerDivisionAvatarBase.Default avatar, int leaderboardPlacementIndex,
int leaderboardNumPlayers, IdlerPlayerDivisionScore playerScore) : base(participantId, avatar)
{
LeaderboardPlacementIndex = leaderboardPlacementIndex;
LeaderboardNumPlayers = leaderboardNumPlayers;
PlayerScore = playerScore;
}
IdlerPlayerDivisionConclusionResult() : base(default, default) { }
}
And pass those values to the DivisionActor.GetParticipantResult
method:
protected override IDivisionParticipantConclusionResult GetParticipantResult(EntityId participantId)
{
if (!Model.Participants.TryGetValue(participantId, out IdlerPlayerDivisionParticipantState state))
return null;
return new IdlerPlayerDivisionConclusionResult(
participantId,
state.PlayerAvatar,
state.SortOrderIndex,
Model.Participants.Count,
state.DivisionScore);
}
Then, we'll fill in the SolveLocalSeasonPlacement
method with our promotion logic:
protected override ParticipantSeasonPlacementResult SolveLocalSeasonPlacement(int lastSeason, int nextSeason, int currentRank, IDivisionParticipantConclusionResult conclusionResult)
{
IdlerPlayerDivisionConclusionResult playerResult = conclusionResult as IdlerPlayerDivisionConclusionResult;
if (playerResult == null)
throw new ArgumentNullException(nameof(conclusionResult), $"Conclusion result was null or not castable to a {nameof(IdlerPlayerDivisionConclusionResult)}");
// Remove inactive players from the bottom rank
if (currentRank == 0 && playerResult.PlayerScore.NumProducerUpgrades == 0)
return ParticipantSeasonPlacementResult.ForRemoval();
// Promote the top 10 players.
if (currentRank < numRanks - 1 && playerResult.LeaderboardPlacementIndex < 10)
return ParticipantSeasonPlacementResult.ForRank(currentRank + 1);
int leaderBoardPlacementFromBottom = playerResult.LeaderboardNumPlayers - playerResult.LeaderboardPlacementIndex - 1;
// Demote the bottom 10 players.
if (currentRank > 0 && leaderBoardPlacementFromBottom < 10)
return ParticipantSeasonPlacementResult.ForRank(currentRank - 1);
// Otherwise stay in the same rank.
return ParticipantSeasonPlacementResult.ForRank(currentRank);
}
Now, our participants can rank up and down based on their performance in their local division.
Find out more about customizing this behavior in Advanced Leagues Customization.
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 the player has joined a Division, they should be able to see themselves as a Division participant.
The league manager includes a few configuration options that allow for customizing the behavior of the leagues and divisions.
The first thing you'll probably want to do is set a custom season length and start date. To do that, add the following options to your Options.<env>.yaml
files:
Leagues:
SeasonCycleStartDate: "2020-01-01 00:00:00Z"
SeasonCycleRecurrence: 1d
SeasonCycleRestPeriod: 2h
SeasonCycleEndingSoonPeriod: 4h
Here's how to configure these values and what each of them means:
SeasonCycleStartDate
is a UTC timestamp of when the first season of the leagues should happen. The format is YYYY-MM-DD hh:mm:ssZ
. The season cycle will not begin before this date, effectively making the leagues system inactive for that period. Each season is preceded by a rest period, so while the first season will begin at the start time, it will become visible for some time before that, controlled by the configured rest period.SeasonCycleRecurrence
is how long a single season lasts, including the rest period. The active time of seasons is the recurrence - rest period
.SeasonCycleRestPeriod
is the configured duration of the resting period between seasons. This should be enough time for the seasonal migration job to complete, which can take a while with larger player counts. The rest period is included in the recurrence value, so the rest period cannot be configured to be longer than the recurrence. We recommend a number between a few hours to a few days, depending on the season length.SeasonCycleEndingSoonPeriod
is a time period before the season ends, during which the players are warned that the season is ending soon. This does not affect the season length in any way but cannot be configured to be longer than the active time of the season.The syntax for configuring the periods is as follows:
y, mo, d, h, m, s
7d
3mo
4h
21d
For example, the below configuration would result in seasons that start on the first day of each month, beginning at the start of the year 2023, with a rest period of one day between seasons.
Leagues:
SeasonCycleStartDate: "2023-01-01 00:00:00Z"
SeasonCycleRecurrence: 1mo
SeasonCycleRestPeriod: 1d
SeasonCycleEndingSoonPeriod: 1d
You can and probably should run different length seasons in different environments. Development environments can have season cycles of days or a few hours to allow for rapid testing, while the production environment would have a season length of an entire month or a week. But this is, of course, specific to your game.
Divisions have two configured sizes: the desired size and the maximum size. The desired size takes effect when the league manager is migrating participants to a new season, so it will try to create as many Divisions as it takes to fill all of them to the desired size. The maximum division size takes effect when new players join the league during a season. The league manager will assign players to existing divisions until they hit the maximum division size, at which point a new division is created. The desired and maximum sizes can be set to the same value.
Leagues:
...season configuration
+ DivisionDesiredParticipantCount: 100
+ DivisionMaxParticipantCount: 200
We did it! In summary, here's what we accomplished:
CalculateDivisionRewardsForParticipant
method.But that's not all there is to the leagues system. If you'd like to further customize the leagues, the Advanced Leagues Customization page offers more information on how to expand and modify the rank-up strategies and how to add server-only logic and state to the leagues.