When a player runs out of time, the server fires a trigger and your game’s transition handler decides what happens. The framework’s job is to count the clock authoritatively (so a stalled or malicious client can’t hold the room hostage); the response — skip-turn, random move, lose-on-time, anything — is yours to write. If you haven’t already declared a host-configurable timer setting, start with Configure match settings in the lobby — the canonical pattern isDocumentation Index
Fetch the complete documentation index at: https://openturn.io/docs/llms.txt
Use this file to discover all available pages before exploring further.
state.deadline = ctx => deadline.after(ctx, ctx.match.config.turnTimeoutMs), where turnTimeoutMs lives in your game’s config schema.
How it works (one paragraph)
Declare adeadline on a state (or phase in gamekit). Declare a timeout response — transition({ kind: "timeout", ... }) in core, or phase.onTimeout in gamekit. When the clock passes the deadline, the server fires the trigger; your handler runs; the resulting event lands in the action log; replays reproduce it deterministically.
Gamekit: phase.onTimeout
Most games are gamekit games. The full surface is two fields on a phase:
onTimeout returns one of the standard move outcomes:
| Return | Meaning |
|---|---|
moves.<name>(args) | Trigger an existing move with chosen args |
{ kind: "stay", endTurn?, patch?, ... } | Mutate state, optionally advance the turn |
{ kind: "goto", phase, ... } | Move to a different phase |
{ kind: "finish", result } | End the game |
null | No-op; the clock is consumed but state doesn’t change |
deadline but no onTimeout, the server fires the trigger and finds nothing to do. The game stalls until something else moves it forward (a player message, a phase change, etc.). This is intentional — there’s no “default” timeout behavior for the framework to pick. Either declare onTimeout, or don’t declare deadline.
If a phase declares onTimeout but no deadline, gamekit throws at definition time — the handler would never fire.
Conditional behavior
onTimeout is just a function; branch on whatever you need:
Different phases, different rules
Each phase has its own(deadline, onTimeout) pair. You can have a 30-second main turn, a 10-second sub-decision phase, and a no-deadline animation phase in the same game:
Core: transition({ kind: "timeout" })
If you’re authoring with raw @openturn/core, the equivalent looks like this:
kind: "timeout" discriminator on the transition is what tells the runtime “this is the timeout handler for this state, not an event handler.” The resolve returns the same shape as event-driven transition resolvers (a typed event input, or null).
Definition-time validation rejects:
- Transitions with both
eventandkind(use one). - Timeout transitions whose
fromortoreferences an unknown state. - Multiple timeout transitions from the same
fromsource (one per state).
What happens server-side
You don’t have to implement anything on the server. The runtime in@openturn/server accepts an injected DeadlineScheduler (which the cloud DO worker and CLI dev shell both provide automatically). After every event, the runtime calls scheduler.setDeadline("turn-timeout", session.getNextDeadline()). When the clock passes, the host fires runtime.fireTimeout(), which:
- Re-checks the deadline against the current snapshot (idempotent — guards against stale alarms in flight after a player moved fractionally before the deadline).
- Looks up the matching
kind: "timeout"transition with parent-fallback. - Applies it through the same dispatch path a player event uses.
- Re-arms the scheduler with the new state’s deadline (which may be
nullif the new state has no timer).
deadlines: { "turn-timeout"?, "idle-reap"? } record in ctx.storage and recomputes Math.min(...) for the DO’s single setAlarm slot. Idle-reap and turn-timeout coexist on the same alarm.
In the CLI dev shell, the scheduler tracks one setTimeout handle per key in memory. Same interface; different mechanism.
You don’t see any of this from the game-author surface — it’s just phase.onTimeout (or transition({ kind: "timeout" })).
Showing the countdown to players
You don’t have to build a clock. The play shell (<PlayShell> in @openturn/bridge, used by both the CLI dev shell and the cloud play page) auto-mounts a compact m:ss countdown in its toolbar. It turns red under 5 seconds and is hidden entirely when no deadline is active. Embedders can suppress the default with <PlayShell showTurnCountdown={false}> or relocate it by mounting <TurnCountdown host={host}> themselves.
The shell-side countdown reads from a typed bridge protocol message: when your game’s controlMeta.deadline changes, <OpenturnProvider> from @openturn/react calls bridgeGame.setDeadline(deadline) automatically. The shell receives it via host.deadline / host.on("deadline-changed", ...) and re-renders. You don’t write any of this — declaring phase.deadline in your game is enough.
Custom in-game UI: useTurnDeadline()
For richer treatment inside your game iframe — pulse animations, “5 seconds left!” banners, urgent state changes in the layout — @openturn/react exports a useTurnDeadline() hook:
controlMeta.deadline from useMatch()’s current snapshot and updates live: 1 Hz baseline, ramping to 10 Hz when remaining drops under 5 seconds (so the final-seconds display feels smooth without re-rendering every 100 ms during normal play). Each tick recomputes from Date.now(), so the value self-corrects after the user backgrounds and refocuses the tab.
Return shape:
| Field | Type | Notes |
|---|---|---|
deadline | number | null | Wall-clock instant in ms (server clock), or null when no deadline is active |
remainingMs | number | max(0, deadline - Date.now()). 0 when deadline is null. |
isExpired | boolean | deadline !== null && remainingMs === 0 |
<PlayShell> countdown and your in-game hook are independent renderings of the same data — both look at the same wall-clock instant. They can coexist (a chrome-level “0:30” plus a game-level “5s left!” banner).
Edge cases worth knowing
- Player disconnects mid-turn. The clock keeps running. Reconnects with whatever time is left.
- Simultaneous-move phases (multiple
activePlayers). The deadline is at the phase level; one fire for the whole group. YouronTimeoutresolver inspects who has and hasn’t moved (via the existing control state) and decides per-player handling. - Bots. Same rules apply. Bots are presumed instant; if one hangs, the timer fires and your handler runs.
- Replay determinism. Timeout-dispatched events land in the action log as normal records (
type: "internal",playerID: null,event: "__timeout"). Replays apply them verbatim — the resolver doesn’t re-run, so RNG-based decisions stay deterministic. - Race against player moves. If the clock fires fractionally after a player advanced state, the runtime’s idempotency check catches it:
fireTimeoutre-reads the current deadline, compares tonow, and no-ops if state already moved.
When NOT to use this
- Animation / transition states with no active player. Just declare
deadline: () => null(or omit it). The scheduler clears and<TurnCountdown>hides. - Pre-deadline notifications and sounds. The framework doesn’t ship browser notifications or alerts. Use
useTurnDeadline()and trigger your own (e.g.Notification.requestPermission+ auseEffectthat fires whenremainingMscrosses a threshold). - “Time pressure” mechanics within a turn (e.g., “score more if you act faster”). That’s gameplay, not timeout enforcement; track it in
Gstate and reference it in your move handlers. Turn timeouts only fire at the deadline.
See also
- Configure match settings in the lobby — the canonical place for
turnTimeoutMsto come from. - Concepts: turns, phases, and control — the underlying
state.deadlinefield andcontrolMetasnapshot exposure. - Build a lobby — where the host configures the timer before play starts.