Openturn’s core contract is that every match is a self-contained reducer:Documentation Index
Fetch the complete documentation index at: https://openturn.io/docs/llms.txt
Use this file to discover all available pages before exploring further.
G is authoritative, transitions are pure, no side effects. That’s great for replay safety and terrible for games where players expect to accumulate things. Profiles are the escape hatch.
A profile is per-(user, gameKey) JSON state owned by the host (openturn-cloud, or your embedding app in local mode). It flows into the match as match.profiles at setup, and back out as a declarative delta computed by a pure function after the match ends. The engine never mutates it.
What’s in a profile vs. what’s in G
Lives in G | Lives in the profile |
|---|---|
| State that any transition needs to read or write during the match | State that persists between matches |
| Board position, decks drawn, HP | Card collections, gold, unlocks, XP, meta-progression |
| Ephemeral — discarded when the room closes | Durable — keyed by (userID, gameKey) |
G. If the player expects it to be there next time they log in, it’s a profile.
The three-step lifecycle
- Hydrate. Before
setupruns, the host reads each seated player’s profile row and puts it onmatch.profiles. Missing entries getprofile.default. - Read. Your
setupreceives the profiles and can copy whatever it needs intoG(e.g. “these are the cards player 0 brought to the match”). Transitions cannot readmatch.profilesdirectly — copy what you need. - Commit. When the match reaches a terminal
result, the engine invokes yourprofile.commit(ctx)which returns a per-player delta. The host applies the delta to storage.
commit is a pure function of (match, profilesAtSetup, result) — the same terminal state always produces the same delta. That’s what makes the whole thing replay-safe and crash-recoverable.
Transitions can also emit deltas mid-match — useful for unlocks, discoveries, or incremental rewards that shouldn’t wait until the match ends. See Mid-match deltas below.
Declaring a profile on your game
defineProfile, applyProfileDelta, computeProfileCommit, and the delta grammar types — same implementation as @openturn/core. Raw-core authors can import the same names from @openturn/core and wire profile into defineGame alongside events, states, and transitions.
Four fields:
schemaVersion: a string you bump when the profile shape changes.default: the shape a never-before-seen player gets on first play. Must be JSON-compatible.parse(optional but recommended): validates/normalizes stored data at hydration. Profiles live across deploys and can outlive a schema change; this is where you defend against that.commit: pure function returning a per-player delta using the delta grammar.
Delta grammar
Deltas are arrays of ops, each targeting a JSON path. Four ops cover most game economies:setwrites an arbitrary JSON value (root set with an empty path replaces the whole profile).inctreats a missing number as0, so{ op: "inc", path: ["cards", "dragon"], value: 1 }awards a card even if the player never had one.pushappends to an array.removedeletes an array index or an object key.
applyProfileDelta(data, ops) runs the list and returns { ok: true, data } or a typed rejection (type_mismatch, out_of_range, invalid_container, …). The cloud re-validates every delta against the live profile before writing.
Mid-match deltas
Some games need profile state to move during the match, not just at the end — Balatro-style unlocks, card discoveries, currency awarded the moment something happens. Any transition can emit a per-player delta alongside its normal state change, and the engine applies it tomatch.profiles atomically with the transition.
In gamekit, every move outcome helper (m.stay, m.endTurn, m.goto, m.finish) takes an options object with a profile field. Build the delta with the profile helper passed into run:
profile helper exposes inc, push, set, remove, and an Immer-style update(playerID, recipe) for batching multiple ops into one delta. It returns a ProfileCommitDeltaMap — the same shape commit returns at the end of the match.
In raw @openturn/core, return profile from a transition resolver alongside G, enqueue, result, etc.:
- Atomic with the transition. The delta is validated and applied before the next transition runs; subsequent resolvers in the same batch — and any end-of-match
commit— see the mutated profile. An apply failure rejects the transition withinvalid_transition_result. - Read-own / write-own, same as commit. Keys not seated in the match are dropped; each entry must target the player’s own profile.
- Idempotent at the host. The applied delta is recorded on
step.transition.profileand surfaced to the host viaonActionProfileCommit({ actionID, delta, roomID, ... }). The dedupe key is(roomID, actionID)rather than(roomID, userID)— one move can emit one delta. Per-action commits are best-effort; a failure does not block gameplay because the local session already moved.
onActionProfileCommit in the server reference. The card-discovery example is the canonical demo.
Authority and trust
Game code runs in a sandboxed iframe; in multiplayer that means untrusted. So:- Hydration is server-side. The cloud reads the
profilestable and injects the result into the match payload. The iframe reads profiles viamatch.profiles; it never writes them. - Commit is server-side. The commit function runs in the Durable Object (worker) — same pure function the client could run, but the client’s delta is never trusted. The cloud re-applies and re-validates.
- Commit is idempotent. Dedup key is
${roomID}:${userID}. A crashed-and-restarted DO that re-emits the same delta gets aduplicatestatus; the profile moves once.
Scope: gameKey, not deployment
Profiles are keyed by(userID, gameKey). One published game — across multiple projects, promotions, or redeploys — sees one profile per player. That’s deliberate: players expect their collection to survive an update.
The tradeoff is that schema changes need to be backwards-compatible. Ship a parse that tolerates old shapes, and/or bump schemaVersion and add a migrate(fromVersion, data) hook in a follow-up. Until external authors can publish under a shared gameKey, namespace collisions are a trust issue — see the security caveats in the how-to guide.
Local mode
Profiles work without openturn-cloud. In local mode the embedding app is the host: passmatch.profiles at session start, read computeProfileCommit(profile, ctx) at the end, persist however you like (localStorage, IndexedDB, a file). The persistent-wins example (gamekit) and its raw-core sibling show the full loop in ~30 lines each.
What to read next
- How-to: Persist player state — the operational recipe for authoring, local testing, and cloud deployment.
- Reference:
@openturn/core— API shape fordefineProfileand the delta grammar. - Reference:
@openturn/server— the host-side hook for receiving mid-match deltas.