You can extend…

Five plug points. Each is one interface in @open-rgs/contract; pick the smallest one that covers the change you want.

The Lua VM (helpers, DSLs, native fast paths)

A LuaExtension is one of three things, often combined: a pure-Lua module returned by require("<name>"), a table of native (TS) helpers merged into that module, or a pre-evaluation source transform applied to every loaded file.

// boot file — install an extension
import { loadLuaMath } from "@open-rgs/core";
import reels from "@open-rgs/ext-reels";

const math = await loadLuaMath("./maths/spin.lua", { extensions: [reels] });
// write your own extension
import type { LuaExtension } from "@open-rgs/contract";

export default {
  name:    "easing",
  version: "0.1.0",
  lua: `
    local M = {}
    function M.lerp(a, b, t) return a + (b - a) * t end
    return M
  `,
  host: () => ({
    fast_pow: (x: number, n: number) => x ** n,   // native, hot-path
  }),
} satisfies LuaExtension;
-- maths/spin.lua — math sees the extension via require
local easing = require("easing")
local y = easing.lerp(0, 100, host.rng_next())
local p = easing.fast_pow(1.05, 7)

Reference extension: @open-rgs/ext-reels — strip generation, payline evaluation, book-of utilities. Deeper: math reference.

The wallet

Implement PlatformAdapter. How you talk to the operator (one WS, three microservices, REST + polling, gRPC) is your call — the orchestrator only sees the interface.

import type { PlatformAdapter } from "@open-rgs/contract";

export class MyAdapter implements PlatformAdapter {
  isHealthy   = false;
  diagnostics = {};
  async connect()    { /* open WS / dial / login */ this.isHealthy = true; }
  disconnect()       { /* tear down */ }

  async openSession(sid, conn)   { /* GET /sessions/:sid → SessionInfo */ }
  async settleSimple(req)        { /* POST /play  → RoundReceipt */ }
  async openComplex(req)         { /* POST /open  → RoundReceipt */ }
  async closeComplex(req)        { /* POST /close → RoundReceipt */ }
  onEvent(handler)               { /* wire WS push → handler(event) */ }
}

Helpers: @open-rgs/adapter-kit for WS/HTTP RPC scaffolding, error mapping, diagnostics. Conformance: @open-rgs/adapter-test-kit runs the seven RPCs + event stream against your adapter. Deeper: adapter reference.

The transport

Implement ClientTransport. The default is binaryTransport — binary-msgpack over WebSocket. You could write a JSON transport, HTTP long-poll, gRPC, anything that produces typed OrchestratorAPI calls.

import type { ClientTransport, OrchestratorAPI } from "@open-rgs/contract";

export function jsonTransport(opts: { port: number }): ClientTransport {
  return {
    async start(api: OrchestratorAPI) {
      // dispatch incoming JSON frames into api.init / api.spin / api.openRound / ...
      return { port: opts.port };
    },
    stop(o) { /* drain + close */ },
    setExtraFetch(fn) { /* mount admin handler on the same Bun.serve */ },
  };
}

Implementing setExtraFetch lets createServer mount /admin/* and probes on your transport's port (single-port mode). Skip it and the caller passes adminPort to get a separate listener. Deeper: wire reference.

Metrics & logs

Bring your own RgsMetrics registry, or use the standard Prometheus one. Bring your own log formatter via @open-rgs/log (bundled: json, console, server-core).

import { createRgsMetrics } from "@open-rgs/core";
import { log } from "@open-rgs/log";

log.setFormat("json");                 // or "console" / "server-core" / a custom fn

await createServer({
  metrics: createRgsMetrics({ prefix: "myco_" }),
  manifest, platform, transport,
});

Metrics surface at GET /admin/metrics. Ring-buffered logs at GET /admin/logs?level=&limit=. Deeper: admin reference.

Simulator marks & expectations

Annotate the math with host.mark.* calls and a expected block. The orchestrator ignores both; the simulator records them and reports deviations vs target.

return {
  kind = "simple", name = "spin", version = "0.1.0", rtp = 0.95,
  expected = {
    hitRate         = { target = 0.32, tolerance = 0.01 },
    rtpContribution = {
      scatter   = { target = 0.18 },
      paylines  = { target = 0.77 },
    },
  },
  play = function(prev, ctx)
    local m = roll()
    if m > 0 then host.mark.tag("win") end
    host.mark.contribute("paylines", m * 0.9)
    host.mark.contribute("scatter",  m * 0.1)
    return { multiplier = m, ops = { ... }, type = m > 0 and "win" or "loss" }
  end,
}

Marks are inert in production — zero cost. Run a sim: bunx open-rgs-sim ./maths/spin.lua --spins 1e8. Reports: per-mode JSON, Markdown, HTML.

Modes (ante, buy, freespins)

Multi-mode games are a manifest concern, not a math concern. Add a mode with a stakeMultiplier, mark it internal: true if only reachable via next_mode, give it its own declaredRtp and optional maxWinMultiplier. Deeper: boot reference.

Idempotency

Configure the key generator + TTL at boot. Default is uuid-v4 with a 5-minute dedupe window. The key is forwarded to every state-changing wallet RPC; the wallet may dedupe.

import { cuidV2 } from "@open-rgs/core/idempotency";

await createServer({
  idempotency: { generate: cuidV2, ttlMs: 600_000 },
  manifest, platform, transport,
});