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.

ts
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:

ts
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 derivednow = epoch + gameTime, where epoch is start read as UTC. Everything follows from that one equation:

  • Pause pauses time. While the game is suspended gameTime freezes, so now freezes 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, so now rewinds with it — encode deadlines as gameTime and 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 same gameTime always yields the same now — 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 tickInterval of 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 onEvent is 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 emitInputEventonEvent) — 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.

ts
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:

ts
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.