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 ElapsedGameSeconds or CurrentDateTime — never re-convert from AccumulatedRealMicroseconds.

// 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

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}";
    }
}

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