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 (wall timestamp, for real-world logic); 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 '@curtain/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 wall-clock timestamp (ms). 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. It is 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 only
for real-world-aware logic (a real-time day/night cycle, "is it after work?") and write
that logic to tolerate jumps. For everything internal, prefer gameTime.
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).
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.
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 ticks may drive AI and image generation (via emitInputEvent → onEvent)
— that's expected for AI games, even with nobody watching.
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 '@curtain/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.