Development instance — data may be reset at any time

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.

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

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 emitInputEventonEvent) — 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.

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

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.