Documentation
¶
Overview ¶
Package sdk is the game engine boundary. A game implements a Handler (event callbacks); the engine owns the Room runtime and hands the game a Room handle to drive output. The lobby holds a RoomCtl. The game never sees channels, the actor goroutine, or any bubbletea type.
Index ¶
- Constants
- Variables
- func NewRoomID() string
- func Register(g Game)
- func ValidRoomID(s string) bool
- func WindowStart(w Window, now time.Time) (time.Time, bool)
- type Account
- type AccountStore
- type Action
- type Aggregation
- type Base
- type Cell
- type Character
- type ChatClient
- type ConfigEntry
- type ConfigKeySpec
- type ConfigStore
- type ConfigType
- type ControlDecl
- type Direction
- type Frame
- type Game
- type GameBase
- type GameMeta
- type Handler
- type HibernationCapable
- type Input
- type InputContext
- type InputKind
- type KVStore
- type Key
- type Kind
- type LeaderboardClient
- type LeaderboardCustom
- type LeaderboardData
- type LeaderboardProvider
- type LeaderboardReader
- type LeaderboardSpec
- type Lifecycle
- type MergeRule
- type MetricFormat
- type Mode
- type Phase
- type Player
- type PlayerResult
- type Registry
- type Result
- type Resumed
- type Room
- type RoomConfig
- type RoomCtl
- type RoomOption
- type Score
- type Services
- type ServicesFactory
- type Snapshot
- type SpectatorClient
- type Standing
- type Status
- type Style
- type TestRoom
- func (t *TestRoom) Advance(d time.Duration)
- func (t *TestRoom) After(d time.Duration, fn func(r Room)) TimerID
- func (t *TestRoom) BroadcastFunc(compose func(p Player) Frame)
- func (t *TestRoom) Cancel(id TimerID)
- func (t *TestRoom) Config() RoomConfig
- func (t *TestRoom) Count() int
- func (t *TestRoom) End(res Result)
- func (t *TestRoom) Every(d time.Duration, fn func(r Room)) TimerID
- func (t *TestRoom) Frame()
- func (t *TestRoom) Has(p Player) bool
- func (t *TestRoom) Identical(f Frame)
- func (t *TestRoom) Input(p Player, in Input)
- func (t *TestRoom) Join(p Player)
- func (t *TestRoom) LastFrame(p Player) (Frame, bool)
- func (t *TestRoom) LastPhase() (Phase, bool)
- func (t *TestRoom) Leave(p Player)
- func (t *TestRoom) Log() *slog.Logger
- func (t *TestRoom) Members() []Player
- func (t *TestRoom) Now() time.Time
- func (t *TestRoom) Rand() *rand.Rand
- func (t *TestRoom) Result() (Result, bool)
- func (t *TestRoom) Send(p Player, f Frame)
- func (t *TestRoom) Services() Services
- func (t *TestRoom) SetFrameRate(time.Duration)
- func (t *TestRoom) SetInputContext(ctx InputContext)
- func (t *TestRoom) SetPhase(name string, open bool, deadline time.Time)
- func (t *TestRoom) SetSimRate(time.Duration)
- func (t *TestRoom) Start()
- func (t *TestRoom) Tick()
- type TimerID
- type Window
Constants ¶
const DefaultAbandonGrace = 60 * time.Second
DefaultAbandonGrace is how long an emptied, hibernate-capable room waits for a rejoin before it auto-hibernates instead of ending (D9). A non-hibernatable room ignores this and ends immediately, as before.
Variables ¶
var ( // ErrRoomFull is returned when the room is at capacity. ErrRoomFull = errors.New("room is full") // ErrRoomClosed is returned when the room is settling or disposed. ErrRoomClosed = errors.New("room is closed") )
Admission errors returned by RoomCtl.Join.
var DefaultLeaderboardSpec = LeaderboardSpec{ MetricLabel: "Score", Direction: HigherBetter, Aggregation: BestResult, Format: Integer, }
DefaultLeaderboardSpec is the spec applied when a game declares none.
Functions ¶
func NewRoomID ¶
func NewRoomID() string
NewRoomID mints a room identifier: a UUIDv7 string — globally unique with no cross-machine coordination, stable across process restarts, and time-ordered (so it indexes well as the directory primary key and sorts by creation time as the checkpoint key prefix). uuid.NewV7 only errors on reader entropy failure, which is unrecoverable, so this treats it like uuid.Must and panics rather than returning an error.
func Register ¶
func Register(g Game)
Register writes a game into the default registry. A duplicate slug is fatal.
func ValidRoomID ¶
ValidRoomID reports whether s is a well-formed room id — a UUID of version 7 in canonical form. It rejects the legacy "<slug>-<seq>" id format, UUIDs of other versions, and the non-canonical spellings uuid.Parse otherwise accepts (braced, urn:uuid: prefix, undashed, upper-case): the id is a directory primary key, so requiring id.String() == s keeps one byte-exact key per room.
func WindowStart ¶
WindowStart returns the inclusive lower bound for a window computed in UTC, and whether the window is bounded at all. AllTime is unbounded. Daily starts at UTC midnight today; Weekly at Monday 00:00 UTC of the current week. No scheduled reset is involved — the boundary is derived from now at query time.
Types ¶
type Account ¶
type Account interface {
ID() string // immutable account UUID
Handle() string // current display handle
Kind() Kind
Store() KVStore // per-user KV, auto-namespaced to this game's slug
}
Account is a live, account-scoped handle a game obtains for a Player. It exposes the account's identity plus a per-user KVStore namespaced to the calling game. It is distinct from Player (a value-comparable, per-connection membership token): Account is fetched on demand and is not a map key.
type AccountStore ¶
AccountStore yields an Account for a Player, with the returned KVStore auto-namespaced to the game whose room owns this Services bundle. It is part of Services; games reach it via Room.Services().Accounts.
type Action ¶
type Action uint8
Action is a resolved, semantic input action. It is the canonical vocabulary every lobby screen and game interprets, decoupled from the raw key or rune.
func Resolve ¶
func Resolve(in Input, ctx InputContext) Action
Resolve maps an Input to a semantic Action for the given context. It is the single source of truth for the canonical control vocabulary:
Up=↑/k Down=↓/j Left=←/h Right=→/l Confirm=Enter/Space Back=Esc/q/Ctrl-C
The letter aliases are active only where letters are not literal input; q is Back in every context except CtxText.
type Aggregation ¶
type Aggregation uint8
Aggregation is how the default provider folds an account's own metrics.
const ( BestResult Aggregation = iota // default: best single metric (MAX/MIN by direction) CumulativeSum // sum of metrics )
type Base ¶
type Base struct{}
Base is embedded by a Handler implementation. It satisfies the unexported handlerSeal() and supplies a no-op default for every callback, so a minimal game overrides only what it needs.
type Cell ¶
Frame and Cell are aliases of the render/canvas types — the SDK never redefines them, so a game cannot exceed the fixed 80x24 canvas.
type Character ¶
type Character struct {
Glyph string // exactly one code point, width 1 everywhere
InkR, InkG, InkB uint8
BgR, BgG, BgB uint8
Fallback byte // printable ASCII shown on non-UTF8 sessions
}
Character is the resolved player character (player-character capability): one single-cell glyph, resolved ink/bg RGB (palette IDs never cross this boundary), and the single-byte ASCII fallback for non-UTF8 sessions. It is value-comparable, populated by the host BEFORE the player is admitted, and FIXED for the life of the connection — the Character Builder is reachable only from the lobby, so a character change always arrives as a NEW Player on the next join.
type ChatClient ¶
type ChatClient interface {
Broadcast(roomID, from, msg string)
}
ChatClient is a room-local chat hook (no-op stub in v1).
type ConfigEntry ¶
ConfigEntry is one stored per-game config row (key, opaque value, write provenance) — the admin "what is set" listing shape returned by the store.
type ConfigKeySpec ¶
type ConfigKeySpec struct {
Key string `json:"key"` // the ConfigStore key the game reads
Title string `json:"title"` // short admin-facing label
Description string `json:"description,omitempty"` // one or two sentences for the admin screen
Type ConfigType `json:"type"` // how the value is edited/validated
Default string `json:"default,omitempty"` // value the game uses when unset ("" = not declared)
Schema string `json:"schema,omitempty"` // JSON Schema document (ConfigJSON only; "" = none)
}
ConfigKeySpec is one game-declared admin-settable config key (decoded from the meta payload's trailing config-spec section).
type ConfigStore ¶
type ConfigStore interface {
// Get returns the value for key and whether it was present. A missing key
// reads as not-found (ok=false) so the game can fall back to its compiled
// default.
Get(ctx context.Context, key string) ([]byte, bool, error)
}
ConfigStore is a durable, read-only per-game configuration surface, already namespaced to one game's slug (the binding supplies the slug; the game names only the key, so it can neither read nor write another game's config). Values are opaque to the platform — a game parses its own document. Mutation is not exposed here: config is written only through the lobby's admin-gated path. A game obtains a ConfigStore via Room.Services().Config.
type ConfigType ¶
type ConfigType uint8
ConfigType tells the admin surface how a declared config value is edited and validated (mirrors the kit/wire type codes).
const ( ConfigText ConfigType = iota // single-line string ConfigNumber // decimal number ConfigBool // true/false ConfigJSON // JSON document (multiline / rich form) )
type ControlDecl ¶
type ControlDecl struct {
Kind InputKind `json:"kind"` // InputRune or InputKey
Rune rune `json:"rune,omitempty"` // the printable rune (Kind == InputRune)
Key Key `json:"key,omitempty"` // the named key (Kind == InputKey)
Label string `json:"label"`
}
ControlDecl is one game-declared extra control: the exact input it sends (a printable rune or a named key) and a short display label.
type Frame ¶
Frame and Cell are aliases of the render/canvas types — the SDK never redefines them, so a game cannot exceed the fixed 80x24 canvas.
type Game ¶
type Game interface {
Meta() GameMeta
NewRoom(cfg RoomConfig, svc Services) Handler
// contains filtered or unexported methods
}
Game is the registry entry. NewRoom returns the game's behavior (a Handler); the engine builds the Room runtime around it. sealed() keeps the interface growable without breaking out-of-tree games.
type GameBase ¶
type GameBase struct{}
GameBase is embedded by a Game implementation to satisfy the unexported sealed() method, keeping Game growable.
type GameMeta ¶
type GameMeta struct {
Slug string `json:"slug"`
Name string `json:"name"`
ShortDescription string `json:"shortDescription"`
MinPlayers int `json:"minPlayers"`
MaxPlayers int `json:"maxPlayers"`
Tags []string `json:"tags,omitempty"`
// Optional per-game lobby mode labels. An empty value means "use the lobby's
// game-agnostic default", so the lobby carries no game-specific wording.
QuickModeLabel string `json:"quickModeLabel,omitempty"` // mode-picker label for Quick; "" -> "Quick match"
SoloModeLabel string `json:"soloModeLabel,omitempty"` // mode-picker label for Solo; "" -> "Solo practice"
PrivateInviteLine string `json:"privateInviteLine,omitempty"` // trailing line in the private-create flash; "" -> "Play begins when a second player joins."
// Leaderboard optionally declares how this game's board behaves (label,
// direction, aggregation, format). Nil means the defaults (best single
// result, higher is better, integer) — see ResolveLeaderboardSpec.
Leaderboard *LeaderboardSpec `json:"leaderboard,omitempty"`
// Config optionally declares the game's admin-settable config keys so the
// admin Game settings area can render typed get/edit forms. Nil/empty
// means no declared surface (the generic editor still works).
Config []ConfigKeySpec `json:"config,omitempty"`
// CtxFeatures is the game's declared negotiated-callback-encoding bitset
// (wire.CtxFeat*; 0 = none). The host honors bits it implements and
// ignores the rest.
CtxFeatures uint32 `json:"ctxFeatures,omitempty"`
// HeartbeatMS is the game's declared wake cadence in milliseconds
// (0 = no declaration). Precedence at room creation: admin
// host.heartbeat_ms config > this declaration > the platform default,
// clamped to the host envelope.
HeartbeatMS int `json:"heartbeatMS,omitempty"`
// Lifecycle is the game's declared end-of-life shape (resumable 0 /
// ephemeral 1 / resident 2). Resident takes effect only when the
// platform grants it; an undeclared or unknown value reads as
// resumable.
Lifecycle Lifecycle `json:"lifecycle,omitempty"`
// WireRevision is the wire revision (wire.Revision) of the kit the
// artifact was built against, stamped by the SDK encoders — the
// mechanical anchor for the deploy-order rule. 0 = unknown (the meta
// predates the field, kit ≤ v2.7.x). A value ABOVE the host's compiled-in
// wire.Revision means the artifact assumes wire semantics this host does
// not implement; the catalog warns when it sees one.
WireRevision uint16 `json:"wireRevision,omitempty"`
// Controls is the game's declared extra controls (decoded from the meta
// payload's trailing declared-controls section): inputs beyond the
// canonical vocabulary, each with a short display label, surfaced by
// touch front ends as tappable affordances that send exactly the
// declared input. Presentation metadata only — declarations change no
// input interpretation. Nil/empty = none declared.
Controls []ControlDecl `json:"controls,omitempty"`
// Hidden is a HOST-SET flag (json:"-", never decoded from a guest's declared
// meta) marking a game live-but-unlisted: it is registered and reachable by
// exact slug (quick-match, direct entry, admin), but the lobby's player-facing
// games menu omits it. The built-in load-test game uses this so real players
// never land in bot rooms (add-loadtest-harness).
Hidden bool `json:"-"`
}
GameMeta is static game metadata referenced by slug.
type Handler ¶
type Handler interface {
OnStart(r Room)
OnJoin(r Room, p Player)
OnLeave(r Room, p Player)
OnInput(r Room, p Player, in Input)
OnTick(r Room, now time.Time)
OnFrame(r Room, snap Snapshot)
OnClose(r Room)
// contains filtered or unexported methods
}
Handler is the game's per-room behavior. The engine invokes these callbacks one at a time on a single actor goroutine, passing a Room handle valid only for the duration of the call. Embed Base for no-op defaults + the seal.
type HibernationCapable ¶
type HibernationCapable interface {
// CanHibernate reports whether this handler is, right now, in a state that
// can be frozen (e.g. a live, un-faulted wasm instance). A handler that
// implements the interface but returns false is treated as not hibernatable.
CanHibernate() bool
}
HibernationCapable is the capability a Handler advertises to opt into hibernation. The engine asserts it against the room's Handler to answer RoomCtl.Hibernatable; a Handler that does not implement it is never frozen (its room ends normally on abandonment / drain instead). gameabi's wasm handler implements it; in-process Go games do not (their state is not portable across a process restart), so they are unaffected.
type Input ¶
Input is the SDK-neutral input event, translated from the transport at the Session boundary. It is NEVER a bubbletea message.
type InputContext ¶
type InputContext uint8
InputContext selects how an Input is interpreted. It governs whether the mobile-friendly letter aliases (j/k/h/l, q) are active or whether the runes are literal input the caller handles itself.
const ( // It is the zero value, so a room defaults to Nav until a game says otherwise. CtxNav InputContext = iota // CtxCommand is for screens whose letters are domain commands (e.g. blackjack // h/s/d/p/r): arrows still navigate, q still backs out, but h/j/k/l are NOT // directions — the caller reads in.Rune for its command. CtxCommand // CtxText is for typing screens: only Esc/Ctrl-C resolve (to Back); every // other input, including q/j/k and printable runes, is ActNone and the caller // reads the raw Input. CtxText )
type KVStore ¶
type KVStore interface {
// Get returns the value for key and whether it was present.
Get(ctx context.Context, key string) ([]byte, bool, error)
// Set writes value for key, recording the merge rule that governs how the
// key reconciles on a future account merge.
Set(ctx context.Context, key string, value []byte, rule MergeRule) error
// Delete removes key (a no-op if absent).
Delete(ctx context.Context, key string) error
}
KVStore is a durable per-user key/value store, already namespaced to one game and one account. Values are opaque to the platform; the MergeSum/MergeMax rules additionally require the value to be a base-10 integer. A game obtains a KVStore via Account.Store().
type Kind ¶
type Kind string
Kind distinguishes a keyless guest from a member (an account holding ≥1 credential — any mix of SSH keys and passkeys). It is computed from credential count, never stored; credential TYPE is not a property of the account.
type LeaderboardClient ¶
LeaderboardClient is the game-facing write side. Games ALWAYS call Post and never branch on eligibility; the implementation records every account-bound result tagged with mode + status (dropping only guests). Reads are NOT here — they live on LeaderboardReader, which games never receive.
type LeaderboardCustom ¶
type LeaderboardCustom interface {
LeaderboardProvider(data LeaderboardData) LeaderboardProvider
}
LeaderboardCustom is the OPTIONAL interface a Game implements to supply its own LeaderboardProvider (deciding where/how its board's values are stored and computed). A Game that does not implement it uses the default provider that aggregates recorded results. The provider is constructed with LeaderboardData so it can perform its own (own-game-scoped) reads.
type LeaderboardData ¶
type LeaderboardData interface {
// ResultScores aggregates recorded results per account for a game + window
// per the spec (the default provider uses this). Returns one Score per
// account (Value = the aggregated metric, Achieved = when it was reached).
ResultScores(ctx context.Context, slug string, spec LeaderboardSpec, w Window) ([]Score, error)
// KVIntValues returns each account's integer value for (slug, key), used by
// KV-backed providers such as the casino peak board.
KVIntValues(ctx context.Context, slug, key string) ([]Score, error)
// ResolveHandles maps account ids to their current live handle, OMITTING any
// account that is merged-away/tombstoned or unknown (so it is excluded from
// boards). The platform — not a provider — owns this.
ResolveHandles(ctx context.Context, ids []string) (map[string]string, error)
}
LeaderboardData is the read surface a provider/reader uses to reach durable data. Both the production Postgres store and the in-memory test store implement it, so the reader and the built-in providers are storage-agnostic.
type LeaderboardProvider ¶
type LeaderboardProvider interface {
// Scores returns each account's value for the window (account-keyed,
// unranked, handle-less).
Scores(ctx context.Context, w Window) ([]Score, error)
}
LeaderboardProvider produces a game's ranked values. A game may supply one to decide where/how its board's data is stored and computed; a game that supplies none uses the default provider that aggregates recorded results. A provider reads only its own game's data and MUST NOT rank or resolve handles.
type LeaderboardReader ¶
type LeaderboardReader interface {
// Spec returns the resolved (nil-default-applied) spec for a game.
Spec(slug string) LeaderboardSpec
// Standings returns a ranked, handle-resolved page for a game + window.
Standings(ctx context.Context, slug string, w Window, limit, offset int) ([]Standing, error)
// PlayerStanding returns one account's rank + value for a game + window, or
// ok=false when the account has no standing on that board.
PlayerStanding(ctx context.Context, slug, accountID string, w Window) (Standing, bool, error)
}
LeaderboardReader is the read side used by the lobby/UI (never handed to games). Implementations compose a game's LeaderboardProvider with platform identity resolution: handles are resolved live, merged/tombstoned accounts excluded, rows ordered by the spec's direction, ranked, and paged.
func NewCachedReader ¶
func NewCachedReader(inner LeaderboardReader, ttl time.Duration) LeaderboardReader
NewCachedReader wraps inner with a short-TTL per-(slug, window) cache of the FULL ranked board (the limit-0 Standings slice). One cached slice serves every page flip, window revisit, and PlayerStanding rank lookup within the TTL — collapsing the lobby's repeated full-table aggregations (each of which otherwise holds one of the few pool connections) into one query per board per TTL. Boards are read-mostly and writes land asynchronously anyway, so a stale-by-seconds board is indistinguishable from a slightly-earlier read. Concurrent misses on one key are single-flighted. Errors are never cached. A ttl <= 0 returns inner unchanged (no caching).
func NewReader ¶
func NewReader(data LeaderboardData, reg *Registry) LeaderboardReader
NewReader builds a LeaderboardReader over the given data backend, resolving each registered game's spec and provider (a game's own provider if it implements LeaderboardCustom, else the default results provider).
type LeaderboardSpec ¶
type LeaderboardSpec struct {
MetricLabel string `json:"metricLabel"` // column header, e.g. "WPM", "Chips", "Time"
Direction Direction `json:"direction"`
Aggregation Aggregation `json:"aggregation"`
Format MetricFormat `json:"format"`
}
LeaderboardSpec is a game's optional declaration of how its board behaves. A nil *LeaderboardSpec on GameMeta means the defaults: best single result, higher is better, integer formatting. The spec carries no behavior of its own; the leaderboard service reads it to aggregate (default provider), order, and format that game's standings.
func ResolveLeaderboardSpec ¶
func ResolveLeaderboardSpec(s *LeaderboardSpec) LeaderboardSpec
ResolveLeaderboardSpec returns the effective spec for a possibly-nil declaration, applying the defaults for a nil spec.
type Lifecycle ¶
type Lifecycle uint8
Lifecycle is a room's end-of-life mode (mirrors the kit declaration).
type MergeRule ¶
type MergeRule string
MergeRule governs how one per-user KV key is reconciled when two accounts merge. The zero value is the empty string; callers SHOULD pass an explicit rule, and the storage layer treats an empty/unknown rule as MergeKeepWinner.
const ( // MergeKeepWinner keeps the surviving account's value on a key collision // (the default) and moves a loser-only key to the winner unchanged. MergeKeepWinner MergeRule = "keep-winner" // MergeKeepLoser takes the merged-away account's value on a collision. MergeKeepLoser MergeRule = "keep-loser" // MergeSum writes the integer sum of the two values (both must be integers). MergeSum MergeRule = "sum" // MergeMax writes the integer maximum of the two values (both integers). MergeMax MergeRule = "max" )
type MetricFormat ¶
type MetricFormat uint8
MetricFormat is how the display layer renders a metric value.
const ( Integer MetricFormat = iota // plain integer (default) Decimal1 // value/10 with one decimal place Duration // seconds rendered as m:ss )
type Mode ¶
type Mode string
Mode is the matchmaking + timing classifier. It is NOT eligibility policy.
type Phase ¶
type Phase struct {
Name string
Open bool
Deadline time.Time
Remaining time.Duration
Settled bool
Result *Result
}
Phase is the engine-published, lobby-visible game phase. The game owns the values (via Room.SetPhase); the engine derives Remaining at read time.
type Player ¶
type Player struct {
AccountID string
Handle string
Kind Kind
// Conn uniquely identifies this CONNECTION, so two concurrent sessions of the
// same account (e.g. the same SSH key opened in two terminals) are DISTINCT
// memberships rather than colliding on one map key. It is an opaque token, not
// a Session reference; Player remains value-comparable. AccountID/Handle/Kind
// still identify the account for leaderboard and identity policy.
Conn string
// Character is the resolved player character, fixed per connection (see
// Character). Value-comparable, so Player remains usable as a map key.
Character Character
// IsSynthetic marks a load-test player (an account minted via the synthetic
// token). Host-side only — it labels metrics so synthetic load is separable
// from real traffic; the game guest never sees it (add-loadtest-harness).
IsSynthetic bool
}
Player is a value-comparable membership token. It is usable as a map key and carries NO Session / io.ReadWriter reference. A reconnect yields a NEW Player.
func (Player) DisplayName ¶
DisplayName is the handle with a "(guest)" marker for guests.
type PlayerResult ¶
PlayerResult is one player's outcome in a settled room.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry is the game roster the hub is constructed with. It is a real value so tests and serve --dev can build a curated roster without global mutation. It is safe for concurrent use: the lobby reads the LIVE view while dynamic games (wasm catalog, quarantine) add and remove entries at runtime.
func (*Registry) All ¶
All returns the games in stable registration order, INCLUDING hidden ones — the set quick-match-by-slug, direct entry, and admin reach.
func (*Registry) Listed ¶
Listed returns the games for the lobby's player-facing menu in stable registration order, EXCLUDING any with Meta().Hidden set (add-loadtest-harness).
func (*Registry) Remove ¶
Remove unregisters a game by slug, returning it (so a quarantine or admin flow can restore it later). Rooms already running the game are untouched — they hold their own Game reference; removal only stops NEW rooms and lobby listing. Re-adding preserves nothing of the old position: it appends.
type Result ¶
type Result struct {
Mode Mode
Rankings []PlayerResult
// RoundSeq is the host-assigned, room-scoped 1-based sequence of this
// leaderboard post (the gameabi host stamps it; the counter survives
// hibernation). The durable leaderboard derives an idempotent round id from
// (roomID, RoundSeq), so a post-restore re-settle of the same round — or a
// retried write — dedupes instead of double-counting. 0 means unassigned
// (a poster without replay determinism); the writer falls back to a random
// round id, which still dedupes its own retries.
RoundSeq uint64
}
Result is the room-level outcome. Rankings contains exactly one PlayerResult for every player that joined (the engine backfills dnf for omissions).
type Resumed ¶
type Resumed interface {
OnResume(r Room)
}
Resumed is the capability a RESTORED Handler advertises so the engine resumes it WITHOUT re-running OnStart (which would re-instantiate and clobber the restored state). A room built with WithResumed calls OnResume in place of OnStart exactly once, at loop entry; the handler uses it to re-establish engine-owned timing (sim/frame rate) it would normally set in OnStart, with no fresh instantiation. A handler that does not implement it falls back to OnStart (harmless for a never-resumed handler).
type Room ¶
type Room interface {
// roster / config / clock
Members() []Player
Has(p Player) bool
Count() int
Config() RoomConfig
Rand() *rand.Rand
Now() time.Time
// push frames (engine hides coalescing, single-writer, close)
Send(p Player, f Frame)
Identical(f Frame)
BroadcastFunc(compose func(p Player) Frame)
// engine-owned timing
After(d time.Duration, fn func(r Room)) TimerID
Every(d time.Duration, fn func(r Room)) TimerID
Cancel(id TimerID)
SetSimRate(d time.Duration)
SetFrameRate(d time.Duration)
// phase publication for the lobby
SetPhase(name string, open bool, deadline time.Time)
// input-context publication for the lobby's play loop: the game declares
// which InputContext applies to its current phase so Back (q/Esc) resolves
// consistently for every game. Defaults to CtxNav until first set.
SetInputContext(ctx InputContext)
// settle exactly once
End(res Result)
Result() (Result, bool)
Services() Services
Log() *slog.Logger
}
Room is the engine-provided handle the game drives. It is valid only inside a callback; a stale handle used afterward (or off-thread) is a logged no-op.
type RoomConfig ¶
type RoomConfig struct {
Mode Mode
Capacity int
MinPlayers int
Seed int64
SeedSet bool
// Lifecycle is the room's resolved end-of-life mode (declaration ∧
// grant, resolved by the matchmaker at construction). It is engine
// state, not wire state: callbacks don't carry it and snapshots don't
// record it — a restore re-resolves it from the game's meta and the
// grant list.
Lifecycle Lifecycle
}
RoomConfig carries the matchmaking/timing classifier and the reproducibility seed. Mode must never drive leaderboard eligibility (that lives in Services).
type RoomCtl ¶
type RoomCtl interface {
Join(p Player) error
Leave(p Player)
Input(p Player, in Input)
Members() []Player
Frames(p Player) <-chan Frame
Done() <-chan struct{}
Snapshot() Phase
// InputContext is the game's currently published input context, so the lobby
// play loop resolves Back (q/Esc) appropriately. Defaults to CtxNav.
InputContext() InputContext
Result() (Result, bool)
Close() error
// Hibernatable reports whether the room's Handler can be frozen and resumed
// (the lobby/drain path only hibernates rooms that say yes). It is answered
// off the actor goroutine from the immutable Handler reference, so it is
// safe to call any time.
Hibernatable() bool
// Hibernate quiesces the room and runs fn ON the actor goroutine at a point
// with no Handler callback on the stack, handing fn the live Handler so the
// caller can freeze it (e.g. gameabi.SnapshotHandler). After fn returns the
// room is disposed WITHOUT delivering a normal end: player frame streams
// close (players see the room go away, not a settled result), no Result is
// published, no leaderboard post or DNF backfill runs — the room is paused,
// not finished. fn runs exactly once; a settled/already-hibernated room
// returns errRoomClosed without calling it. Hibernate blocks until fn has
// run (or the room is gone).
Hibernate(fn func(h Handler) error) error
// Checkpoint runs fn ON the actor goroutine at a quiescent point (no Handler
// callback on the stack), handing fn the live Handler so the caller can take a
// NON-destructive snapshot (e.g. gameabi.CheckpointHandler + CheckpointStore)
// — the room keeps running afterward. This is the durability seam for periodic
// checkpoints and drain snapshots (room-hosting spec "Periodic Room
// Checkpoints", design D5), distinct from the disposing Hibernate. A
// settled/ended/hibernated room returns errRoomClosed without calling fn; an
// fn error is returned and the room stays live. Checkpoint blocks until fn has
// run (or the room is gone).
Checkpoint(fn func(h Handler) error) error
}
RoomCtl is the engine control surface held by the lobby/hub. The game never sees it; the lobby never sees Room.
func NewRoomRuntime ¶
func NewRoomRuntime(roomID string, h Handler, cfg RoomConfig, svc Services, opts ...RoomOption) RoomCtl
NewRoomRuntime builds the engine runtime around a game's Handler and starts the single actor goroutine. It returns the lobby-facing RoomCtl.
type RoomOption ¶
type RoomOption func(*roomRuntime)
RoomOption tunes a room runtime at construction. Options are host-only (the lobby/matchmaker set them); games never see them.
func WithAbandonGrace ¶
func WithAbandonGrace(grace time.Duration) RoomOption
WithAbandonGrace overrides the empty-room reprieve window without wiring hibernation — the knob ephemeral rooms (and tests) use: a rejoin within the window finds the room alive; at expiry the lifecycle's abandonment action runs (<=0 ⇒ DefaultAbandonGrace).
func WithAbandonHibernate ¶
func WithAbandonHibernate(fn func(h Handler) error, grace time.Duration) RoomOption
WithAbandonHibernate wires the abandonment + drain hibernation path. fn is run ON the actor goroutine at a quiescent point with the live Handler (the same contract as RoomCtl.Hibernate's caller fn) and must freeze + persist it; grace is the empty-room reprieve before an abandoned room auto-hibernates (<=0 ⇒ DefaultAbandonGrace). Without this option a room is never hibernated: Hibernatable reports false and an abandoned room ends normally.
func WithResumed ¶
func WithResumed() RoomOption
WithResumed marks the runtime as resuming a RESTORED handler: the loop calls the handler's OnResume (if it implements Resumed) instead of OnStart, so the already-instantiated, memory-restored handler is not re-instantiated. Used by the lobby resume flow. A handler that does not implement Resumed falls back to OnStart.
type Score ¶
Score is one account's value for a window, account-keyed and NOT ranked, carrying no handle. A LeaderboardProvider returns these; the platform resolves handles, excludes merged accounts, ranks, and pages.
type Services ¶
type Services struct {
Leaderboard LeaderboardClient
Accounts AccountStore
Config ConfigStore // slug-bound, read-only per-game config (may be nil)
Chat ChatClient
Spectate SpectatorClient
Log *slog.Logger
}
Services is the per-room bundle of shared concerns, constructed by a ServicesFactory. Games reach shared concerns ONLY via Room.Services() and MUST drop the reference in OnClose.
type ServicesFactory ¶
type ServicesFactory interface {
// For builds the Services for a room, tagging the logger with room + slug.
For(roomID, slug string) Services
}
ServicesFactory constructs a per-room Services. It has distinct implementations for production (durable) and dev (in-memory).
type Snapshot ¶
type Snapshot interface {
Members() []Player
Config() RoomConfig
Now() time.Time
}
Snapshot is the frozen, read-only room state handed to OnFrame and to the BroadcastFunc closure. Per-viewer composition reads only this (plus the viewing Player), never live room state.
type SpectatorClient ¶
SpectatorClient is a read-only join hook (no-op stub in v1).
type Standing ¶
type Standing struct {
Rank int
AccountID string
Handle string // resolved live at read time
Value int
Achieved time.Time
}
Standing is one resolved, ranked leaderboard row for display.
func RankStandings ¶
func RankStandings(scores []Score, dir Direction, resolve func(accountID string) (string, bool), limit, offset int) []Standing
RankStandings turns provider Scores into ranked, handle-resolved Standings. It is the single place ordering, ranking, paging, and merge-exclusion live, so every reader (durable or in-memory) behaves identically regardless of where the Scores came from. resolve returns an account's live handle and whether it is live; a false drops the row (a merged/tombstoned or unknown account never appears). Ordering honors dir; ties break by earliest Achieved, then AccountID. Ranks are assigned before paging, so a later page keeps true ranks. limit <= 0 means no limit.
type Style ¶
Frame and Cell are aliases of the render/canvas types — the SDK never redefines them, so a game cannot exceed the fixed 80x24 canvas.
type TestRoom ¶
type TestRoom struct {
Clock time.Time
Frames map[Player][]Frame // every frame pushed, per player
Phases []Phase // every SetPhase call
InputCtx InputContext // latest SetInputContext value (zero = CtxNav)
Ended bool
Res Result
// contains filtered or unexported fields
}
TestRoom is a published Room test double. It drives a Handler's callbacks synchronously with an injectable clock and records emitted frames, published phases, and the settled Result — so a game's logic can be unit-tested with no goroutine, channel, or socket.
func NewTestRoom ¶
func NewTestRoom(g Game, cfg RoomConfig, svc Services) *TestRoom
NewTestRoom builds a TestRoom for game g with the given config. The clock starts at an arbitrary fixed instant; use Advance to move it.
func NewTestRoomFor ¶
func NewTestRoomFor(h Handler, cfg RoomConfig, svc Services) *TestRoom
NewTestRoomFor builds a TestRoom driving an explicit Handler. Useful for white-box tests that need direct access to the handler's state.
func (*TestRoom) Advance ¶
Advance moves the clock forward and fires any due timers (in time order).
func (*TestRoom) BroadcastFunc ¶
func (*TestRoom) Config ¶
func (t *TestRoom) Config() RoomConfig
func (*TestRoom) Frame ¶
func (t *TestRoom) Frame()
Frame invokes OnFrame with a frozen snapshot at the current clock.
func (*TestRoom) SetFrameRate ¶
func (*TestRoom) SetInputContext ¶
func (t *TestRoom) SetInputContext(ctx InputContext)
SetInputContext records the latest published input context, exposed via InputCtx for assertions.