An AI-vs-AI chess match needs to play one move at a time, pause between moves, persist game state for a live viewer, and continue across process restarts and serverless function terminations — without re-playing moves that already happened. On Resonate, the match is a generator function where each move is a ctx.run checkpoint, the inter-move pause is a ctx.sleep durable timer, and the next game is launched via ctx.detached so each game runs in its own fresh root invocation. The example deploys the generator as a Google Cloud Function via the @resonatehq/gcp adapter; when the workflow hits a pending promise the function terminates, and the Resonate server reinvokes it when the promise resolves.
The shape of the solution
export function* chessGame(ctx: Context): Generator<any, void, any> {
const chess = new Chess();
while (!chess.isGameOver()) {
const pFn = chess.turn() === "w" ? gePlayer : aiPlayer;
const san = yield* ctx.run(pFn, chess.fen());
chess.move(san);
const state: GameState = { gameOver: false, fen: chess.fen(), san };
yield* ctx.run(publish, state);
yield* ctx.sleep(10 * 1000);
}
const final: GameState = { gameOver: true, result: gameResult(chess) };
yield* ctx.run(publish, final);
yield* ctx.detached(chessGame);
}
// from resonate-chess-gcp/chess-match/src/chess.ts:9-26The workflow is registered and exposed as a Cloud Function HTTP handler by the GCP adapter:
import { Resonate } from "@resonatehq/gcp";
import { Firestore } from "@google-cloud/firestore";
import { chessGame } from "./chess";
const resonate = new Resonate();
const firestore = new Firestore();
resonate.setDependency("firestore", firestore);
resonate.register("chessGame", chessGame);
export const handler = resonate.handlerHttp();
// from resonate-chess-gcp/chess-match/src/index.ts:1-12The durable primitives in play
ctx.run(pFn, chess.fen())— durable child invocation of the per-turn player function (gePlayerfor white,aiPlayerfor black). The returned SAN string is checkpointed, so on replay the SDK feeds the cached move back into the generator instead of asking the player again.chess-match/src/chess.ts:14.ctx.run(publish, state)— checkpointed Firestore write tochess-games/current.chess-match/src/chess.ts:18and again at:23for the final state.ctx.sleep(10 * 1000)— durable 10-second pause between moves. When the generator hits the sleep, the Cloud Function invocation ends; the Resonate server starts a fresh invocation when the timer completes.chess-match/src/chess.ts:19.ctx.detached(chessGame)— tail call that re-roots the next game as a fresh top-level invocation rather than a child of the current one. Caps the per-invocation replay scope at one game forever.chess-match/src/chess.ts:25. The SDK documents this as the forever-loop pattern: "Each invocation plays exactly one game, then spawns the next as a fresh root and returns. Replay scope is bounded to one game forever." (resonate-sdk-ts/src/context.ts:520-528).ctx.getDependency("firestore")— pulls the Firestore client registered at module init out of the context.chess-match/src/chess.ts:72.resonate.register("chessGame", chessGame)— registers the generator under a stable name so the server can address it across invocations.chess-match/src/index.ts:10.resonate.handlerHttp()— the@resonatehq/gcpHTTP adapter; returns the Google Cloud Functions HTTP handler. Validates the inboundExecuteMsg, mints OIDC credentials for outbound calls to the Resonate server, and replies withcompletedorsuspended.chess-match/src/index.ts:12; adapter behavior atresonate-faas-gcp-ts/src/index.ts:152-232.
What the SDK handles vs. what you write
You write a single TypeScript generator with a while (!chess.isGameOver()) loop, an in-memory chess.js board, two player functions, and a Firestore-write side effect. There is no continuation token, no event-loop process, no per-move polling, no retry wrapper around the Anthropic call, no game-state serialization between invocations, and no manual "is this a replay or a fresh run?" branch.
The SDK and the GCP adapter handle everything around the yields. Each yield* ctx.run(...) and yield* ctx.sleep(...) is a durable promise: argument list and result are persisted before the function returns. On the next invocation the SDK replays the generator from the top, advancing each completed promise immediately from the persisted history until it reaches the first pending one (README.md:36). The Cloud Function invocation terminates as soon as the workflow suspends on a pending promise — there is no long-running process, no idle compute, no timer thread. The Resonate server holds the timer for ctx.sleep, and when it fires the server reinvokes the Cloud Function URL the adapter computed from the inbound request headers (resonate-faas-gcp-ts/src/index.ts:190-216). The chess.js board state is rebuilt deterministically on each replay because every move that produced it came back from a durable ctx.run result.
Failure modes covered
- Cloud Function invocation ends between moves. Each
ctx.runandctx.sleepcheckpoints to the durable promise store. On the next invocation, the SDK replays the generator and advances completed promises immediately rather than re-executing them, then continues at the first pending one.chess-match/src/chess.ts:14,18,19,23; replay semantics inREADME.md:36. - Workflow runtime exceeds any single Cloud Function timeout. The 10-second
ctx.sleepbetween moves ends each invocation; the server starts a fresh invocation when the timer completes.chess-match/src/chess.ts:19; adapter contract inresonate-faas-gcp-ts/README.md:11-15. - Claude returns an illegal move.
aiPlayerparses the response against the legal-move list and throwsillegal move from Claude: <text>if neither SAN nor UCI parsing yields a legal move.chess-match/src/chess.ts:60-65. Because the throw happens insidectx.run(aiPlayer, fen), the SDK retries the player step. Earlierctx.runresults in the same game are not re-executed because they're already in the durable promise store. - Replay history grows unbounded across many games. The
ctx.detached(chessGame)tail call at the end of a game re-roots the next game as a fresh invocation, so the per-invocation replay scope is bounded to one game no matter how long the worker has been running.chess-match/src/chess.ts:25. The SDK docstring is explicit about why this matters: without the split, "once replay duration exceeds the acquired-task lease, the server reassigns the task mid-execution (error code 1199) and cadence collapses" (resonate-sdk-ts/src/context.ts:536-540). - Process-local resources (Firestore client) need to be available after a restart. The client is registered once at module init via
resonate.setDependency("firestore", firestore)(chess-match/src/index.ts:8) and pulled inside the side-effect function viactx.getDependency("firestore")(chess-match/src/chess.ts:72). Fresh invocations get a fresh client without re-running prior moves.
When to reach for this pattern
- If you're running an unbounded workflow on serverless infrastructure with per-invocation timeouts and need the workflow itself to outlive any single invocation.
- If a workflow loops "forever" over discrete units of work (one game, one batch, one cycle) and you want per-invocation replay cost bounded to a single unit instead of growing with total uptime —
ctx.detachedof the workflow function from its own tail is the mechanism. - If a long-running workflow has natural pause points where doing nothing is fine for seconds or minutes —
ctx.sleepends the serverless invocation entirely rather than billing for idle compute. - If a workflow includes a step that can fail intermittently (LLM call returning bad output, external API hiccup) and you want the SDK to retry just that step without re-running earlier steps.
- If you're considering polling state out of a database every N seconds to figure out "where the workflow is" — replace that with a generator whose position is the durable history itself.
Sources
- Example repo: https://github.com/resonatehq-examples/resonate-chess-gcp
- Workflow source:
chess-match/src/chess.ts(chessGame,gePlayer,aiPlayer,publish) - Entry point:
chess-match/src/index.ts(Resonateinstantiation, dependency, register,handlerHttp) - SDK pin:
@resonatehq/gcp^0.2.0→@resonatehq/sdk^0.10.0(chess-match/package.json:14-17) - Resonate TypeScript SDK: https://github.com/resonatehq/resonate-sdk-ts —
ctx.detachedforever-loop docstring atsrc/context.ts:520-540;ctx.run/ctx.sleep/ctx.detachedinterface atsrc/context.ts:209-232. - Resonate GCP Cloud Functions adapter: https://github.com/resonatehq/resonate-faas-gcp-ts —
handlerHttpimplementation atsrc/index.ts:152-232. - Live viewer (static page, Firestore-backed): https://resonatehq-examples.github.io/resonate-chess-gcp/
- Resonate docs: https://docs.resonatehq.io
