Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-unseeded-room-rng.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kit": patch
---

Fix every unseeded wasm room sharing one deterministic RNG stream. The guest runtime seeds the SDK room PRNG (`r.Rand()` in Go, `rng_u64` in Rust) from the CallContext seed verbatim, but for a room without an operator-set seed the host encoded the raw config zero — so every room of every game drew the identical "random" sequence (identical blackjack shoes across rooms; casino outcomes learnable by replaying a throwaway room and then betting on the known stream). The host now derives a per-room seed from the host CSPRNG when `SeedSet` is false and carries it in the room's config, so the Ctx (and the WASI entropy source, which previously used the guessable room-start timestamp) are seeded per room. Host-side only: existing game binaries pick up the fix without a rebuild. Explicitly seeded runs (`--seed` / `-seed`, conformance, hibernation restore) are unchanged and stay deterministic.
7 changes: 7 additions & 0 deletions ABI.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ u16 memberCount on `leave`, includes the departed entry
u8 settled 0/1
```

**Seed.** The guest runtime seeds the SDK room PRNG from `seed` verbatim, so
the host MUST populate it with a per-room unpredictable value when no seed was
requested — never a shared constant (every room would deal the identical
"random" stream). `seedSet=1` means an operator explicitly requested this seed
(deterministic dev/test runs, hibernation restore); the value is equally
binding either way.

**Roster-epoch member-section forms (minor addition).** For a guest whose
meta declares `CtxFeatRosterEpoch` (§4.2), the host MAY replace the member
section with one of two sentinel forms keyed on `memberCount` (real rosters
Expand Down
19 changes: 17 additions & 2 deletions host/gameabi/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package gameabi

import (
"context"
"errors"
crand "crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"io"
"log/slog"
Expand Down Expand Up @@ -529,7 +531,20 @@ func (h *wasmHandler) OnStart(r sdk.Room) {
h.nowNanos = r.Now().UnixNano()
h.seed = h.cfg.Seed
if !h.cfg.SeedSet {
h.seed = h.nowNanos
// The guest runtime seeds its SDK PRNG (r.Rand()) from the CallContext
// seed verbatim, so an unseeded room MUST carry a per-room seed in its
// cfg — left at the zero value, every room of every game would share
// one deterministic stream (identical blackjack shoes; casino outcomes
// learnable from a throwaway room). Derived from the host CSPRNG, not
// the room clock: a start timestamp is guessable to within a small
// search window, which a wagering game cannot afford.
var b [8]byte
if _, err := crand.Read(b[:]); err == nil {
h.seed = int64(binary.LittleEndian.Uint64(b[:]))
} else {
h.seed = h.nowNanos // CSPRNG unreachable; degraded, not fatal
}
h.cfg.Seed = h.seed
}

inst, err := h.game.compiled.Instance(context.Background(),
Expand Down
55 changes: 55 additions & 0 deletions host/gameabi/seed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package gameabi

import (
"log/slog"
"strings"
"testing"

"github.com/shellcade/kit/v2/host/sdk"
)

// readGameRand starts a fixture room with cfg, presses 'g', and returns the
// guest's logged r.Rand() value — the SDK room PRNG the guest runtime seeds
// from the CallContext seed (NOT the 'r' WASI entropy source).
func readGameRand(t *testing.T, cfg sdk.RoomConfig) string {
t.Helper()
g := loadFixture(t, Options{})
cap := &logCapture{}
tr := sdk.NewTestRoom(g, cfg, sdk.Services{Log: slog.New(cap)})
tr.Start()
tr.Join(p1)
tr.Input(p1, runeIn('g'))
line, ok := cap.findLine("fixture: game_rand=")
if !ok {
t.Fatalf("no game_rand log; got %v", cap.lines())
}
return strings.TrimPrefix(line, "fixture: game_rand=")
}

// TestUnseededRoomsGetDistinctGameRand: two production-shaped rooms (no
// explicit seed: Seed=0, SeedSet=false) must NOT share an r.Rand() stream.
// The guest runtime seeds its PRNG from the CallContext seed verbatim, so the
// host must place a per-room derived seed there when the operator did not set
// one — otherwise every room of every game deals the identical "random"
// sequence (and casino outcomes are learnable by replaying a fresh room).
func TestUnseededRoomsGetDistinctGameRand(t *testing.T) {
cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1}
a, b := readGameRand(t, cfg), readGameRand(t, cfg)
if a == b {
t.Fatalf("two unseeded rooms produced the identical r.Rand() stream: %s", a)
}
}

// TestExplicitSeedKeepsGameRandDeterministic: the dev/--seed contract is
// untouched by the unseeded-room derivation — the same explicit seed still
// reproduces the same r.Rand() stream.
func TestExplicitSeedKeepsGameRandDeterministic(t *testing.T) {
cfg := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 7, SeedSet: true}
if s1, s2 := readGameRand(t, cfg), readGameRand(t, cfg); s1 != s2 {
t.Fatalf("same explicit seed produced different r.Rand() streams: %s vs %s", s1, s2)
}
other := sdk.RoomConfig{Mode: sdk.ModeSolo, Capacity: 1, MinPlayers: 1, Seed: 8, SeedSet: true}
if s1, s3 := readGameRand(t, cfg), readGameRand(t, other); s1 == s3 {
t.Fatalf("different explicit seeds produced the identical r.Rand() stream: %s", s1)
}
}
Binary file modified host/gameabi/testdata/fixture/fixture.wasm
Binary file not shown.
5 changes: 5 additions & 0 deletions host/gameabi/testdata/fixture/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// 'c' config_get("greeting"), logged
// 't' log the guest's own time.Now().UnixNano() (== the room clock)
// 'r' log 8 bytes from crypto/rand (the WASI-virtualized entropy source)
// 'g' log the next value from r.Rand() (the SDK room PRNG, Ctx-seeded)
// 'd' arm a 250ms countdown deadline; each wake renders remaining ms, then
// when CallContext time passes the deadline it renders BOOM and ends
//
Expand Down Expand Up @@ -149,6 +150,10 @@ func (rm *room) OnInput(r kit.Room, p kit.Player, in kit.Input) {
// rooms with the same seed log identical entropy.
_, _ = crand.Read(rm.entropy[:])
r.Log("fixture: rand=" + hex8(rm.entropy))
case 'g':
// The SDK room PRNG — seeded by the guest runtime from the CallContext
// seed, distinct from the 'r' WASI entropy source.
r.Log("fixture: game_rand=" + strconv.FormatInt(r.Rand().Int63(), 10))
case 'd':
// Arm a countdown anchored to the room clock; the host drives it forward
// with wakes (CallContext time), no host-side timer involved.
Expand Down
Loading