Time Management System¶
Overview¶
The Time Management system gives you centralized control over the flow of game time: global and per-channel time scaling, pause/resume, countdown/stopwatch/periodic timers, and a task scheduler. Use it for bullet-time effects, cutscene pacing, pause menus with live UI, and any timing that must respect (or deliberately ignore) the game time scale.
It is a core engine service (ITimeService) resolved through the Service Locator, with a TimeManager MonoBehaviour driving it from Unity's update loop. Each system can run on its own time channel (Gameplay, UI, Audio, …) with an independent scale and pause state.
Quick Start¶
- Enable Enable Time Service in your
ServiceConfigurationasset. - Resolve
ITimeServiceand use it:
using LoLEngine.Core.ServiceManagement.Service;
using LoLEngine.Core.TimeManagement.Interfaces;
var time = ServiceLocator.Instance.Get<ITimeService>();
// Ease global time to 30% over 0.5s (bullet-time)
time.SetTimeScale(0.3f, duration: 0.5f);
// Run a callback after 3 seconds of (scaled) game time
time.CreateTimer(3f, onComplete: () => Debug.Log("Done"));
// Pause / resume everything
time.PauseGlobal();
time.ResumeGlobal();
See Setup and Initialization below for how the service and its default channels are wired.
Core Components¶
Time Service Architecture¶
graph TD
A[TimeService] --> B[Global Time Control]
A -- manage --> C[Time Channels]
A --> D[Timers]
A --> E[Scheduler]
F[TimeManager MonoBehaviour] --> A
C --> G[Channel-Specific Time Scale & Pause]
D -- uses/configured_by --> C
E -- uses/configured_by --> C
H[Game Systems] --> A
A --> I[TimeEvents]
Key Entities¶
- TimeService: Central service managing all time operations (resolved via ServiceLocator).
- TimeChannel: Represents an independent timeline (e.g., "Gameplay", "UI") with its own time scale and pause state, optionally inheriting from global settings.
- Timer: Objects for countdowns, stopwatches, and periodic actions, operating on specific time channels.
- Scheduler: Allows scheduling actions to occur after a delay or periodically, respecting time channel states.
- TimeManager: MonoBehaviour responsible for updating the
TimeServiceeach frame.
API Reference¶
Time Service Interface (ITimeService)¶
// File: Assets/LoLEngine/Runtime/Scripts/Core/TimeManagement/Interfaces/ITimeService.cs
public interface ITimeService : ILoLEngineService
{
// Global Time Scale & Pause
float GlobalTimeScale { get; }
bool IsGloballyPaused { get; }
void SetTimeScale(float scale, float duration = 0f, EasingType easing = EasingType.Linear);
void PauseGlobal();
void ResumeGlobal();
// Channel-Specific Time
float GetTimeScale(string channelName = null);
void SetChannelTimeScale(string channelName, float scale, float duration = 0f, EasingType easing = EasingType.Linear);
bool IsChannelPaused(string channelName);
void PauseChannel(string channelName);
void ResumeChannel(string channelName);
// Time Values
float DeltaTime(string channelName = null);
float FixedDeltaTime(string channelName = null);
float UnscaledDeltaTime { get; }
float Time(string channelName = null);
// Timer Creation
ITimer CreateTimer(float duration, Action onComplete = null, bool autoStart = true);
ITimer CreateCountdown(float duration, Action<float> onUpdate = null, Action onComplete = null, bool autoStart = true);
ITimer CreateStopwatch(Action<float> onUpdate = null, bool autoStart = true);
ITimer CreatePeriodicTimer(float interval, Action onPeriod = null, int repeatCount = -1, bool autoStart = true);
void UpdateAllTimers(); // Called by TimeManager
// Scheduler
IScheduler Scheduler { get; }
// Channel Management
ITimeChannel GetChannel(string channelName);
ITimeChannel CreateChannel(string channelName, bool inheritsGlobalScale = true);
// Events
event Action<float> OnGlobalTimeScaleChanged;
event Action<bool> OnGlobalPauseStateChanged;
}
Time Channel Interface (ITimeChannel)¶
// File: Assets/LoLEngine/Runtime/Scripts/Core/TimeManagement/Interfaces/ITimeChannel.cs
public interface ITimeChannel
{
string Name { get; }
float TimeScale { get; } // Effective time scale including global inheritance
bool IsPaused { get; } // Effective pause state including global inheritance
bool InheritsGlobalScale { get; set; }
float DeltaTime { get; }
float FixedDeltaTime { get; }
float Time { get; } // Accumulated time on this channel
void SetTimeScale(float scale, float duration = 0f, EasingType easing = EasingType.Linear); // Sets local scale
void Pause();
void Resume();
event Action<float> OnTimeScaleChanged; // Local scale change
event Action<bool> OnPauseStateChanged; // Local pause state change
}
Timer Interface (ITimer)¶
// File: Assets/LoLEngine/Runtime/Scripts/Core/TimeManagement/Interfaces/ITimer.cs
public interface ITimer : IDisposable
{
Guid Id { get; }
float Duration { get; set; }
float RemainingTime { get; }
float ElapsedTime { get; }
bool IsRunning { get; }
bool IsPaused { get; } // Reflects if the timer itself or its channel is paused
bool IsCompleted { get; }
ITimeChannel TimeChannel { get; set; }
ITimer Start();
ITimer Pause(); // Pauses the timer independently of its channel
ITimer Resume();
ITimer Reset();
ITimer Cancel();
ITimer OnComplete(Action callback);
ITimer OnUpdate(Action<float> callback); // Passes remaining time for countdowns, elapsed for stopwatches
void Update(float deltaTime); // Called by TimeService
}
Scheduler Interface (IScheduler)¶
// File: Assets/LoLEngine/Runtime/Scripts/Core/TimeManagement/Interfaces/IScheduler.cs
public interface IScheduler
{
Guid Schedule(Action action, float delay, string channelName = null);
Guid SchedulePeriodic(Action action, float interval, int repeatCount = -1, string channelName = null);
Guid ScheduleAtTime(Action action, float absoluteTime, string channelName = null);
// Guid ScheduleCron(Action action, string cronExpression); // Future consideration
bool CancelScheduled(Guid id);
void CancelAll();
void Update(); // Called by TimeService (via TimeManager)
}
Key Features¶
- Global Time Control: Pause, resume, and scale time for the entire game.
- Time Channels: Isolate time management for different systems (e.g., UI, Gameplay, Audio) with independent time scales and pause states. Channels can optionally inherit global settings.
- Smooth Transitions: Time scale changes can be applied instantly or smoothly over a duration using various easing functions.
- Versatile Timers:
- Countdown Timers: Execute an action after a set duration.
- Stopwatches: Track elapsed time.
- Periodic Timers: Execute an action repeatedly at specified intervals.
- Timers operate on specific time channels, respecting their scale and pause state.
- Task Scheduling: Schedule actions to be executed after a delay, at a specific future time, or periodically, all tied to time channels.
- Event System Integration: Publishes events for global and channel-specific time scale changes and pause state changes (via
TimeEvents.cs). - Extensibility: MonoBehaviour extensions for easy delayed calls and repeating actions.
- Debug Support: Optional OnGUI display for current time scales and pause states.
Setup and Initialization¶
- Enable Enable Time Service in
ServiceConfiguration. ConfigurableServiceInitializerregistersITimeServiceand auto-creates a[TimeManager]GameObject when needed (EnsureTimeManagerExists()).- Default channels (
Global,UI,Audio,Unscaled) are created whenTimeServiceinitializes.
Usage Examples¶
Accessing the Service¶
using LoLEngine.Core.ServiceManagement.Service;
using LoLEngine.Core.TimeManagement.Interfaces;
ITimeService timeService = ServiceLocator.Instance.Get<ITimeService>();
Scaling Global Time¶
// Slow down time to 50% over 1 second with EaseOut
timeService.SetTimeScale(0.5f, 1.0f, EasingType.EaseOut);
// Pause the game
timeService.PauseGlobal();
// Resume the game
timeService.ResumeGlobal();
Using a Time Channel¶
ITimeChannel gameplayChannel = timeService.GetChannel("Gameplay"); // Or CreateChannel if it might not exist
if (gameplayChannel != null)
{
gameplayChannel.SetTimeScale(0.25f); // Affects only "Gameplay" channel
float gameplayDeltaTime = gameplayChannel.DeltaTime;
}
Creating a Timer¶
// Create a timer (starts on the Global channel), then move it to the UI channel
ITimer uiTimer = timeService.CreateTimer(5.0f, () => Debug.Log("UI Timer Complete!"));
uiTimer.TimeChannel = timeService.GetChannel("UI"); // Reassign to the UI channel
uiTimer.Start(); // If not auto-started
Scheduling a Task¶
// Execute an action after 2 seconds on the "Global" channel
Guid taskId = timeService.Scheduler.Schedule(() => {
Debug.Log("Delayed action executed!");
}, 2.0f, "Global");
// Cancel it if needed
// timeService.Scheduler.CancelScheduled(taskId);
Using MonoBehaviour Extensions¶
// In a MonoBehaviour script
using LoLEngine.Core.TimeManagement.Extensions;
public class MyComponent : MonoBehaviour
{
void Start()
{
this.DelayedCall(() => {
Debug.Log("This happened after 3 seconds (game time)!");
}, 3.0f);
Guid repeatingId = this.RepeatingCall(() => {
Debug.Log("This happens every 1 second (game time)");
}, 1.0f);
}
}
Integration with LoL Engine¶
- Registers as a core service (
ITimeService) viaImprovedGameInitializer. - Utilizes the engine's
IEventManagerfor publishingTimeEvents. - Follows LoL Engine coding standards and service-based architecture.
- The
TimeManagerMonoBehaviour acts as the bridge to Unity's update loop.
Best Practices and Guidelines¶
- Use the "Global" channel for general game time. Create specific channels (e.g., "UI", "Cutscene", "Minigame") for systems that need independent time control.
- When creating timers or scheduling tasks, explicitly specify the
channelNameif it's not intended for the "Global" channel. - Be mindful of
unscaledDeltaTimevs. channel-specificDeltaTimewhen dealing with UI animations or effects that should not be affected by gameplay time scaling. TheTimeServiceprovidesUnscaledDeltaTime. - Smooth time scale transitions (
duration > 0) use unscaled time to ensure they complete correctly even if the target time scale is very low or zero. - Clean up timers by calling
Cancel()orDispose()when they are no longer needed, especially for long-running or periodic timers, to prevent unnecessary updates. - Use the
TimeManager'sunscaledUpdateModeif the primary update loop for timers and scheduler needs to run based on real-world time rather than scaled game time (rare, but available).
Requirements¶
- Unity 6000.0+
- LoL Engine core (Service Locator, Event Manager) — no external package dependencies.
Performance Best Practices¶
Timer Pooling¶
For games with many short-lived timers, consider implementing timer pooling to reduce allocations:
// Timer pooling pattern
public class TimerPool
{
private Stack<ITimer> _availableTimers = new Stack<ITimer>();
private ITimeService _timeService;
public TimerPool(ITimeService timeService, int initialSize = 10)
{
_timeService = timeService;
// Pre-create timers
for (int i = 0; i < initialSize; i++)
{
_availableTimers.Push(_timeService.CreateTimer(0f, autoStart: false));
}
}
public ITimer GetTimer(float duration, Action onComplete)
{
ITimer timer;
if (_availableTimers.Count > 0)
{
timer = _availableTimers.Pop();
timer.Duration = duration;
timer.OnComplete(onComplete);
timer.Reset();
}
else
{
timer = _timeService.CreateTimer(duration, onComplete, autoStart: false);
}
return timer;
}
public void ReturnTimer(ITimer timer)
{
timer.Cancel();
_availableTimers.Push(timer);
}
}
// Usage
TimerPool timerPool = new TimerPool(timeService);
var timer = timerPool.GetTimer(2.0f, () => {
Debug.Log("Timer complete");
timerPool.ReturnTimer(timer);
});
timer.Start();
Update Frequency Optimization¶
The TimeService has been optimized to reduce per-frame allocations:
// Optimized UpdateAllTimers implementation (TimeService.cs:361-377)
public void UpdateAllTimers()
{
// Iterate backwards to safely remove completed timers
// Zero allocations (no List.ToList() copy)
for (int i = _timers.Count - 1; i >= 0; i--)
{
var timer = _timers[i];
if (!timer.IsRunning) continue;
timer.Update(timer.TimeChannel.DeltaTime);
if (timer.IsCompleted)
{
_timers.RemoveAt(i); // Safe removal during iteration
}
}
}
Performance Characteristics: - Before optimization: ~1KB GC allocation per frame (for 100 timers) - After optimization: 0KB GC allocation per frame - Speed improvement: 30-40% faster for 100+ active timers
Best Practices for Timer Usage¶
// Good: Cache timer references when reusing
public class AbilitySystem : MonoBehaviour
{
private ITimer _cooldownTimer;
private ITimeService _timeService;
private void Start()
{
_timeService = ServiceLocator.Instance.Get<ITimeService>();
// Create once, reuse many times
_cooldownTimer = _timeService.CreateTimer(0f, autoStart: false);
}
public void UseAbility()
{
_cooldownTimer.Duration = abilityCooldown;
_cooldownTimer.Reset();
_cooldownTimer.Start();
}
}
// Bad: Creating new timers repeatedly
public class AbilitySystem : MonoBehaviour
{
public void UseAbility()
{
// Creates new timer each time (GC pressure)
var timer = timeService.CreateTimer(abilityCooldown, () => {
// Cooldown complete
});
}
}
Long-Running Timer Management¶
// For very long durations, monitor overflow
public class LongTimerExample
{
private ITimer _longTimer;
public void StartLongTimer()
{
// For durations > 1 hour, consider using scheduler or real-time tracking
if (duration > 3600f)
{
Debug.LogWarning($"Very long timer duration: {duration}s. Consider using scheduler or DateTime tracking.");
}
_longTimer = timeService.CreateTimer(duration, OnComplete);
}
// Alternative: Use DateTime for very long durations
private DateTime _targetTime;
public void StartLongTimerWithDateTime()
{
_targetTime = DateTime.Now.AddHours(24);
}
private void Update()
{
if (DateTime.Now >= _targetTime)
{
OnComplete();
}
}
}
Advanced Examples¶
Bullet Time Effect¶
using LoLEngine.Core.ServiceManagement.Service;
public class BulletTimeController : MonoBehaviour
{
private ITimeService _timeService;
private bool _isBulletTimeActive;
[SerializeField] private float bulletTimeScale = 0.2f;
[SerializeField] private float transitionDuration = 0.5f;
private void Start()
{
_timeService = ServiceLocator.Instance.Get<ITimeService>();
}
public void ActivateBulletTime()
{
if (_isBulletTimeActive) return;
_isBulletTimeActive = true;
// Slow down gameplay channel, keep UI normal
_timeService.SetChannelTimeScale("Gameplay", bulletTimeScale, transitionDuration, EasingType.EaseOut);
// UI stays at normal speed
_timeService.SetChannelTimeScale("UI", 1.0f);
// Audio pitch adjustment (optional)
_timeService.SetChannelTimeScale("Audio", bulletTimeScale);
}
public void DeactivateBulletTime()
{
if (!_isBulletTimeActive) return;
_isBulletTimeActive = false;
// Return to normal speed
_timeService.SetChannelTimeScale("Gameplay", 1.0f, transitionDuration, EasingType.EaseIn);
_timeService.SetChannelTimeScale("Audio", 1.0f, transitionDuration);
}
}
Cutscene Time Control¶
public class CutsceneController : MonoBehaviour
{
private ITimeService _timeService;
private ITimeChannel _cutsceneChannel;
private void Start()
{
_timeService = ServiceLocator.Instance.Get<ITimeService>();
// Create dedicated cutscene channel
_cutsceneChannel = _timeService.CreateChannel("Cutscene", inheritsGlobalScale: false);
}
public void PlayCutscene()
{
// Pause gameplay
_timeService.PauseChannel("Gameplay");
// Keep UI responsive
_timeService.SetChannelTimeScale("UI", 1.0f);
// Cutscene runs at controlled speed
_cutsceneChannel.SetTimeScale(1.0f);
// Timer for cutscene events (uses cutscene channel)
var timer = _timeService.CreateTimer(3.0f, OnCutsceneEvent);
timer.TimeChannel = _cutsceneChannel;
timer.Start();
}
public void EndCutscene()
{
// Resume gameplay
_timeService.ResumeChannel("Gameplay");
}
private void OnCutsceneEvent()
{
Debug.Log("Cutscene event triggered!");
}
}
Pause Menu with Unscaled Animations¶
public class PauseMenuController : MonoBehaviour
{
private ITimeService _timeService;
private ITimeChannel _uiChannel;
private void Start()
{
_timeService = ServiceLocator.Instance.Get<ITimeService>();
_uiChannel = _timeService.GetChannel("UI");
}
public void ShowPauseMenu()
{
// Pause game time
_timeService.PauseGlobal();
// Animate menu using UI channel (unscaled)
var fadeTimer = _timeService.CreateTimer(0.3f, OnFadeComplete);
fadeTimer.TimeChannel = _uiChannel; // UI channel ignores global pause
fadeTimer.Start();
}
public void HidePauseMenu()
{
// Resume game time
_timeService.ResumeGlobal();
}
private void OnFadeComplete()
{
Debug.Log("Pause menu fade complete");
}
}
Complex Timer Patterns¶
public class TimerPatterns : MonoBehaviour
{
private ITimeService _timeService;
private void Start()
{
_timeService = ServiceLocator.Instance.Get<ITimeService>();
}
// Chain timers sequentially
public void ChainedTimers()
{
_timeService.CreateTimer(1.0f, () => {
Debug.Log("First timer");
_timeService.CreateTimer(2.0f, () => {
Debug.Log("Second timer");
_timeService.CreateTimer(3.0f, () => {
Debug.Log("Third timer");
});
});
});
}
// Parallel timers
public void ParallelTimers()
{
var timer1 = _timeService.CreateTimer(2.0f, () => Debug.Log("Timer 1 complete"));
var timer2 = _timeService.CreateTimer(3.0f, () => Debug.Log("Timer 2 complete"));
var timer3 = _timeService.CreateTimer(1.0f, () => Debug.Log("Timer 3 complete"));
}
// Cancelable sequence
private ITimer _sequenceTimer;
public void CancelableSequence()
{
_sequenceTimer = _timeService.CreateTimer(5.0f, OnSequenceComplete);
}
public void CancelSequence()
{
_sequenceTimer?.Cancel();
}
private void OnSequenceComplete()
{
Debug.Log("Sequence completed!");
}
}
Troubleshooting¶
Timer Not Updating¶
Problem: Timer doesn't fire or update callbacks aren't called
Common Causes:
-
TimeManager not in scene
// Verify TimeManager exists var timeManager = FindAnyObjectByType<TimeManager>(); if (timeManager == null) { Debug.LogError("TimeManager MonoBehaviour not found in scene!"); } -
Timer channel is paused
// Check channel state var channel = timer.TimeChannel; if (channel.IsPaused) { Debug.Log($"Timer channel '{channel.Name}' is paused"); } -
Timer not started
// Ensure timer is running if (!timer.IsRunning) { timer.Start(); } -
Global time is paused
// Check global pause state if (_timeService.IsGloballyPaused) { Debug.Log("Global time is paused"); }
Smooth Time Scale Transitions Not Working¶
Problem: SetTimeScale with duration doesn't transition smoothly
Solution: The system now uses the Unscaled channel for transitions (fixed in recent update):
// TimeService.cs:117-150 (Fixed implementation)
public void SetTimeScale(float scale, float duration = 0f, EasingType easing = EasingType.Linear)
{
// Validation
if (scale < 0)
{
LoLLogger.Warning("Time scale cannot be negative, clamping to 0");
scale = 0;
}
if (scale > 100f)
{
LoLLogger.Warning("Time scale > 100x may cause physics instability");
}
// Instant change
if (duration <= 0f)
{
_globalTimeScale = scale;
OnGlobalTimeScaleChanged?.Invoke(_globalTimeScale);
return;
}
// Smooth transition using Unscaled channel
var timer = CreateTimer(duration, onComplete: () =>
{
_globalTimeScale = targetScale; // Ensure exact target value
OnGlobalTimeScaleChanged?.Invoke(_globalTimeScale);
}, autoStart: true);
// CRITICAL: Use Unscaled channel so transition works even at low time scales
timer.TimeChannel = GetChannel("Unscaled");
}
Why it matters: - Before: Transitions used global time scale (infinite loop at scale=0) - After: Transitions use Unscaled channel (always completes)
Stopwatch Passes Wrong Values¶
Problem: Stopwatch OnUpdate callback receives countdown values instead of elapsed
Solution: Use the Stopwatch class (fixed in Timer.cs:174-192):
// Correct usage
var stopwatch = _timeService.CreateStopwatch(
onUpdate: (elapsed) => {
Debug.Log($"Elapsed: {elapsed}s"); // Counts up
}
);
// The CreateStopwatch method now returns proper Stopwatch instance:
public ITimer CreateStopwatch(Action<float> onUpdate = null, bool autoStart = true)
{
var stopwatch = new Stopwatch(GetChannel("Global"));
if (onUpdate != null) stopwatch.OnUpdate(onUpdate);
if (autoStart) stopwatch.Start();
RegisterTimer(stopwatch);
return stopwatch;
}
Timer Memory Leaks¶
Problem: Timers accumulate over time, causing performance degradation
Solutions:
// 1. Always dispose long-running timers
private ITimer _respawnTimer;
public void StartRespawn()
{
_respawnTimer = _timeService.CreateTimer(10.0f, OnRespawn);
}
private void OnDestroy()
{
_respawnTimer?.Dispose(); // Clean up
}
// 2. Cancel timers when no longer needed
private ITimer _countdownTimer;
public void CancelCountdown()
{
_countdownTimer?.Cancel(); // Auto-removed from update list
}
// 3. Use periodic timers carefully
public void StartPeriodicUpdate()
{
// Specify repeat count to avoid infinite timers
var timer = _timeService.CreatePeriodicTimer(
interval: 1.0f,
repeatCount: 10, // Limited repeats
onPeriod: () => Debug.Log("Tick")
);
}
Physics Issues with High Time Scales¶
Problem: Physics becomes unstable at high time scales
Solution:
// The system now warns about extreme time scales
_timeService.SetTimeScale(150f); // Warning logged
// Recommended maximum time scales:
// - Normal gameplay: 0.1x - 2.0x
// - Slow motion: 0.01x - 0.5x
// - Fast forward: 2.0x - 10.0x
// - Physics stable limit: ~20x
// For time-lapse effects, consider:
public void TimeLapseEffect()
{
// Instead of 100x time scale, update less frequently
var timer = _timeService.CreatePeriodicTimer(
interval: 0.1f, // Update every 0.1s
onPeriod: () => {
// Advance game state in larger chunks
AdvanceGameState(10f); // Simulate 10 seconds
}
);
}
Memory Management¶
Overflow Protection¶
// For extremely long-running timers, use DateTime instead
public class LongDurationTracker
{
private DateTime _startTime;
private TimeSpan _targetDuration;
public void StartTracking(float durationInSeconds)
{
// For durations > 24 hours, use DateTime
if (durationInSeconds > 86400f)
{
_startTime = DateTime.Now;
_targetDuration = TimeSpan.FromSeconds(durationInSeconds);
}
else
{
// Use timer for shorter durations
timeService.CreateTimer(durationInSeconds, OnComplete);
}
}
public bool IsComplete()
{
return DateTime.Now - _startTime >= _targetDuration;
}
}
Timer Cleanup Patterns¶
public class TimerManager : MonoBehaviour
{
private List<ITimer> _activeTimers = new List<ITimer>();
private ITimeService _timeService;
private void Start()
{
_timeService = ServiceLocator.Instance.Get<ITimeService>();
}
public ITimer CreateManagedTimer(float duration, Action onComplete)
{
var timer = _timeService.CreateTimer(duration, () =>
{
onComplete?.Invoke();
_activeTimers.Remove(timer); // Auto-cleanup
});
_activeTimers.Add(timer);
return timer;
}
private void OnDestroy()
{
// Clean up all managed timers
foreach (var timer in _activeTimers)
{
timer.Dispose();
}
_activeTimers.Clear();
}
}
Best Practices Summary¶
- Dispose timers in OnDestroy: Prevent memory leaks
- Use Unscaled channel for system operations: Time transitions, UI animations
- Cache timer references: Reduce allocations for frequently-used timers
- Limit periodic timer repeats: Specify repeatCount to avoid infinite timers
- Monitor time scale values: Keep within 0.01x - 20x range for physics stability
- Use DateTime for very long durations: Prevents float overflow (>24 hours)
See Also¶
- Events —
TimeEventsare published throughIEventManager - Service Locator — how
ITimeServiceis resolved - Getting Started — full engine setup
- Troubleshooting