sdk

package
v2.14.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 17, 2026 License: MIT Imports: 13 Imported by: 0

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

View Source
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

View Source
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.

View Source
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

func ValidRoomID(s string) bool

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

func WindowStart(w Window, now time.Time) (time.Time, bool)

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

type AccountStore interface {
	For(p Player) Account
}

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.

const (
	ActNone Action = iota
	ActUp
	ActDown
	ActLeft
	ActRight
	ActConfirm
	ActBack
)

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.

func (Base) OnClose

func (Base) OnClose(Room)

func (Base) OnFrame

func (Base) OnFrame(Room, Snapshot)

func (Base) OnInput

func (Base) OnInput(Room, Player, Input)

func (Base) OnJoin

func (Base) OnJoin(Room, Player)

func (Base) OnLeave

func (Base) OnLeave(Room, Player)

func (Base) OnStart

func (Base) OnStart(Room)

func (Base) OnTick

func (Base) OnTick(Room, time.Time)

type Cell

type Cell = canvas.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

type ConfigEntry struct {
	Key       string
	Value     []byte
	UpdatedAt time.Time
	UpdatedBy string
}

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 Direction

type Direction uint8

Direction is whether a higher or lower metric ranks better.

const (
	HigherBetter Direction = iota // default: bigger metric wins (WPM, chips)
	LowerBetter                   // smaller metric wins (time trials)
)

type Frame

type Frame = canvas.Grid

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

type Input struct {
	Kind InputKind
	Rune rune
	Key  Key
}

Input is the SDK-neutral input event, translated from the transport at the Session boundary. It is NEVER a bubbletea message.

func KeyInput

func KeyInput(k Key) Input

KeyInput builds a named-key input.

func RuneInput

func RuneInput(r rune) Input

RuneInput builds a printable-rune input.

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 (
	// CtxNav is for menus and list/value screens: all letter aliases are active.
	// 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 InputKind

type InputKind uint8

InputKind distinguishes a printable rune from a named key.

const (
	InputRune InputKind = iota
	InputKey
)

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 Key

type Key uint8

Key is a named (non-printable) key.

const (
	KeyNone Key = iota
	KeyEnter
	KeyBackspace
	KeyEsc
	KeyTab
	KeyUp
	KeyDown
	KeyLeft
	KeyRight
	KeyCtrlC
)

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.

const (
	KindGuest  Kind = "guest"
	KindMember Kind = "member"
)

type LeaderboardClient

type LeaderboardClient interface {
	Post(slug string, r Result)
}

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).

const (
	LifecycleResumable Lifecycle = 0 // hibernate on abandon (default)
	LifecycleEphemeral Lifecycle = 1 // end + dispose after the abandon grace
	LifecycleResident  Lifecycle = 2 // granted singleton; ticks while empty
)

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.

const (
	ModeQuick   Mode = "quick"
	ModePrivate Mode = "private"
	ModeSolo    Mode = "solo"
)

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

func (p Player) DisplayName() string

DisplayName is the handle with a "(guest)" marker for guests.

func (Player) Guest

func (p Player) Guest() bool

Guest reports whether the player is a keyless guest.

type PlayerResult

type PlayerResult struct {
	Player Player
	Metric int
	Rank   int
	Status Status
}

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 Default

func Default() *Registry

Default returns the package-level default registry.

func NewRegistry

func NewRegistry() *Registry

NewRegistry returns an empty registry.

func (*Registry) Add

func (r *Registry) Add(g Game) error

Add registers a game. A duplicate slug is an error.

func (*Registry) All

func (r *Registry) All() []Game

All returns the games in stable registration order, INCLUDING hidden ones — the set quick-match-by-slug, direct entry, and admin reach.

func (*Registry) Get

func (r *Registry) Get(slug string) (Game, bool)

Get looks up a game by slug.

func (*Registry) Listed

func (r *Registry) Listed() []Game

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) MustAdd

func (r *Registry) MustAdd(g Game)

MustAdd registers a game, panicking on a duplicate slug (fatal).

func (*Registry) Remove

func (r *Registry) Remove(slug string) (Game, bool)

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

type Score struct {
	AccountID string
	Value     int
	Achieved  time.Time
}

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

type SpectatorClient interface {
	Open(roomID string) error
}

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 Status

type Status string

Status is a player's terminal outcome.

const (
	StatusFinished Status = "finished"
	StatusDNF      Status = "dnf"
	StatusFlagged  Status = "flagged"
)

type Style

type Style = canvas.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

func (t *TestRoom) Advance(d time.Duration)

Advance moves the clock forward and fires any due timers (in time order).

func (*TestRoom) After

func (t *TestRoom) After(d time.Duration, fn func(r Room)) TimerID

func (*TestRoom) BroadcastFunc

func (t *TestRoom) BroadcastFunc(compose func(p Player) Frame)

func (*TestRoom) Cancel

func (t *TestRoom) Cancel(id TimerID)

func (*TestRoom) Config

func (t *TestRoom) Config() RoomConfig

func (*TestRoom) Count

func (t *TestRoom) Count() int

func (*TestRoom) End

func (t *TestRoom) End(res Result)

func (*TestRoom) Every

func (t *TestRoom) Every(d time.Duration, fn func(r Room)) TimerID

func (*TestRoom) Frame

func (t *TestRoom) Frame()

Frame invokes OnFrame with a frozen snapshot at the current clock.

func (*TestRoom) Has

func (t *TestRoom) Has(p Player) bool

func (*TestRoom) Identical

func (t *TestRoom) Identical(f Frame)

func (*TestRoom) Input

func (t *TestRoom) Input(p Player, in Input)

Input delivers an input and invokes OnInput.

func (*TestRoom) Join

func (t *TestRoom) Join(p Player)

Join admits a player and invokes OnJoin.

func (*TestRoom) LastFrame

func (t *TestRoom) LastFrame(p Player) (Frame, bool)

LastFrame returns the most recent frame pushed to p, if any.

func (*TestRoom) LastPhase

func (t *TestRoom) LastPhase() (Phase, bool)

LastPhase returns the most recently published phase.

func (*TestRoom) Leave

func (t *TestRoom) Leave(p Player)

Leave removes a player and invokes OnLeave.

func (*TestRoom) Log

func (t *TestRoom) Log() *slog.Logger

func (*TestRoom) Members

func (t *TestRoom) Members() []Player

func (*TestRoom) Now

func (t *TestRoom) Now() time.Time

func (*TestRoom) Rand

func (t *TestRoom) Rand() *rand.Rand

func (*TestRoom) Result

func (t *TestRoom) Result() (Result, bool)

func (*TestRoom) Send

func (t *TestRoom) Send(p Player, f Frame)

func (*TestRoom) Services

func (t *TestRoom) Services() Services

func (*TestRoom) SetFrameRate

func (t *TestRoom) SetFrameRate(time.Duration)

func (*TestRoom) SetInputContext

func (t *TestRoom) SetInputContext(ctx InputContext)

SetInputContext records the latest published input context, exposed via InputCtx for assertions.

func (*TestRoom) SetPhase

func (t *TestRoom) SetPhase(name string, open bool, deadline time.Time)

func (*TestRoom) SetSimRate

func (t *TestRoom) SetSimRate(time.Duration)

func (*TestRoom) Start

func (t *TestRoom) Start()

Start invokes OnStart.

func (*TestRoom) Tick

func (t *TestRoom) Tick()

Tick invokes OnTick at the current clock.

type TimerID

type TimerID uint64

TimerID identifies a scheduled timer.

type Window

type Window uint8

Window is a leaderboard time window.

const (
	AllTime Window = iota
	Daily
	Weekly
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL