Add a GameClock autoload with pausable, speed-scaled ticks #65

Closed
opened 2026-09-07 20:37:16 +01:00 by liamjd · 2 comments
Owner

Part of milestone #5. Nothing in the game ticks yet, so task work and idle-pawn polling have nothing to run on, and BuildingType.build_time is an int with no unit.

Add a GameClock autoload that emits a tick signal at a configurable simulation rate, independent of frame rate.

AC:

  • tick(tick_number: int) signal emitted at a configurable ticks-per-second rate, accumulated from _process delta so it is frame-rate independent
  • paused property stops ticks; speed multiplier (1x, 2x, 3x) scales the rate
  • now (current tick count) readable; monotonic; starts at 0 on a new run
  • Opening the main menu pauses the clock (hook GameState.main_menu_changed)
  • Unit tests: advancing by a delta produces the expected number of ticks, pause emits none, speed scales the count
  • Decide and document the unit of build_time as ticks

Not in scope: a calendar/day cycle. The threat forecast timeline will want one later and can derive it from ticks.

Part of milestone #5. Nothing in the game ticks yet, so task work and idle-pawn polling have nothing to run on, and `BuildingType.build_time` is an int with no unit. Add a `GameClock` autoload that emits a `tick` signal at a configurable simulation rate, independent of frame rate. AC: - `tick(tick_number: int)` signal emitted at a configurable ticks-per-second rate, accumulated from `_process` delta so it is frame-rate independent - `paused` property stops ticks; `speed` multiplier (1x, 2x, 3x) scales the rate - `now` (current tick count) readable; monotonic; starts at 0 on a new run - Opening the main menu pauses the clock (hook `GameState.main_menu_changed`) - Unit tests: advancing by a delta produces the expected number of ticks, pause emits none, speed scales the count - Decide and document the unit of `build_time` as ticks Not in scope: a calendar/day cycle. The threat forecast timeline will want one later and can derive it from ticks.
Author
Owner

Two things the spec deliberately doesn't decide

Re-entrancy. tick.emit() inside the loop hands control to arbitrary listeners mid-drain, and one of them may set paused = true (a task completes, triggering a game-over dialog). Your options are to let the loop finish the drain, or to make the condition while not paused and _accumulator >= 1.0. The second is more consistent with the accumulator rule you chose — the undelivered ticks stay banked and fire on resume. No test pins it yet; if you pick one, it wants a test.

Float drift. This is the one that may bite you. test_the_tick_count_does_not_depend_on_frame_rate advances 2.0 seconds in 300 steps, which lands exactly on a tick boundary after 300 roundings — the worst case for accumulated error. If it comes out 19 instead of 20, don't reach for an epsilon in the comparison; that hides genuine drift. Nudge the test off the boundary instead (2.05 seconds, asserting 20). I set that boundary up when writing the spec and it's the one number in the file I'd expect to move.

Two things the spec deliberately doesn't decide Re-entrancy. tick.emit() inside the loop hands control to arbitrary listeners mid-drain, and one of them may set paused = true (a task completes, triggering a game-over dialog). Your options are to let the loop finish the drain, or to make the condition while not paused and _accumulator >= 1.0. The second is more consistent with the accumulator rule you chose — the undelivered ticks stay banked and fire on resume. No test pins it yet; if you pick one, it wants a test. Float drift. This is the one that may bite you. test_the_tick_count_does_not_depend_on_frame_rate advances 2.0 seconds in 300 steps, which lands exactly on a tick boundary after 300 roundings — the worst case for accumulated error. If it comes out 19 instead of 20, don't reach for an epsilon in the comparison; that hides genuine drift. Nudge the test off the boundary instead (2.05 seconds, asserting 20). I set that boundary up when writing the spec and it's the one number in the file I'd expect to move.
Author
Owner

Two of this issue's AC items turned out to belong to the calendar rather than the clock, and have moved to #87:

  • "Decide and document the unit of build_time as ticks" — #66 had already answered this without naming it. Task.work(ctx, ticks) decrements work_remaining, so build effort is pawn-ticks (labour), not elapsed time. That makes it a data-model change across BuildingType and three .tres files, now #88.
  • "now starts at 0 on a new run" — GameClock is an autoload that outlives a run and nothing resets _now, so a second contract currently starts on day 400. The reset() is tracked in #87 alongside the other run-start state.

Also deferred to #87: making ticks_per_second a const. It is a mutable var today, and a mid-run change silently invalidates every computed forecast and desyncs a replay.

What #65 still owes: the re-entrancy decision (a listener that sets paused mid-drain should stop the drain — while not paused and _accumulator >= 1.0), which has a failing test against it in test/unit/test_game_clock.gd.

Two of this issue's AC items turned out to belong to the calendar rather than the clock, and have moved to #87: - **"Decide and document the unit of `build_time` as ticks"** — #66 had already answered this without naming it. `Task.work(ctx, ticks)` decrements `work_remaining`, so build effort is **pawn-ticks** (labour), not elapsed time. That makes it a data-model change across `BuildingType` and three `.tres` files, now #88. - **"`now` starts at 0 on a new run"** — `GameClock` is an autoload that outlives a run and nothing resets `_now`, so a second contract currently starts on day 400. The `reset()` is tracked in #87 alongside the other run-start state. Also deferred to #87: making `ticks_per_second` a `const`. It is a mutable `var` today, and a mid-run change silently invalidates every computed forecast and desyncs a replay. What #65 still owes: the re-entrancy decision (a listener that sets `paused` mid-drain should stop the drain — `while not paused and _accumulator >= 1.0`), which has a failing test against it in `test/unit/test_game_clock.gd`.
Sign in to join this conversation.
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
liamjd/UntitledColonyBuilder#65
No description provided.