Calendar: convert ticks to an in-game date, and fix the units of time #87

Closed
opened 2026-09-12 08:21:00 +01:00 by liamjd · 1 comment
Owner

#65 gives us a monotonic tick counter. Everything the design needs from time beyond that — day/night, eclipses, tidal peaks, seasons, and the forecast the player buys sensor tiers to see — is a pure function of GameClock.now. This issue adds that function and the constants it reads.

Nothing here needs new state, and advance() does not change. It is deliberately filed ahead of the threat system because the threat timeline cannot be designed until the units exist.

Proposed constants

Layer Constant Nature
real seconds -> ticks ticks_per_second = 10 engineering: simulation fidelity, invisible to design
ticks -> in-game days TICKS_PER_DAY = 1200 design: pacing
in-game days -> real minutes derived TICKS_PER_DAY / (tps * speed)

These two must be independent constants. Defining TICKS_PER_DAY = 120 * ticks_per_second means that tuning tps later for pawn-movement smoothness silently rewrites every authored duration in the game.

Day divided into 24 hours of 50 ticks. 1200 divides by 2, 3, 4, 6, 8, 12 and 24, which matters when placing eclipse windows on fractions of a day.

What that buys:

ticks at 1x at 3x
hour 50 5 s 1.7 s
day 1,200 2 min 40 s
tidal cycle (~14 d) 16,800 28 min 9 min
60-day contract 72,000 2 h 40 min

Seasons cannot fit inside a run

Per docs/colony-builder-ideas.md, the long cycle is the gas giant's eccentric orbit around the star — years, not days. Against a ~60-day contract the player experiences a slice of a season and never a whole one.

But for gameplay reasons, I am willing to compress this - suspension of disbelief and all that.

So the calendar must be queried at an absolute moon-date the run is seeded into ("you land in late winter"), not counted from tick 0 of the run. A run therefore has both a run-relative day number (for the contract deadline and the UI) and an absolute date (for the threat timeline). Getting this wrong is cheap now and expensive after the forecast UI exists.

AC

  • A GameDate (or equivalent) that converts now -> day / hour / phase, and back: ticks_until(day) for the forecast query
  • A basic UI to show this on the screen (top-right corner probably)
  • Run start seeds an absolute moon-date; run-relative day and absolute date are both available and clearly distinguished
  • ticks_per_second becomes a const (or is settable only before the first tick) — a mid-run change invalidates every computed forecast and desyncs a replay
  • GameClock.reset() zeroes _now and _accumulator, satisfying #65's "starts at 0 on a new run": the autoload outlives a run, so landing a second contract currently starts on day 400
  • Unit tests: tick <-> date round-trips at boundaries (tick 0, last tick of a day, exact day boundary), and ticks_until agreeing with the forward conversion
  • Constants documented in docs/, since they are pacing decisions and not implementation details

Not in scope: rendering a day/night cycle, the threat timeline itself, and sensor-tier lead time. This is the substrate all three need.

Depends on #65. Blocks the build_time unit issue, which needs TICKS_PER_DAY.

#65 gives us a monotonic tick counter. Everything the design needs from time beyond that — day/night, eclipses, tidal peaks, seasons, and the forecast the player buys sensor tiers to see — is a **pure function of `GameClock.now`**. This issue adds that function and the constants it reads. Nothing here needs new state, and `advance()` does not change. It is deliberately filed ahead of the threat system because the threat timeline cannot be designed until the units exist. ## Proposed constants | Layer | Constant | Nature | |---|---|---| | real seconds -> ticks | `ticks_per_second` = 10 | engineering: simulation fidelity, invisible to design | | ticks -> in-game days | `TICKS_PER_DAY` = 1200 | design: pacing | | in-game days -> real minutes | *derived* | `TICKS_PER_DAY / (tps * speed)` | **These two must be independent constants.** Defining `TICKS_PER_DAY = 120 * ticks_per_second` means that tuning tps later for pawn-movement smoothness silently rewrites every authored duration in the game. Day divided into 24 hours of 50 ticks. 1200 divides by 2, 3, 4, 6, 8, 12 and 24, which matters when placing eclipse windows on fractions of a day. What that buys: | | ticks | at 1x | at 3x | |---|---|---|---| | hour | 50 | 5 s | 1.7 s | | day | 1,200 | 2 min | 40 s | | tidal cycle (~14 d) | 16,800 | 28 min | 9 min | | 60-day contract | 72,000 | 2 h | 40 min | ## Seasons cannot fit inside a run Per `docs/colony-builder-ideas.md`, the long cycle is the gas giant's eccentric orbit around the star — years, not days. Against a ~60-day contract the player experiences a *slice* of a season and never a whole one. But for gameplay reasons, I am willing to compress this - suspension of disbelief and all that. So the calendar must be queried at an **absolute moon-date the run is seeded into** ("you land in late winter"), not counted from tick 0 of the run. A run therefore has both a run-relative day number (for the contract deadline and the UI) and an absolute date (for the threat timeline). Getting this wrong is cheap now and expensive after the forecast UI exists. ## AC - [x] A `GameDate` (or equivalent) that converts `now` -> day / hour / phase, and back: `ticks_until(day)` for the forecast query - [ ] A basic UI to show this on the screen (top-right corner probably) - [ ] Run start seeds an absolute moon-date; run-relative day and absolute date are both available and clearly distinguished - [x] `ticks_per_second` becomes a `const` (or is settable only before the first tick) — a mid-run change invalidates every computed forecast and desyncs a replay - [x] `GameClock.reset()` zeroes `_now` and `_accumulator`, satisfying #65's "starts at 0 on a new run": the autoload outlives a run, so landing a second contract currently starts on day 400 - [x] Unit tests: tick <-> date round-trips at boundaries (tick 0, last tick of a day, exact day boundary), and `ticks_until` agreeing with the forward conversion - [x] Constants documented in `docs/`, since they are pacing decisions and not implementation details Not in scope: rendering a day/night cycle, the threat timeline itself, and sensor-tier lead time. This is the substrate all three need. Depends on #65. Blocks the `build_time` unit issue, which needs `TICKS_PER_DAY`.
Author
Owner

