Skip to content

guest sdk: large-room scale — 1024 lazy frame baselines + cross-callback roster cache#25

Merged
BCook98 merged 1 commit into
mainfrom
large-room-scale
Jun 7, 2026
Merged

guest sdk: large-room scale — 1024 lazy frame baselines + cross-callback roster cache#25
BCook98 merged 1 commit into
mainfrom
large-room-scale

Conversation

@BCook98

@BCook98 BCook98 commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Takes the guest SDK from 16-player rooms to 1024, found while load-testing large rooms (50→1000 players, 200 measured 50ms ticks per size, roguelike-shaped guest with per-player scrolling viewports).

1. rosterCap 16 → 1024, lazy per-slot baselines

The SDK silently drops Send for a roster index ≥ rosterCap — the old 16 was a hard invisible wall for any larger room. Raising it naively would cost a ~47 MiB static baseline table, so per-slot baselines (~45 KiB each) are now allocated on first commit: guest linear memory tracks the active roster, not the cap.

Identical reconciles only allocated slots; a never-sent-to slot stays not-present and recovers via its first per-player Send opening with a keyframe (unconditionally accepted) — the same recovery path as a roster change. No wire/ABI change (the send index was already u32-framed).

2. Cross-callback roster cache

The host re-sends the full member list in every callback payload, but rosters only change on join/leave/index-shift. decodeCtx now skims the member section's raw bytes (additive wire.(*Rd).SkipStr) and memcmps against the previous callback's region:

  • match → the previously decoded []Player is reused, zero allocations
  • mismatch → re-decode + invalidate baselines (replaces the rosterFingerprint hash; the byte compare is strictly stronger)

This matters because under -gc=leaking (the recommended profile while the TinyGo GC issue is open) the old per-callback decode leaked the roster at callback rate — measured ~100 KB/callback at 1000 players, OOMing the guest within seconds of large-room play.

Lifetime contract change: the slice from Room.Members() is valid for the duration of the callback; copy any Player you retain (long-lived state keys by AccountID, as before — no example game is affected).

Measured (Apple M4 Pro, 200 ticks/size, conservative-GC guest)

players mean input delivery guest leak rate (-gc=leaking) snapshot size @1000p
before 5.4 ms @1000p OOM at tick 17 (1000p) / 95 (500p) 7.7 MB
after 2.7 ms @1000p OOM at tick 39 / 197 — rate halved 1.3 MB

Tick-time scaling (wake compose+send path) is unchanged by design — this PR removes the per-callback roster tax, not the render cost.

Known follow-up (out of scope here)

The remaining O(members)-per-callback cost is the payload itself: the host re-encodes and the guest re-copies the full roster bytes every callback (inputBytes() alloc — still leaked under -gc=leaking). Removing it means not resending an unchanged roster — a protocol-level change (e.g. roster epoch + on-change resend) that needs host-side coordination and an ABI design pass.

Tests

  • TestRosterCache — cache hit reuses the backing slice; join/leave re-decode; first-callback semantics
  • TestLazyBaselineHighSlot — slot 900: keyframe-then-delta with lazy allocation (regression guard for the silent-drop wall)
  • TestIdenticalThenSendReconciles-adjacent host conformance updated separately in the engine
  • full go test ./... green

🤖 Generated with Claude Code

…ack roster cache

Two changes that take the SDK from 16-player rooms to 1024:

rosterCap 16 -> 1024, with lazy per-slot baselines. The SDK silently
drops Send for a roster index >= rosterCap, so the old cap was a hard
invisible wall for any room beyond 16 players. Each ~45 KiB baseline
slot is now allocated on first commit (guest memory tracks the active
roster, not the cap), and Identical reconciles only allocated slots —
a never-sent-to slot recovers via its first send opening with a
keyframe, 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. decodeCtx now skims the member section's raw bytes (additive
wire.(*Rd).SkipStr) and memcmps them against the previous callback's:
on a match the previously decoded []Player is reused with zero
allocation; only a real change re-decodes. Replaces the roster
fingerprint hash (the byte compare is strictly stronger). Under
-gc=leaking the old per-callback decode leaked the roster at callback
rate (~100 KB/callback at 1000 players — guest OOM within seconds of
large-room play); steady-state callbacks are now allocation-free in
the member path. Members() slices are valid for the duration of the
callback — copy retained Players (long-lived state keys by AccountID,
as before).

Load-benchmarked at 50..1000 players (200 ticks/size): mean input
delivery halved at every size, guest heap growth halved under
-gc=leaking, hibernation snapshots ~6x smaller at 1000 players.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@BCook98
BCook98 marked this pull request as ready for review June 7, 2026 00:01
@BCook98
BCook98 merged commit ae1e169 into main Jun 7, 2026
5 checks passed
@BCook98
BCook98 deleted the large-room-scale branch June 7, 2026 00:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant