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
14 changes: 14 additions & 0 deletions .changeset/kittest-kv-unavailable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"kit": minor
---

`kittest.Room` gains an opt-in `KVUnavailable` chaos knob that replays the
production host's KV degradation exactly: while set, `Get` reports every key
as missing (`nil, false, nil`) and `Set`/`Delete` return `nil` without
persisting — the ABI has no error channel, so a real store outage never
surfaces a Go error. This lets authors test the read-absent-reinit hazard
(the natural `Get → missing → initialize → Set` wallet pattern silently
resets saved state during a store blip), previously impossible to simulate
because the double's KV always succeeded. GUIDE.md's Durable state section
now documents the degradation semantics and the conservative-missing-read
guidance, and `ExampleRoom_kvUnavailable` demonstrates the failing pattern.
14 changes: 14 additions & 0 deletions .changeset/license-and-player-bounds-docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"kit": patch
---

The repo now ships an MIT `LICENSE` file at the root — `rust/Cargo.toml`
already declared `license = "MIT"`, but the module itself carried no license
text, leaving authors without usage terms. Doc consistency fixes ride along:
GUIDE.md now states the platform player bound explicitly as 1..1024
(`wire.RosterCap`) in both the Multiplayer and Large rooms sections, and the
smoke-script section documents that smoke scripts drive at most 8 seats (the
runner clamps `MinPlayers` to the seat count, so large-room games still pass;
large-room behavior is covered by `shellcade-kit check`'s budget gates). The
Rust README notes that the next shellcade-kit release makes `shellcade-kit
play` accept a game directory and run the cargo wasm build itself.
17 changes: 17 additions & 0 deletions .changeset/meta-wire-revision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"kit": minor
---

Wire-revision provenance: the packed Meta gains a trailing presence-guarded
`u16 wireRevision` — a single monotonic counter of wire-visible minor
additions (`wire.Revision`, currently 4; ledger in its docs and ABI.md §4.2),
stamped automatically by both the Go and Rust guest SDKs and never set by
the author. Old hosts ignore the bytes; artifacts built with older kits
decode as revision 0 (unknown). This gives the deploy-order rule (ABI.md §5)
its mechanical anchor: a host can now warn on or refuse artifacts declaring
a revision above the one it implements instead of loading them blind, and
record per-artifact contract provenance. Pure additive trailer following the
established pattern — ABI major stays 2. The Rust SDK's `WIRE_REVISION` is
pinned to `wire.Revision` by a new Go cross-check test
(`wire.TestRustWireRevisionMatchesWire`), and the Go/Rust meta goldens are
updated in lockstep.
12 changes: 12 additions & 0 deletions .changeset/wire-rostercap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"kit": minor
---

`wire.RosterCap` — the roster ceiling for per-index frame baselines (1024) is
now a contract constant in the `wire` package instead of a hand-mirrored
literal, and ABI.md §3 documents the bound. The Go guest SDK's internal
`rosterCap` adopts it directly; the Rust SDK's `ROSTER_CAP` is asserted equal
by a new Go cross-check test (`wire.TestRustRosterCapMatchesWire`, which
parses the Rust source so no Rust toolchain is needed). No encoding or
behavior change — purely promoting an existing protocol invariant into the
contract package both sides compile against.
84 changes: 82 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,74 @@ on:
# Pinned shellcade-kit toolchain: the author binary the arcade ships, attached
# to this repo's matching release. Bump the version AND the linux/amd64 sha
# (from that release's checksums.txt) together when adopting a new toolchain.
# 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.2.0"
SHELLCADE_KIT_SHA256: "eab71d43085ce894a0075af0c3af71c9e23c8f3150cc50e46d0df3668742eff7"
SHELLCADE_KIT_VERSION: "2.3.0"
SHELLCADE_KIT_SHA256: "79fbc6d7290c52fa1c0b9a513e08599195af5af17d8d945926520763d7ac62bb"

jobs:
# Toolchain pin lockstep: the SHELLCADE_KIT_VERSION pin above is a manual
# mirror of the newest published shellcade-kit binary release, and it has
# drifted before (2.2.0 lingered after v2.3.0 shipped). Two mechanical
# checks keep it honest:
# 1. the binary fetched at tag vX must EMBED kit vX — parse the `kit`
# line of `shellcade-kit version` (the kit module version baked in via
# debug.ReadBuildInfo, NOT the binary's own version line);
# 2. the pin must equal the newest release that actually carries
# shellcade-kit binaries (binaries attach to existing kit releases, so
# bare module tags without assets don't count).
# Runs on the nightly schedule too: staleness appears without a push.
kit-pin:
runs-on: ubuntu-latest
steps:
- name: fetch pinned shellcade-kit (sha256-verified)
run: |
set -euo pipefail
ver="${SHELLCADE_KIT_VERSION}"
asset="shellcade-kit_${ver}_linux_amd64.tar.gz"
base="https://github.com/shellcade/kit/releases/download/v${ver}"
curl -fsSL -o "${asset}" "${base}/${asset}"
echo "${SHELLCADE_KIT_SHA256} ${asset}" | sha256sum -c -
tar -xzf "${asset}" shellcade-kit
sudo install shellcade-kit /usr/local/bin/shellcade-kit
- name: pinned binary embeds the pinned kit version (lockstep)
run: |
set -euo pipefail
shellcade-kit version
embedded="$(shellcade-kit version | awk '$1 == "kit" { print $2 }')"
if [ "${embedded}" != "v${SHELLCADE_KIT_VERSION}" ]; then
echo "::error::lockstep violation: the v${SHELLCADE_KIT_VERSION} shellcade-kit binary embeds kit ${embedded}."
echo "A binary released at tag vX must be built against kit vX (see CLAUDE.md 'Lockstep')."
echo "Bump SHELLCADE_KIT_VERSION and SHELLCADE_KIT_SHA256 in ci.yml to a release whose binary matches its tag."
exit 1
fi
- name: pin is the newest published binary release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Newest release carrying shellcade-kit binaries — NOT releases/latest
# blindly, and not bare module tags: the private repo attaches the
# binaries to this repo's existing releases after a kit tag, so only
# releases with a shellcade-kit linux/amd64 asset are candidates.
newest="$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=30" --jq \
'[.[] | select(.draft or .prerelease | not)
| select(any(.assets[].name; startswith("shellcade-kit_") and endswith("_linux_amd64.tar.gz")))
][0].tag_name')"
echo "pin: v${SHELLCADE_KIT_VERSION} newest binary release: ${newest}"
if [ -z "${newest}" ] || [ "${newest}" = "null" ]; then
echo "::error::could not determine the newest shellcade-kit binary release"
exit 1
fi
if [ "${newest}" != "v${SHELLCADE_KIT_VERSION}" ] && \
[ "$(printf '%s\n' "v${SHELLCADE_KIT_VERSION}" "${newest}" | sort -V | tail -n1)" = "${newest}" ]; then
echo "::error::SHELLCADE_KIT_VERSION (${SHELLCADE_KIT_VERSION}) is stale: the newest published shellcade-kit binary release is ${newest}."
echo "Bump SHELLCADE_KIT_VERSION to ${newest#v} and SHELLCADE_KIT_SHA256 to the"
echo "linux/amd64 entry of that release's checksums.txt in .github/workflows/ci.yml."
exit 1
fi

test:
runs-on: ubuntu-latest
steps:
Expand All @@ -25,6 +88,23 @@ jobs:
- run: go build ./...
- run: go vet ./...
- run: go test ./...
# The committed crossverify .dgld vectors are a snapshot of the Go
# reference encoder; without this gate a Go-side byte-output change
# leaves them silently representing a HISTORICAL encoder (the rust job's
# golden step keeps passing against the stale bytes). Re-emit from the
# current encoder and require an exact match. Emission is deterministic
# (no RNG; committed .fseq inputs), so this is non-flaky.
- name: crossverify golden vectors are fresh (byte-identity vs the CURRENT Go reference)
run: |
set -euo pipefail
out="$(mktemp -d)"
DIFFBENCH_GOLDEN_DIR="$out" go test -run TestEmitGolden ./internal/diffbench/
if ! diff -r "$out" crossverify/tests/golden; then
echo "::error::committed crossverify golden vectors are STALE: the Go reference encoder's byte output changed."
echo "Review the change (it is wire-visible), then regenerate and commit:"
echo " DIFFBENCH_GOLDEN_DIR=crossverify/tests/golden go test -run TestEmitGolden ./internal/diffbench/"
exit 1
fi
- name: no private imports (the module must build from ABI.md alone)
run: |
! grep -rn "shellcade/shellcade" --include="*.go" . || (echo "FORBIDDEN private import" && exit 1)
Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/govulncheck.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: govulncheck
on:
push:
branches: [main]
pull_request:
schedule:
# Nightly (07:30 UTC, after CI's cron): new vulns land in the Go vuln DB
# without any commit here, so a schedule — not just push/PR — is the gate
# that actually catches them.
- cron: "30 7 * * *"

