ServicesAlmanac

Almanac

Shared simulated time. A world owns a virtual clock (a real epoch plus a dilation factor), a custom calendar of months, seasons and day phases, and a seeded weather model. Every client that asks the same world what time it is gets the same answer, and every client that asks about the same region on the same virtual day gets the same weather.

When to reach for it: MMO world time with synchronised night spawns, farming-sim seasons and festivals, storm fronts every client renders identically, cooldowns expressed in game time, and simulations such as Biome that need a clock they can pause.

When not to: per-user cooldowns and regenerating resources measured in real seconds belong to Chrono; running work at a real wall-clock instant belongs to Job Queue. Almanac has no per-user timer; it answers "what time is it in this world" for everybody at once.

Concepts

  • A world is the unit of time, owning one clock, one calendar and one weather model. Worlds are readable by every authenticated caller in the tenant; creating, deleting or steering one needs a platform admin token.
  • Time is computed, never stored. The clock keeps an append-only list of (effective_from, dilation) segments plus a pause ledger, and now is integrated from those on every read. Nothing ticks in the background, so changing the rate never rewrites history.
  • Dilation is virtual time per unit of real time - 24.0 is one virtual day per real hour. It must exceed zero: a frozen world is a paused world, not a zero-dilation one.
  • The calendar is yours: hours_per_day, days_per_month, the month names, the season spans over those months, and the day phases by start hour.
  • Weather is a pure function of (seed, virtual day, region), so there is no stored history to drift. Regions are free-form strings you invent.
  • Content windows are the calendar-driven half of the service and have their own page: Content windows.

Create a world

World creation takes a JSON definition and requires a platform admin token:

{
  "name": "eldoria",
  "dilation": 24.0,
  "calendar": {
    "hours_per_day": 24, "days_per_month": 30,
    "months": ["Frostwane", "Seedfall", "Sunhigh", "Emberfade"],
    "seasons": [{ "name": "Winter", "start_month": 0, "length_months": 1 },
                { "name": "Spring", "start_month": 1, "length_months": 1 }],
    "day_phases": [{ "name": "dawn", "start_hour": 5 }, { "name": "day", "start_hour": 8 },
                   { "name": "dusk", "start_hour": 18 }, { "name": "night", "start_hour": 21 }]
  },
  "weather": { "seed": 42, "model": "front_system_v1",
               "front_speed_kph": 30.0, "volatility": 0.4 }
}
snug almanac create --file ./eldoria.json
snug almanac list
snug almanac get    --world-id GgMcwrwWABuCsQMFUQct
snug almanac delete --world-id GgMcwrwWABuCsQMFUQct

Season month indices and phase start hours must fall inside the calendar they belong to: a season starting at month 9 of a four-month year, or a phase at hour 30 of a 24-hour day, is 400 invalid_calendar. Deleting a world takes its windows and transition history with it, after which every sub-route answers 404 world_not_found.

Ask a world what it is like right now

now is the hot path - one call returns the decoded virtual instant, the season, the regional weather, which content windows are open, and when each next flips.

snug almanac now         --world-id GgMcwrwWABuCsQMFUQct --region northreach
snug almanac clock now   --world-id GgMcwrwWABuCsQMFUQct   # clock only, plus raw virtual_ms
snug almanac until-phase --world-id GgMcwrwWABuCsQMFUQct --phase dawn

Over HTTP that is GET /api/v1/almanac/worlds/{world_id}/now:

curl -s -H "Authorization: Bearer $SNUG_API_TOKEN" \
  "http://localhost:4000/api/v1/almanac/worlds/GgMcwrwWABuCsQMFUQct/now?region=northreach"
{
  "status": 200,
  "msg": "OK",
  "data": {
    "world_id": "GgMcwrwWABuCsQMFUQct",
    "virtual_time": { "year": 0, "month": "Frostwane", "month_index": 0,
                      "day": 2, "hour": 4, "minute": 58, "phase": "night" },
    "season": "Winter",
    "weather": { "region": "northreach", "condition": "snow",
                 "intensity": 0.148, "front_bearing_deg": 306.0 },
    "active_windows": [{ "window_id": "yBdmQ...", "instance": 1, "ends_in_real_ms": 285347 }],
    "next_transitions": [{ "window_id": "yBdmQ...", "at_real": "2026-08-29T13:00:20.717654Z" }],
    "paused": false
  }
}