Decisions from the design conversation

Recorded here rather than rewritten into the body above, so the original reasoning stays legible. Everything below refines the AC; none of it removes one.

The calendar is the orbital mechanics, and a contract is six orbits

A contract is ~6 in-game months and ~2 hours of real play, with 4-6 threat events inside it. That only reconciles with the 2-hour figure already in this issue if a month is 10 days — which is the physically correct answer rather than a fudge, because on a moon the "month" is the orbital period around the gas giant.

Unit Physical meaning Length Real time @1x Drives
hour 1/24 rotation 50 ticks 5 s placing event windows; never displayed
day moon's rotation 1,200 ticks 2 min day/night, solar power
month moon's orbit around the giant 10 days 20 min eclipse, tidal quakes
season - 3 months 1 h cold snap, storm weighting
year giant's orbit around the star 12 months (120 d) 4 h the eccentric long cycle

A 60-day contract is then exactly 72,000 ticks = 2 hours at 1x, and spans two seasons, so "you land in late winter" is an arc the player actually experiences rather than a label. One significant event per orbit gives 6 per contract, the top of the 4-6 range, with no random director needed.

4 seasons x 3 months x 10 days divides evenly in both directions, which is what keeps ticks_until exact.

The calendar is a projection, never an accumulator

All arithmetic happens in ticks. GameDate is produced by date_at(tick) and is read-only. There is deliberately no "advance this date by a day" function, because a second code path for time is what makes round-trips drift. This is what makes the round-trip AC provable rather than approximately true.

One crossing point between run-relative and absolute

GDScript cannot give absolute ticks and run-relative ticks distinct types - both are int, and the compiler will happily let them be mixed. The substitute: the addition run_epoch_tick + GameClock.now appears exactly once, in a named accessor on GameState, and no other file writes that +. Calendar functions take an absolute tick and say so in the parameter name.

run_epoch_tick lives on GameState beside run_seed (it is run configuration, seeded deterministically), so GameClock never learns the calendar exists and stays a pure tick source.

Constants live with the calendar, not the clock

TICKS_PER_DAY moves off GameClock to GameCalendar (common/game_calendar.gd). Deriving TICKS_PER_DAY from TICKS_PER_HOUR is fine and is not what this issue warned against - both are design constants. The prohibition is only on reaching across to ticks_per_second, which is engineering.

Two pacing knobs, two jobs: in-game durations are retuned via the calendar constants, real-world pace via ticks_per_second / speed. Neither disturbs the other.

The HUD shows contract progress, not a date

Top-right shows "Month 3 of 6" plus a day/night indicator, framing time as "how much of my contract is left" - the decision the player is actually making. Minutes are not tracked or displayed at all; days and hours exist in the model but not on screen. The absolute moon-date stays available through the API for the threat timeline, satisfying "both available and clearly distinguished" structurally rather than visually.

Two gaps this opens, neither previously in scope here:

  • "Month 3 of 6" needs a contract length, and there is no Contract concept yet - needs GameState.contract_days = 60 as a named placeholder pointing at the future contract resource.
  • What the widget shows at 7 of 6 needs deciding; the design explicitly allows running over.

Already satisfied by #65

  • reset() zeroes _now and _accumulator - globals/GameClock.gd:49
  • ticks_per_second is settable only before the first tick - globals/GameClock.gd:8

