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
25 changes: 25 additions & 0 deletions .changeset/large-room-scale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"kit": minor
---

Large-room scale: the guest SDK now supports rooms of up to 1024 players.

- **Per-index frame baselines raised 16 → 1024, allocated lazily.** The SDK
used to silently drop `Send` for a roster index ≥ 16; the per-slot baseline
table is now sized for 1024 consumers but each ~45 KiB slot is allocated on
first commit, so guest linear memory tracks the ACTIVE roster, not the cap.
A broadcast (`Identical`) reconciles only allocated slots; a never-sent-to
slot recovers via its first send opening with a keyframe (unconditionally
accepted) — the same path as a roster change. No wire/ABI change.
- **Cross-callback roster cache.** The host re-sends the full member list in
every callback payload, but rosters change only on join/leave/index-shift.
The SDK now skims the member section's raw bytes (new additive
`wire.(*Rd).SkipStr`) and compares them to the previous callback's: on a
match the previously decoded `[]Player` is reused with zero allocation;
only a real roster change re-decodes. This replaces the roster fingerprint
hash (the byte compare is strictly stronger) and removes an O(members)
allocation from EVERY callback — which, under `-gc=leaking`, leaked the
roster at callback rate and OOM'd long-lived large rooms (~100 KB/callback
at 1000 players). Lifetime contract: the slice from `Room.Members()` is
valid for the duration of the callback; copy `Player` values you retain
(long-lived state should be keyed by `AccountID`, as before).
142 changes: 92 additions & 50 deletions internal/game/codec.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package game

import "github.com/shellcade/kit/v2/wire"
import (
"bytes"

"github.com/shellcade/kit/v2/wire"
)

// The guest side of the ABI codecs: thin mappings between wire types (the
// canonical encodings owned by gamekit/wire) and the authoring types.
Expand All @@ -13,28 +17,82 @@ type callContext struct {
settled bool
}

// Roster cache: the host re-sends the full member list in EVERY callback
// payload, but rosters change only on join/leave/index-shift. Decoding N
// members afresh per callback is O(N) string allocations per input/wake —
// under -gc=leaking (the recommended build profile while the TinyGo GC issue
// is open) those allocations are PERMANENT, so a long-lived large room leaks
// its roster at callback rate (load testing measured ~100KB leaked per
// callback at 1000 players, OOMing the guest within seconds of play).
//
// Instead, the raw wire bytes of the member section are compared against the
// previous callback's (a ~100B/member memcmp); on a match the previously
// decoded []Player is reused with ZERO allocation, and only a real roster
// change re-decodes. The byte compare is strictly stronger than the old
// rosterFingerprint hash (which this replaces): any join/leave/index-shift/
// kind-change alters the bytes.
//
// Lifetime contract: the members slice handed to a callback (and returned by
// Room.Members()) is valid for the DURATION OF THAT CALLBACK; a later roster
// change re-decodes into the same backing array. Games that retain players
// across callbacks must copy the Player values they keep (kit games key
// long-lived state by AccountID already).
var (
rosterCache []Player
rosterCacheBytes []byte
)

