Skip to content

Calendar Time v2.1.0

Game Facts Driven by Clock Time

Use this pattern when a gameplay system should respond to the clock's authoritative time-of-day rather than wall-clock time, per-action timers, or a coarse half-day heuristic. It turns "the clock expo

Status
Current
Version
v2.1.0
Source updated
2026-08-09
Generated on
2026-09-01

Use this pattern when a gameplay system should respond to the clock's authoritative time-of-day rather than wall-clock time, per-action timers, or a coarse half-day heuristic. It turns "the clock exposes time" into "the clock drives observable simulation behavior."

Typical case: an AI planner that only plans a night action when the world is actually dark. Or an event system that only rolls night encounters after dusk.

If the only consumer is a visual tint or a once-per-day rollover, prefer the lighting rig or signal_bus.date_changed guides instead. This pattern is for game logic that needs a fact, not a signal.

When to use this pattern

  • Your system already reasons in facts/flags, and "it is night" is one of them.
  • You want that fact to come from one authoritative clock, not from each consumer re-deriving "night" independently (which drifts).
  • You need the fact during planning/decisions, not just for a UI tween.

When NOT to use this pattern

  • The consumer is presentation-only (use Godot delta; see Clock-Driven Animation Pattern).
  • The consumer can subscribe to a clock signal directly (clock.signal_bus TOD / date_changed events) — signals are preferred over polling when an event exists.

Authority rule

When Calendar Time is present and healthy, the clock owns temporal truth. Game-local fallback inference must never override it.

Concretely: read the authoritative time-of-day first. Only fall back to a game-local heuristic when the authoritative stage is unavailable (plugin absent, clock unassigned, or no DayNightCycleService bound). The fallback must be strictly weaker/secondary so the clock always wins once it can speak.

Who owns what

  • The plugin owns the stage. The clock reports the current TOD stage. It is objective and save/load-stable.
  • The game owns the fact. Whether a Dusk instant counts as "night" is game policy. It can differ per system (sleep, encounters, visibility). The fact source below is a game-owned mapping. It is never authored by the plugin. In Thistletide it lives in src/bridges/calendar_time/calendar_time_goap_facts.gd. That file belongs to the game, not to the addon.

Recipe

A game-local fact source derives facts only through the plugin's public surface — it never reads private clock state. It is then injected additively into whatever system consumes facts (a GOAP bridge, an AI blackboard, an event gate). The consumer appends the source's facts to its own; existing facts are never removed, so omitting the source at runtime is a no-op.

The canonical reference is Thistletide's CalendarTimeGoapFacts (src/bridges/calendar_time/), adapted below.

1. A minimal, copyable fact source

class_name GameTimeFactSource
extends RefCounted

# Game-local fact keys. `is_night` already exists on the consumer's action
# table; the finer stage facts are additive, observation-level facts.
const FACT_IS_NIGHT := "is_night"
const FACT_IS_DAWN  := "is_dawn"
const FACT_IS_DAY   := "is_day"

# Authoritative source: a GameClock whose DayNightCycleService reports TOD.
var _clock :GameClock
# Optional game-local fallback used only when the clock reports no TOD stage
# (e.g. plugin absent in a headless run). A Callable keeps this snippet free
# of any game-specific heuristic type — each game supplies its own.
var _fallback_is_night :Callable = Callable()

# `clock` is the game clock. `fallback_is_night` is an OPTIONAL zero-arg
# predicate returning true when your game's own heuristic says "night".
func configure(clock :GameClock, fallback_is_night :Callable = Callable())-> void:
    _clock= clock
    _fallback_is_night= fallback_is_night

func is_night()-> bool:
    return get_facts().has(FACT_IS_NIGHT)

# Deterministic facts for the current authoritative instant. Called each
# planning/evaluation tick; cheap and side-effect free.
func get_facts()-> PackedStringArray:
    var facts :PackedStringArray = []
    # Authoritative path: the plugin's DayNightCycleService classifies the
    # current instant by the calendar's dawn/day/dusk/night schema. Reads the
    # public TOD name only (duck-typed: works with a raw GameClock or an
    # adapter that exposes get_time_of_day()).
    if _clock!= null and _clock.has_method("get_time_of_day"):
        var tod :String = str(_clock.get_time_of_day())
        match tod:
            "Night","Dusk":
                facts.append(FACT_IS_NIGHT)
            "Dawn":
                facts.append(FACT_IS_DAWN)
            "Daytime":
                facts.append(FACT_IS_DAY)
        # Authority rule: if the clock spoke, trust it.
        return facts
    # Fallback: plugin TOD unavailable (e.g. plugin absent in a headless run)
    # -> the game-local heuristic. Never overrides the authoritative clock.
    if not _fallback_is_night.is_null()and _fallback_is_night.call():
        facts.append(FACT_IS_NIGHT)
    return facts

2. Inject the facts additively

In the consumer (e.g. the system that builds the start-fact list for your planner), append the source's facts and let existing facts win:

func _build_facts():
    var facts :PackedStringArray = []
    # ... your game's own survival/perception facts ...
    # Authoritative calendar_time facts. Additive only: a night/dawn/dusk/day
    # fact is appended only if the game hasn't already built it, so existing
    # facts are untouched and omitting the source at runtime is a no-op.
    if _time_fact_source!= null and _time_fact_source.has_method("get_facts"):
        for fin _time_fact_source.get_facts():
            if not facts.has(f):
                facts.append(f)
    return facts

3. Let a single fact gate a night-only behavior

Register a behavior whose precondition is the night fact, so the action is simply not planable during day:

# In your action/goal table:
#   id        = "sleep_at_night"
#   pre       = [FACT_TIRED, FACT_IS_NIGHT]
#   effect    = [FACT_RESTED]
#   in_place  = true

The fact is additive and only present when the clock reports night (or the fallback heuristic fires). So the action is inert by day and reachable at night — no if is_night() branches scattered through the behavior. The planner's goal selection can still return FACT_RESTED from a tired fact; only the path to that goal changes with the clock.

In a BFS planner that selects the first applicable action, register the night action before the generic fallback so it wins at night. Its FACT_IS_NIGHT precondition never holds by day, so day planning is unchanged.

Save/load and determinism

  • Do not serialize the fact. Facts are derived from the clock every tick, so a saved is_night flag would drift. Restore the clock (see Examples) and re-derive.
  • For save/load-safe, frame-exact snapshots, prefer CalendarTimeSemanticState.from_clock(clock) — a stable, schema-versioned read that is the canonical consumer surface for "what time is it right now."

See also