Use high-resolution time¶
Use Ludus::FoundationTime for CPU elapsed-time measurements, real-time timeouts,
and explicit frame samples. The module supplies allocation-free, exception-free
values and noexcept operations. Each timer belongs to its caller; there is no
registration, callback service or initialization step.
Add time to a game¶
In an SDK-consuming CMake project, link the target to your existing executable:
find_package(Ludus CONFIG REQUIRED)
target_link_libraries(your_game PRIVATE Ludus::FoundationTime)
Replace your_game with your application's existing target. Use the project's
selected SDK and C++23 setup; see project setup. Include
time.hpp when naming timestamps/arithmetic and timers.hpp when naming timers.
These headers are explicit opt-ins, outside Base's core.h.
| Your task | Use | Ownership and units |
|---|---|---|
| Measure CPU work | Now(), TryElapsed, ToSeconds |
Same-domain Timestamp readings; integer Duration nanoseconds |
| Accumulate active time across pauses | Stopwatch |
Owner-local state and explicitly supplied timestamps |
| Poll a real-time timeout | Deadline |
Owner-local armed flag; checked timestamp plus duration |
| Sample a presentation frame | FrameClock |
Persistent application-owned baseline and chosen maximum delta |
| Gameplay cooldown or authoritative schedule | Game-owned simulation tick deadlines | Advances with successful simulation ticks, including pause/single-step policy |
Timestamp and Duration are distinct types. Zero is valid in both. Nanoseconds
are the storage unit, not a physical resolution or wakeup guarantee. Supported
native timing uses a steady clock; the browser path uses the pinned Emscripten
clock, whose precision can be reduced by browser policy. Logging and profiling
share this source while preserving their existing timestamp units.
Subtract before converting¶
Capture timing::Now() before and after the work being measured. Subtract those
integer readings with TryElapsed, then convert the resulting interval. Avoid
converting two large absolute timestamps to floating-point seconds before
subtracting: a short interval can disappear at long uptime.
This complete helper returns the reason for failure and preserves seconds on
failure:
#include <ludus/foundation/base/types.h>
#include <ludus/foundation/time/time.hpp>
namespace timing = ludus::foundation::time;
timing::TimeStatus
TryElapsedSeconds(timing::Timestamp begin, timing::Timestamp end, ludus::foundation::float64& seconds) noexcept
{
timing::Duration interval;
const auto status = timing::TryElapsed(begin, end, interval);
if (status != timing::TimeStatus::Ok)
{
return status;
}
seconds = timing::ToSeconds(interval);
return timing::TimeStatus::Ok;
}
Pause a stopwatch¶
Start begins a new interval and discards earlier history. Pause accumulates
active time; Resume excludes the paused gap. Read observes without advancing
state, and Reset stops the watch. Pausing a gameplay watch does not pause a UI
watch or stop the underlying clock.
These synthetic readings need no sleep: two active seconds, a 90-second pause, and three more active seconds produce five seconds of accumulated time.
#include <ludus/foundation/time/time.hpp>
#include <ludus/foundation/time/timers.hpp>
namespace timing = ludus::foundation::time;
bool ExamplePausedStopwatch() noexcept
{
timing::Stopwatch watch;
watch.Start(timing::Timestamp{1'000'000'000});
if (watch.Pause(timing::Timestamp{3'000'000'000}) != timing::TimeStatus::Ok ||
watch.Resume(timing::Timestamp{93'000'000'000}) != timing::TimeStatus::Ok)
{
return false;
}
timing::Duration elapsed;
return watch.Read(timing::Timestamp{96'000'000'000}, elapsed) == timing::TimeStatus::Ok &&
elapsed.Nanoseconds == 5'000'000'000;
}
In a live update, supply a sample from Now() instead. Share one sample among
related timer consumers. A paused read returns frozen time; a running read checks
against the latest transition, not the previous observational read. Callers
remain responsible for a monotonic sequence from one clock domain.
Arm and poll a timeout¶
Deadline executes no work itself. The caller polls it and owns the operation,
cancellation and delivery policy. Equality is due; expiration keeps it armed.
Explicitly cancel or rearm after handling the timeout if it should fire once.
Zero delay is immediately due, and failed rearming preserves the old deadline.
#include <ludus/foundation/time/time.hpp>
#include <ludus/foundation/time/timers.hpp>
namespace timing = ludus::foundation::time;
bool ExampleDeadline() noexcept
{
timing::Deadline timeout;
if (timeout.Arm(timing::Timestamp{1'000'000'000}, timing::Duration{500'000'000}) != timing::TimeStatus::Ok)
{
return false;
}
if (timeout.IsExpired(timing::Timestamp{1'499'999'999}) || !timeout.IsExpired(timing::Timestamp{1'500'000'000}))
{
return false;
}
timeout.Cancel();
return !timeout.IsArmed() && !timeout.IsExpired(timing::Timestamp{2'000'000'000});
}
Use simulation ticks for a cooldown that should stop during game pause. A real-time deadline keeps advancing when simulation stops.
Keep frame stalls visible¶
Keep one FrameClock across frames and sample it once at the presentation
boundary. Pass the returned value to all consumers. The first sample establishes
a baseline with zero elapsed time. This example deliberately chooses a 250 ms
application cap; FoundationTime has no global frame cap.
#include <ludus/foundation/time/time.hpp>
#include <ludus/foundation/time/timers.hpp>
namespace timing = ludus::foundation::time;
bool ExampleFrameStall() noexcept
{
timing::FrameClock frames{timing::Duration{250'000'000}};
if (!frames.Sample(timing::Timestamp{1'000'000'000}).IsBaseline)
{
return false;
}
const auto stall = frames.Sample(timing::Timestamp{2'000'000'000});
if (stall.Status != timing::TimeStatus::Ok || stall.Elapsed.Nanoseconds != 250'000'000 ||
stall.Discarded.Nanoseconds != 750'000'000)
{
return false;
}
const auto next = frames.Sample(timing::Timestamp{2'010'000'000});
return next.Status == timing::TimeStatus::Ok && next.Elapsed.Nanoseconds == 10'000'000 &&
next.Discarded.Nanoseconds == 0;
}
For a successful sample, raw elapsed time is accepted Elapsed plus Discarded.
The example reports the full one-second stall, accepts 250 ms and drops 750 ms;
the dropped time does not reappear as debt in the next frame. Preserve both
fields for diagnostics. Profiling scopes retain raw work measurements.
Call Reset() at startup, pause/resume, visibility suspension and session
replacement, according to application policy. Reset establishes a new baseline
on the next sample. A zero maximum delta deliberately freezes accepted time.
When adopting this helper into an existing simulation driver, preserve its
existing discard accounting and apply the frame clamp once.
Read the timestamp once at the callback boundary, apply that callback's lifecycle
resets, then sample. A browser stops animation callbacks for a hidden page, so a
hidden frame may never arrive: reset on the platform's queued suspension signal
instead (Ludus browser windows queue InputReset when hidden or blurred). Keep a
high-water mark across resets if regressed samples are possible; otherwise the
first sample after Reset() becomes a baseline below an earlier reading. The
world reference driver
shows these rules with accepted, clamped and excluded totals.
Pace a native frame loop¶
Ludus::PlatformPacing waits for the next frame in a native loop. Link it with
target_link_libraries(your_game PRIVATE Ludus::PlatformPacing) and include
ludus/platform/frame_pacing.h. Configure a FramePacer once, then call Pace()
after each frame, before the next frame reads its timestamp:
#include <ludus/platform/frame_pacing.h>
namespace platform = ludus::platform;
bool RunCappedLoop(bool (*runFrame)()) noexcept
{
platform::FramePacer pacer;
// 30 frames per second; a frame more than four intervals late re-anchors the schedule.
if (pacer.Configure({ .Interval = {33'333'333}, .MaximumLateness = {133'333'332} }) != platform::PacingStatus::Ok)
{
return false;
}
const platform::PacingClock clock = platform::GetSystemPacingClock();
while (runFrame() == true)
{
(void)pacer.Pace(clock);
}
return true;
}
The pacer keeps absolute targets. It rechecks the clock after every early or
interrupted wake, and advances each target from the previous one, so oversleeping
does not accumulate. A late frame starts at once; beyond the maximum lateness the
schedule re-anchors rather than running missed frames back to back. Call Reset()
at startup, session activation and resume from suspension. Simulation pause is
not a pacing event.
Pacing does not replace the swapchain. Native backends already block in
presentation at the display rate, so an uncapped game may only need to pace frames
that present nothing, as the world reference does.
A cap that does not divide the display refresh rate produces uneven frame
intervals. Browser builds must not pace: their wait reports Unsupported, and
presentation callbacks already pace the page. The pacer never spins, and a wake
can still be late under load; see the
architecture and
measurements.
Tests pass a PacingClock whose wait advances synthetic time instead of sleeping.
Inspect frame timing in the editor¶
While a native Play session runs, open the Timing tab beside Live Inspector (or View > Timing). The game host measures its own loop and reports:
| Row | What the host measured |
|---|---|
| Pacing target | Its frame-start schedule: 60 Hz, a 16.667 ms interval |
| Frame interval | Minimum, mean and maximum time between frame starts over the last 120 measured frames |
| Wake lateness | How long after each target the frame actually started |
| Pacing outcomes | Frames that waited, started late, were rescheduled after a stall, or started early because a wait failed |
| Skipped targets | Missed targets dropped instead of run back to back, and early operating-system wakes |
| Frame time | Total accepted frame time and the time beyond the 250 ms clamp |
| Frames, Clock | Frame and simulation tick counts; frame-clock baselines and clock regressions |
A steady game shows a mean interval at the target and small lateness. A maximum
far above the mean is a hitch; a Rescheduled count means the host abandoned its
schedule after one. A committed reload counts a new baseline instead of a long frame; a rejected one shows its stall.
These are whole-session diagnostics for inspection; repeat controlled runs before
comparing builds.
The panel asks for one report per second only while it is visible. Every value is
an interval or count computed by the host, so readings from the two processes'
clocks are never subtracted. The world reference application keeps its own
--pacing-report; it is not an editor-hosted session. See the
telemetry contract.
Handle failures and debug surprising readings¶
| Result or symptom | Meaning and recovery |
|---|---|
Overflow |
Addition cannot fit. TryAdd preserves its output; Deadline::Arm preserves its previous state. Reject the interval or choose a bounded policy; do not wrap or assume rearming succeeded. |
ClockRegression |
A supplied end/transition precedes its baseline. Checked arithmetic and stopwatch failures preserve outputs/state. FrameClock returns zero with this status and keeps its high-water mark, avoiding duplicate recovery time. Check sample ordering and ownership before resetting. |
InvalidState |
A stopwatch is stopped when read, or a pause/resume transition is invalid. Inspect GetState(); Start is a new interval, not a substitute for resume. |
| Equal readings | Valid zero elapsed time. Actual clock granularity can be coarser than the storage unit. |
| Unexpected catch-up after a load or hidden tab | Audit lifecycle resets, accepted/discarded time and the simulation debt policy. The clock backend alone does not decide whether suspension should advance gameplay. |
These values require caller synchronization if shared between threads. Timestamps establish no happens-before relationship. Do not compare them with UTC, simulation ticks, GPU counters, audio sample positions or another machine's clock. The editor and runtime are separate processes: exchange runtime-reported intervals/status through their protocol rather than subtracting editor samples from host samples. Clock epochs are not persistent save/replay identities.
What ships and what follows¶
| Area | Status |
|---|---|
| Shared native/browser clock, checked arithmetic and owner-local helpers | Implemented; explicit public SDK component |
| Logging/profiling clock producers | Use the shared source |
| Frame-loop migration | Implemented in the world reference and smoke applications; one FrameClock clamp per loop |
| Rational integer fixed-step driver | Implemented in the world reference TickDriver: checked uint64 phase, exact tick accounting, separate clamp and catch-up loss |
| Native frame pacing | Implemented: Ludus::PlatformPacing FramePacer and system wait; the world reference paces idle frames, hidden windows at 10 Hz and an optional cap |
| Bounded gameplay event queue | Planned separate integration |
| Editor Timing view and runtime timing telemetry | Implemented: GameHost paces its play loop and reports bounded intervals/status on Status; a read-only Timing panel shows them. No timer controls ship |
| Time scaling, additional platform/worker domains, GPU/audio/network clock integration | Separate requirements and validation needed |
Existing Qt timers serve editor event-loop scheduling. A measurement API does not replace that scheduling role, and the native pacer does not guarantee a precise wakeup.
Sources and further reading¶
The architecture and Gems review, ADR 0017, and validation evidence record the adopted ideas, remaining scope and measurement limits. Thanks to Noel Llopis, The Clock: Keeping Your Finger on the Pulse of the Game, Game Programming Gems 4, §1.3, pp. 27–34, for independent pause state and shared frame observations. The same review credits Michael Harvey and Carl S. Marshall, Scheduling Game Events, Game Programming Gems 3, §1.1, pp. 5–14, and John Bolton, A Basic Scheduler, Game Engine Gems 1, chapter 25, pp. 409–414, for ideas informing the planned scheduling contract. Historical chapter code was not copied; local reference PDFs are outside the public wiki.
See the time API, timer API, and boundary tests for exact implementation behavior.