// decodeCtx decodes a CallContext and returns the reader positioned at the
// event-specific extra payload.
func decodeCtx(b []byte) (callContext, *wire.Rd) {
// event-specific extra payload, plus whether the roster changed since the
// previous callback (true on the first callback).
func decodeCtx(b []byte) (callContext, *wire.Rd, bool) {
r := &wire.Rd{B: b}
wc := wire.DecodeCtx(r)
c := callContext{
nowUnixNanos: wc.NowUnixNanos,
cfg: RoomConfig{
Mode: Mode(wc.Mode),
Capacity: int(wc.Capacity),
MinPlayers: int(wc.MinPlayers),
Seed: wc.Seed,
SeedSet: wc.SeedSet,
},
settled: wc.Settled,
c := callContext{nowUnixNanos: r.I64()}
c.cfg.Seed = r.I64()
c.cfg.SeedSet = r.Bool()
c.cfg.Mode = Mode(r.U8())
c.cfg.Capacity = int(r.U16())
c.cfg.MinPlayers = int(r.U16())

// Skim the member section (count + per-member strings) without decoding,
// to find its extent for the cache compare.
start := r.Off
n := int(r.U16())
for i := 0; i < n && !r.Bad; i++ {
r.SkipStr() // handle
r.SkipStr() // account id
r.SkipStr() // conn
r.U8() // kind
}
for _, p := range wc.Members {
c.members = append(c.members, Player{
AccountID: p.AccountID, Handle: p.Handle, Conn: p.Conn, Kind: Kind(p.Kind),
})
region := r.B[start:r.Off]

changed := rosterCacheBytes == nil || !bytes.Equal(region, rosterCacheBytes)
if changed {
rr := &wire.Rd{B: region}
cnt := int(rr.U16())
rosterCache = rosterCache[:0]
for i := 0; i < cnt && !rr.Bad; i++ {
var p Player
p.Handle = rr.Str()
p.AccountID = rr.Str()
p.Conn = rr.Str()
p.Kind = Kind(rr.U8())
rosterCache = append(rosterCache, p)
}
if r.Bad {
// Malformed member section: don't prime the cache — every
// malformed callback stays "changed" and decodes what it can
// (the pre-cache behavior). A well-formed region is ≥2 bytes
// (the count), so a primed cache is never nil.
rosterCacheBytes = nil
} else {
rosterCacheBytes = append(rosterCacheBytes[:0], region...)
}
}
return c, r
c.members = rosterCache

c.settled = r.Bool()
return c, r, changed
}

// encodeMeta packs GameMeta for the meta export.
Expand Down Expand Up @@ -104,18 +162,22 @@ func encodeFrame(f *Frame) []byte {
// ---- frame diffing state (ABI v2) -------------------------------------------

// rosterCap is the fixed compile-time roster ceiling for per-index baselines.
// At 24-byte cells the baseline table is (rosterCap+broadcast)*FrameBytes ≈
// 0.78 MB of guest linear memory, lazily grown, far under the 32 MiB cap.
const rosterCap = 16
// 1024 supports large-room games (the SDK SILENTLY DROPS Send for an index
// >= rosterCap, so the cap must comfortably exceed any real roster).
// Guest linear memory stays proportional to the ACTIVE roster, not the cap:
// per-slot baselines are lazily allocated on first commit — ~45 KiB per
// actively-sent-to consumer instead of a ~47 MiB static table.
const rosterCap = 1024

// Per-consumer SDK baseline state, allocated once and reused forever (leaking-GC
// safe; one room per instance + serial callbacks ⇒ no locking). Each slot holds
// the last full frame the SDK sent that consumer (24-byte canonical cells), the
// host-returned epoch it was stamped with, and a present flag. Index rosterCap
// is the broadcast (Identical) slot. deltaScratch is the pre-sized worst-case
// (keyframe-sized) delta buffer written by index — never wire.Buf, which grows.
// Baseline slots are nil until first committed (lazy; see rosterCap note).
var (
baselines [rosterCap + 1][wire.FrameBytes]byte
baselines [rosterCap + 1][]byte // wire.FrameBytes each once allocated
baselineEpoch [rosterCap + 1]uint32
baselinePresent [rosterCap + 1]bool
deltaScratch [wire.MaxDeltaBytes]byte
Expand Down Expand Up @@ -145,7 +207,7 @@ func buildSendPayload(slot int, packed []byte) []byte {
n := wire.BuildKeyframe(packed, deltaScratch[:], epoch)
return deltaScratch[:n]
}
n := wire.BuildFrameDelta(baselines[slot][:], packed, deltaScratch[:], epoch)
n := wire.BuildFrameDelta(baselines[slot], packed, deltaScratch[:], epoch)
if n >= wire.KeyframeBytes {
n = wire.BuildKeyframe(packed, deltaScratch[:], epoch)
}
Expand All @@ -154,37 +216,17 @@ func buildSendPayload(slot int, packed []byte) []byte {

// commitBaseline records `packed` as slot `slot`'s baseline and stamps it with
// the host-returned epoch, marking it present. Called on a successful send.
// The slot buffer is allocated on first commit (lazy; leaking-GC safe because
// a slot is allocated at most once and reused forever).
func commitBaseline(slot int, packed []byte, returnedEpoch uint32) {
copy(baselines[slot][:], packed)
if baselines[slot] == nil {
baselines[slot] = make([]byte, wire.FrameBytes)
}
copy(baselines[slot], packed)
baselineEpoch[slot] = returnedEpoch
baselinePresent[slot] = true
}

// rosterFingerprint is a cheap fixed-scratch identity of the current roster used
// to detect a roster change between callbacks (join/leave/index-shift): the
// member count plus a rolling hash of each member's account id + kind. It needs
// no allocation and survives across callbacks as a package global.
var (
lastRosterPrint uint64
lastRosterSet bool
)

func rosterFingerprint(members []Player) uint64 {
h := uint64(1469598103934665603) // FNV-1a offset
mix := func(b byte) { h ^= uint64(b); h *= 1099511628211 }
mix(byte(len(members)))
mix(byte(len(members) >> 8))
for _, p := range members {
for i := 0; i < len(p.AccountID); i++ {
mix(p.AccountID[i])
}
mix(0)
mix(byte(p.Kind))
mix('|')
}
return h
}

// encodeResult packs a Result against the current roster (player -> index).
func encodeResult(res Result, roster []Player) []byte {
var wr wire.Result
Expand Down
100 changes: 86 additions & 14 deletions internal/game/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,10 @@ func resetDiffState() {
for i := range baselinePresent {
baselinePresent[i] = false
baselineEpoch[i] = 0
for j := range baselines[i] {
baselines[i][j] = 0
}
baselines[i] = nil // lazy slots: drop, re-allocated on next commit
}
lastRosterSet = false
lastRosterPrint = 0
rosterCache = nil
rosterCacheBytes = nil
}

func frameWith(text string) *Frame {
Expand Down Expand Up @@ -189,22 +187,96 @@ func TestIdenticalReconcilesAllSlotsThenSend(t *testing.T) {
}
}

func TestRosterChangeInvalidates(t *testing.T) {
// ctxPayload wire-encodes a CallContext for decodeCtx tests.
func ctxPayload(members ...wire.Player) []byte {
var w wire.Buf
wire.EncodeCtx(&w, wire.Ctx{
NowUnixNanos: 1, Seed: 7, SeedSet: true,
Mode: 0, Capacity: 1000, MinPlayers: 1,
Members: members,
})
return w.B
}

func TestRosterCache(t *testing.T) {
resetDiffState()
p1 := rosterFingerprint([]Player{{AccountID: "a", Kind: KindMember}})
p2 := rosterFingerprint([]Player{{AccountID: "a", Kind: KindMember}, {AccountID: "b", Kind: KindMember}})
if p1 == p2 {
t.Fatal("roster fingerprint collision on join")
ada := wire.Player{Handle: "ada", AccountID: "a", Conn: "c1", Kind: 1}
bob := wire.Player{Handle: "bob", AccountID: "b", Conn: "c2", Kind: 1}

// First callback: changed=true, members decoded.
c1, _, changed := decodeCtx(ctxPayload(ada))
if !changed {
t.Fatal("first callback must report a roster change")
}
if len(c1.members) != 1 || c1.members[0].AccountID != "a" {
t.Fatalf("decoded members = %+v", c1.members)
}

// Same roster bytes: changed=false and the SAME backing slice is reused
// (zero member allocations — the -gc=leaking lifeline).
c2, _, changed := decodeCtx(ctxPayload(ada))
if changed {
t.Fatal("identical roster must not report a change")
}
if len(c2.members) != 1 || &c1.members[0] != &c2.members[0] {
t.Fatal("unchanged roster did not reuse the cached members slice")
}

// A join: changed=true, members re-decoded.
c3, _, changed := decodeCtx(ctxPayload(ada, bob))
if !changed {
t.Fatal("join must report a roster change")
}
if len(c3.members) != 2 || c3.members[1].AccountID != "b" {
t.Fatalf("post-join members = %+v", c3.members)
}
// Same roster -> same print.
if p1 != rosterFingerprint([]Player{{AccountID: "a", Kind: KindMember}}) {
t.Fatal("fingerprint not stable for identical roster")

// A leave back to the original shape: changed again, and correct.
c4, _, changed := decodeCtx(ctxPayload(ada))
if !changed || len(c4.members) != 1 {
t.Fatalf("leave: changed=%v members=%+v", changed, c4.members)
}
// invalidateBaselines clears present.

// invalidateBaselines clears present (the decodeCall side effect of a
// roster change).
baselinePresent[0] = true
baselinePresent[broadcastSlot] = true
invalidateBaselines()
if baselinePresent[0] || baselinePresent[broadcastSlot] {
t.Fatal("invalidateBaselines did not clear present")
}
}

// TestLazyBaselineHighSlot: slots beyond the old 16-slot table work and are
// allocated lazily (the 1024-player patch's regression guard — the SDK used
// to silently drop sends for index >= 16).
func TestLazyBaselineHighSlot(t *testing.T) {
resetDiffState()
const slot = 900
if baselines[slot] != nil {
t.Fatal("slot 900 allocated before first commit")
}
f := frameWith("deep")
packed := append([]byte(nil), encodeFrame(f)...)

// First send to the slot: keyframe form (not present), then commit.
payload := buildSendPayload(slot, packed)
if !wire.IsKeyframe(payload) {
t.Fatal("first send to a fresh high slot must be a keyframe")
}
commitBaseline(slot, packed, 5)
if baselines[slot] == nil || !baselinePresent[slot] {
t.Fatal("commit did not lazily allocate + mark the high slot")
}
if !bytes.Equal(baselines[slot], packed) {
t.Fatal("high-slot baseline does not match the committed frame")
}

// Second send: a delta against the lazily-allocated baseline.
f2 := frameWith("deeq")
packed2 := encodeFrame(f2)
payload2 := buildSendPayload(slot, packed2)
if wire.IsKeyframe(payload2) {
t.Fatal("second send to the slot should be a delta, not a keyframe")
}
}
13 changes: 11 additions & 2 deletions internal/game/room.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,19 @@ func (r *room) Identical(f *Frame) {
returned = uint32(hostIdentical(m.Offset()))
m.Free()
}
// Reconcile the broadcast slot and EVERY per-index baseline.
// Reconcile the broadcast slot and every ALLOCATED per-index baseline.
// Unallocated (never sent-to) slots are NOT allocated here — committing
// to all rosterCap slots would materialize the whole lazy table on the
// first broadcast. An unallocated slot is instead left not-present, so a
// later per-player Send to it opens with a keyframe (unconditionally
// accepted) — same recovery path as a roster change.
commitBaseline(broadcastSlot, packed, returned)
for i := 0; i < rosterCap; i++ {
commitBaseline(i, packed, returned)
if baselines[i] != nil {
commitBaseline(i, packed, returned)
} else {
baselinePresent[i] = false
}
}
}

Expand Down
23 changes: 12 additions & 11 deletions internal/game/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,23 @@ func ExportMeta() int32 {
}

// decodeCall decodes the input payload into a Room for this callback. It also
// runs the roster-change backstop (D7): a cheap fixed-scratch fingerprint of the
// roster is compared to the previous callback's; on any change (join/leave/
// index-shift) every per-index baseline is invalidated so the next send to each
// slot is a keyframe. This is the host-authority backstop, not the primary
// resync (the host's epoch bump is) — but it keeps the guest's baselines from
// diffing across a roster renumber.
// runs the roster-change backstop (D7): decodeCtx's roster cache compares the
// member section's raw wire bytes against the previous callback's; on any
// change (join/leave/index-shift) every per-index baseline is invalidated so
// the next send to each slot is a keyframe. This is the host-authority
// backstop, not the primary resync (the host's epoch bump is) — but it keeps
// the guest's baselines from diffing across a roster renumber. The byte
// compare is strictly stronger than the fingerprint hash it replaced, and on
// an unchanged roster the callback decodes ZERO member strings (allocation-
// free — load-bearing under -gc=leaking, where per-callback roster decodes
// leak at callback rate and OOM long-lived large rooms).
func decodeCall() (*room, *wire.Rd) {
ctx, r := decodeCtx(inputBytes())
ctx, r, rosterChanged := decodeCtx(inputBytes())
if rng == nil {
rng = rand.New(rand.NewSource(ctx.cfg.Seed))
}
print := rosterFingerprint(ctx.members)
if !lastRosterSet || print != lastRosterPrint {
if rosterChanged {
invalidateBaselines()
lastRosterPrint = print
lastRosterSet = true
}
return &room{ctx: ctx, rng: rng}, r
}
Expand Down
Loading
Loading