jobs:
govulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version-file: go.mod }
# Deliberately unpinned: vuln scanning wants the current tool and the
# current database, unlike the reproducible build toolchain above.
- run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
46 changes: 45 additions & 1 deletion ABI.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ reserved-zero** and a guest MUST read only the low 32 bits (§4.6, §5).
therefore **survives hibernation** (§8) — a guest does not re-issue it on
resume.

Roster indices addressed by `send` are bounded by **`RosterCap` = 1024**
(`wire.RosterCap`): a guest SDK sizes its per-index baseline table to
`RosterCap` slots plus the broadcast slot and silently drops `Send` for an
index ≥ `RosterCap`, and the host bounds-checks the index and sizes its
per-slot cache (§4.6) the same way. The cap is a shared protocol invariant:
raising it is a coordinated change to `wire`, every guest SDK, and the host —
never to one of them alone.

Scoping is host-side: the guest names only a roster index and a key — the
account and the game's namespace are derived by the host. A guest cannot
address another game's data or a non-member account.
Expand Down Expand Up @@ -162,6 +170,7 @@ u16 configSpecCount (trailing; see
u32 ctxFeatures trailing large-room section (see below); bit 0 = CtxFeatRosterEpoch
u16 heartbeatMS 0 = no declaration
u8 lifecycle trailing (see below); 0 resumable · 1 ephemeral · 2 resident
u16 wireRevision trailing (see below); 0 = unknown (the meta predates the field)
```

`slug` must be non-empty; the host refuses artifacts whose slug or version it
Expand Down Expand Up @@ -212,6 +221,34 @@ resident room runs with zero members — see the zero-member wake rule in
§4.1's roster-epoch notes; `start` precedes the first `join` universally,
so an empty roster is already legal in every callback).

**Wire-revision field (minor addition).** A trailing `u16` after the
lifecycle byte, presence-guarded under the same rules (absent = `0`): the
**wire revision** of the kit the artifact was built against — a single
monotonic counter of the wire-visible minor additions within an ABI major
(§5), `wire.Revision` in code. It is stamped automatically by SDK encoders,
never set by the author, and declares the newest wire feature the artifact
may assume the host understands. The ledger so far: `1` config-spec section
· `2` large-room section + roster-epoch sentinels · `3` lifecycle byte ·
`4` this field itself. `0` means unknown: the meta predates the field
(revisions 1–3 existed before it, so artifacts of those eras cannot declare
them — only `0` or values ≥ `4` are ever observed). A hand-rolled guest
(§4.7) SHOULD stamp the revision whose features it actually uses; omitting
the field (= declaring `0`) is always safe but forfeits the skew protection
below.

Host semantics (normative): a host compares an artifact's declared revision
against the revision it was itself built against. An artifact declaring a
revision **at or below** the host's — or `0`, the legacy value — loads
normally. An artifact declaring a revision **above** the host's MUST NOT be
loaded blind: at publish/verify time the host SHOULD refuse it with a
diagnostic naming both revisions (the author rebuilt against a newer kit
than the host runs), and at catalog/boot load time it SHOULD skip the
artifact with a warning rather than fail, so a fleet mid-upgrade self-heals
once the lagging host catches up. This is the mechanical anchor for §5's
deploy-order rule: without it a too-new artifact surfaces only as every
delta container being rejected (a frozen screen), not as a diagnosable
version skew.

### 4.3 Frame (the delta container and its cell)

A frame is delivered as a **frame-delta container** (§4.5), a variable-length
Expand Down Expand Up @@ -456,7 +493,14 @@ following rules are normative:
understands every feature of the kit version it was built against, and the
host advertises nothing. That ordering is what lets a flag-gated feature ship
as a minor — every prior host already rejected the flag while it was
unassigned — without resurrecting a capability gate.
unassigned — without resurrecting a capability gate. The rule is no longer
merely operational: the meta's trailing `wireRevision` (§4.2) is its
mechanical anchor. Every wire-visible minor addition appends an entry to the
`wire.Revision` ledger and bumps the constant **in the same change**; SDKs
stamp it into every artifact, and a host warns on or refuses (at verify
time) / skips (at load time) artifacts declaring a revision above its own —
so a violated deploy order degrades into a diagnosable, self-healing
per-artifact skip instead of a silently frozen room.

Consciously rejected (so they are not relitigated): **>3 code points per cell**
(family ZWJ emoji — the future path, if ever needed, is a flag-gated side table,
Expand Down
33 changes: 28 additions & 5 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,18 @@ scores, `keep-winner` (default) for everything else. **`sum`/`max` values MUST
be base-10 ASCII integers** (e.g. `strconv.Itoa` — `"990"`), within int64;
anything unparsable falls back to keep-winner at merge time.

**The store can degrade, and you won't see an error.** The ABI gives your game
no error channel for KV: when the host's store has a transient failure, `Get`
reports the key as **missing** (`nil, false, nil`) and `Set`/`Delete` return
`nil` without persisting. That makes the natural
`Get → missing → initialize starting balance → Set` wallet pattern a trap — a
store blip reads a veteran's wallet as absent, and your "new player" write can
clobber the durable value. Treat a missing read conservatively (defer the
initializing write, or use `sum`/`max` rules so a blip-era write cannot win at
merge time), and test the scenario with `kittest`: set
`r.KVUnavailable = true` and your suite sees exactly those production
semantics (see `ExampleRoom_kvUnavailable` in the `kittest` package).

Per-game configuration (tunable by arcade admins without your involvement)
arrives through `r.Services().Config.Get(ctx, "key")` — read it on a slow
cadence in `OnWake` and fall back to compiled defaults when absent.
Expand All @@ -366,8 +378,10 @@ the editor, they don't gate the store.

## Multiplayer

Rooms hold 1–N players (your `GameMeta` declares the range). Render
**per-player views** by composing a frame per member:
Rooms hold 1–N players: your `GameMeta` declares the range, and the platform
bound is **1..1024** (`wire.RosterCap` — the same constant that sizes the
frame-delta roster on both sides of the ABI). Render **per-player views** by
composing a frame per member:

```go
for _, p := range r.Members() {
Expand Down Expand Up @@ -409,8 +423,9 @@ leaves:

## Large rooms: 100+ players in one room

The SDK supports rooms of up to 1024 players, but a large room only stays
inside the wake budget if the game follows three disciplines:
The SDK supports rooms of up to 1024 players (`wire.RosterCap`, the platform's
hard ceiling), but a large room only stays inside the wake budget if the game
follows three disciplines:

**Declare your heartbeat.** A roguelike or board game does not need the 50ms
default. Declare your real cadence and the platform honors it (an admin
Expand Down Expand Up @@ -517,6 +532,11 @@ Authoring tips:
reveal) so the preview shows what each seat sees.
- `advance` must be a whole number of heartbeats — the parser rejects
ambiguous durations rather than rounding.
- **Smoke scripts drive at most 8 seats** — a screen-preview tool, not a
load harness. Large-room games (up to the platform's 1..1024 bound) still
pass smoke: the runner clamps your `MinPlayers` to the scripted seat count,
and large-room behavior is exercised by `shellcade-kit check`'s budget
gates, not by smoke screens.

The `smoke` package exposes the same machinery as Go API (`smoke.Parse`,
`smoke.Run`, `smoke.RenderANSI`) if you want shots inside your own tests.
Expand Down Expand Up @@ -557,7 +577,10 @@ code, same verdict.

For unit tests, `github.com/shellcade/kit/v2/kittest` is an in-memory `Room` +
`Services` with a virtual clock, seeded RNG, and recorded
frames/posts/settles — drive your `Handler` directly and assert.
frames/posts/settles — drive your `Handler` directly and assert. Its
`KVUnavailable` knob replays the host's KV degradation (reads come back
missing, writes silently drop — see Durable state) so you can prove your
wallet code survives a store blip.

## What your game can't do (on purpose)

Expand Down
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Brandon Cook

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
15 changes: 15 additions & 0 deletions crossverify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ DIFFBENCH_GOLDEN_DIR=/path/to/out rustup run stable cargo test --release --test
emitter `kit/internal/diffbench/golden_test.go`. The input frame is stored as an
encoder-independent changed-cell list so verification is not circular.)

Two CI gates keep the committed vectors a live reference rather than a
historical snapshot: the kit `test` job re-emits them from the current Go
encoder and diffs against `tests/golden` (so a Go byte-output change cannot
silently strand this harness on old bytes), and
`kit/internal/diffbench/parity_test.go` asserts the emitter is byte-identical
to the production `wire.BuildFrameDelta`/`wire.BuildKeyframe` encoders Go
guests actually ship.

The same generated-vector discipline covers the scalar encodings (meta / ctx /
result): `kit/wire/scalar_golden_test.go` emits and freshness-gates
`kit/rust/tests/golden/scalars.txt`, which the SDK crate replays in
`rust/src/wire.rs` (`mod scalar_golden`) — byte-identity for guest-encoded
payloads (meta, result), field + reader-position assertions over the
host-encoded ctx forms.

## Perf sanity

```sh
Expand Down
Loading
Loading