One wrinkle worth knowing: that guard tests now > 0, and reset() puts now back to 0, so tps becomes mutable again between runs. That is probably the desired behaviour (a new run is a new forecast) but it is currently accidental rather than stated.

Knock-on for #88

TICKS_PER_DAY moves to GameCalendar, so #88's conversion reads GameCalendar.TICKS_PER_DAY rather than GameClock.TICKS_PER_DAY. #88's own reasoning - effort denominated in pawn-ticks, authored as build_days: float - stands unchanged and is the right call; see the comment there for what the 10-day orbit does to those numbers.

## Decisions from the design conversation Recorded here rather than rewritten into the body above, so the original reasoning stays legible. Everything below refines the AC; none of it removes one. ### The calendar is the orbital mechanics, and a contract is six orbits A contract is ~6 in-game months and ~2 hours of real play, with 4-6 threat events inside it. That only reconciles with the 2-hour figure already in this issue if a month is **10 days** — which is the physically correct answer rather than a fudge, because on a moon the "month" *is* the orbital period around the gas giant. | Unit | Physical meaning | Length | Real time @1x | Drives | |---|---|---|---|---| | hour | 1/24 rotation | 50 ticks | 5 s | placing event windows; never displayed | | day | moon's rotation | 1,200 ticks | 2 min | day/night, solar power | | month | moon's orbit around the giant | 10 days | 20 min | eclipse, tidal quakes | | season | - | 3 months | 1 h | cold snap, storm weighting | | year | giant's orbit around the star | 12 months (120 d) | 4 h | the eccentric long cycle | A 60-day contract is then exactly 72,000 ticks = 2 hours at 1x, and spans two seasons, so "you land in late winter" is an arc the player actually experiences rather than a label. One significant event per orbit gives 6 per contract, the top of the 4-6 range, with no random director needed. 4 seasons x 3 months x 10 days divides evenly in both directions, which is what keeps `ticks_until` exact. ### The calendar is a projection, never an accumulator **All arithmetic happens in ticks. `GameDate` is produced by `date_at(tick)` and is read-only.** There is deliberately no "advance this date by a day" function, because a second code path for time is what makes round-trips drift. This is what makes the round-trip AC provable rather than approximately true. ### One crossing point between run-relative and absolute GDScript cannot give absolute ticks and run-relative ticks distinct types - both are `int`, and the compiler will happily let them be mixed. The substitute: the addition `run_epoch_tick + GameClock.now` appears **exactly once**, in a named accessor on `GameState`, and no other file writes that `+`. Calendar functions take an absolute tick and say so in the parameter name. `run_epoch_tick` lives on `GameState` beside `run_seed` (it is run configuration, seeded deterministically), so `GameClock` never learns the calendar exists and stays a pure tick source. ### Constants live with the calendar, not the clock `TICKS_PER_DAY` moves off `GameClock` to `GameCalendar` (`common/game_calendar.gd`). Deriving `TICKS_PER_DAY` from `TICKS_PER_HOUR` is fine and is not what this issue warned against - both are design constants. The prohibition is only on reaching across to `ticks_per_second`, which is engineering. Two pacing knobs, two jobs: in-game durations are retuned via the calendar constants, real-world pace via `ticks_per_second` / `speed`. Neither disturbs the other. ### The HUD shows contract progress, not a date Top-right shows **"Month 3 of 6" plus a day/night indicator**, framing time as "how much of my contract is left" - the decision the player is actually making. Minutes are not tracked or displayed at all; days and hours exist in the model but not on screen. The absolute moon-date stays available through the API for the threat timeline, satisfying "both available and clearly distinguished" structurally rather than visually. Two gaps this opens, neither previously in scope here: - "Month 3 of 6" needs a contract length, and there is no Contract concept yet - needs `GameState.contract_days = 60` as a named placeholder pointing at the future contract resource. - What the widget shows at **7 of 6** needs deciding; the design explicitly allows running over. ### Already satisfied by #65 - `reset()` zeroes `_now` and `_accumulator` - `globals/GameClock.gd:49` - `ticks_per_second` is settable only before the first tick - `globals/GameClock.gd:8` One wrinkle worth knowing: that guard tests `now > 0`, and `reset()` puts `now` back to 0, so tps becomes mutable again between runs. That is probably the desired behaviour (a new run is a new forecast) but it is currently accidental rather than stated. ### Knock-on for #88 `TICKS_PER_DAY` moves to `GameCalendar`, so #88's conversion reads `GameCalendar.TICKS_PER_DAY` rather than `GameClock.TICKS_PER_DAY`. #88's own reasoning - effort denominated in pawn-ticks, authored as `build_days: float` - stands unchanged and is the right call; see the comment there for what the 10-day orbit does to those numbers.
Sign in to join this conversation.
No milestone
No project
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#87
No description provided.