One quantum, one ladder, one place where real seconds exist.
Everything the world does — a march, a harvest, a lord's errand, the sun — is
measured in ticks. The tick is an integer, the clock is a single uint64,
and every unit above it is a whole number of the one below.
1 tick the world's quantum
64 ticks = 1 real second (2^6) the fixed simulation step
8192 ticks = 1 game day (2^13) = 128 real seconds
32 days = 1 season (2^5)
128 days = 1 year (2^7) = 2^20 ticks exactly
core/time.h owns all of it. macro/seasons.h owns the top two rungs.
The world used to run on four unrelated rhythms: the frame's dt, a float
minute accumulator, a 0.5-second AI cadence, and a daily queue budgeted per
frame. Four rhythms is four places to drift, a save that could not state the
time to better than a fraction of a minute, and a simulation whose outcome
depended on the frame rate of the machine that ran it. All four are gone —
each is now a whole number of ticks on the one ladder.
Now the frame earns whole steps and nothing else moves the world. The clock cannot drift because it never accumulates: it is a counter, and the calendar is read off it.
A day holds 1440 minutes and 1440 is not a power of two. It does not need to be, because the minute is derived, and on an 8192-tick day the derivation is exact integer arithmetic:
1440 / 8192 = 45 / 256 => minute = (t * 45) >> 8 // 0..1439
24 / 8192 = 3 / 1024 => hour = (t * 3) >> 10 // 0..23
Since floor(floor(a/b)/c) == floor(a/(b*c)), the hour derived directly and the
hour implied by minute / 60 are the same number for every tick that exists —
two readings of one instant cannot disagree. time_ladder_test walks all 8192
ticks of a day and proves it, along with: the display is gap-free (no minute is
ever skipped) and onto (every one of the 1440 is shown), and naming an hh:mm
round-trips back to the first tick that reads it.
The price, stated plainly: an hour is 341.33 ticks, so hour boundaries fall between ticks and a minute lasts 5 ticks or 6. That is 16 ms of real time, and it never accumulates.
Advancing by a duration aims at a target, not at a length. "Six minutes from
here" depends on where in the current minute you stand, so
ticks_to_advance_minutes(from, n) computes the first tick that reads n
minutes later. Ask for a rounded length instead and a clock standing a quarter
of the way into 08:00 can advance five minutes when six were paid for.
THE TICK IS PRIMARY. The world's time is the number of ticks that have RUN — a day is 8192 of them, a year is 2^20, and "128 real seconds" is only what that comes to on a machine keeping up. Ticks are born from the loop turning, never from the clock moving.
So one turn of the loop is one tick and one frame — the frame rate and the
world's tick rate are the same number. A low frame rate is therefore not a
choppier picture of a world moving at its usual pace, as it was under the old
variable-dt loop where a bigger dt covered the gap; it is the world itself
living slower, because fewer ticks happened.
The wall clock is consulted for exactly one purpose: if a turn finished quicker than a tick is worth, wait for the remainder, so the world can never run FASTER than nominal. It is never consulted to decide that ticks are owed. There is no accumulator and no debt.
loop turn: poll input
ONE fixed step of the world
draw once
if the turn was quicker than a tick, wait out the difference
The wait lands ON the beat, not near it. No OS sleep is exact, and one that
returns EARLY would start the next tick before its time — the world running
faster than nominal, which this rule forbids. So the loop sleeps almost all of
the remainder and spins out the last ~0.2 ms. The spin is a real cost (a busy
loop burns a core), which is why it is a sliver and not the whole millisecond
SDL_Delay's rounding would force: about 1 % of one core. If a sleep overshoots
anyway, the turn is simply late — being late is allowed, being early is not.
Everything follows from that without a single special case:
- a slow turn is just a slow turn — one tick, later. Nothing is lost.
- a machine that cannot sustain the rate runs at a lower frame rate and a slower world. It does not make the world live less.
- a suspended process — a closed laptop, an hour on a breakpoint — ran no turns and advanced no ticks. On resume it carries on. There is no gap to detect and nothing to catch up, because real time was never what produced ticks in the first place.
The present mode is a TIME decision, not a graphics one. The world advances
one tick per turn, so anything that gates how fast the loop may turn also gates
how fast the world lives. VK_PRESENT_MODE_FIFO_KHR blocks until the display's
refresh, which on a 60 Hz screen would hold the loop to 60 turns a second — the
world running 6 % slow because of the monitor, not because the game was busy.
That is the real world reaching into the simulation through a side door.
So the swapchain prefers MAILBOX: it returns immediately and shows the newest
finished frame, leaving the pace to the loop's own wait — exactly the tick rate
on any display. FIFO remains the fallback because it is the only mode Vulkan
guarantees, and where it is all the surface offers, the world's rate follows the
display. The debug HUD says which one is in force.
Note the difference from a slow machine, which is legitimate: if a TICK takes longer than its period the frame rate falls and the world slows, and that is the model working. What must not happen is the world slowing while the game had time to spare.
The developer simspeed multiplier runs several ticks per turn and carries its
fractional part, so 1.0 is exact and only a deliberate fast-forward rounds.
One turn is one tick unless something buys more, and there are exactly two
buyers: the fast-forward multiplier (toolbar >>, console simspeed) and the
rest aim (Z, console rest). Both go through one door,
promote_turn_ticks (app/main.cpp), which is the only producer of the tick
count frame() is given — the loop keeps no promotion state of its own. Rest
REPLACES the multiplier rather than multiplying with it: the rest is aimed at a
full bar, not at a pace, and its own 128 ticks a turn is the whole of what it is
allowed to buy.
Promotion belongs to the live map and nowhere else — fast_forward_allowed
is Playing && worldLoaded && !subworld.active(). The subworld is real time (a
swing, a fall and a cast are all quoted in it); a menu or an unloaded world has
no clock worth promoting. A scene that refuses drops both aims to 1× rather
than suspending them (owner: «очень жёстко»), so the world, the toolbar button
and the console readout cannot disagree, and climbing back onto the map never
resumes a speed armed in a scene the player has left.
The permission is derived every turn, never stored — the same law as THE
pause (ARCHITECTURE.md, pause_reasons), and for the same reason. The
multiplier used to be gated once, on the toolbar BUTTON, by
!subworld.active(); a gate on the button guards the ACT, never the STATE, so
an armed 4× survived a dive into the subworld, Esc to the menu and starting a
whole new game, because after the click nothing ever asked again (owner, in
play 2026-09-09). A derived gate cannot leak, because there is nothing to
forget — and the exits are precisely the paths that would have been forgotten
one at a time: a load that returns early, the menu reached from a modal, a
subworld entered by falling down a hole.
App::simSpeed is therefore an INTENT, not a permission. Nothing outside that
door may reset it — the load path, the menu and the subworld entrance
deliberately do not know the field exists.
In the subworld, kSubworldTickDivisor = 64 simulation steps buy one tick of
world time: a game hour costs ~341 real seconds instead of 5. The simulation
does not slow down — your body still moves at the full step rate, it is the
day that stretches. The leftover steps are kept as a whole number in the tick
runtime, so pausing, saving or walking out mid-divisor loses nothing.
The divisor is also the visual-pace rung (owner verdict, 2026-09-03): the
subworld walking speed DERIVES from it (movement_cost.h kSubworldWalkTilesPerSecond — a formula, not a number) under the parity
anchor «crossing a cell's scene below costs the same game minutes as crossing
that cell above». ÷16 made every body race at 96 tiles/s; ÷64 reads as 24 —
tested in play and accepted. Retuning the feel = moving this one rung (128 →
12 t/s, 256 → 6 t/s); the trade of bigger rungs is that a dungeon crawl
spends almost no world time.
Because the whole macro world reads the same clock, the lords outside slow down with it. Nobody crosses the continent while you clear one room.
That is literal, not a figure of speech: macro NPC AI is quoted in world ticks
(kAiTicks = 32), so it wakes once per half-hour of game time wherever the
player is standing. Measured on the seed-locked subworld_time smoke, a
thousand simulation steps underground: 24186 NPC thinks before, 852 after —
one sweep instead of thirty-one. Today that saves a fraction of a millisecond,
because a fresh world holds ~850 macro NPCs. It is written for the world that
holds thousands of parties, where it is the difference between a subworld that
runs and one that does not.
Real seconds appear in exactly one constant, kTicksPerRealSecond. Everything
else is denominated in game time:
| Rate | Unit | Where |
|---|---|---|
| macro march | 8 cells per game hour | macro/movement_cost.h |
| bar recovery | ⅛ of the bar per game hour, in BOTH worlds | macro/player_recovery.cpp |
| travel stamina | 1 SP × terrain weight per cell, not per hour | macro/movement_cost.h |
| macro NPC thinking | every 32 ticks | macro/npc_ai.h |
| player time-in-cell | every 32 ticks | app/main.cpp |
| subworld walk | 96 tiles per real second | app/main.cpp |
| spell cooldown | authored seconds → steps | content/spells/spell_book.cpp |
| melee cooldown | authored seconds → steps | ecs/systems.cpp |
| sustained mana drain | per step | content/spells/spell_book.cpp |
The last four rows are the subworld's own denominator, and all four are now
honest. The three cooldown/drain rows are quoted in STEPS, below. The walk
row — once the A8 debt ("two walking speeds diverging ×4") — is CLOSED BY
DERIVATION (2026-08-24): the march was recalibrated to
kMacroWalkCellsPerHour = 8 (owner: «степени двойки»; a brisk paved pace at
the world's own scale — the old 32 was a courier's gallop miscalled walking),
with kStaminaPerCell = 2 as the pure level-1 base (retuned 2026-09-09 — the
2026-08-24 value of 1 put the anchor 1.75× out, see below) — every modifier
(travel skill, overload, terrain √) multiplies ON TOP, a fresh walker drops
after ~6.9 game hours of ROAD and ~4.9 of open country, and a night's rest
(⅛ of the bar per hour) buys the whole bar back.
The anchor is now a compile-time gate (kRoadHoursPerFreshBar), and the
reason is a time-ladder lesson worth keeping: what is balanced is GAME HOURS,
what is priced is the CELL, and the hours are the PRODUCT of the per-cell price
and the pace. A product has no name to fail under. The 2026-08-24 pass moved
both factors (pace 32 → 8, price 7/16 → 1); the product fell 14 → 8 SP per game
hour and every hour quoted in the comments grew 1.75× without a single test
going red — every test derived its expectation from the same constants it was
guarding. Design numbers belong in literals, asserted; derived numbers belong
in code, never in prose. With the map no longer galloping,
kSubworldWalkTilesPerSecond = 96 carries its derivation beside it
(app/main.cpp): 8 cells/game hour = 8000 tiles per subworld game hour, and
an hour down there lasts 85⅓ real seconds, so the honest rate is
8000 / 85.33 = 93.75 tiles/s — rounded up 2.4 % to 96, a named fantasy
allowance, not a tuned number. The 4× disagreement was the map's, not the
ground floor's.
The STEP, not the second. The three cooldown rows above used to be floats
decremented by a dt of real seconds, while this page claimed exactly ONE
exception — so the law said one thing and the code did another in three places,
which is how an audit ends up reporting an exploit that does not exist (19.26
claimed magic was 16× more productive underground; it is not, because nothing
regenerates underground at all). They are integers now, counted in simulation
STEPS (core/time.h kStepsPerSecond), which changes nothing about how a fight
feels and everything about what it is: no wall clock, no float, deterministic,
and the same length of FIGHT whether the clock above races or crawls.
Taking "everything in ticks" literally was measured and rejected: a world tick underground is 0.25 real seconds, so a one-second cooldown would become sixteen real seconds and combat would stop being combat.
The point of the rest of the table: the length of a day is a matter of feel, not of balance. Lengthen it and the world simply takes longer to live through — the marching economy does not move a point, because the march was never quoted in real seconds.
The step is for things the world does. The frame is for things the player sees.
tick_macro_npc_visuals — the easing of a macro NPC's drawn position toward the
cell its AI put it in — is interpolation for the eye, and it runs once per
frame, because that is when there is something to look at.
But it is drawn tighter than "once per frame": nothing that writes game state
is ever handed the real duration of a turn. Every dt inside a step is the
compile-time constant kStepSeconds, and the easing — pure interpolation for
the eye, but it WRITES TO THE ECS — is handed the ticks the world actually
lived this turn, stats.timeTick.ticksAdvanced × kStepSeconds. So a slow
machine cannot smooth it at a different pace than the world moved it.
Once per frame is not once per tick, and that gap was a shipped bug
(2026-09-09). The easing used to be handed one tick's worth flat — the same
number only while a turn IS one tick. A turn is several whenever time is
promoted (>>, the rest aim), and then a squad's Position ran N times further
than its VisualPos was allowed to follow: the gap grew every frame until it
passed the snap bound and the squad teleported. On the road at rest speed that
was exactly three cells a frame — 128 / kAiTicks = 4 thinks × 0.75 cells — and
the owner saw it as squads jumping.
The denominator has to be TICKS and not frames for a reason worth stating, since
the numbers happen to coincide at 1×: MacroNpcRuntime::visualSpeed is quoted
in cells per kAiPeriodSeconds, which IS kAiTicks of world clock. Two
quantities counted in different units only agree by accident. It also carries
the subworld for free — down there tick_world_subworld_steps advances the
macro clock once per kSubworldTickDivisor steps, so macro squads think rarely
and smooth rarely, in the same crawling ratio.
One consequence, deliberate: a paused world lives no ticks, so a body caught mid-glide by the pause now holds its intermediate position instead of easing into its cell over a stopped world.
Real time is read in exactly three places in the whole game, and not one of them can change what the world does:
| where | what for | touches the world |
|---|---|---|
| the loop's wait | whether to pause before the next turn | no |
macro.record(… SDL_GetTicks()) |
shader animation clock (water shimmer) | no, drawing only |
| new-game seed when none is given | which world you get, not how it runs | no |
There is no fourth row, and that is the point. It used to have one — the measured length of a turn, handed to the macro NPC visual easing — and it was removed for being the sort of thing that gets mistaken for simulation later.
- The save states the instant exactly, in one
uint64(sincekSaveVersion18; today the save is v42) — a tick number, not a duration. - A month of resting and a single frame cost the same three lines. Minutes,
hours and days are linear in the tick, so what an advance covered is a
subtraction however large the jump —
world_tick.cppno longer walks the clock forward a minute at a time. - 0 HP means dead, reliably. The coarse pre-tick frame used to run recovery and the death check in the same call, so a player at exactly zero could round his way back to 1 before anything noticed. On a fixed step the rule bites the same way every time.
time_ladder_test— the ladder itself: derivation exact over a whole day, gap-free, onto, invertible, whole-day advances at zero residue, and the year at exactly 2^20 ticks.world_tick_parity_test— no drift: ten thousand one-tick advances and one ten-thousand-tick advance land on the same instant, report the same elapsed time and queue the same daily work. Plus the subworld divisor keeping its remainder across a split.save_roundtrip_test— the clock survives a save exactly, mid-minute.macro_travel_parity_test— the travel economy in cells per game hour.