Caputchin
Marketplace-Spiel-Entwicklung

Der Replay-Vertrag

Der Replay-Vertrag ist die eine verpflichtende Fläche, auf die sich ein abspielbares Spiel mit der Plattform einigt. Er wird als @caputchin/replay-contract veröffentlicht: ein bewusst winziges, abhängigkeitsfreies Paket (es wird in ein versiegeltes Replay-Isolate geladen, also zieht es nichts herein). Alles andere an deinem Spiel, die Engine, der Renderer, das Trace-Format, gehört dir.

Ein Spiel, das ein konformes run ausliefert, ist abspielbar und kann so einen Site-Key gaten; ein Spiel ohne eines ist trotzdem einbettbar, aber nur als UX (es zeigt Nicht abspielbar). Sieh dir Ein Spiel an den Marketplace ausliefern für diese Unterscheidung an.

Die run-Funktion

Ein abspielbares Spiel liefert ein JS- oder WASM-Modul, das eine Funktion namens run exportiert:

run(seed, config, trace) -> { passed, score, durationMs }
type RunFn<C = unknown> = (
  seed: Seed,
  config: C | null,
  trace: Uint8Array | string,
) => Verdict | Promise<Verdict>
ArgumentQuelleBedeutung
seedServerDer Pro-Runde-Seed. Das server-eigene Setup der Runde.
configServerDie Gameplay-Config, nullable und opak: die Plattform inspiziert sie nie, und sie wird beim Replay serverseitig neu aufgelöst, nie vom Client behauptet. null bedeutet "nutz die eigenen Standards des Runs". Beim MVP übergibt der Server null; Pro-Site-Config ist eine aufgeschobene Phase.
traceClientDer opake Blob, den der Client des Spielers emittierte, den allein diese Funktion interpretiert. Die Plattform parst oder typisiert ihn nie; er ist rohe Bytes oder ein String, nur durch ein Größen-Cap und das Isolate-CPU-Limit begrenzt.

Die Argument-Reihenfolge kodiert das Vertrauensmodell: seed und config sind das Server-Setup der Runde; trace ist die Eingabe des Spielers. Gate-beeinflussende Werte (eine Bestehens-Schwelle, Leben) sind sicher aus config zu lesen, wären aber ein Bypass, wenn aus dem Client-trace gelesen.

Das Modul muss die Funktion unter dem Namen run exportieren (die Konstante RUN_EXPORT_NAME). Der Replay-Host ruft sie auf und awaitet das Ergebnis (die WASM-Instanziierung ist beim ersten Aufruf asynchron).

Der Seed

type Seed = readonly [number, number, number, number]   // 128 bits, 4 u32 words, MSW first

Der Seed sind die niedrigen 128 Bit von SHA-256(sessionId : gameId : roundIndex), getragen als vier vorzeichenlose 32-Bit-Wörter, höchstwertiges Wort zuerst. Das deriveSeed(sessionId, gameId, roundIndex) des Pakets berechnet ihn; der Server leitet ihn sowohl beim Ausgeben der Runde als auch erneut beim Abspielen ab, sodass er nie als vertrauenswürdige Client-Eingabe über die Leitung reitet.

Entscheidend ist, der Seed bindet einen Trace an eine Session, ein Spiel und eine Runde: einen fremden oder früheren Trace unter einem anderen Seed abzuspielen liefert passed: false. Diese Bindung ist, wie Trace-Injektion und Replay-Angriffe verteidigt werden, also musst du jede Zufälligkeit daraus seeden, damit das Live-Spiel und das Server-Replay übereinstimmen.

Das Urteil

interface Verdict {
  readonly passed: boolean      // drives the verification decision
  readonly score: number        // game-defined, any finite number
  readonly durationMs: number   // finite, non-negative
}

passed ist das einzige Feld, das die CAPTCHA-Entscheidung treibt; score und durationMs werden im ausgegebenen Token getragen (und einem künftigen Scoreboard). Das parseVerdict(value) des Pakets validiert einen nicht-vertrauenswürdigen Rückgabewert und behandelt ein fehlerhaftes Ergebnis als abgelehnte Runde, nie als bestehende, sodass ein run, das wirft oder Müll zurückgibt, geschlossen fehlschlägt.

Determinismus ist die ganze Anforderung

Die eine harte Regel des Vertrags: run muss rein und deterministisch über zwei Laufzeiten sein, der Browser des Spielers und das Server-Isolate. Identische (seed, config, trace) müssen in beiden ein identisches Urteil liefern. Die häufigen Wege, das zu brechen, alle von der Publish-Selbstprüfung als run-not-conforming gefangen:

  • Date.now(), performance.now(), Math.random() oder andere nicht-deterministische Globals lesen;
  • externen Zustand lesen (DOM, Netzwerk, Speicher), den das Replay-Isolate nicht bereitstellt;
  • sich auf Fließkomma-Mathematik verlassen, die sich zwischen Laufzeiten unterscheidet.

Wie du Determinismus erreichst (Festkomma, WASM-Spec-Floats oder IEEE-754 plus ein Neutralisierungs-Shim), ist deine Wahl; die Plattform hostet nur das Replay. Das Engine-Kit stellt fertige deterministische Primitive bereit, wenn du lieber nicht deine eigenen rollst.

Wie der Server es nutzt

Das Artefakt, das das Isolate lädt, ist dein run-Bundle, entweder das Live-entry oder das dedizierte run-Artefakt aus dem Manifest.

Siehe auch

Auf dieser Seite