until-phase always counts to the next start of the named phase, so asking for the phase you are already in returns nearly a full virtual day, not zero. An undefined phase name is 400 invalid_request.

Steer the clock

All three clock commands require a platform admin token.

snug almanac clock pause  --world-id GgMcwrwWABuCsQMFUQct --reason "maintenance"
snug almanac clock resume --world-id GgMcwrwWABuCsQMFUQct
snug almanac clock set    --world-id GgMcwrwWABuCsQMFUQct --dilation 240

Pausing genuinely freezes virtual time - clock now returned the identical virtual_ms three real seconds apart - and pausing twice is 409 clock_already_paused. Resuming credits the frozen span to accumulated_pause_ms, counted in virtual milliseconds, and time picks up exactly where it stopped.

clock set appends a segment rather than replacing the clock. After raising dilation from 24 to 240 the world carried both segments - the original {"dilation": 24.0, "effective_from": "...12:46:47.799815Z"} alongside the new {"dilation": 240.0, "effective_from": "...12:48:30.696899Z"} - so everything before the boundary keeps its original rate and past timestamps never move. Pass --effective-from to schedule the change at a chosen real instant.

Weather

Weather resolves per virtual day, not per virtual hour. Forecast points are emitted hourly but hold their value across a whole virtual day - in a 96-hour forecast the condition changed only at offsets 0, 24 and 48.

snug almanac weather forecast  --world-id GgMcwrwWABuCsQMFUQct --region northreach --virtual-hours 48
snug almanac weather override  --world-id GgMcwrwWABuCsQMFUQct --file ./overrides.json
snug almanac weather overrides --world-id GgMcwrwWABuCsQMFUQct
{ "points": [ { "virtual_hour_offset": 0,  "condition": "rain", "intensity": 0.686 },
              { "virtual_hour_offset": 24, "condition": "snow", "intensity": 0.148 } ] }

Repeating a forecast returns a byte-identical body, and a different region string yields a different but equally stable timeline. The horizon clamps silently: virtual_hours=99999 returns 8760 points rather than an error.

Designers can pin conditions over that baseline with an admin-only override file, {"overrides": [{ "region": "northreach", "condition": "storm", "intensity": 0.9 }]}, optionally bounded by from_virtual_day / to_virtual_day. The request replaces the whole ordered list, so posting {"overrides": []} restores the canonical timeline. Verified: with that override in place now reported storm at 0.9 for northreach while southmoor still reported its baseline clear, and clearing the list brought northreach straight back to rain at 0.686.

Behaviours and gotchas

  • An hour before your first phase belongs to the last phase. With phases at 5/8/18/21, hour 0 decodes as night - phase lookup wraps around the day rather than failing.
  • Days are 1-indexed; years and hours are 0-indexed. A fresh world starts at year 0, day: 1, hour: 0.
  • GET /almanac/worlds is not paginated. The handler ignores page and page_size and always returns the first ALMANAC_LIST_PAGE_SIZE (50) worlds, reporting page_size: 50 whatever you ask for. Track world IDs yourself rather than relying on the listing.
  • Reads are tenant-wide. A second, non-admin user in the same tenant listed, fetched and snapshotted a world the admin created; every mutation (create, delete, pause, resume, clock set, window create, window preview, weather override) answered 403 insufficient_permissions for them.
  • user_local_tz is accepted but inert. now returned an identical virtual time and identical active windows for Pacific/Auckland and America/Los_Angeles, and a nonsense zone still returned 200. The same holds for weather.geo_index and a window schedule's anchor: "user_local_midnight", per_user_phase_offset and max_offset_changes_per_30d. Do not build timezone fairness on them.

Limits and configuration

Up to 1000 worlds, 200 windows per world, 64 months and 24 day phases per calendar, 64 weather overrides, an 8760 virtual-hour forecast horizon and a 100-instance preview cap - all tunable, along with the transition fanout worker's poll interval and scan limit, via the ALMANAC_* variables in the Almanac CONFIG reference.

Reference

  • Almanac API - every endpoint, callable
  • Content windows - rotations, preview-ahead and transitions
  • Related: Biome for simulations driven by a world clock, Chrono for per-user timers, Lottery for the weighted tables a rotation source names, PubSub for the channel a subscription points at

On this page