Consuming Time Scaling in Godot Games
How to consume the WorldTime plugin's time system correctly in your game logic, avoid common inconsistency traps, and achieve consistent time behavior across all systems.
Architecture Overview
WorldTime maintains two separate time tracks:
┌─────────────────────────────────────────────────────────────────────┐
│ REAL TIME (int64 µs) │
│ AccumulatedRealMicroseconds │
│ - Exact, lossless │
│ - Used ONLY for: pause/resume, save/load │
│ - Never use this for game calculations │
└─────────────────────────────────────────────────────────────────────┘
× TimeScale.GameSecondsPerRealSecond
↓ (float multiplication happens here)
┌─────────────────────────────────────────────────────────────────────┐
│ GAME TIME (float seconds) │
│ ElapsedGameSeconds, CurrentDateTime │
│ - What ALL game systems use │
│ - Consumed by: crops, aging, agents, UI, day/night │
└─────────────────────────────────────────────────────────────────────┘
Critical: The multiplication from real→game time happens once at the boundary. After that, all game logic operates purely on game time.
The Single Most Important Rule
Always read game time from
ElapsedGameSecondsorCurrentDateTime— never re-convert fromAccumulatedRealMicroseconds.
// WRONG — re-converting from real time ignores TimeScale changes
float gameSec = state.AccumulatedRealMicroseconds / 1_000_000f * state.TimeScale.GameSecondsPerRealSecond;
// CORRECT — use the pre-computed game time
float gameSec = state.ElapsedGameSeconds;
How Time Advances
In WorldTimeAdvanceSystem.Update():
// 1. Real time → int64 microseconds (ONCE, exact)
long deltaMicroseconds = HoursTime.RoundToMicroseconds(deltaTime);
state.AccumulatedRealMicroseconds += deltaMicroseconds;
// 2. Real delta × TimeScale = game delta (float, at boundary)
float gameDeltaSeconds = deltaTime * state.TimeScale.DeltaMultiplier;
// 3. All subsequent arithmetic uses game time (exact for calendar ops)
state.ElapsedGameSeconds += gameDeltaSeconds;
state.CurrentDateTime = state.CurrentDateTime.AddSeconds(gameDeltaSeconds);
Common Inconsistency Traps
Trap 1: Mixing Real Delta with Game Time
// WRONG — applies TimeScale twice
float growth = crop.GrowthRate * delta * timeManager.TimeScale;
// CORRECT — game delta is already scaled
float growth = crop.GrowthRate * gameDeltaSeconds;
// BEST — use the provided game delta from event
eventBus.Subscribe<TimeAdvancedEvent>(e => {
float gameDeltaMinutes = e.GameDeltaMinutes; // already scaled
float cropGrowth = crop.GrowthRate * gameDeltaMinutes;
});
Trap 2: Using Godot's Time.GetTicksMsec() for Game Durations
// WRONG — Godot's wall-clock time, ignores TimeScale
if (Time.GetTicksMsec() - _cooldownStart > _cooldownDuration) { }
// CORRECT — game-time based cooldown
if (gameTime.ElapsedGameSeconds - _cooldownStartGameTime > _cooldownDuration) { }
Trap 3: Hardcoding 60 Seconds per Minute
// WRONG — assumes game minute = 60 real seconds
float gameMinutes = realDelta * 60.0f;
// CORRECT — use the TimeScale
float gameMinutes = realDelta * state.TimeScale.GameSecondsPerRealSecond / 60.0f;
Trap 4: Float Comparison for Time Boundaries
// WRONG — float comparison is fragile
if (currentTime.Hours == 6 && currentTime.Minutes == 0) { } // may never hit exactly
// CORRECT — use epsilon or int64 comparison
if (Math.Abs(currentTime.Hours - 6f) < 0.001f && Math.Abs(currentTime.Minutes) < 0.001f) { }
// BEST — use the event system
eventBus.Subscribe<DateChangeEvent>(e => { /* new day */ });
Trap 5: Saving Float Time, Then Resuming
// WRONG — float drift accumulates over many save/load cycles
saveData["elapsedGameSeconds"] = state.ElapsedGameSeconds;
// CORRECT — save the exact int64 accumulator
saveData["accumulatedRealMicroseconds"] = state.AccumulatedRealMicroseconds;
Trap 6: Mixing Engine.TimeScale with GameSecondsPerRealSecond
WorldTime does not read Godot's Engine.TimeScale. If you pass _Process(delta) into Update(), that delta is already multiplied by the engine:
// WRONG — compounds: Engine.TimeScale × GameSecondsPerRealSecond
Engine.TimeScale = 2.0f;
runtime.Update((float)delta); // delta from _Process is already 2× wall clock
// CORRECT — one owner of sim speed (keep Engine.TimeScale at 1.0)
runtime.Update((float)delta);
state.TimeScale.GameSecondsPerRealSecond = 60f;
// BEST — fixed-step timer (see godot-delta-contract.md)
director.Advance(FixedStepSeconds); // always 1/60 real sec; scale via tick count + TimeScale
See Godot delta contract for the full boundary rules and MoonBark Idle's fixed-step pattern.
The int64 Microsecond Design
WorldTime converts Godot's float deltaTime to int64 microseconds once per tick, then performs all subsequent arithmetic as pure integer math.
Godot engine → float deltaTime → RoundToMicroseconds() → int64 microseconds
(per-tick loss) ONE TIME loss rest is exact
Why This Matters
| Scenario | Pure Float | int64 Microseconds |
|---|---|---|
| Add 86400 seconds (1 day) | Date unchanged (carry bug) | Date advances correctly |
| Pause → resume after 1h | ~0.5s drift | Exact |
| 100 consecutive day boundaries | Drift compounds | Each boundary exact |
| 60Hz tick truncation | Accumulates | Bounded (~0.5µs/tick max) |
The truncation at the float→int64 boundary is deterministic (same hardware = same result) and bounded (~57µs/day, ~2.8min/year) — acceptable for gameplay, not acceptable for banking software.
Pattern: Consistent Time Consumption
Subscribe to Events (Recommended)
public partial class CropSystem : Node
{
private WorldTimeBootstrap? _bootstrap;
public void Configure(WorldTimeBootstrap bootstrap) => _bootstrap = bootstrap;
public override void _Ready()
{
_bootstrap?.SignalBus.OnDateChanged += OnDateChanged;
}
private void OnDateChanged(DateChangeEvent evt)
{
GrowAllCrops();
}
public override void _ExitTree()
{
if (_bootstrap != null)
_bootstrap.SignalBus.OnDateChanged -= OnDateChanged;
}
}
Poll Game Time Each Frame (When Needed)
public partial class DayNightDisplay : Label
{
[Export] public TimeProvider? TimeProvider { get; set; }
public override void _Process(double delta)
{
if (TimeProvider == null || TimeProvider.IsPaused)
return;
float hour = TimeProvider.TimeOfDay;
Text = $"Hour: {hour:F1}";
}
}
Fixed-step simulation (recommended for production)
Prefer a timer + fixed real step over per-frame _Process delta for WorldTime advancement:
// Each sim tick — constant real seconds (e.g. 1/60)
_worldTimeRuntime.Update(IdleSimulationDirector.FixedStepSeconds);
// Fast-forward: run more ticks per timer fire, not Engine.TimeScale
void OnTimer()
{
float budget = timer.WaitTime * hudSpeedMultiplier;
int ticks = (int)MathF.Floor(accumulator / FixedStepSeconds);
for (int i = 0; i < ticks; i++)
AdvanceSingleTick(); // WorldTime + ECS systems
}
WorldTimeTickNode remains valid for demos when Engine.TimeScale == 1.0. See Godot delta contract.
Pattern: Time Scale Changes
Dynamic Time Scale for Gameplay
public partial class TimePacingManager : Node
{
[Export] public TimeProvider? TimeProvider { get; set; }
public override void _Process(double delta)
{
if (TimeProvider == null)
return;
float hour = TimeProvider.TimeOfDay;
if (hour >= 23f || hour < 5f)
TimeProvider.SetTimeScale(480f); // 8 game min / real sec
else if (hour >= 5f && hour < 8f)
TimeProvider.SetTimeScale(120f);
else
TimeProvider.SetTimeScale(60f);
}
}
Pause/Resume (Preserves Exact Time)
public void TogglePause(WorldTimeBootstrap bootstrap)
{
if (bootstrap.Provider.IsPaused)
bootstrap.Resume();
else
bootstrap.Pause();
}
Pattern: Save/Load
// Prefer ECS JSON from the runtime the game owns
string json = runtime.SaveToJson();
runtime.LoadFromJson(json);
See saving-loading.md for field-level patterns.
Pattern: Multi-System Coordination
Pass one WorldTimeBootstrap (or shared IWorldTimeRuntime + TimeSignalBus) from game bootstrap:
public partial class GameCoordinator : Node
{
[Export] public WorldTimeBootstrap? WorldTime { get; set; }
public override void _Ready()
{
GetNode<CropSystem>("CropSystem").Configure(WorldTime!);
GetNode<DayNightDisplay>("DayNight").TimeProvider = WorldTime!.Provider;
}
}
Quick Reference
| What You Want | Use This | Not This |
|---|---|---|
| Game elapsed seconds | state.ElapsedGameSeconds |
AccumulatedRealMicroseconds / 1_000_000 * TimeScale |
| Pause-resume exactness | AccumulatedRealMicroseconds |
ElapsedGameSeconds |
| Day/night cycle | CurrentDateTime.Time |
Time.GetTicksMsec() |
| Crop growth | TimeAdvancedEvent.GameDeltaMinutes |
delta * TimeScale |
| Save game time | AccumulatedRealMicroseconds (int64) |
ElapsedGameSeconds (float) |
| UI display | CurrentDateTime |
Manual calculation |
| Sim speed | GameSecondsPerRealSecond + tick count |
Engine.TimeScale + WorldTime |
| Godot integration | Fixed-step Update(realStep) |
_Process(delta) with Engine.TimeScale != 1 |
Related
- Godot delta contract —
Engine.TimeScale, fixed-step, single owner of sim speed - Time Precision — Why int64 microseconds, float conversion
- Custom Time Scale — TimeScale configuration
- Events — Event patterns for time reactivity
- Saving & Loading — Serialization patterns