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

Room lifecycle declarations: `GameMeta.Lifecycle` chooses what happens when
everyone leaves the room.

- `LifecycleResumable` (default, byte-compat): hibernate on abandonment,
player-driven resume — today's behavior.
- `LifecycleEphemeral`: after the abandonment grace the room ends and
disposes — no snapshot, no Resume-menu entry. Right for casual social
rooms; the grace still protects against connection blips.
- `LifecycleResident`: one long-lived room per slug (persistent worlds):
ticks with zero players, periodic checkpoints, boot auto-restore.
Granted per slug by the platform — an ungranted declaration behaves as
resumable. Cannot combine with `MinPlayers > 1` (validated at meta
encode, like all trailer fields).

Carried as a trailing presence-guarded byte after the large-room meta
section (ABI major stays 2; older payloads decode as resumable, older hosts
ignore the byte). Rust crate mirrors the field, validation, and goldens.
`GUIDE.md` gains "Choosing a lifecycle".
16 changes: 16 additions & 0 deletions ABI.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ u16 configSpecCount (trailing; see
· str default ("" = not declared) · str schema ("" = none; json only)
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
```

`slug` must be non-empty; the host refuses artifacts whose slug or version it
Expand Down Expand Up @@ -196,6 +197,21 @@ resolves the wake heartbeat at room creation as: admin `host.heartbeat_ms`
config > declared `heartbeatMS` > platform default (50ms), clamped to
[20ms, 1000ms] — a declaration is authoring intent, never authority.

**Lifecycle byte (minor addition).** A trailing `u8` after the large-room
section, presence-guarded under the same rules (absent = `0`): the room's
end-of-life declaration. `0` resumable — hibernate on abandonment,
player-driven resume (the historical behavior and the default). `1`
ephemeral — after the abandonment grace the room ends and disposes: no
snapshot, no resume entry (right for casual social rooms whose match has no
meaning without its players). `2` resident — one long-lived room per slug;
the declaration takes effect only when the platform grants it, and an
ungranted declaration behaves as resumable. Hosts MUST treat lifecycle
values they do not implement as resumable. SDKs reject undefined values and
the resident + `minPlayers > 1` combination at `meta()` encode time (a
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).

### 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
24 changes: 24 additions & 0 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,30 @@ The native runner also rides terminal resizes: shrink the window below 80×24
and it shows a "terminal too small" notice, then repaints your game the moment
you grow it back — the same letterboxing the arcade does over SSH.

## Choosing a lifecycle

`GameMeta.Lifecycle` declares what happens to your room when everyone
leaves:

- **`LifecycleResumable`** (the default): the room hibernates and players
can resume it later from the lobby. Right for games where an interrupted
match is worth returning to — chess, anything with long-arc state.
- **`LifecycleEphemeral`**: after the abandonment grace the room ends and
disposes — no snapshot, no resume entry. Right for casual social rooms
(slots, card tables, quick board games) where a match without its players
is meaningless. The grace still protects against connection blips: a
rejoin within it finds the room intact.
- **`LifecycleResident`**: one long-lived room per slug — the persistent-
world shape. It keeps ticking with zero players, checkpoints
periodically, and survives deploys without anyone resuming it. Declaring
it is a REQUEST: the platform grants residency per slug (always-on
compute is an operator decision), and an ungranted declaration simply
behaves as resumable. Resident games must tolerate `r.Count() == 0` in
every callback (all games should — `OnStart` fires before the first
join), should idle-throttle expensive work when nobody is online, and
cannot declare `MinPlayers > 1`. Ending a resident room (`r.End`) is the
world-reset primitive: the next join creates a fresh world.

## Large rooms: 100+ players in one room

The SDK supports rooms of up to 1024 players, but a large room only stays
Expand Down
4 changes: 4 additions & 0 deletions internal/game/codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,10 @@ func encodeMeta(m GameMeta) []byte {
if err := wire.ValidateMetaTrailer(wm.CtxFeatures, wm.HeartbeatMS); err != nil {
panic("kit: invalid GameMeta: " + err.Error())
}
wm.Lifecycle = uint8(m.Lifecycle)
if err := wire.ValidateLifecycle(wm.Lifecycle, wm.MinPlayers); err != nil {
panic("kit: invalid GameMeta: " + err.Error())
}
return wire.EncodeMeta(wm)
}

Expand Down
10 changes: 10 additions & 0 deletions internal/game/largeroom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,14 @@ func TestMetaTrailerEncode(t *testing.T) {
}
assertPanics("bad heartbeat", GameMeta{Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 2, HeartbeatMS: 5}, "HeartbeatMS")
assertPanics("unknown feature bit", GameMeta{Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 2, CtxFeatures: 1 << 9}, "undefined bit")
assertPanics("undefined lifecycle", GameMeta{Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 2, Lifecycle: 7}, "Lifecycle")
assertPanics("resident multi-floor", GameMeta{Slug: "g", Name: "G", MinPlayers: 2, MaxPlayers: 8, Lifecycle: LifecycleResident}, "zero members")
}

func TestLifecycleEncode(t *testing.T) {
b := encodeMeta(GameMeta{Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 8, Lifecycle: LifecycleEphemeral})
wm, err := wire.DecodeMeta(b)
if err != nil || wm.Lifecycle != wire.LifecycleEphemeral {
t.Fatalf("lifecycle encode: %v %d", err, wm.Lifecycle)
}
}
20 changes: 20 additions & 0 deletions internal/game/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,28 @@ type GameMeta struct {
// to its envelope and an admin config override always wins; out-of-range
// declarations are an authoring bug and panic at meta encode time.
HeartbeatMS int

// Lifecycle optionally declares the room's end-of-life shape. The zero
// value (LifecycleResumable) is today's behavior: hibernate on abandon,
// player-driven resume. LifecycleEphemeral ends and disposes the room
// after the abandon grace (no snapshot, no Resume entry) — right for
// casual social rooms. LifecycleResident declares one long-lived room
// per slug; it takes effect only when the platform grants it (an
// ungranted declaration behaves as resumable). Undefined values and
// resident-with-MinPlayers>1 are authoring bugs and panic at meta
// encode time.
Lifecycle Lifecycle
}

// Lifecycle is the room end-of-life declaration.
type Lifecycle uint8

const (
LifecycleResumable Lifecycle = Lifecycle(wire.LifecycleResumable)
LifecycleEphemeral Lifecycle = Lifecycle(wire.LifecycleEphemeral)
LifecycleResident Lifecycle = Lifecycle(wire.LifecycleResident)
)

// CtxFeatRosterEpoch opts the game into the ctx roster-epoch encoding: the
// host sends the full member list only when the roster changes (with an
// epoch), and a 6-byte unchanged marker otherwise — the large-room callback
Expand Down
5 changes: 5 additions & 0 deletions kit.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ type (
GameMeta = game.GameMeta
LeaderboardSpec = game.LeaderboardSpec
ConfigKeySpec = game.ConfigKeySpec
Lifecycle = game.Lifecycle
ConfigType = game.ConfigType
Direction = game.Direction
Aggregation = game.Aggregation
Expand All @@ -95,6 +96,10 @@ const (

CtxFeatRosterEpoch = game.CtxFeatRosterEpoch

LifecycleResumable = game.LifecycleResumable
LifecycleEphemeral = game.LifecycleEphemeral
LifecycleResident = game.LifecycleResident

ConfigText = game.ConfigText
ConfigNumber = game.ConfigNumber
ConfigBool = game.ConfigBool
Expand Down
2 changes: 1 addition & 1 deletion rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ pub use input::{Action, Input, InputContext, Key};
pub use room::Room;
pub use types::{
Aggregation, ConfigKeySpec, ConfigType, Direction, Kind, Leaderboard, MergeRule, Meta,
MetricFormat, Mode, Outcome, Player, PlayerResult, RoomConfig, Status, CTX_FEAT_ROSTER_EPOCH,
MetricFormat, Mode, Outcome, Player, PlayerResult, RoomConfig, Status, CTX_FEAT_ROSTER_EPOCH, Lifecycle,
};

// Native-only scriptable host double for `cargo test` of games and the SDK
Expand Down
17 changes: 17 additions & 0 deletions rust/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,22 @@ pub struct Meta {
/// host clamps to its envelope and an admin override always wins;
/// out-of-range declarations panic at `meta()` encode time.
pub heartbeat_ms: u16,

/// Room end-of-life declaration. `Resumable` (default) hibernates on
/// abandon; `Ephemeral` ends and disposes after the abandon grace (no
/// snapshot, no Resume entry); `Resident` declares one long-lived room
/// per slug (takes effect only when the platform grants it).
/// Resident with `min_players > 1` panics at `meta()` encode time.
pub lifecycle: Lifecycle,
}

/// Room end-of-life declaration (wire values 0/1/2).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Lifecycle {
#[default]
Resumable = 0,
Ephemeral = 1,
Resident = 2,
}

/// Opts the game into the ctx roster-epoch encoding: the host sends the full
Expand Down Expand Up @@ -258,6 +274,7 @@ impl Meta {
config: &[],
ctx_features: 0,
heartbeat_ms: 0,
lifecycle: Lifecycle::Resumable,
};
}

Expand Down
11 changes: 9 additions & 2 deletions rust/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,13 @@ pub(crate) fn encode_meta(m: &Meta) -> Vec<u8> {
}
w.u32(m.ctx_features);
w.u16(m.heartbeat_ms);
// Trailing lifecycle byte (ABI.md §4.2, spec minor). Always written;
// resident with min_players > 1 is an authoring bug (a resident room
// runs with zero members), mirroring Go's wire.ValidateLifecycle.
if m.lifecycle == crate::types::Lifecycle::Resident && m.min_players > 1 {
panic!("shellcade-kit: invalid Meta: lifecycle Resident cannot require min_players {}", m.min_players);
}
w.u8(m.lifecycle as u8);
w.b
}

Expand Down Expand Up @@ -463,7 +470,7 @@ mod tests {
],
..Meta::DEFAULT
};
let golden = "0600676f6c64656e0600476f6c64656e0e00676f6c64656e206669787475726501000400020001006101006200000000000001050073636f726501000202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e0000000000000000000000";
let golden = "0600676f6c64656e0600476f6c64656e0e00676f6c64656e206669787475726501000400020001006101006200000000000001050073636f726501000202000c006f6464732d76617269616e740c004f6464732076617269616e740a005041522073686565742e0312007b226e616d65223a2244656661756c74227d11007b2274797065223a226f626a656374227d04006d6f7464060042616e6e65720d00466c6f6f722062616e6e65722e000000000000000000000000";
let got: String = encode_meta(&m).iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(got, golden, "Rust meta encoding diverges from the Go golden");
}
Expand Down Expand Up @@ -596,7 +603,7 @@ mod tests {
};
let got: String = encode_meta(&m).iter().map(|b| format!("{b:02x}")).collect();
// trailer = u32 1 LE + u16 100 LE = "01000000" + "6400"
assert!(got.ends_with("0000010000006400"), "trailer bytes diverge from the Go encoding: ...{}", &got[got.len()-16..]);
assert!(got.ends_with("000001000000640000"), "trailer bytes diverge from the Go encoding: ...{}", &got[got.len()-18..]);
}
}

43 changes: 39 additions & 4 deletions wire/largeroom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,49 @@ func TestMetaTrailerRoundTrip(t *testing.T) {
t.Fatalf("trailer round-trip = features %#x heartbeat %d", got.CtxFeatures, got.HeartbeatMS)
}

// Pre-trailer payload: chop the trailing 6 bytes (u32 + u16).
old := b[:len(b)-6]
// Pre-large-room payload: chop the trailing 7 bytes (u32 + u16 + u8).
old := b[:len(b)-7]
got, err = DecodeMeta(old)
if err != nil {
t.Fatalf("pre-trailer decode: %v", err)
}
if got.CtxFeatures != 0 || got.HeartbeatMS != 0 {
t.Fatalf("pre-trailer payload decoded nonzero trailer: %#x %d", got.CtxFeatures, got.HeartbeatMS)
if got.CtxFeatures != 0 || got.HeartbeatMS != 0 || got.Lifecycle != LifecycleResumable {
t.Fatalf("pre-trailer payload decoded nonzero trailer: %#x %d %d", got.CtxFeatures, got.HeartbeatMS, got.Lifecycle)
}

// Pre-lifecycle payload (kit v2.6.0 era): chop only the lifecycle byte —
// the large-room section decodes, lifecycle defaults to resumable.
v26 := b[:len(b)-1]
got, err = DecodeMeta(v26)
if err != nil {
t.Fatalf("pre-lifecycle decode: %v", err)
}
if got.CtxFeatures != CtxFeatRosterEpoch || got.HeartbeatMS != 100 || got.Lifecycle != LifecycleResumable {
t.Fatalf("pre-lifecycle payload: %#x %d %d", got.CtxFeatures, got.HeartbeatMS, got.Lifecycle)
}
}

func TestLifecycleRoundTripAndValidation(t *testing.T) {
m := Meta{Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 8, Lifecycle: LifecycleEphemeral}
got, err := DecodeMeta(EncodeMeta(m))
if err != nil || got.Lifecycle != LifecycleEphemeral {
t.Fatalf("lifecycle round-trip: %v %d", err, got.Lifecycle)
}
cases := []struct {
lc uint8
minP uint16
ok bool
}{
{LifecycleResumable, 2, true},
{LifecycleEphemeral, 1, true},
{LifecycleResident, 1, true},
{LifecycleResident, 2, false}, // resident runs with zero members
{7, 1, false}, // undefined value
}
for _, c := range cases {
if err := ValidateLifecycle(c.lc, c.minP); (err == nil) != c.ok {
t.Errorf("ValidateLifecycle(%d, %d) err=%v want ok=%v", c.lc, c.minP, err, c.ok)
}
}
}

Expand Down
36 changes: 36 additions & 0 deletions wire/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,23 @@ type Meta struct {
// values.
CtxFeatures uint32
HeartbeatMS uint16

// Lifecycle is the room end-of-life declaration (spec minor addition;
// trailing byte after HeartbeatMS): 0 resumable (hibernate on abandon —
// today's behavior and the zero-value default), 1 ephemeral (end +
// dispose on abandon; no snapshot, no Resume entry), 2 resident (one
// long-lived granted room per slug). Hosts treat values they do not
// implement as resumable.
Lifecycle uint8
}

// Lifecycle values for the meta trailer.
const (
LifecycleResumable uint8 = 0
LifecycleEphemeral uint8 = 1
LifecycleResident uint8 = 2
)

// Config value type codes (how the admin surface renders/validates a value).
const (
ConfigText uint8 = 0
Expand Down Expand Up @@ -449,6 +464,9 @@ func EncodeMeta(m Meta) []byte {
// + declared heartbeat. Always written; older decoders ignore the bytes.
w.U32(m.CtxFeatures)
w.U16(m.HeartbeatMS)
// Trailing lifecycle byte (spec minor addition). Always written; older
// decoders ignore it.
w.U8(m.Lifecycle)
return w.B
}

Expand Down Expand Up @@ -496,6 +514,10 @@ func DecodeMeta(b []byte) (Meta, error) {
m.CtxFeatures = r.U32()
m.HeartbeatMS = r.U16()
}
// Trailing lifecycle byte, presence-guarded: absent = resumable.
if !r.Bad && r.Off < len(r.B) {
m.Lifecycle = r.U8()
}
if err := r.Err(); err != nil {
return Meta{}, err
}
Expand Down Expand Up @@ -550,6 +572,20 @@ const (
HeartbeatMaxMS uint16 = 1000
)

// ValidateLifecycle is the shared authoring rule set for the lifecycle
// declaration, enforced at meta() encode time by both SDKs: the value must
// be a defined lifecycle, and resident cannot be combined with
// minPlayers > 1 (a resident room runs with zero members).
func ValidateLifecycle(lifecycle uint8, minPlayers uint16) error {
if lifecycle > LifecycleResident {
return fmt.Errorf("wire: Lifecycle %d undefined (0 resumable, 1 ephemeral, 2 resident)", lifecycle)
}
if lifecycle == LifecycleResident && minPlayers > 1 {
return fmt.Errorf("wire: Lifecycle resident cannot require MinPlayers %d — a resident room runs with zero members", minPlayers)
}
return nil
}

// ValidateMetaTrailer is the shared authoring rule set for the large-room
// meta section, enforced at meta() encode time by both SDKs (the same
// fail-fast posture as ValidateConfigSpecs): no undefined ctx-feature bits;
Expand Down
Loading