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
12 changes: 6 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ on:
# The kit-pin job below fails CI when this pin falls behind the newest
# published binary release, so drift can't accumulate silently again.
env:
SHELLCADE_KIT_VERSION: "2.8.0"
SHELLCADE_KIT_SHA256: "e7cd9dde11ff9a8c60815dee056a1c33bc35e6aef7c8131417c52e28b9c4a195"
SHELLCADE_KIT_VERSION: "2.9.0"
SHELLCADE_KIT_SHA256: "9db05e285fb0d9ea41c1f2e772d1658814d9048bc9bee0e18e89d3b2a2be203d"

jobs:
# Toolchain pin lockstep: the SHELLCADE_KIT_VERSION pin above is a manual
Expand Down Expand Up @@ -121,7 +121,7 @@ jobs:
- uses: actions/setup-go@v5
with: { go-version-file: kit/go.mod }
- uses: acifani/setup-tinygo@v2
with: { tinygo-version: "0.41.0" }
with: { tinygo-version: "0.41.1" }
- run: sudo apt-get update && sudo apt-get install -y binaryen
- name: fetch pinned shellcade-kit (sha256-verified)
run: |
Expand Down Expand Up @@ -149,7 +149,7 @@ jobs:
go mod edit -replace github.com/shellcade/kit/v2=../kit
go mod tidy
go build ./...
tinygo build -opt=1 -no-debug -gc=leaking -o cigame.wasm \
tinygo build -opt=1 -no-debug -gc=conservative -o cigame.wasm \
-target wasip1 -buildmode=c-shared .
ls -la cigame.wasm
shellcade-kit check cigame.wasm
Expand Down Expand Up @@ -232,7 +232,7 @@ jobs:
- uses: actions/setup-go@v5
with: { go-version-file: kit/go.mod }
- uses: acifani/setup-tinygo@v2
with: { tinygo-version: "0.41.0" }
with: { tinygo-version: "0.41.1" }
- run: sudo apt-get update && sudo apt-get install -y binaryen
- name: fetch pinned shellcade-kit (sha256-verified)
run: |
Expand All @@ -253,7 +253,7 @@ jobs:
cd cigame
go mod edit -replace github.com/shellcade/kit/v2=../kit
go mod tidy
tinygo build -opt=2 -no-debug -gc=leaking -o cigame.wasm \
tinygo build -opt=2 -no-debug -gc=conservative -o cigame.wasm \
-target wasip1 -buildmode=c-shared .
ls -la cigame.wasm
shellcade-kit check cigame.wasm
8 changes: 5 additions & 3 deletions ABI.md
Original file line number Diff line number Diff line change
Expand Up @@ -543,11 +543,13 @@ is what keeps the diff a `memcmp` and a from-scratch encoder tiny).
- **Frames pass by pointer** in SDKs. A by-value ~46KB frame struct explodes
into thousands of wasm locals: ~50× compile time, ~15× artifact size, and
optimizer OOMs.
- TinyGo: dev profile `-opt=1 -no-debug -gc=leaking -target wasip1
- TinyGo: dev profile `-opt=1 -no-debug -gc=conservative -target wasip1
-buildmode=c-shared` (~seconds). Release profile `-opt=2` (CI only).
`-opt=0` is unsupported (oversized functions crash wazero's arm64 backend).
`-gc=leaking` is the interim profile while TinyGo's conservative GC fault is
tracked; SDKs must keep the steady state allocation-free (reused encode and
`-gc=conservative` is the profile since 2026-06-11: leaking GC OOM-trapped
long-lived rooms against the host's linear-memory cap, and the TinyGo-0.41
conservative-GC fault does not reproduce on 0.41.1. SDKs must still keep
the steady state allocation-free (reused encode and
baseline buffers, freed Extism allocations). v2's per-consumer baseline table
(one packed 24-byte-cell grid per roster slot + a broadcast slot + a
keyframe-sized delta scratch) is reused package globals written by index, not
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ and example games. Third-party game authors import this module.
- Frames pass by **pointer** in all SDK surfaces (see ABI.md §6) — never
by value.
- Keep the steady state allocation-free in guest paths (reused encode
buffers, freed Extism memory) while the TinyGo GC issue is open.
buffers, freed Extism memory) — under `-gc=conservative` steady-state
allocations are GC pressure inside the callback deadline.

## Docs live here, and they are the product

Expand Down Expand Up @@ -53,7 +54,7 @@ go build ./... && go vet ./...
# end-to-end author journey (CI mirrors this): scaffold, build, verify.
go run ./cmd/shellcade-kit new mygame
cd mygame && go mod tidy && go run . # native dev runner
tinygo build -opt=1 -no-debug -gc=leaking \
tinygo build -opt=1 -no-debug -gc=conservative \
-o mygame.wasm -target wasip1 -buildmode=c-shared .
```

Expand Down
5 changes: 3 additions & 2 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,8 @@ nothing until something happens to move.

The frame-delta layer already makes CLEAN-but-resent frames cheap on the
wire; dirty tracking makes them free in CPU too. Allocation discipline
matters as much as ever under `-gc=leaking`: compose into a reused
matters as much as ever — under `-gc=conservative` steady-state allocations
are GC pressure (pauses inside the callback deadline): compose into a reused
`kit.Frame`, write cells directly, avoid per-player-per-wake string
building.

Expand Down Expand Up @@ -546,7 +547,7 @@ The `smoke` package exposes the same machinery as Go API (`smoke.Parse`,
| Stage | Command | What it proves |
|---|---|---|
| iterate | `go run .` (~0.1s) | gameplay, rendering, logic |
| artifact | `tinygo build -opt=1 -no-debug -gc=leaking -target wasip1 -buildmode=c-shared .` (~4s) | the real wasm builds |
| artifact | `tinygo build -opt=1 -no-debug -gc=conservative -target wasip1 -buildmode=c-shared .` (~4s) | the real wasm builds |
| verify | `shellcade-kit check game.wasm` | ABI conformance, budgets, determinism — the same gate the arcade runs |
| play it | `shellcade-kit play game.wasm --seats 2` | the artifact, on the production engine |

Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,20 @@ Rules of the road:
Flags: `-seed N -heartbeat 50ms -config k=v -handle name`.
2. **Artifact check (~4s):** build the real wasm and verify it:

tinygo build -opt=1 -no-debug -gc=leaking -o game.wasm \
tinygo build -opt=1 -no-debug -gc=conservative -o game.wasm \
-target wasip1 -buildmode=c-shared .
3. **Release:** `-opt=2` in CI (minutes — never in your inner loop).

- `-opt=1` skips binaryen/wasm-opt (the slow part). Release builds can use
`-opt=2`; expect minutes, run it in CI not your inner loop.
- `-opt=0` is NOT supported (giant unoptimized functions crash wazero's
arm64 compiler).
- `-gc=leaking` is required for now: TinyGo 0.41's conservative GC faults in
this reactor configuration (recorded finding; kit keeps the steady state
allocation-free so the leak rate is negligible for play sessions).
- `-gc=conservative` is the build profile (since 2026-06-11): leaking GC made
every allocation permanent, so long-lived rooms hit the host's 32 MiB cap
and trapped (~52 min of play in production). The previously recorded
TinyGo-0.41 conservative-GC fault does not reproduce on 0.41.1
(200k-callback soak, flat memory). Keep steady-state paths allocation-free
anyway — it minimizes GC pauses inside the callback deadline.

## Test and play

Expand Down
9 changes: 5 additions & 4 deletions internal/game/codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ type callContext struct {
// 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).
// GC pressure under -gc=conservative (the recommended build profile), and
// under -gc=leaking builds 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
Expand Down
2 changes: 1 addition & 1 deletion internal/game/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ func TestRosterCache(t *testing.T) {
}

// Same roster bytes: changed=false and the SAME backing slice is reused
// (zero member allocations — the -gc=leaking lifeline).
// (zero member allocations — the roster-cache lifeline; see codec.go).
c2, _, changed := decodeCtx(ctxPayload(ada))
if changed {
t.Fatal("identical roster must not report a change")
Expand Down
3 changes: 2 additions & 1 deletion internal/game/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ func ExportMeta() int32 {
// 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
// free — still load-bearing under -gc=conservative (GC pressure), and
// essential under -gc=leaking builds, where per-callback roster decodes
// leak at callback rate and OOM long-lived large rooms).
func decodeCall() (*room, *wire.Rd) {
ctx, r, rosterChanged := decodeCtx(inputBytes())
Expand Down
Loading