Retries, ops and the universal client

Three pieces that go together: a retry that cannot pay twice, a vocabulary a client can read without knowing the game, and a runner that plays either round shape and prints what happened.

A retry must not pay twice

open-rgs already derives a stable idempotency key for the wallet, so a resent call arrives with the same key and a compliant wallet collapses it to one money movement. That guarantee has a hole in it, and the hole is not hypothetical: it depends on the wallet. Some wire protocols have no field to carry the key at all. Against a wallet like that, a client retry after a timeout runs the math a second time and moves money a second time.

So the orchestrator caches the call. Keyed on session plus the client's own token, a repeat is answered from cache and never reaches math or the wallet, which means the guarantee no longer depends on the wallet honouring anything. It is on by default.

await rgs.spin({ betIndex: 0, idempotencyKey: "tok-A" });
await rgs.spin({ betIndex: 0, idempotencyKey: "tok-A" });

The second call above returns the first call's response, byte for byte. One round ran, one settle happened, the balance moved once.

The retry that actually hurts

The dangerous resend is not the one that arrives after the first finished. It is the one that arrives while the first is still running, a client that gave up at five seconds against a round still executing at six. A cache holding only completed responses misses that case entirely and lets both run.

tok-A tok-A
1 round
Second arrival coalesces onto the first call's promise rather than starting a second round.

The entry is created before the work starts and holds the promise, so the second caller awaits the first. Both callers get one result because there was only ever one round.

Failures stay retryable

Only successes are kept. A failed call is dropped from the cache, because the honest answer to "your spin failed with PLATFORM_UNAVAILABLE" is that the client should be able to try again for real. Caching the failure would turn a transient wallet blip into a permanently poisoned token.

fail ok
entry dropped, next attempt runs
A rejection clears its own entry, so the same token can run for real on the next attempt.

Scoping and limits

Tokens are chosen by clients, so two players can pick the same one. Entries are scoped by session, which makes a collision between players impossible; within one session a collision is the player's own client resending, which is the whole point.

player-ak
player-bk
Same token, different sessions, different entries. Cross-player collision cannot happen.

Calls are also tagged by phase, so a client reusing one token across a spin and a close does not collapse them into each other. Entries expire on a TTL and the store is bounded, oldest evicted first, the window only needs to outlive a client's retry behaviour, not a session.

createServer({
  manifest,
  platform,
  requestCache: { ttlMs: 120_000, max: 10_000 },
});

This is per process, not per cluster. A retry that lands on a different pod finds an empty cache and runs for real. The cross-pod guard is still the wallet's own dedupe on the idempotency key, and this does not replace it, it closes the case where the wallet cannot help. The per-session lock keeps a single pod's concurrent calls in order; sticky sessions make the cache effective in practice, and are not a correctness requirement for anything else.

A call with no token is not cached, because there is nothing stable to deduplicate on. That is the documented cost of not sending one. Turning the cache off entirely restores the previous behaviour:

createServer({
  manifest,
  platform,
  requestCache: false,
});

An op vocabulary, opt in

Op is unknown in the contract on purpose: math authors define their own visual instructions and the engine only forwards them. That is the right default and it is not changing. The cost is that every client is bespoke, nothing generic can render a game it has never seen, so smoke tests, replay tools and integration harnesses end up game-specific too.

@open-rgs/contract/ops is the middle ground. Nine shapes covering what a slot actually needs to say, and stopping there: board, win, cascade, respin, coin, award, feature, meter, message.

{
  "ops": [
    { "kind": "board", "shape": [3, 3, 3], "cells": ["A", "A", "B", "K", "A", "Q", "B", "B", "A"] },
    { "kind": "win", "symbol": "A", "count": 3, "amount": 2, "cells": [0, 4, 8], "ways": 4 },
    { "kind": "cascade", "step": 1, "cleared": [0, 4, 8], "multiplier": 2 },
    { "kind": "message", "text": "Big win" }
  ]
}

A game that emits these can be driven by any client that understands them. A game that does not is unaffected: nothing in the engine reads or validates ops, and the module is types plus a type guard with no runtime cost unless you call it.

Mixing your own in

Canonical and bespoke ops travel in the same stream. A generic client renders what it recognises and ignores the rest, so adopting the vocabulary is not all-or-nothing, use it for the parts a tool should be able to read, and keep your own shapes for the parts only your client draws.

3 rendered, 1 passed through
Canonical ops are decoded and drawn. An unrecognised op passes through untouched.
import { canonicalOps, opsTotal } from "@open-rgs/contract/ops";

const drawable = canonicalOps(res.ops);
const shown = opsTotal(res.ops);

Ops describe, they never pay

This is a visual log, not a settlement record. The round's multiplier is what pays; an op saying amount: 250 is describing what to draw. Divergence between them is a presentation bug and never a money one, because nothing downstream of the client reads these.

2+1
= 3x
opsTotal sums win and award ops. Compare it against the settled multiplier in a test.

Which makes opsTotal a cheap assertion worth having: if the ops add up to something other than what the round paid, the presentation and the math have drifted apart, and you would rather find that in CI than in a player complaint.

A client that plays anything

RgsClient speaks the wire. UniversalClient is the layer above it that every integration otherwise rewrites: play a round to completion whatever shape it is, retry safely, keep a readable log, and finish a round the last session left open.

import { RgsClient, UniversalClient, describeRound } from "@open-rgs/client";

const rgs = new RgsClient("ws://localhost:8080/wss");
await rgs.connect();

const uc = new UniversalClient(rgs);
const init = await uc.init(sid);

await uc.resumeIfUnfinished(init);

const round = await uc.playRound(0);
console.log(describeRound(round));

It is not a game client and has no opinion about presentation. It is for smoke-testing a build, driving an integration against a wallet sandbox, capturing a transcript to diff after a change, and proving a deferred-close round survives a disconnect.

Round shape is discovered, not declared

It tries a simple spin first. A complex mode answers INVALID_MODE, and the runner switches to open, step, close rather than needing to be told which kind of game it is talking to.

spinINVALID_MODE
openclose
Spin is refused, so the runner opens the round and steps it to a terminal state.

Every call carries a token, and a retry inside the client reuses it, so the server answers from cache instead of running the round again. Retries only fire on transport and platform failures, a rejection the client caused will reject identically next time, so retrying it just wastes the round.

const uc = new UniversalClient(rgs, {
  decide: (awaiting) => awaiting.type === "gamble"
    ? { type: "gamble", value: "red" }
    : { type: awaiting.type },
  maxSteps: 50,
  retries: 3,
});

decide answers whatever a round is waiting for; the default picks the first offered option, which walks most games end to end. maxSteps is the guard against a math bug that never reaches a terminal state, without it a broken isTerminal hangs the runner instead of failing it.

A transcript you can diff

describeRound renders the round as one stable line per event. For a test fixture, a CI log, or a bug report attachment, greppable text rather than a pretty picture.

spin
  board [3,3,3] A A B K A Q B B A
  win A x3 = 2 (4 ways)
  cascade 1 cleared=3 x2
  message Big win
= 2x  balance 98000  ops 2x

And resuming is one call. A deferred-close game answers init with a resume block; resumeIfUnfinished returns undefined when there is nothing to resume, so it is safe to call unconditionally after every init.

The same thing as a command

For a server you just deployed, open-rgs-play is the runner without the script around it. No game code, no fixtures, no knowledge of which modes are simple and which are complex.

bunx open-rgs-play ws://localhost:8080/wss --rounds 20
bunx open-rgs-play ws://localhost:8080/wss --rounds 5 --retry-token
bunx open-rgs-play ws://localhost:8080/wss --sid s1 --mode deferred --abandon
bunx open-rgs-play ws://localhost:8080/wss --sid s1 --resume

--retry-token sends every round with the same token, so the balance must move exactly once however many rounds you ask for; the command exits non-zero if it does not. --abandon walks away mid-round like a player closing the tab, and --resume comes back to it, which is the replay path end to end in two commands.

A single round prints its full transcript, a run of them prints one line each and a summary, and --json gives you the whole thing structured. Exit code is 1 on any failure, so it drops straight into CI.