Appearance
Timers and Scheduling
This page describes how to run work at a later time on the game server, by storing deadlines in the model and checking them on demand.
Appearance
This page describes how to run work at a later time on the game server, by storing deadlines in the model and checking them on demand.
MetaTime.MetaTime.Now. On the client it is the device clock, which is not synchronized with the server.CurrentTime. It advances with the model's ticks, which follow the Game Clock.Game features often need something to happen later, such as a cooldown expiring, an energy refill, or a daily reset. This page covers the patterns to use first, and then the timer facilities an actor offers for the cases that need one.
Three rules cover most of the timing needs of a game server:
GameTick(). Write the moment the thing completes into the model and compare against it when asked. Time and Ticks shows this with a worked example.The fundamental rule looks like this in the model:
[MetaMember(1)] public MetaTime CooldownEndsAt { get; private set; }
public bool IsReady => CurrentTime >= CooldownEndsAt;The rest of this section walks through the other two rules with an example where a level unlocks for every player at a scheduled time. The model stores the next level as NextLevel and its unlock time as NextLevelAvailableAt.
Check the deadline when a session starts. A player who was offline when the level unlocked gets it at login, and nothing runs for players who never come back. If the deadline is still ahead, schedule a one-shot timer for it, so that a player who is online at that moment gets the level on time. The timer is cancelled with the actor at logout, as Actor Timers explains below.
protected override Task OnSessionStartAsync(PlayerSessionParams sessionParams, bool isFirstLogin)
{
CheckNextLevel();
return Task.CompletedTask;
}
void CheckNextLevel()
{
if (MetaTime.Now >= Model.NextLevelAvailableAt)
EnqueueServerAction(new PlayerUnlockLevel(Model.NextLevel));
else
ScheduleExecuteOnActorContext(Model.NextLevelAvailableAt, CheckNextLevel);
}The unlock itself is a synchronized server action. It checks that the level is still the next one, then grants it and moves NextLevel and NextLevelAvailableAt forward to the next unlock:
[ModelAction(ActionCodes.PlayerUnlockLevel)]
public class PlayerUnlockLevel : PlayerSynchronizedServerActionCore<PlayerModel>
{
public int Level { get; private set; }
...
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
// Already unlocked
if (Level != player.NextLevel)
return MetaActionResult.Success;
if (commit)
{
...
}
return MetaActionResult.Success;
}
}The one-shot timer covers only the next unlock. Nothing re-arms it after the action runs, and the next step removes that limitation.
If the unlock time is the same for everyone, such as a daily reset at midnight, every online player now has a timer aimed at the same instant. At the deadline they all fire together, and the server takes a spike of unlocks at a moment when nothing else changed. Replace the one-shot with a randomized periodic timer that polls the deadline. The first poll is randomized, as Declarative Periodic Timers explains below, so the unlocks spread out over the interval instead of landing at once.
A poll is also more robust than a timer armed at the deadline. It reads the current value every time, so it stays correct when an admin edits the value, an entity import replaces the state, or a time skip moves the clock, as described in Testing Game Schedules with Time Skipping. A timer armed at the deadline has to be re-armed on each of those paths, and a missed one means the deadline silently never fires.
protected override Task OnSessionStartAsync(PlayerSessionParams sessionParams, bool isFirstLogin)
{
CheckNextLevel();
return Task.CompletedTask;
}
[PeriodicTimerHandler(intervalSeconds: 60)]
void CheckNextLevel()
{
if (MetaTime.Now >= Model.NextLevelAvailableAt)
EnqueueServerAction(new PlayerUnlockLevel(Model.NextLevel));
else
ScheduleExecuteOnActorContext(Model.NextLevelAvailableAt, CheckNextLevel);
}The model lags behind enqueued actions
Enqueuing PlayerUnlockLevel does not update Model.NextLevelAvailableAt until the client has executed the action. While the action is enqueued but not yet executed, MetaTime.Now >= Model.NextLevelAvailableAt still holds, and the next poll may enqueue another unlock action. The already-unlocked check in the action prevents duplicated unlocks.
The cost is a delay of up to one interval for a player who is online when the deadline passes.
A player looking at the locked level now waits up to a minute after the countdown reaches zero. The client already has NextLevelAvailableAt in its copy of the model, so let the screen count down to it and, once it passes, tell the server right away instead of waiting for the poll. The client executes an action, and the action notifies the actor through a server listener, as described in Client and Server Listeners. The action checks the deadline against Game Logic Time, which is the same on the client and the server.
[ModelAction(ActionCodes.PlayerNextLevelCountdownFinished)]
public class PlayerNextLevelCountdownFinished : PlayerAction
{
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
if (player.CurrentTime < player.NextLevelAvailableAt)
return ActionResult.NextLevelNotAvailable;
if (commit)
player.ServerListener.OnNextLevelCountdownFinished();
return MetaActionResult.Success;
}
}public interface IPlayerModelServerListener
{
void OnNextLevelCountdownFinished();
}void IPlayerModelServerListener.OnNextLevelCountdownFinished()
{
EnqueueServerAction(new PlayerUnlockLevel(Model.NextLevel));
}int _countdownFinishedForLevel = -1;
void Update()
{
PlayerModel player = MetaplayClient.PlayerModel;
if (player.CurrentTime >= player.NextLevelAvailableAt &&
_countdownFinishedForLevel != player.NextLevel)
{
_countdownFinishedForLevel = player.NextLevel;
OnCountdownFinished();
}
}
void OnCountdownFinished()
{
MetaplayClient.PlayerContext.ExecuteAction(new PlayerNextLevelCountdownFinished());
}Players looking at the screen get the level the moment it unlocks, which is the only place it matters. Everyone else gets it from the poll or the login check, spread out over time instead of all at once.
An actor can run work at a later time, either repeatedly on an interval or once after a delay. It can schedule a method to be called, or a command to be sent to itself and handled like any other incoming command.
A timer cannot wake a sleeping entity
A command goes straight to the running actor. It does not travel through the entity messaging system, which is what spawns an entity that has gone to sleep. Timers therefore follow the actor lifetime, and the Entity Lifecycle section covers how brief that can be. Scheduling a reward on a player entity for six hours from now does nothing once the player logs out, because the actor stops and all of its timers are cancelled with it. Work that has to happen at a specific time must be driven by something that is still running.
The [PeriodicTimerHandler] attribute starts a periodic timer for a method of the actor:
[PeriodicTimerHandler(intervalSeconds: 60)]
void CheckExpiredItems()
{
// This is called once a minute, as long as the Entity remains running
RemoveExpiredItems();
}A periodic timer cannot be stopped or reconfigured. It runs until the actor shuts down.
The first invocation lands roughly one interval after the timer starts. The exact delay is randomized, so entities that start together, such as after a wave of logins, do not all fire at once. Pass initialDelaySeconds to use a fixed initial delay instead.
Slow handlers drop invocations
If a handler runs longer than its interval, or the process was paused, the missed invocations are dropped. Catching up would run a large number of timer invocations at once. A warning is logged when invocations are dropped.
The interval of a declarative timer is an attribute argument, so it has to be a compile-time constant. To create a timer with a non-constant interval, such as one read from runtime options or game configs, or a timer that starts only under some condition, call StartRandomizedPeriodicTimer() in Initialize(). The timer sends a command to the actor itself on an interval, and a method marked with the [CommandHandler] attribute handles it.
// The timer command. A singleton instance is enough, as the command carries no data.
public class CheckExpiredItems
{
public static CheckExpiredItems Instance { get; } = new CheckExpiredItems();
CheckExpiredItems() { }
}
protected override async Task Initialize()
{
await base.Initialize();
ItemExpiryOptions opts = RuntimeOptionsRegistry.Instance.GetCurrent<ItemExpiryOptions>();
if (opts.ExpiryCheckEnabled)
StartRandomizedPeriodicTimer(opts.ExpiryCheckInterval, CheckExpiredItems.Instance);
}
[CommandHandler]
void HandleCheckExpiredItems(CheckExpiredItems _)
{
RemoveExpiredItems();
}There are two variants, and they differ only in when the first invocation happens. StartRandomizedPeriodicTimer() randomizes the initial delay the same way the declarative timers do, and it is the one to prefer. StartPeriodicTimer() uses the interval as the initial delay, and an overload lets you set the two separately. Use it when the first invocation has to happen at a predictable time.
ScheduleExecuteOnActorContext() runs a callback once, after a delay or at a given time. If the time is already in the past, the callback is enqueued right away. A pending timer can be cancelled with a CancellationToken.
ScheduleExecuteOnActorContext(TimeSpan.FromSeconds(30), () =>
{
// Called after 30 seconds
});
ScheduleExecuteOnActorContext(accessToken.ExpiresAtUtc, () =>
{
// Called at the given UTC time
});
ScheduleExecuteOnActorContext(Model.SeasonEndsAt, () =>
{
// Called when MetaTime.Now (Game Clock) reaches the given point in time
//
// Note: Model.CurrentTime (Game Logic Time) may not have reached
// Model.SeasonEndsAt yet if pending ticks have not been flushed and run
});