8.1 KiB
ModernUO Timer System
This document covers ModernUO's timer system: the timer wheel scheduler, delay calls, timer execution tokens, and patterns for timer usage in game content.
Overview
ModernUO uses a 3-layer hierarchical timer wheel for scheduling delayed and recurring actions. The system is single-threaded and processes timers during each game loop tick.
Timer vs EventScheduler: The timer wheel is for game-tick delays and repeats (8ms precision, sub-second to ~16 days). For wall-clock/calendar scheduling — daily resets, weekly events, holiday seasons — use
EventSchedulerinstead (1-second granularity, timezone-aware, calendar recurrence patterns). Seedev-docs/event-scheduler.md.
Architecture
Timer Wheel
3-layer wheel with 4096 slots per layer:
| Layer | Resolution | Range |
|---|---|---|
| 0 | 8ms | ~32.8 seconds |
| 1 | ~32.8s | ~22 minutes |
| 2 | ~22m | ~16 days |
- Tick rate: 8ms (minimum precision)
- All delays are rounded up to nearest 8ms boundary
- O(1) insert and remove operations
Execution Flow
Timer.Slice(tickCount)called each game loop iteration- Wheel rotates to current slot
- All timers in the slot are executed via
OnTick() - Repeating timers are re-inserted at next interval
- Finished timers call
OnDetach()for cleanup
API Reference
Timer.StartTimer (Fire-and-Forget with Pooling)
Preferred for most use cases. Timers are automatically pooled for reuse.
// Immediate execution
Timer.StartTimer(callback);
// Delayed execution
Timer.StartTimer(TimeSpan.FromSeconds(5), callback);
// Repeating
Timer.StartTimer(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1), callback);
// Repeating with count limit
Timer.StartTimer(TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1), 10, callback);
// Delayed start, then repeating
Timer.StartTimer(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(1), callback);
Timer.StartTimer with Token (Cancellable)
Use when you need to cancel the timer later.
private TimerExecutionToken _token;
// Start with token
Timer.StartTimer(TimeSpan.FromSeconds(5), DoWork, out _token);
// Check state
if (_token.Running) { }
var remaining = _token.RemainingCount; // int.MaxValue if infinite
var next = _token.Next; // DateTime of next tick
var index = _token.Index; // Times ticked so far
// Cancel (safe to call multiple times)
_token.Cancel();
Timer.DelayCall (Returns Timer Object)
Legacy-style API that returns the timer object directly.
var timer = Timer.DelayCall(TimeSpan.FromSeconds(5), DoWork);
timer.Stop(); // Cancel
// With state parameters (avoids lambda allocation)
Timer.DelayCall(TimeSpan.FromSeconds(2), ProcessTarget, mobile, item);
Timer.DelayCall(TimeSpan.FromSeconds(1), DoWork, arg1, arg2, arg3);
// Supports up to 5 state parameters
Timer.Pause (Awaitable)
For async/await patterns:
await Timer.Pause(TimeSpan.FromMilliseconds(100));
await Timer.Pause(500); // Milliseconds overload
Safe because EventLoopContext routes continuations to the main thread.
TimerExecutionToken
Lightweight struct for tracking fire-and-forget timers:
public struct TimerExecutionToken
{
public bool Running { get; } // Is timer still active?
public int Index { get; } // How many times OnTick fired
public int RemainingCount { get; } // Ticks remaining (int.MaxValue if infinite)
public DateTime Next { get; } // When next tick fires
public void Cancel(); // Stop and return to pool
}
Key behaviors:
Cancel()is safe to call multiple times- Default value (
default(TimerExecutionToken)) hasRunning = false - NOT serializable -- restore in
[AfterDeserialization]
Patterns
Pattern 1: Simple Delayed Action
// Delete item after 10 seconds
Timer.StartTimer(TimeSpan.FromSeconds(10), Delete);
Pattern 2: Cancellable Recurring Timer
private TimerExecutionToken _checkTimer;
public MyItem() : base(0x1234)
{
Timer.StartTimer(
TimeSpan.FromSeconds(5), // Initial delay
TimeSpan.FromSeconds(5), // Repeat interval
CheckExpiry, // Callback
out _checkTimer // Token for cancellation
);
}
public override void OnAfterDelete()
{
_checkTimer.Cancel();
base.OnAfterDelete();
}
private void CheckExpiry()
{
if (Core.Now >= _expireTime)
Delete();
}
Pattern 3: Timer Restoration After Deserialization
[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();
}
}
Pattern 4: Serializable Timer Field
[SerializableField(0, setter: "private")]
private Timer _decayTimer;
[DeserializeTimerField(0)]
private void DeserializeDecayTimer(TimeSpan delay)
{
_decayTimer = Timer.DelayCall(delay, Delete);
_decayTimer.Start();
}
public void BeginDecay(TimeSpan delay)
{
_decayTimer?.Stop();
_decayTimer = new InternalTimer(this, delay);
_decayTimer.Start();
}
public override void OnAfterDelete()
{
_decayTimer?.Stop();
_decayTimer = null;
base.OnAfterDelete();
}
Pattern 5: Custom Timer Class
private class DecayTimer : Timer
{
private readonly Corpse _corpse;
public DecayTimer(Corpse c, TimeSpan delay) : base(delay)
{
_corpse = c;
}
protected override void OnTick()
{
if (!_corpse.GetFlag(CorpseFlag.NoBones))
_corpse.TurnToBones();
else
_corpse.Delete();
}
}
Pattern 6: State-Carrying Delay (No Lambda)
// Instead of lambda (allocates closure):
Timer.StartTimer(TimeSpan.FromSeconds(2), () => ProcessTarget(from, target));
// Use state parameters (no allocation):
Timer.DelayCall(TimeSpan.FromSeconds(2), ProcessTarget, from, target);
private static void ProcessTarget(Mobile from, Mobile target)
{
// Process...
}
Timer Pool
Timers created via Timer.StartTimer() are pooled for reuse:
- Initial pool: 1024 timers (configurable:
timer.initialPoolCapacity) - Max pool: 16x initial (configurable:
timer.maxPoolCapacity) - Pool refills asynchronously when depleted
Timer.CheckTimerPool()called each game loop to monitor
ISerializableExtensions for Timers
// Extension methods on ISerializable:
entity.Stop(timer); // Stop + MarkDirty
entity.Start(timer); // Start + MarkDirty
entity.Restart(timer, delay, interval); // Stop + reconfigure + Start + MarkDirty
entity.Stop(ref timer); // Stop + null + MarkDirty
Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
Serializing TimerExecutionToken |
Build error / data corruption | Leave unserialized, use [AfterDeserialization] |
| Not cancelling on delete | Timer fires on deleted entity | Cancel in OnDelete()/OnAfterDelete() |
Using Thread.Sleep |
Blocks game loop | Use await Timer.Pause() |
| Creating timer in deserialization | Timer starts before world is ready | Use [AfterDeserialization] |
| Lambda in hot-path timer | Allocates closure every time | Use state parameters |
Key File References
- Timer base class:
Projects/Server/Timer/Timer.cs - DelayCall + StartTimer:
Projects/Server/Timer/Timer.DelayCall.cs - Timer wheel:
Projects/Server/Timer/Timer.TimerWheel.cs - Pool management:
Projects/Server/Timer/Timer.Pool.cs - State timers:
Projects/Server/Timer/Timer.DelayStateCall.cs - Token:
Projects/Server/Timer/TimerExecutionToken.cs - Serializable extensions:
Projects/Server/Serialization/ISerializableExtensions.cs