Time, ticks & background play
How to build time-driven games — countdown timers, pets, farms, day/night, idle simulations. Pair onTick with tickInterval to run code on a fixed cadence; read the two frozen clocks state.gameTime (game-active ms, the one for timers) and state.now (a wall-clock timestamp by default, or a derived false clock in game mode); choose a clock mode ('wall' vs { start } false time) and let players fast-forward game-mode sessions; emit input events from a tick to bridge into onEvent; and opt into backgroundExecution to keep ticking up to 48h after the player leaves.
Most games only react to the player. A time-driven game also acts on its own: a
turn timer counting down, a pet that gets hungrier, a farm that grows, a day that turns
to night, a body-state simulation that drifts. You build those with onTick — a small,
deterministic function the engine calls on a fixed cadence.
If your game has no time element, skip this entirely: omit onTick and tickInterval
and nothing here applies. For the exact field signatures see the SDK reference; this
recipe is the "how time works" picture that sits on top of them.
onTick + tickInterval
Add an onTick handler and a tickInterval to defineBackend. The two are a
package deal — one without the other is rejected at definition time. tickInterval
is in milliseconds, must be an integer, and has a minimum of 1000 (one second);
there is no default, because a wrong cadence is worse than a loud error.
import { defineBackend } from '@aichatgames/sdk'
defineBackend<InputEvent, OutputEvent, State>({
initialState: { secondsLeft: 30, expired: false },
tickInterval: 1000, // fire onTick once per second of game-active time
onTick: ({ state, emitInputEvent }) => {
const { secondsLeft } = state.get()
if (secondsLeft <= 0) return
state.set(d => { d.secondsLeft -= 1 })
if (secondsLeft - 1 === 0) emitInputEvent({ type: 'time-up' }) // cross zero once
},
onEvent: async (event, { state, emit, io }) => {
if (event.type === 'time-up') {
state.set(d => { d.expired = true })
// …react: an agent line, an image, a score — onEvent has the full io toolkit.
}
},
})onTick is deterministic and synchronous: it receives state but not io — no
agents, no image generation, no random, no awaiting. It's for cheap, recurring state
math. When something noteworthy happens, call emitInputEvent to hand off to onEvent,
which has the full toolkit. Don't emit on every tick — each emitted event is a full
onEvent cycle that can cost credits. Emit only on a meaningful transition (a counter
crossing zero, a meter passing a threshold). See the SDK reference §Tick for more on the
onTick vs onEvent split.
The two clocks: gameTime and now
Every step (a tick or an event) carries two timestamps, both available as state.now
and state.gameTime. They are frozen values, not live clock reads: each is fixed for
the whole step, so two reads in one handler always agree, and there is no dt to add up.
A tick's per-step increment is simply your tickInterval.
state.gameTime — cumulative game-active milliseconds. This is the clock for almost
all timer logic. It starts at 0, advances at real-time rate only while the game is
running, pauses when the game suspends (the player leaves), and rewinds when a
turn is edited or rewound. It's an exact integer, so it never drifts and replays
identically. A game with no onTick never arms the tick loop, so its gameTime stays
frozen at 0 forever — gameTime is meaningful only for ticking games. Use it for
countdowns, cooldowns, a 10-minute day/night cycle, pet decay, anything measured in
game time.
state.now — a timestamp (ms), the wall clock by default. For a tick it's the grid
point's due time (when the tick should have fired), not when the handler actually
ran — so a backlogged tick carries a now slightly in the past. For user input it's the
server's receipt time. In the default wall clock mode it's a real-world timestamp
and can jump forward across dormancy: when a player returns after a gap, the next
step's now is the current wall time — use it for real-world-aware logic (a real-time
day/night cycle, "is it after work?") and write that logic to tolerate jumps. In
game mode now is instead a derived false clock (epoch + gameTime) that pauses,
rewinds, and fast-forwards with game time — see Clock mode below. For everything
internal, prefer gameTime.
In wall mode the two advance together during live play and diverge only across dormancy
(where now jumps ahead and gameTime does not) and on rewind (where gameTime steps
back); in game mode now is locked to gameTime, so they never diverge.
Clock mode
By default a game runs on wall time: state.now is the real-world clock described
above. A game can instead run on game time ("false time") by declaring the fiction's
opening instant:
import { defineBackend } from '@aichatgames/sdk'
defineBackend<InputEvent, OutputEvent, State>({
clock: { start: '2030-01-05T08:00' }, // game mode — the fiction opens at this instant
tickInterval: 10_000,
onTick,
onEvent,
})clock is 'wall' (the default) or { start }, where start is the fiction's opening
datetime — 'YYYY-MM-DDTHH:MM' or '…:SS', validated at definition time. Declaring a
start is how you opt into game mode: there's no false time without saying when the
fiction begins. Any parseable instant works, pre-1970 included (a period piece).
In game mode state.now stops being the wall clock and becomes derived —
now = epoch + gameTime, where epoch is start read as UTC. Everything follows
from that one equation:
- Pause pauses time. While the game is suspended
gameTimefreezes, sonowfreezes with it — no dormancy catch-up. The fiction's clock stands still while the player is away (the trade-off vs wall mode's "her life goes on in real time"). - Rewind rewinds time. Editing or rewinding a turn re-anchors
gameTime, sonowrewinds with it — encode deadlines asgameTimeand they stay correct. - Fast-forward accelerates time. The player can race the clock so day/night and timed beats compress (see Fast-forward below). Wall mode can never fast-forward.
- Deterministic per
gameTime. The epoch is fixed (not the session's creation time), so the samegameTimealways yields the samenow— a game that renders the clock into an agent prompt behaves identically run to run.
The fiction is framed in UTC. In game mode the platform pins state.timezone to
'UTC', so every player sees the identical fictional clock and there's no DST inside the
fiction. state.timezone always means "the zone now renders in" — the player's local
zone in wall mode, the fiction's UTC in game mode — so your day-phase / time-of-day
rendering off state.now + state.timezone works unchanged in both. The player's
real-world timezone is deliberately not observable in game mode; the fiction keeps its own
clock.
Choosing. Use wall (the default) when the game should track the player's real day — "she texts you in the morning", "the shop shuts at 5pm". Use game when the fiction has its own timeline you want to pause, rewind, and fast-forward — a life sim, a countdown scenario, anything where watching in real time would be unplayable.
Changing clock (mode or start) mid-session is allowed — a revision can flip it.
state.now then jumps to the new rule on the next step (game→wall can even jump
backwards, if fast-forwarding pushed the false clock past real time). It's a declared
discontinuity: write clock-reading code to tolerate a jump, the same way wall-mode code
already tolerates a dormancy jump.
Ticks fire on the game clock
Ticks are scheduled on the game clock, not the wall clock: onTick runs each time
gameTime crosses a multiple of tickInterval. Consequences worth knowing:
- Tick spacing is exact game time. Consecutive ticks are always exactly
tickIntervalof game-active time apart — no partial intervals, no scheduler drift. - Suspends are invisible to spacing. Suspend mid-interval and the next tick fires after the remainder of that interval once play resumes — the grid is anchored to game start, so a pause doesn't shift it.
- A slow handler defers ticks, it doesn't drop them. Ticks continue to be due while
an
onEventis awaiting (an agent call, image gen); they're processed after the handler, and the whole backlog drains in order — your oxygen timer won't freeze because an image is generating. (Don't pair a 1s tick with a 20s handler, though — a permanent backlog is a design problem.) - Causal emission. An event emitted from a tick (or from another event) is processed
at the emitter's same instant — same
now/gameTime— ahead of any later-stamped step. A tick and everything it triggers form one logical instant.
To schedule something "for later," don't look for a scheduler API — just compare
gameTime (or now) inside onTick.
Edit & rewind rewind the clock
When a past turn is edited/replayed, the engine preserves that turn's pre-input tick
state and reuses its recorded now/gameTime (re-running with cached responses), so
the turn reproduces exactly and tick progress is never lost.
When a turn is rewound or edited, the game clock rewinds with it: state, now, and
gameTime all return to that turn's recorded values, and later turns are discarded.
Rewinding un-spends that game time — it's exactly as if the game had been suspended
for the interval you cut. (Tick every second, edit a turn from a minute ago → those 60s
of ticks are gone, and gameTime did not advance across them.) This is intended: encode
deadlines as gameTime values in state and they rewind correctly for free.
Fast-forward
A game-mode game with an onTick can be fast-forwarded: the player asks the
platform to accelerate the game clock so a long stretch — a two-hour read, a night's sleep
— compresses to a wall-minute or two. It needs a connected client (it's a spectator
control), the platform owns the rate (one capped speed, not a multiplier the game or
player picks), and it's session-global — every watcher sees the same speed. Availability
is exactly game mode + onTick; a wall-mode or tickless game can't fast-forward (false
time without ever fast-forwarding is still perfectly coherent — you keep pause-pauses-time
and rewind).
The author contract: every emitInputEvent breaks fast-forward. Fast-forward drops
back to 1× at the first beat a tick emits — and on any player input, and on rewind. This
is the cost model, not just polish: ticks are free (synchronous state math), but each
emitted event is a full onEvent cycle that can spend credits, so halting at the first
one makes fast-forward compress time without multiplying spend — the same beats fire
at the same gameTimes, just sooner. The player watches the beat, then presses ▸▸ again.
So your emission discipline decides whether fast-forward is usable: emit only the beats you actually want to interrupt it. A game that emits every tick makes fast-forward a per-tick no-op — it drops out immediately, every time. Emit on meaningful transitions only, and suppress low-priority beats when nothing needs the player (e.g. while the character sleeps), so fast-forward can cover ground.
Reach scales with tickInterval. The platform caps the effective rate at roughly 8
ticks per second, so a coarser tick reaches further: a 10-second tick fast-forwards ~80× (a
2-hour read → ~1.5 wall-minutes), a 1-second tick only ~8×. A fine tick buys finer
simulation resolution at the cost of fast-forward reach — pick the coarsest tick your
simulation tolerates. (The rate is a ceiling; if onTick can't keep up the clock just
runs slower, never breaking.)
UI. The built-in Chat shows a ▸▸ control automatically when the session supports
fast-forward, and hides it while a turn streams or the session is suspended; pass
showFastForward={false} to suppress it. A custom frontend builds its own from
gameHistory.canFastForward, the reactive gameHistory.speed (1 normal, >1 while
fast-forwarding), and gameHistory.fastForward() / gameHistory.stopFastForward() — the
SDK reference lists these gameHistory members.
Background execution (keep ticking after the player leaves)
By default a game suspends the instant the last player leaves — the game clock pauses
and no ticks fire until someone returns. Set backgroundExecution: true (requires
onTick; default off) to instead keep ticking for up to 48 hours after the last
player disconnects. While backgrounded, any output the game emits is delivered as a push
notification. After 48h the session stops ticking and the clock pauses until the next
visit.
Background play needs the player's permission. Declaring backgroundExecution makes
the platform ask them once, in-game ("Keep playing while you're away?"); until they allow
it, the session suspends on disconnect like any normal game. Design for both outcomes —
the game must still make sense for a player who declines (see
background-play for the player-side view: permission, spending
limits, pause).
Time the ask with a dynamic declaration. backgroundExecution also takes a function
of game state — backgroundExecution: s => s.hatched — for games that only grow into
background play. The platform re-resolves it after every settled turn, so the permission
ask appears exactly when the state first says it matters, not the moment a brand-new
player opens the game. The function must be pure (given state in, boolean out, no
side effects); if it throws, the capability is treated as off. requestNotifications
takes the same form.
Background ticks may drive AI and image generation (via emitInputEvent → onEvent)
— that's expected for AI games, even with nobody watching. Background spend is bounded by
the player's balance and their optional per-game spending limit, so a game that runs dry
mid-window simply pauses.
The 48-hour design rule: any irreversible background effect must resolve within 48h. A neglected pet must die (or become irrecoverable) within 48h; a crop must wilt, or be harvestable, within 48h. After 48h the game can no longer act on its own, so anything that must happen has to happen inside that window.
import { defineBackend } from '@aichatgames/sdk'
// A pet that decays in game time and can die while you're away.
defineBackend<InputEvent, OutputEvent, State>({
initialState: { hunger: 0, alive: true },
tickInterval: 60_000, // one tick per game-minute
backgroundExecution: true, // keep decaying after the player leaves
onTick: ({ state, emitInputEvent }) => {
const { hunger, alive } = state.get()
if (!alive) return
const next = hunger + 1
state.set(d => { d.hunger = next })
// Dies within 48h of neglect: 48h × 60 ticks/h = 2880 < the cap. Good.
if (next >= 2880) {
state.set(d => { d.alive = false })
emitInputEvent({ type: 'pet-died' }) // → onEvent sends the "your pet died" push
}
},
onEvent: async (event, { state }) => {
if (event.type === 'feed') state.set(d => { d.hunger = 0 })
// 'pet-died' → narrate the loss; emitted output becomes the push while backgrounded.
},
})Marking what happened while away
When the player reopens a backgrounded game, the blocks produced while they were
gone are already in recentBlocks (they're persisted like any other). To highlight
that unseen delta, read gameHistory.lastSeenSeq — the seq of the last block they
saw before leaving. Any block with a greater seq is new:
const firstUnseen = recentBlocks.findIndex(b => b.seq > (gameHistory.lastSeenSeq ?? Infinity))The built-in Chat draws a "New while you were away" divider before that block
automatically; a custom frontend uses lastSeenSeq the same way. It's undefined
when there's nothing to mark (a first-ever visit) or after a return more than 48h
later (the boundary is in-memory only, so it's gone once the session has been evicted).
When a tick throws
An exception in onTick is caught and logged, the tick's partial changes are
discarded, and ticking continues — one bad tick won't kill a pet. But after 10
consecutive failed ticks the tick loop stops (a permanently broken handler
shouldn't spin forever); a successful tick resets the counter. A handler error — tick or
event — also surfaces to the player as a toast linking to the log page, and always
appears in the Logs/Raw panel. Keep onTick total and cheap.