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, andnowis 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.0is 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 GgMcwrwWABuCsQMFUQctSeason 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 dawnOver 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 240Pausing 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/worldsis not paginated. The handler ignorespageandpage_sizeand always returns the firstALMANAC_LIST_PAGE_SIZE(50) worlds, reportingpage_size: 50whatever 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) answered403 insufficient_permissionsfor them. user_local_tzis accepted but inert.nowreturned an identical virtual time and identical active windows forPacific/AucklandandAmerica/Los_Angeles, and a nonsense zone still returned200. The same holds forweather.geo_indexand a window schedule'sanchor: "user_local_midnight",per_user_phase_offsetandmax_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