Skip to main content

Timers

ModernUO uses a hierarchical timer wheel for scheduling delayed and recurring actions. The system is single-threaded, lock-free, and processes timers during each game loop tick.


Overview​

The timer wheel has 3 layers with 4,096 slots each:

LayerResolutionRange
08ms~32.8 seconds
1~32.8s~22 minutes
2~22m~16 days

Key characteristics:

  • O(1) insert and remove -- adding thousands of timers does not slow the server.
  • No locks -- the entire system runs on the main game thread.
  • No TimerPriority -- this concept from RunUO is removed entirely.
  • 8ms minimum precision -- all delays round up to the nearest 8ms boundary.

Timer.StartTimer (Preferred)​

The primary API for creating timers. Timers are automatically pooled for reuse.

Immediate Execution​

Timer.StartTimer(callback);

Delayed Execution​

Timer.StartTimer(TimeSpan.FromSeconds(5), callback);

Repeating​

// Repeat every second, starting after 1 second
Timer.StartTimer(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1), callback);

Repeating with Count Limit​

// Execute 10 times, once per second, starting immediately
Timer.StartTimer(TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1), 10, callback);

Delayed Start, Then Repeating​

// Wait 5 seconds, then repeat every second
Timer.StartTimer(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(1), callback);

Cancellation with TimerExecutionToken​

When you need to cancel a timer later, pass an out token:

private TimerExecutionToken _token;

// Start a cancellable repeating timer
Timer.StartTimer(
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(5),
DoWork,
out _token
);

// Cancel the timer (safe to call multiple times)
_token.Cancel();

Token Properties​

PropertyTypeDescription
RunningboolWhether the timer is still active
RemainingCountintTicks remaining (int.MaxValue if infinite)
NextDateTimeWhen the next tick fires
IndexintHow many times OnTick has fired so far

Lifecycle Pattern​

Always cancel tokens when the owning entity is deleted:

private TimerExecutionToken _checkTimer;

[Constructible]
public MyItem() : base(0x1234)
{
Timer.StartTimer(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5), Check, out _checkTimer);
}

public override void OnAfterDelete()
{
_checkTimer.Cancel();
base.OnAfterDelete();
}
warning

TimerExecutionToken is not serializable. Never add [SerializableField] to a token. Restore timers in [AfterDeserialization] instead.


Timer.DelayCall (Legacy)​

Returns a Timer object directly. Useful when you need state parameters to avoid lambda allocation:

// Basic delay call
var timer = Timer.DelayCall(TimeSpan.FromSeconds(5), DoWork);
timer.Stop(); // Cancel

// With state parameters (no closure allocation)
Timer.DelayCall(TimeSpan.FromSeconds(2), ProcessTarget, mobile, item);

// Supports up to 5 state parameters
Timer.DelayCall(TimeSpan.FromSeconds(1), DoWork, arg1, arg2, arg3);

When to Use DelayCall​

Prefer Timer.StartTimer for most cases. Use Timer.DelayCall when:

  • You need to pass state parameters to avoid lambda/closure allocation on hot paths.
  • You need the Timer object reference for advanced control.

Timer Restoration After Deserialization​

Timers do not survive server restarts. Save the relevant timing data as a serialized field, then restart the timer after the world loads.

Pattern: Save Expiration Time​

[SerializationGenerator(0)]
public partial class TimedItem : Item
{
private TimerExecutionToken _timer; // NOT serialized

[SerializableField(0)]
[DeltaDateTime]
private DateTime _expireTime;

[Constructible]
public TimedItem() : base(0x1234)
{
_expireTime = Core.Now + TimeSpan.FromHours(1);
StartTimer();
}

private void StartTimer()
{
Timer.StartTimer(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), Check, out _timer);
}

[AfterDeserialization]
private void AfterDeserialization() => StartTimer();

public override void OnAfterDelete()
{
_timer.Cancel();
base.OnAfterDelete();
}

private void Check()
{
if (Core.Now >= _expireTime)
{
Delete();
}
}
}

Key points:

  • [DeltaDateTime] stores the time as an offset from Core.Now, so it adjusts correctly if the server is down for a while.
  • [AfterDeserialization] runs after the entity's fields are loaded -- this is where you restart timers.
  • The TimerExecutionToken field has no serialization attribute.

Pattern: Timer Field with DeserializeTimerField​

For Timer objects (not tokens), use [DeserializeTimerField]:

[SerializableField(0, setter: "private")]
private Timer _decayTimer;

[DeserializeTimerField(0)]
private void DeserializeDecayTimer(TimeSpan delay)
{
_decayTimer = Timer.DelayCall(delay, Delete);
_decayTimer.Start();
}

public override void OnAfterDelete()
{
_decayTimer?.Stop();
_decayTimer = null;
base.OnAfterDelete();
}

The serialization system saves the remaining delay and passes it to your deserialize method.


Common Mistakes​

MistakeProblemFix
Adding [SerializableField] to TimerExecutionTokenBuild error or data corruptionLeave unserialized; use [AfterDeserialization] to restart
Not cancelling timer on deleteTimer fires on a deleted entity, causing errorsCancel in OnAfterDelete()
Using Thread.SleepBlocks the entire game loopUse await Timer.Pause()
Creating timer inside deserializationTimer starts before the world is fully loadedUse [AfterDeserialization]
Lambda capturing state in hot-path timerAllocates a closure object every invocationUse Timer.DelayCall with state parameters

Avoiding Lambda Allocation​

// BAD on hot paths -- allocates a closure each time
Timer.StartTimer(TimeSpan.FromSeconds(2), () => ProcessTarget(from, target));

// GOOD -- state parameters, no allocation
Timer.DelayCall(TimeSpan.FromSeconds(2), ProcessTarget, from, target);

private static void ProcessTarget(Mobile from, Mobile target)
{
// Process...
}

Quick Reference​

Fire-and-Forget​

// One-shot after 10 seconds
Timer.StartTimer(TimeSpan.FromSeconds(10), Delete);

Cancellable Repeating Timer​

private TimerExecutionToken _token;

Timer.StartTimer(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5), Tick, out _token);

// Later:
_token.Cancel();

Awaitable Pause​

await Timer.Pause(TimeSpan.FromMilliseconds(100));
await Timer.Pause(500); // Milliseconds overload

This is safe because EventLoopContext routes continuations back to the main thread.