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.