GAME API

Game API reference

This is the human page for the Game and Helper contract. Each method has a signature, parameters, return value, a longer explanation, and at least one named example. Agents should still start from docs/agent/GAME_CONTRACT.md. The Go types in internal/games/game.go win if any of this disagrees with the code.

The pattern follows MDN method pages and Go-style godoc. Lead with what the call does. Then show the exact signature. Then parameters, return value, when it runs, and copyable Go from the Testing package where that package already does the job.

Overview

Lobby owns roster, join, wait list, audience, seats, identity, operator knobs, and the start gate. A game reads those facts through games.Helper. It never writes them.

A game owns run logic, per-player run state keyed by Lobby player id, optional extra pages, and after Start the board and phone fragments that the /board and / shells show. See Shells.

Host drives Load, Start, Stop, and Shutdown. Load constructs the selected package in-process. There is no folder scan and no child process.

This build ships Apples for Humanity (internal/games/apples), Quick Quips (internal/games/quips), and Testing (internal/games/testing). Testing remains the copyable wiring sample on this page.

Package games cannot import a child game. The child imports games for Helper and Register. Host blank-imports each child so Catalog fills in.

Load a game

Compile-time, in this order:

  1. Add internal/games/<name>. Do not name the package testing. Product Testing uses package testinggame.
  2. Implement games.Game.
  3. Call games.Register from init.
  4. Blank-import the package from internal/host/handler.go.

Runtime Load is POST /settings/load. Admin session. Restore prompt is not pending. No round is started. Catalog contains the id. If MaxPlayers is greater than 0, seated count must be at or under that max. Host creates <dataDir>/games/<ID>, then calls Load.

Start still needs at least one seated player. If MinPlayers is greater than 0, seated count must also meet that floor.

Lifecycle

Host actionGame method/ and /board/play/ POSTKV
LoadLoadLobby404kept
StartStartgamemountedkept
PausePausegamemountedkept
ResumeResumegamemountedkept
StopStopLobby404kept
ShutdownStop if started, then ShutdownLobby404kept

Finish on Helper is graceful Stop. Operator abort is also Stop. Unload or picking another game is Shutdown. Stop does not unload the package. Start can run again. A process restart re-Loads the selected id with Lobby still on /board. It does not restore an in-progress Start.

Loader

Register and Catalog live on package games. Games call Register. Host reads Catalog.

gamesinit

Register

Adds a compile-time game factory to the in-process catalog.

Syntax

func Register(f Factory)

Parameters

f
Factory with a non-empty ID and a non-nil New. Empty ID or nil New is dropped with no error and no log line.

Return value

None.

Description

Call this from init in the game package. Host never calls Register itself. Duplicate IDs are not rejected here. The last factory host indexes by ID wins in the runtime map. Keep IDs unique.

Examples

Register Testing
package testinggame

import "github.com/KroniK907/grabbag/internal/games"

func init() {
    games.Register(games.Factory{
        ID:  "testing",
        New: func() games.Game { return New() },
    })
}
Blank-import so Catalog is not empty

Without this line in internal/host/handler.go, Load returns conflict for that id.

import (
    _ "github.com/KroniK907/grabbag/internal/games/testing"
)
gameshost

Catalog

Returns a copy of every registered factory.

Syntax

func Catalog() []Factory

Parameters

None.

Return value

A new slice. Mutating it does not change the catalog. Host copies it into a map keyed by ID when the handler starts.

Description

Games do not call this. Host does, once, in NewHandler. There is no runtime rescan.

Examples

Host builds the runtime index
handler, err := NewHandler(db, lanJoinURL)
// NewHandler calls games.Catalog() and indexes Factory.ID.

Game

Host calls these methods on your value. Implement the whole interface. Settings and Play may return nil. Board, Phone, Assets, and Scenarios come from ui.Tenant, which games.Game embeds. The Lobby and the host's locked board are tenants too.

MethodHost calls it
IDcatalog key, logs, KV, data dir
NameLobby rail label after Load
MinPlayersStart gate
MaxPlayersLoad gate and Lobby max
Loadafter Factory.New()
Settingsafter Load, inlined on /settings
Startoperator Start or auto-start
Boardboard shell after Start
BoardButtonsLobby TV rail after Load, before Start
Phonephone shell after Start, any signed-in player
Helpphone shell Help button, through GET /help
Play/play/ mux
Pauseoperator or auto-pause
Resumeoperator Resume
Stopround end
Shutdownunload
Assetswhenever a shell shows the game
Scenarios/dev/ui/ gallery and tests
Game

ID

Returns the catalog key for this package.

Syntax

ID() string

Parameters

None.

Return value

A stable lowercase id. Testing uses testing. Host uses it for KV namespace, the data dir name, log lines, and POST /settings/load.

Description

This is not the player-facing label. Keep it short and unique. Changing it orphans old KV rows.

Examples

Return a constant
const id = "testing"

func (g *Game) ID() string { return id }
Game

Name

Returns the player-facing label on the Lobby rail after Load.

Syntax

Name() string

Parameters

None.

Return value

A display string. Empty Name falls back to ID on the board extras.

Description

Use this for humans. Spaces and capitals are fine. ID stays the machine key.

Examples

Show Testing on the rail
func (g *Game) Name() string { return "Testing" }
GameStart gate

MinPlayers

Returns the extra seated-count floor required before Start.

Syntax

MinPlayers() int

Parameters

None.

Return value

0 means the game does not declare a floor. Host still requires at least one seated player. A positive value refuses Start when seated count is below it.

Description

This is not the Lobby seat cap. Lobby already has its own cap. Use MinPlayers when the rules need a table of a certain size, such as two teams.

Examples

Testing waits for one seated player
func (g *Game) MinPlayers() int { return 1 }
GameLoad gate

MaxPlayers

Returns the extra seated-count ceiling applied at Load and stored on Lobby.

Syntax

MaxPlayers() int

Parameters

None.

Return value

0 means the game does not declare a ceiling. The Lobby seat cap still applies. A positive value refuses Load when seated count is already above it, then host calls SetGamePlayerMax with that value.

Description

Load checks this before Load(helper). Start does not re-check MaxPlayers. If you need a hard table size, declare it here so Lobby stops filling extra seats.

Examples

No extra ceiling
func (g *Game) MaxPlayers() int { return 0 }
Gamelifecycle

Load

Stores the host Helper and prepares settings after the operator picks this game.

Syntax

Load(h Helper) error

Parameters

h
Host-owned Helper. Keep this pointer. You will need it on later requests. Host may pass the same helper again on Start.

Return value

nil on success. Any other error rolls the previous loaded id back. The operator sees a generic load failure.

Description

Host has already created <dataDir>/games/<ID>. KV from earlier sessions is already there. Read knobs in Load or on each settings render. Do not start the round here. /board stays Lobby until Start. BoardButtons and Settings become live after a successful Load.

If another game was loaded, host already called Shutdown on it.

Examples

Keep the helper on the Game value
func (g *Game) Load(h games.Helper) error {
    g.mu.Lock()
    defer g.mu.Unlock()
    g.helper = h
    return nil
}
Gameafter Load

Settings

Returns the mux host inlines under Game Settings on /settings.

Syntax

Settings() http.Handler

Parameters

None.

Return value

An http.Handler, or nil if the game has no knobs. Nil is fine. Host then omits the Game Settings block.

Description

Host strips /settings/game. A GET of / on this mux is what gets captured into the settings page. Forms must post under /settings/game/.... After a POST, redirect to /settings so the operator stays on the host page.

This mux is live after Load and dies after Shutdown. It stays up across Stop.

Examples

Hide latency checkbox
func (g *Game) Settings() http.Handler {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /{$}", g.getSettings)
    mux.HandleFunc("POST /hide-latency", g.postHideLatency)
    return mux
}

The form action in Testing is /settings/game/hide-latency. The handler writes KV and publishes roster, then redirects to /settings.

Gamelifecycle

Start

Begins the round. After this returns nil, host hands / and /board to the game.

Syntax

Start(h Helper) error

Parameters

h
The same Helper family as Load. Store it again. Do not assume the Load pointer is the only one you will ever see.

Return value

nil to proceed. A non-nil error keeps Lobby on / and /board. Host logs a generic start failure unless the gate refused first (no seated players, or below MinPlayers).

Description

Host already checked seated count. Start the ticker, deal cards, or seed scores here. Host then marks the round active, publishes roster then round, and every shell swaps to your tenant. A second Start while already started is a no-op on the host side.

Examples

Start the Testing tick
func (g *Game) Start(h games.Helper) error {
    g.mu.Lock()
    defer g.mu.Unlock()
    g.helper = h
    g.paused = false
    g.startTickerLocked()
    return nil
}
Gameafter Start

Board

Writes the TV fragment the board shell shows after Start.

Syntax

Board(w http.ResponseWriter, r *http.Request)

Parameters

w
Response writer. Send a fragment, not a document: no <html>, no <link>, no <script>.
r
The board request. Use HasAdmin if End game should only show for a signed-in operator tab.

Return value

None. Write the body yourself.

Description

Before Start, host never calls this. The Lobby is the board tenant. After Start, the shell swaps your fragment into its stage, and back to the Lobby on Stop. The shell owns the document, the theme, the stream, and the overlay, and it loads your CSS and JS from Assets. Host also renders the fragment inside the first GET /board when a round is already running.

Listen for your own SSE names with HTMX. Do not open an EventSource. Details are under Live SSE.

Examples

Render board.html with seated rows
func (g *Game) Board(w http.ResponseWriter, r *http.Request) {
    g.render(w, "board.html", g.boardData(r), http.StatusOK)
}

Testing lists h.Seated(), flashes a row on tap, and shows End game when h.HasAdmin(r) is true. End game is an hx-post form that answers 204.

GameLobby rail

BoardButtons

Returns up to three Lobby TV rail links under the audience count, after Load and before Start.

Syntax

BoardButtons() []BoardButton

Parameters

None.

Return value

Zero or more BoardButton values. Host skips empty Label or Path. Host keeps the first three. HostOnly buttons are omitted unless the board request has an admin session.

Description

After Start the game is the board tenant, so these controls go away. Point Path at a GET under /play if the page should work after Load. POST /play is still 404 until Start.

Examples

About Testing on the rail
func (g *Game) BoardButtons() []games.BoardButton {
    return []games.BoardButton{{
        Label: "About Testing",
        Path:  "/play/info",
    }}
}
Gameafter Start

Phone

Writes the phone column for a signed-in player after Start. Seated, wait-list, and audience phones all reach this method.

Syntax

Phone(w http.ResponseWriter, r *http.Request)

Parameters

w
Host captures these bytes and the phone shell shows them in its centered column. Leave, Help, and the claimed host's panel are in the shell frame.
r
The phone request. Read the player with PlayerFromRequest.

Return value

None.

Description

Do not send a full HTML document. No <html>, <link>, or <script>. The shell has the toolbar, Help, Leave, and your assets. Do not draw your own Help or Leave button. Size the column with the tiers in Phone frame. Unknown cookies never reach this method. Lobby still renders join.

A claimed-host phone gets the Host panel from the shell, not from you. Put End game in the body if that player should end the round from the play column, as Testing does with ClaimedHost.

Examples

Tap button plus optional End game
func (g *Game) Phone(w http.ResponseWriter, r *http.Request) {
    g.render(w, "phone.html", g.phoneData(r), http.StatusOK)
}

The template is a <div> with hx-post="/play/tap". Host wraps it in the Lobby play-phone fragment.

Gameui.HelpTenant

Help

Writes the rules for the phone shell's Help sheet.

Syntax

Help(w http.ResponseWriter, r *http.Request)

Parameters

w
The sheet body: an HTML fragment. The shell owns the sheet, its "How to play" title, and its close button.
r
The phone's GET /help. Host forwards it only while your game is the phone's tenant.

Return value

None.

Description

games.Game embeds ui.HelpTenant, so every game must provide help. The shell draws Help only when the tenant fetch says "help": true. The sheet opens over your phone, which stays mounted, and closes with ✕, Esc, or a tap on the backdrop. The Lobby and the locked board are plain tenants and show no Help. Keep /play/howto as the full page for the board's How to play button; share the sections through one template. Add a preview with Shell: ui.ShellHelp.

Examples

func (g *Game) Help(w http.ResponseWriter, r *http.Request) {
    g.run.Render(w, "help", nil, http.StatusOK)
}
Game

Play

Returns the mux mounted at /play/ for POSTs, partials, and game static files.

Syntax

Play() http.Handler

Parameters

None.

Return value

An http.Handler, or nil. Nil makes every /play request 404.

Description

Host strips /play. Register POST /tap, not /play/tap. GET is allowed after Load so info pages and CSS can load from a Lobby rail button. POST is 404 until Start, and 404 again after Stop. After Stop the Game value is still loaded, so GET handlers should tolerate a paused or stopped round.

Full extra documents after Load are Play GET routes. Mutations that must run before Start POST through Settings at /settings/game/. Do not add a Game method or host mount for extra pages. Do not open Play POST before Start. Operator-only extra GET pages check HasAdmin in the game. Signed-out GET is plain 401 text. Host does not make every Play GET admin-only. Hiding a HostOnly BoardButton does not protect the URL. Apples for Humanity ships the first extra operator page: GET /play/picker, pack toggles POST to /settings/game/, success is 303 back to the picker. A failed write or a refused-after-Start toggle returns 200 HTML so the checkboxes match stored state. GET /play/howto is public. In a round the shell's Help sheet shows Help. Quick Quips uses the same picker mount as Prompt Library.

Examples

Tap, end, partials, and CSS
func (g *Game) Play() http.Handler {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /info", g.getInfo)
    mux.HandleFunc("POST /tap", g.postTap)
    mux.HandleFunc("POST /end", g.postEnd)
    mux.HandleFunc("GET /partials/board", g.getBoardFrame)
    mux.HandleFunc("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(files))))
    return mux
}
Gamelifecycle

Pause

Stops accepting play for a moment without ending the round.

Syntax

Pause() error

Parameters

None.

Return value

nil on success. Host then sets paused, writes a log line, and publishes pause.

Description

Operator Pause and auto-pause on seated disconnect both land here. Audience disconnects do not. A game that returns false from PauseOnDisconnect gets no auto-pause, only operator and Helper Pause. A game may no-op and still return nil. Testing stops its 1s tick and ignores taps. Host may Resume while the disconnected player is still gone.

This is not Helper.Pause. Helper.Pause asks host to pause the room. Game.Pause is host telling you the room is pausing.

Examples

Stop the tick, keep tap counts
func (g *Game) Pause() error {
    g.mu.Lock()
    defer g.mu.Unlock()
    g.paused = true
    g.stopTickerLocked()
    return nil
}
Gamelifecycle

Resume

Starts accepting play again after Pause.

Syntax

Resume() error

Parameters

None.

Return value

nil on success. Host clears paused and publishes pause again so pages swap Pause/Resume chrome.

Description

If Load never ran, Testing returns nil and does not start a tick. You can no-op. Do not call Helper.Resume from here. Host already owns that transition.

Examples

Restart the Testing tick
func (g *Game) Resume() error {
    g.mu.Lock()
    defer g.mu.Unlock()
    g.paused = false
    if g.helper == nil {
        return nil
    }
    g.startTickerLocked()
    return nil
}
Gamelifecycle

Stop

Ends the round, wipes in-memory run state, and keeps the package loaded.

Syntax

Stop() error

Parameters

None.

Return value

nil on success. Host returns / and /board to Lobby, may cycle seats if the stop is graceful, resets ready, and publishes roster then round.

Description

Finish, operator Stop, and Shutdown-while-started all call this. Keep Helper. Keep KV. Drop scores, ticks, and flash marks. Start can run again without another Load. Do not delete files in DataDir unless that is your own save format and you mean to.

Examples

Clear taps, keep hide-latency KV
func (g *Game) Stop() error {
    g.mu.Lock()
    defer g.mu.Unlock()
    g.taps = make(map[string]int)
    g.flashed = make(map[string]time.Time)
    g.paused = false
    g.stopTickerLocked()
    return nil
}
Gamelifecycle

Shutdown

Unloads the package. Drop the helper and stop leftover goroutines.

Syntax

Shutdown() error

Parameters

None.

Return value

nil on success. Host clears the selected game id and Game Settings disappears.

Description

Called on unselect, on picking another game, and during process teardown of the previous game inside Load. If a round was active, host calls Stop first. KV stays until the operator hits Clear game data.

Examples

Nil the helper
func (g *Game) Shutdown() error {
    g.mu.Lock()
    defer g.mu.Unlock()
    g.stopTickerLocked()
    g.helper = nil
    g.taps = make(map[string]int)
    return nil
}
Gameui.Tenant

Assets

Lists the stylesheets and scripts the shells load for your board and phone.

Syntax

Assets() ui.Assets type Assets struct { CSS []string JS []string Layout []string External []string }

Parameters

None.

Return value

One list covers both surfaces. CSS and JS are usually game.css and game.js under games.StaticPath(id) (/games/<id>/static/) with a version query. Host serves that path from your Play() static files whichever game is loaded, so a slow phone never gets another game's script. Layout is an optional layout.js with pure sizing functions (see grabbagShell.layout). External is third-party stylesheets such as web fonts.

Description

The shell loads each file once per visit. It removes your CSS when it unmounts you and keeps External sheets. Your JS must call grabbagShell.register and do nothing else at the top level. runtimekit.Assets(id, version) builds the usual pair.

Examples

Runtime kit game with web fonts
func (g *Game) Assets() ui.Assets {
    a := runtimekit.Assets(id, assetVersion)
    a.External = []string{fontsURL}
    return a
}
Gameui.Tenant-dev-preview

Scenarios

Lists UI states the host gallery at /dev/ui/ can show without anyone playing to them.

Syntax

Scenarios() []ui.Scenario

Parameters

None. Host calls it on a fresh New() value. There is no Load and no Helper.

Return value

One ui.Scenario per page state. Each has a Surface and Name, a Viewer, a Frame (phone, TV, or page), an optional Shell, a seated-count range, and a Render(w, ui.Preview) func. Host passes the theme, seated count, JSON merge patches, and an asset URL prefix in ui.Preview.

Description

Render builds the same view struct Board or Phone would build, then calls ui.RenderScenario. A board fragment uses Shell: ui.ShellBoard, and host wraps it in the static board shell with your Assets. The static shell loads no htmx, no SSE, no heartbeat, and no game.js, so nothing mounts, and timers hold their rendered value. Your CSS and your layout still apply. <html data-static> lets CSS freeze an animation that would make screenshots differ run to run. Where the game has an engine, drive it to the state with fixed names, cards, seed, and clock. Then a preview cannot show a state the game never reaches. A phone body uses Shell: ui.ShellPlayPhone, and host wraps it in the Lobby phone and the phone frame the way it wraps a live phone. A Shell: ui.ShellHelp scenario shows your Help body in the open sheet. The gallery and cmd/grabbag-uishots also show phone scenarios at iPad portrait, iPad landscape, and desktop sizes. Players with MinPlayers > 0 are swept at min, middle, 12, and max. MaxPlayers 0 sweeps to 64 so stress cases show. Only host started with -dev-preview serves these, along with cmd/grabbag-uishots.

Examples

A board at every table size
func (g *Game) Scenarios() []ui.Scenario {
    return []ui.Scenario{{
        Surface: "board", Group: "live", Name: "live", Viewer: "tv",
        Frame: ui.FrameTV, Shell: ui.ShellBoard, MinPlayers: 1,
        Render: func(w io.Writer, p ui.Preview) error {
            view := boardView{}
            for _, name := range ui.PreviewNames(p.Players) {
                view.Players = append(view.Players, playerRow{DisplayName: name})
            }
            return ui.RenderScenario(w, pages, "board.html", view, p)
        },
    }}
}
Saved variant

previews/board.live.paused.json containing {"Paused": true} adds live~paused when the list passes through ui.WithVariants.

Gameoptional

PauseOnDisconnect

Says whether the operator's Auto-pause on seated disconnect applies to this game. Implement games.DisconnectPolicy to opt in or out.

Syntax

type DisconnectPolicy interface { PauseOnDisconnect() bool }

Parameters

None. Host asks when a seated phone drops during a round, and when it draws /settings.

Return value

false skips auto-pause for this game. true, or not implementing the interface, follows the operator setting.

Description

Auto-pause is a room setting, off by default. When it is on, host pauses the round as soon as any seated phone drops. That suits a game that waits on every phone. A game where the host can Continue past a missing player, and where run state lives on the server so a phone that wakes repaints the same moment, can return false. A phone going to sleep then never stops the room. /settings notes under the switch that the loaded game keeps playing. Operator Pause and Helper.Pause still reach the game. Host checks with games.PausesOnDisconnect(g).

Examples

Keep playing when a phone sleeps
// Borrowed Truths: the host's Continue covers every teller action.
func (g *Game) PauseOnDisconnect() bool { return false }

Shells

/board and / are persistent shells. Each loads once. After that the shell swaps tenants (the Lobby, a game, the locked board) in place, so the TV never flashes and nothing a page holds is lost.

How the shells work

GET /board and GET / return the shell with the current tenant already inside #shell-stage. The shell owns the document, the one SSE connection, the theme, notices, the connection overlay, and the heartbeat. Tenants never own those.

When the tenant may have changed, the shell fetches GET /board/tenant or /tenant. It loads any assets it has not loaded, fades out, unmounts the old tenant, swaps the fragment, mounts the new one, and fades in. Lobby to game and back on the TV fades out over 1.5s and in over 0.5s. Other TV swaps take 200ms, and phones 150ms. prefers-reduced-motion swaps instantly.

Swaps run one at a time and the latest wins. A response for an older generation is dropped. If the fetch fails, the current tenant stays on screen and the shell retries. If your mount throws, the shell logs it to /settings/log and leaves your server-rendered fragment in place.

Phone frame

The phone shell goes edge to edge on every device. The shell owns the frame: a 48px toolbar, the side gutters, Help, Leave, the Help sheet, and the claimed host's panel. Your tenant owns only the column, #shell-stage.

The column is centered and 720px wide, with at least a 64px gutter each side. Without room for that the frame is in phone mode and the column fills the width. Help and Leave sit at the ends of the toolbar in phone mode and in the gutters in gutter mode. A screen that wants more room puts data-column="wide" on its outer element and gets a 1200px column. That is markup only and survives htmx swaps. A game that would rather have bigger elements stays at 720px.

#shell-stage is a size container named column. Design against three tiers of its width, never the viewport's: compact under 560px, regular 560 to 959px, and wide from 960px, which only the wide opt-in reaches.

@container column (min-width: 560px) { .wordbox-hand { grid-template-columns: repeat(2, 1fr); } }
@container column (min-width: 960px) { .wordbox-hand { grid-template-columns: repeat(4, 1fr); } }

The shell sets data-frame="phone" or "gutter" on #shell-frame for its own controls. Do not style against it, and do not put anything in the gutters. From 1170px the claimed host's panel stays open on the left (320px) and collapses to a strip the browser remembers. Below that it is the slide-out drawer.

client

grabbagShell.register

Gives the shell your board and phone lifecycles.

Syntax

grabbagShell.register(id, { board: { mount(root, ctx), unmount() }, phone: { mount(root, ctx), unmount() }, })

Parameters

id
Your catalog id. The Lobby registers as lobby.
board, phone
The shell calls the lifecycle for its own surface. unmount is optional.

Description

The shell calls mount after your fragment is in the stage. root is the stage. Use ctx for every listener, timer, and frame loop, and they stop by themselves. hx-* attributes and listeners on elements inside your fragment need nothing, because they go away with the markup.

Examples

Before and after
// before
document.addEventListener("htmx:afterSwap", onSwap);
setInterval(tick, 1000);

// after
grabbagShell.register("quips", {
  board: { mount(root, ctx) { ctx.on(document, "htmx:afterSwap", onSwap); ctx.every(1000, tick); } },
  phone: { mount(root, ctx) { ctx.on(document, "htmx:afterSwap", onPhoneSwap); } },
});
client

ctx

Scoped helpers that clean up when the shell unmounts you.

Syntax

ctx.root, ctx.surface, ctx.tenant ctx.signal AbortSignal, aborts when unmount begins ctx.on(target, type, fn, opts) addEventListener with signal; returns off() ctx.every(ms, fn) setInterval; returns cancel() ctx.after(ms, fn) setTimeout; returns cancel() ctx.frame(fn) requestAnimationFrame loop; return false to stop ctx.cleanup(fn) your own teardown ctx.audio board only: your audio scope (see ctx.audio)

Description

Unmount runs in this order: abort signal, cancel timers and frames, run cleanup callbacks newest first, call your unmount, then the shell removes your CSS and swaps the markup. A listener on document or window, or a timer or frame you start without ctx, outlives you, and the browser suite fails on it. Undo anything you set on html or body in ctx.cleanup.

Examples

Viewport tracking that leaves no trace
mount(root, ctx) {
  ctx.on(window, "resize", sync);
  ctx.cleanup(function () {
    document.documentElement.style.removeProperty("--quips-visual-height");
  });
  sync();
}
clientboard onlyoptional

ctx.audio

Music and sound effects on the TV, in the style of FMOD and Wwise adaptive music.

Syntax

ctx.audio.define({files, regions, cues, stings, snapshots}) ctx.audio.cue(name) handle: start, to, channel, end, stop, filter, playing .start({timeline, countdown, seconds, held, variant, at, over}) .to(variant, {at, over}) Promise, resolves when heard .channel(name).fadeTo(volume, {at, over}) .channel(name).filter({highpass, lowpass, q}, {at, over}) .end({at, over}) outro at the sync point, or fade out .stop({over}) .started, .finished, .playing ctx.audio.sting(name, {volume}) ctx.audio.snapshot(name | null, seconds) ctx.audio.filter(target, {highpass, lowpass, q, volume}, {over}) ctx.audio.ready() Promise, every file loaded or failed at: "now" | "beat" | "bar" | {bars: N} | "loop"

Description

Only the board gets ctx.audio. Phones and static previews get undefined, so guard with if (!ctx.audio) return;. The host's mixer has a master level plus a Music and an Effects layer. The operator sets them on /settings; your code never sees them.

Files and regions. Files are paths relative to the folder of your game.js, normally static/audio/, in any format the browser decodes. Regions are audio sprites, {file, start, end} in seconds, with optional loopStart, loopEnd, and markers relative to the region start. Many regions can share one file. Keep each game under 3 MB of audio (2 MB for the Lobby); a Go test enforces it.

Cues. A cue is an optional intro, looping variants (each a list of stems), and an optional outro (horizontal resequencing). channels are extra loops whose volume you fade (vertical layering). Give bpm and beatsPerBar to quantize to beats and bars; without them, beat and bar mean the end of the loop. One music cue plays at a time: starting another crossfades over 1.5 s. If every variant and channel has the same loop length, they run together and to crossfades in place. Otherwise the new variant starts from its top at the sync point, like a Wwise Music Switch Container.

Timelines. A cue can carry named timelines. A countdown timeline keys steps by seconds left on a ui-countdown and follows its grabbag:countdown-tick. A late start applies every step already passed. If time is added, the music goes back to what the remaining steps imply. A free timeline keys steps in bars: a script (repeating every loop bars when set), then then.shuffle entries picked at random, never the same twice in a row. Free bars stop counting while held() is true, by default while your root contains [data-audio-hold] or a frozen countdown. Steps are {to}, {fade, to}, {snapshot}, or {sting}, with optional over and at.

Stings and snapshots. Stings overlap freely; a global cap of 32 voices drops the oldest. On unmount a music-layer sting fades out with the cues, so a one-shot theme crossfades into the next music; effects stings play out. Snapshots ramp volume and filters on several targets at once: music, effects, <cue>, or <cue>.<variant or channel>. telephone is built in. Audio has no pause signal of its own, so choose what a pause sounds like.

Sync. Each change returns a promise that resolves with the time it is heard. The root receives grabbag:audio-beat, grabbag:audio-bar, and grabbag:audio-marker, corrected for output latency.

Teardown and failures. Unmount fades your cues out, clears your snapshot and filters, and drops your buffers. A new frame of the same tenant keeps the scope: mount gets the same ctx.audio with cues still playing, so check cue(name).playing before start. A missing file or unknown name logs to the console and plays nothing; promises still resolve. Never make game logic wait on audio. -dev-bench has a Board audio panel for tuning loops, timelines, and transitions.

Examples

Timer music that builds and ends on the bar
mount(root, ctx) {
  if (!ctx.audio) return;
  ctx.audio.define({
    files: { timer: "audio/timer.ogg" },
    regions: {
      calm:  { file: "timer", start: 0,  end: 16 },
      tense: { file: "timer", start: 16, end: 32 },
      outro: { file: "timer", start: 32, end: 36 },
    },
    cues: {
      timer: {
        bpm: 120, variants: { calm: ["calm"], tense: ["tense"] }, outro: "outro",
        timelines: { answer: { kind: "countdown", steps: { 20: { to: "tense", over: 2 } } } },
      },
    },
  });
  var cue = ctx.audio.cue("timer").start({ timeline: "answer", countdown: root.querySelector("[data-countdown]") });
  ctx.on(root, "grabbag:countdown-done", function () { cue.end({ at: "bar" }); });
}
Muffle the music while paused
ctx.on(document.body, "htmx:afterSwap", function () {
  ctx.audio.snapshot(root.querySelector(".is-paused") ? "telephone" : null, 0.8);
});
clientoptional

grabbagShell.layout

Registers sizing CSS cannot do, such as fitting text to a box.

Syntax

grabbagShell.layout(id, { board: function (root) {}, phone: function (root) {}, })

Description

Put this in a layout.js listed in Assets.Layout. Each function only measures and sets styles inside root. The shell runs it after mount, after every swap, on resize, and once fonts load, and also in static previews, where game.js does not load. A Go guard rejects listeners, timers, frames, observers, network, htmx, and navigation in layout files.

Try CSS first. The shell sets --shell-visual-height (the visible height, which shrinks when a phone keyboard opens) and html.shell-short (under 760px) on <html>, and a grid can follow its item count with :has(> :nth-child(n)).

Examples

Fit Apples answer cards
grabbagShell.layout("apples", {
  board: function (root) {
    root.querySelectorAll(".apples-tv .apples-slots > .apples-slot").forEach(fitCard);
  },
});

No reloads

Inside a shell nothing reloads the page. Every form is hx-post, never <form method="post">, and no script calls location.reload. Answer an htmx POST with a partial, or with 204 when an SSE event or the tenant trigger repaints. A redirect is wrong: htmx follows it and fetches a whole shell document.

The shell reloads only through grabbagShell.hardReload(reason), for two reasons: kicked (the host kicked this phone or cleared the room) and host-restarted (the host's boot ID changed while the stream was down). A short network drop reconnects, refetches the tenant, and remounts without a reload.

Extra pages such as /play/howto, pickers, and /settings are normal documents and may reload.

Changing the tenant

The shell refetches its tenant on the round and tenant events, after an SSE reconnect, and when a response carries HX-Trigger: grabbag:tenant. Host already sends those for Start, Stop, Load, Unload, Join, Leave, Stand, Sit, sign-in, and the admin-only board switch. A game ends its round with Finish, which publishes round.

Examples

An htmx End game
h.Finish()
if r.Header.Get("HX-Request") != "" {
    w.WriteHeader(http.StatusNoContent)
    return
}

Helper

Host implements this. You receive it in Load and Start. Do not parse cookies, open host.sqlite, or write host.log yourself.

Helper

Seated

Returns every player who currently occupies a seat.

Syntax

Seated() []Player

Parameters

None.

Return value

A snapshot of Player values with Seated true. Empty if the roster read fails. Order follows Lobby's player list, not a scoreboard you control.

Description

This is the running table. Key in-memory maps by Player.ID. Disconnected seated people stay here until Kick or end-of-round cleanup. Do not use this to decide who may Join. Lobby already did that.

Examples

Paint the Testing board list
for _, p := range h.Seated() {
    row := playerRow{
        ID:          p.ID,
        DisplayName: p.DisplayName,
        AvatarSeed:  p.AvatarSeed,
        Connected:   p.Connected,
        Taps:        g.taps[p.ID],
    }
    view.Players = append(view.Players, row)
}
Helper

Waiting

Returns players on the wait list.

Syntax

Waiting() []Player

Parameters

None.

Return value

Players with Waiting true. Join during a round can put someone here and also in audience.

Description

Wait list is Lobby-owned. Fill-empty-seats consumes waiters, not audience-only people. You may show this list as "up next". You must not move people on or off it.

Examples

Count the line
n := len(h.Waiting())
h.Log("waiters " + strconv.Itoa(n))
Helper

Audience

Returns everyone who is not seated.

Syntax

Audience() []Player

Parameters

None.

Return value

Players with Audience true. Host sets that as !Seated, so waiters are included.

Description

Use this for watch or vote UI. Audience-only people are never consumed by fill. A wait toggle drops wait and keeps audience. If you need waiters excluded, filter on !p.Waiting yourself.

Examples

Audience minus the wait line
var watchers []games.Player
for _, p := range h.Audience() {
    if !p.Waiting {
        watchers = append(watchers, p)
    }
}
Helper

Player

Looks up one roster row by Lobby player id.

Syntax

Player(id string) (Player, bool)

Parameters

id
Lobby player id. Same string you stored from Seated or PlayerFromRequest.

Return value

The row and true if it exists. Zero Player and false if not. There is no error channel. A store read failure looks like a miss (empty list).

Description

Use this when you already have an id from run state and need a fresh name or connected flag. Do not cache DisplayName across a whole night if the operator unlocked names. Re-read when you render.

Examples

Skip a tap if the id left
p, ok := h.Player(id)
if !ok || !p.Seated {
    return
}
Helper

PlayerFromRequest

Resolves the player cookie on an incoming request.

Syntax

PlayerFromRequest(r *http.Request) (Player, bool, error)

Parameters

r
The HTTP request. Helper reads the player cookie. It does not read the admin cookie.

Return value

The player, whether a row was found, and a cookie/store error. Treat err != nil as a failed lookup. ok == false with err == nil means no player cookie or no matching row.

Description

This is how play POSTs know who tapped. Host already blocked unseated phones from Phone. Play routes do not get that filter. Check player.Seated on /play POSTs yourself, as Testing does on tap.

Examples

Count a seated tap
player, ok, err := h.PlayerFromRequest(r)
if err != nil || !ok || !player.Seated {
    w.WriteHeader(http.StatusNoContent)
    return
}
g.taps[player.ID]++
Helper

DataDir

Returns the on-disk folder reserved for this game id.

Syntax

DataDir() string

Parameters

None.

Return value

While loaded, <storeDir>/games/<ID>. If nothing is loaded, <storeDir>/games. On Windows the store dir is under %LOCALAPPDATA%\grabbag.

Description

Host created the id folder during Load. Write your own files here if KV blobs are a poor fit. Host does not parse them. Stop and Shutdown do not delete the folder.

Examples

Write a round dump
path := filepath.Join(h.DataDir(), "last-round.json")
_ = os.WriteFile(path, payload, 0o600)
Helper

KVGet

Reads an opaque byte value namespaced by this game id.

Syntax

KVGet(key string) ([]byte, bool, error)

Parameters

key
Your key. Host does not parse it. Empty key fails on write. Reads of a missing key return ok == false.

Return value

Stored bytes, whether a row existed, and a store error. Missing key is not an error.

Description

This is the game_kv table in host.sqlite. Survives Stop, Shutdown, and process restart. Operator Clear game data deletes every key for this id. Do not store Lobby identity here. That already lives on the player row.

Examples

Hide latency flag
raw, ok, err := h.KVGet("hide-latency")
hide := err == nil && ok && string(raw) == "1"
Helper

KVSet

Writes an opaque byte value namespaced by this game id.

Syntax

KVSet(key string, value []byte) error

Parameters

key
Non-empty. Empty game id or key returns an error from store.
value
Any bytes. Nil is stored as empty. Host does not parse the payload.

Return value

nil on success.

Description

Upsert. Use it for knobs that should outlive a round. After a successful write, publish something if open pages should refresh. Testing publishes roster after Hide latency so the board drops the latency column.

Examples

Save a checkbox
value := []byte("0")
if r.FormValue("hide") == "1" {
    value = []byte("1")
}
if err := h.KVSet("hide-latency", value); err != nil {
    http.Error(w, "Could not save Hide latency.", http.StatusInternalServerError)
    return
}
Helper

Finish

Asks host to end the round as a graceful Stop.

Syntax

Finish()

Parameters

None.

Return value

None. Host swallows Stop errors.

Description

This is End game. Host calls Game.Stop. If After a game is Cycle seats, the table rotates. Empty seats still fill when Keep seats is on. Host publishes round, and every shell swaps back to the Lobby. Call this from a /play/end htmx POST after you check HasAdmin or ClaimedHost, then answer 204. Finish does not Shutdown. The package stays loaded so the operator can Start again.

Examples

End from the phone or the board
if !g.mayEnd(h, r) {
    w.WriteHeader(http.StatusNoContent)
    return
}
h.Finish()
w.WriteHeader(http.StatusNoContent)
Helper

Pause

Asks host to pause the running round.

Syntax

Pause()

Parameters

None.

Return value

None. Host swallows errors. If nothing is started, the call is refused inside host and ignored here.

Description

Prefer operator Pause on the Host drawer unless your rules need an in-game pause control. Host will call Game.Pause and publish pause. Do not recurse. Game.Pause should not call Helper.Pause.

Examples

A game-owned pause button
func (g *Game) postPause(w http.ResponseWriter, r *http.Request) {
    if h := g.helperNow(); h != nil {
        h.Pause()
    }
    w.WriteHeader(http.StatusNoContent)
}
Helper

Resume

Asks host to resume a paused round.

Syntax

Resume()

Parameters

None.

Return value

None.

Description

Same split as Pause. Host calls Game.Resume and publishes pause. The operator may Resume while a seated player is still disconnected.

Examples

Resume from play
if h := g.helperNow(); h != nil {
    h.Resume()
}
Helper

Publish

Sends a named server-sent event on the room hub.

Syntax

Publish(name string)

Parameters

name
Event name. Pages listen with HTMX sse:name. A slow page may miss an event. Every event means "fetch current state", not "here is a delta".

Return value

None.

Description

One EventSource per page, already opened by host chrome. Do not open another. The name is the only signal games get. The data line is always update. Put the new score in a GET partial. See Live SSE.

Do not publish round for a score tick. Host uses round to swap Lobby vs game documents. pause, roster, theme, log, and notice are also host names. Publishing roster is reasonable when a knob changes a board that also shows Lobby facts.

Examples

Tick and tap
h.Publish("testing")
h.Publish("tap")

The Testing list listens with hx-trigger="sse:testing, sse:tap, sse:roster, htmx:sseOpen from:body".

Helper

Notify

Pushes a host-owned toast on the room EventSource.

Syntax

Notify(target, typ, message string, seconds int)

Parameters

target
board, host, seated, audience, or waiting. Anything else is a no-op. host is /settings with an admin session, plus the claimed-host phone. One screen, one queue if it matches several targets.
typ
Built-in looks are error, warning, and info. Other strings still deliver, styled as info, and stamp data-notice-type so game CSS can restyle. Empty type becomes info.
message
Toast text. Blank after trim is a no-op.
seconds
0 stays until Close. Negative uses 3 seconds. Values above 30 clamp to 30.

Return value

None.

Description

Host chrome owns the shell: slide-in from the top, type mark, message, Close. Waiting queue caps at 4. This is not Log. Call both if you want a durable line. Slow pages miss the event. There is no replay.

Examples

Warn seated players
h.Notify("seated", "warning", "Last round of this deck.", 5)
Helper

Log

Writes a line into the host log ring, prefixed with the game id.

Syntax

Log(line string)

Parameters

line
Plain text. Host stores <ID>: plus this string. If nothing is loaded, the call is a no-op.

Return value

None.

Description

Operators read this on the settings log page. Do not log secrets. Do not write host.log yourself.

Examples

Trace a finish
h.Log("player ended the round")
Helper

Theme

Returns the room palette id so extra documents match /settings on first paint.

Syntax

Theme() string

Parameters

None.

Return value

neon-dark (the default) or neon-light. Stamp it on html[data-theme]. Host also publishes theme when the operator flips palettes. ui.NormalizeTheme maps unknown values to neon-dark.

Description

Board and phone fragments inherit the shell's theme. Extra full pages you serve from Play (such as /play/info) must set this themselves.

Examples

Chrome on About Testing
view := boardView{
    Chrome: ui.Chrome{Title: "About Testing", Theme: ui.DefaultTheme},
}
if h := g.helperNow(); h != nil {
    view.Chrome.Theme = ui.NormalizeTheme(h.Theme())
}
Helper

HasAdmin

Reports whether this request carries a valid admin session.

Syntax

HasAdmin(r *http.Request) bool

Parameters

r
The request. Helper reads the admin cookie and checks the session table. Games never see the cookie value.

Return value

true when the session is valid.

Description

Use this for board End game, or any control that should match Room settings. Claimed-host on a phone is a different bit, Player.ClaimedHost. Testing allows End game from HasAdmin on the TV and from ClaimedHost on the phone. Settings POSTs are already gated by host with requireAdmin. You do not need HasAdmin there.

Examples

Who may end
func (g *Game) mayEnd(h games.Helper, r *http.Request) bool {
    if h.HasAdmin(r) {
        return true
    }
    player, ok, err := h.PlayerFromRequest(r)
    return err == nil && ok && player.ClaimedHost
}

Types

Player

A Lobby row as a game may read it. Games never write these fields.

Syntax

type Player struct { ID string DisplayName string AvatarSeed string Seated bool Waiting bool Audience bool ClaimedHost bool Connected bool LastHeartbeatRTT time.Duration }

Fields

ID
Stable Lobby player id. Key run state by this. Reconnect keeps the same id in the same seat.
DisplayName
Nickname. May lock while a game runs, depending on host settings.
AvatarSeed
Procedural SVG seed. Render with ui.AvatarSVG. Do not store image blobs.
Seated
Occupies a seat.
Waiting
On the wait list.
Audience
Not seated. Host sets this to !Seated.
ClaimedHost
This player claimed host. Phone End game in Testing keys off this.
Connected
Heartbeat currently live. Host maps this from !Disconnected.
LastHeartbeatRTT
Last RTT. Zero means none yet. Testing hides this when KV says so.

Examples

Format latency
if p.LastHeartbeatRTT > 0 {
    row.Latency = strconv.FormatInt(p.LastHeartbeatRTT.Milliseconds(), 10) + " ms"
}

BoardButton

One Lobby rail control a loaded game may publish before Start.

Syntax

type BoardButton struct { Label string Path string HostOnly bool }

Fields

Label
Button text. Empty is skipped.
Path
href. Empty is skipped. Usually a GET under /play.
HostOnly
When true, host omits the control unless the board request has an admin session.

Examples

Public scores plus a host deck
[]games.BoardButton{
    {Label: "Public scores", Path: "/play/scores"},
    {Label: "Host only deck", Path: "/play/deck", HostOnly: true},
}

Factory

How a game package announces itself to Catalog.

Syntax

type Factory struct { ID string New func() Game }

Fields

ID
Must match Game.ID() in practice. Host indexes the runtime map by this, then constructs with New.
New
Must return a fresh unloaded Game. Called on every Load.

See also

Register

Host HTTP

These routes are host-owned. Games do not register them. Admin session required except where noted. Restore-pending rooms return 409.

POST /settings/load

Constructs the selected catalog id and calls Load.

Syntax

POST /settings/load Content-Type: application/x-www-form-urlencoded game_id=testing

Parameters

game_id
Catalog id. Unknown id or a started round returns 409.

Return value

204 with HX-Trigger: grabbag:tenant for an htmx request, otherwise 303 to /.

Description

The game library posts this. You do not. After success, Settings and BoardButtons are live. Board is still Lobby.

POST /settings/start

Calls Start when a game is loaded and at least one player is seated.

Syntax

POST /settings/start

Parameters

None.

Return value

204 with the tenant trigger for htmx, otherwise 303 to /. When the gate refuses, the host gets a notice.

Description

Auto-start on Lobby can call the same runtime method without this POST. Second Start while running succeeds as a no-op redirect.

POST /settings/stop

Calls Stop.

Syntax

POST /settings/stop graceful=1

Parameters

graceful
1 lets Lobby cycle seats. Finish uses this path inside host. A bare Stop from the drawer may omit it.

Return value

204 with the tenant trigger for htmx, otherwise 303 to /.

POST /settings/shutdown

Unloads the package. Stop first if a round is active, then Shutdown.

Syntax

POST /settings/shutdown

Parameters

None.

Return value

204 with the tenant trigger for htmx, otherwise 303 to /.

POST /settings/pause

Calls Game.Pause.

Syntax

POST /settings/pause

Return value

204 on success. 409 if no started game.

Description

The Host drawer uses HTMX. Pages listen for sse:pause.

POST /settings/resume

Calls Game.Resume.

Syntax

POST /settings/resume

Return value

204 on success. 409 if no started game.

POST /settings/clear-game-data

Deletes every game_kv row for the loaded id.

Syntax

POST /settings/clear-game-data

Return value

303 to /settings. 409 if nothing is loaded.

Description

Lobby seats and host.log stay. Stop and Shutdown do not do this. Games must not impersonate this route.

GET /tenant, /board/tenant

The shell's tenant fetch. No admin session needed; it uses the same cookie and admin checks as / and /board.

Return value

JSON: tenant (id), generation, boot, theme, notices, stream, player (shows Leave), kicked, help (shows Help), host (the claimed host's panel for the frame), assets, and html (the fragment).

Description

The board gets the locked tenant when the admin-only board is on and the request has no admin session. A locked board connects to /shell/events/locked, which carries only tenant and theme. POST /shell/error takes the shell's error reports and writes them to the host log. GET /help forwards to the phone's mounted tenant's Help, or is 404 for a tenant without one.

Mounts

Public paths stay host-owned. Lobby vs game is a tenant swap inside the / and /board shells.

PathAfter LoadAfter Start
/phone shell, Lobby tenantphone shell, game Phone wrapped by host, any signed-in player
/boardboard shell, Lobby tenant plus BoardButtonsboard shell, game Board
/settingsHost operatorsame, Game Settings inlined
/settings/game/*Settings() muxsame
/play/*Play() GETPlay() GET and POST

Games must not register /, /board, or /settings as mux roots.

Extra documents live under /play/ as GET handlers. Forms that change settings before Start post under /settings/game/. The inlined Game Settings column is still GET /settings/game/, not those documents.

Live SSE

GrabBag.gg does not push HTML down the wire. It pokes open pages with a name, and those pages pull the current document or partial. That is the whole live system.

How it works

The shells open one EventSource on GET /lobby/events, the in-process hub for the room. For each named event the shell fires sse:<name> on every element whose hx-trigger lists it, the same as HTMX's SSE extension, so your fragments listen with plain hx-trigger. /settings and other operator pages use ui-start, which puts the SSE extension on body. ui-start-quiet (setup and these docs pages) does not connect.

Helper.Publish sends an SSE frame like this.

event: tap
data: update

Host itself uses a different helper, PublishData, for theme (the palette id), log (the log line), and notice (toast JSON from Notify). Your scores, names, and tap counts belong in the GET you run after the poke, not in the event body. A missed notice is gone. Do not treat it as a log.

The hub writes a comment ping every 15 seconds so proxies keep the stream. Those pings are not named events. Nothing in your templates should listen for them.

Reserved names

Host and Lobby already publish these. Listening is fine. Publishing them from a game is almost always wrong, except roster after a knob that other pages should reread.

NameWho publishesWhat pages do
rosterjoin, leave, ready, seats, disconnect, Load, and similarSwap roster partials
roundhost on Start, Stop, ShutdownShells refetch their tenant (Lobby vs game swap)
tenanthost and Lobby when what shells show changed: Load, sign-in, admin-only board, KeepShells refetch their tenant
pausehost on Pause and ResumeSwap pause chrome and paused game frames
themehost when the palette flipsSet html[data-theme]
loghost log ringRefresh the settings log tail
noticehost via Helper.NotifyToast in host chrome; the page keeps or ignores it from target

If you publish round or tenant because a prompt advanced, every open TV and phone refetches its whole tenant. Use your own name.

Your own names

Pick a short lowercase token. Prefix with the game id when the word is generic. Testing uses testing for a 1s room tick and tap when someone hits the plunger. Only one game is loaded at a time, so tap will not collide with another package. It can still collide with a reserved host name. Do not call your event round.

Publish after the run state you care about is already stored. Listeners will GET immediately. If you publish first and then write the map, a fast TV can fetch the old count.

Examples

Publish after a tap is counted
g.taps[player.ID]++
g.mu.Unlock()
h.Publish("tap")
Publish a clock tick
if h != nil {
    h.Publish("testing")
}

A settings POST that only changes KV can publish roster if the board list already listens for it. Testing does that for Hide latency. You can also publish your own name and list it on the same hx-trigger.

Listen in HTML

HTMX turns a named event into sse:that-name on the same EventSource. Point hx-get at a Play partial. Swap the node that holds the live bits, not your whole fragment.

Examples

Refresh a list on tick, tap, roster, and reconnect
<div
  id="testing-list"
  class="testing-list"
  hx-get="/play/partials/board-list"
  hx-trigger="sse:testing, sse:tap, sse:roster, htmx:sseOpen from:body"
  hx-swap="outerHTML"
>

htmx:sseOpen from:body runs when the shell's stream first connects. After a drop the shell remounts the whole tenant instead. The first HTML already has the list, so the extra GET on first open is cheap insurance, not the first paint.

Swap the outer frame only on pause
<div
  id="testing-board"
  hx-get="/play/partials/board"
  hx-trigger="sse:pause"
  hx-swap="outerHTML"
>

Testing keeps pause on the outer frame and ticks on the inner list so a 1s tick does not rebuild End game and the heading.

Register those GET paths on your Play mux (GET /partials/board-list, not /play/partials/board-list). Do not add sse-connect. Do not construct new EventSource("/lobby/events") in a script. The overlay, theme, reconnect, and the locked board's restricted stream all hang on the shell's one connection.

Missed events

Each page gets a buffer of 16 named events. If the tab is slow, the hub skips that subscriber and moves on. There is no replay. The next event, or htmx:sseOpen, should GET a partial that represents now, not "plus one since last time".

That is why Testing stores tap counts on the server and re-renders the list. A missed tap only delays the flash. The next testing tick still shows the true total.

If you animate from the event itself and ignore the GET, a dropped poke is a dropped animation with no recovery. Prefer server state plus refetch.

Runtime kit

Optional. Package runtimekit in internal/games/runtimekit is the match runtime that Apples for Humanity, Quick Quips, and Borrowed Truths share. Host never sees it. Every hook above still belongs to your Game. The kit only does the plumbing behind them.

runtimekitoptional

When to use it

Use the kit when your game is turns, timers, and phone buttons that change one shared match. Skip it when you want your own runtime. Testing skips it.

Description

Split the game in two. The engine is rules and run state: no HTTP, no Helper, no goroutines, and the clock comes in as an argument. The Game holds a *runtimekit.Runner[*engine] plus views, templates, and extra routes. The Runner owns:

  • one mutex for run state;
  • the timer tick;
  • the player and host POST pipeline: sign-in or host check, form parse, 404 with no match, your gate, your action, publish, then the phone reply;
  • Helper Pause, Resume, and Finish calls with the lock released, because host calls straight back into your Game;
  • page chrome, template render, and the static mount.

Your Game methods hand off to the Runner. Your rules stay in the engine.

runtimekitsetup

New and Config

Builds an unloaded Runner wired to your engine type through hooks.

Syntax

func New[E any](cfg Config[E]) *Runner[E] type Config[E any] struct { Game string Event string Pages *template.Template AssetVersion string Tick time.Duration Advance func(e E, now time.Time) Result Hold func(e E, paused bool, now time.Time) Gate func(e E, p games.Player, paused bool) string Reply func(e E, p games.Player, r *http.Request, msg string) (name string, data any) Cleanup func(e E, unload bool) }

Fields

Game
Player-facing name, used in render errors.
Event
The SSE name published when a Result has Changed. See Your own names.
Pages, AssetVersion
Your parsed templates and the ?v= for game.css and game.js.
Tick
How often Advance runs. Zero is 250ms.
Advance
Runs every tick while a match runs and is not paused. Fire expired timers here. Nil means no timers and no tick.
Hold
Freezes or thaws the engine clock on host Pause and Resume. Fold in your own holds here, such as a reshuffle overlay, and call Runner.Hold when they change.
Gate
Refuses a player or host action before it runs. Return the phone error, or "". Nil lets every action run, paused or not.
Reply
Picks the template and view that answer an action. msg is the phone error or "". e is nil when the action ended the match.
Cleanup
Runs on Stop and Shutdown after the tick stops and before the engine is dropped. unload is true on Shutdown. The helper is still set.

Every hook runs with the lock held.

Examples

Wire Borrowed Truths
func New() *Game {
    g := &Game{}
    g.run = runtimekit.New(runtimekit.Config[*engine]{
        Game:         "Borrowed Truths",
        Event:        event,
        Pages:        pages,
        AssetVersion: assetVersion,
        Advance: func(e *engine, now time.Time) runtimekit.Result {
            finish, changed := e.Advance(now)
            return runtimekit.Result{Changed: changed, Finish: finish}
        },
        Hold: func(e *engine, paused bool, now time.Time) { e.SetPaused(paused, now) },
        Gate: func(_ *engine, _ games.Player, paused bool) string {
            if paused {
                return "The match is paused."
            }
            return ""
        },
        Reply: func(_ *engine, p games.Player, _ *http.Request, msg string) (string, any) {
            return "phone-frame", g.phoneViewLocked(p, msg)
        },
        Cleanup: func(*engine, bool) { g.dropRunLocked() },
    })
    return g
}

Apples for Humanity leaves actions open while paused, so its Gate only refuses during the reshuffle overlay.

runtimekitlifecycle

Lifecycle

The Runner methods your Game hooks hand off to.

Syntax

func (r *Runner[E]) Load(h games.Helper) func (r *Runner[E]) Start(h games.Helper, begin func(h games.Helper) (E, error)) error func (r *Runner[E]) Pause() error func (r *Runner[E]) Resume() error func (r *Runner[E]) Stop() error func (r *Runner[E]) Shutdown() error

Description

Load stores the helper and drops any match. Start runs begin under the lock to build the engine, then starts the tick. An error from begin leaves no match running, and host keeps Lobby on /. Pause and Resume flip the paused flag and call Hold. Stop runs Cleanup and drops the engine. Shutdown does that and drops the helper. None of them touch KV.

Examples

Hand off the Game hooks
func (g *Game) Load(h games.Helper) error {
    g.run.Load(h)
    return nil
}

func (g *Game) Start(h games.Helper) error {
    return g.run.Start(h, func(h games.Helper) (*engine, error) {
        return newEngine(g.loadSettings(h), rosterOf(h.Seated()), g.run.Rand(), g.run.Now()), nil
    })
}

func (g *Game) Pause() error    { return g.run.Pause() }
func (g *Game) Resume() error   { return g.run.Resume() }
func (g *Game) Stop() error     { return g.run.Stop() }
func (g *Game) Shutdown() error { return g.run.Shutdown() }
runtimekitPlay POST

Act and HostAct

Runs one phone button against the engine and answers with the fresh phone.

Syntax

type Action[E any] func(e E, p games.Player, now time.Time) Result func (r *Runner[E]) Act(w http.ResponseWriter, req *http.Request, fn Action[E]) func (r *Runner[E]) HostAct(w http.ResponseWriter, req *http.Request, fn Action[E]) func (r *Runner[E]) HostPause(w http.ResponseWriter, req *http.Request) func (r *Runner[E]) HostResume(w http.ResponseWriter, req *http.Request) func (r *Runner[E]) Player(w http.ResponseWriter, req *http.Request) (games.Player, bool) func (r *Runner[E]) HostPlayer(w http.ResponseWriter, req *http.Request) (games.Player, bool)

Return value

None. The Runner writes the response: 404 before Load or with no match, 401 with no player cookie, 403 for a non-host on the host routes, 400 for a bad form, or 200 with the template Reply picks.

Description

Act is for any signed-in player. HostAct lets the claimed host or an admin session through. An admin with no player cookie gets the zero Player. Both parse the form before your action runs, so close over the request for form values. HostPause and HostResume are ready-made phone host buttons. Resume skips the Gate, since a paused Gate would refuse it.

Player and HostPlayer are the same checks for handlers that do not fit the pipeline, such as a photo upload that answers 204.

Examples

A vote button
func (g *Game) postVote(w http.ResponseWriter, r *http.Request) {
    g.run.Act(w, r, func(e *engine, p games.Player, now time.Time) runtimekit.Result {
        if msg := e.Vote(p.ID, r.FormValue("pick")); msg != "" {
            return runtimekit.Result{Err: msg}
        }
        return runtimekit.Result{Changed: true}
    })
}

mux.HandleFunc("POST /host/pause", g.run.HostPause)
mux.HandleFunc("POST /host/resume", g.run.HostResume)
runtimekitSSE

Result, Apply, Defer

What an action or tick asks the Runner to do after run state changed.

Syntax

type Result struct { Err string Changed bool Events []string Pause bool Resume bool Finish bool } func (r *Runner[E]) Apply(res Result) func (r *Runner[E]) Defer(res Result) func (r *Runner[E]) Publish(names ...string)

Fields

Err
The phone error for the player who acted. Reply gets it as msg.
Changed
Publish Config.Event.
Events
More SSE names to publish, in order, after Config.Event. Borrowed Truths publishes only borrowedtruths-facts while phones are mid-write, so their text boxes are not swapped.
Pause, Resume, Finish
Call the matching Helper method with the lock released.

Description

Act, HostAct, and the tick apply the Result for you. Call Apply from your own handlers. It may release and retake the lock, so read the engine again afterwards: after Finish it is gone. Defer publishes now and holds the host calls for the next Apply or tick. Use it where the lock must stay held, such as a view that syncs the roster and may need to Pause.

Examples

End the match from a custom handler
g.run.Lock()
g.run.Apply(runtimekit.Result{Changed: true, Finish: true})
view := g.phoneViewLocked(p)
g.run.Unlock()
runtimekitstate

Lock and state

Reads run state from code that is not a hook.

Syntax

func (r *Runner[E]) Lock() func (r *Runner[E]) Unlock() func (r *Runner[E]) Engine() E func (r *Runner[E]) Running() bool func (r *Runner[E]) Paused() bool func (r *Runner[E]) Helper() games.Helper func (r *Runner[E]) HelperLocked() games.Helper func (r *Runner[E]) Now() time.Time func (r *Runner[E]) Rand() *rand.Rand func (r *Runner[E]) Hold()

Description

Hold the lock for Engine, Running, Paused, HelperLocked, Rand, Hold, Publish, Apply, and Defer. Helper, Now, and the methods on other cards take it themselves. Hooks already run with it held.

Examples

A settings form frozen during a match
g.run.Lock()
frozen := g.run.Running()
g.run.Unlock()
runtimekitengine

Timer

One phase countdown kept as plain data in your engine.

Syntax

type Timer struct { Kind string End time.Time Total time.Duration Left time.Duration } func (t *Timer) Arm(kind string, d time.Duration, now time.Time, paused bool) func (t *Timer) Clear() func (t *Timer) Freeze(now time.Time) func (t *Timer) Thaw(now time.Time) func (t *Timer) Extend(d time.Duration) func (t Timer) On() bool func (t Timer) Frozen() bool func (t Timer) Expired(now time.Time) bool func (t Timer) Remaining(now time.Time) time.Duration func (t Timer) View(now time.Time) TimerView

Description

Arm starts a countdown. Pass the engine's paused flag so a timer armed during a pause starts frozen. Freeze and Thaw are what Hold calls. A frozen timer has a zero End, keeps its remainder in Left, and never expires. Kind says what fires on expiry. The client gets it as detail.kind on countdown events.

Examples

Fire the vote timer from Advance
func (e *engine) Advance(now time.Time) runtimekit.Result {
    if e.Paused || !e.Timer.Expired(now) {
        return runtimekit.Result{}
    }
    e.Timer.Clear()
    e.closeVote()
    return runtimekit.Result{Changed: true}
}

func (e *engine) openVote(now time.Time) {
    e.Timer.Arm("vote", 20*time.Second, now, e.Paused)
}
runtimekituiclient

Countdown

Draws a Timer on the board or a phone with the shared countdown script.

Syntax

type TimerView struct { On, Frozen bool Kind string EndUnix, EndMs int64 Seconds int TotalSeconds int } func (v TimerView) Clock() string {{template "ui-countdown" .Timer}} inside the element's opening tag data-countdown-value on the text node

Description

The shells and ui-start load /static/countdown.js. It paints m:ss into each [data-countdown-value] every 250ms and after every swap. While data-countdown-frozen is set, or in a static preview, it shows the rendered seconds. Seconds round up, so the last second shows 0:01.

It sets --countdown-left (1 to 0) and --countdown-turn on the element for bars and conic rings. A running countdown fires grabbag:countdown-tick when the shown second changes and grabbag:countdown-done once at zero. Both bubble with detail.seconds and detail.kind. A repaint of the same countdown fires nothing new, and a page that loads after the end fires nothing. Hook sound or animation cues there. Do not write a second timer loop in game.js.

Examples

A timer pill with a ring
{{if .Timer.On}}
<div class="wordbox-timer" {{template "ui-countdown" .Timer}}>
  <span class="wordbox-ring" aria-hidden="true"></span>
  <strong data-countdown-value>{{.Timer.Clock}}</strong>
</div>
{{end}}

.wordbox-ring {
  background: conic-gradient(var(--ui-accent-2) var(--countdown-turn, 1turn), transparent 0);
}
Beep in the last three seconds
ctx.on(document, "grabbag:countdown-tick", function (ev) {
  if (ev.detail.kind === "vote" && ev.detail.seconds <= 3) {
    beep();
  }
});
runtimekitui

Page, Render, Static

Chrome, template output, and asset serving every game repeats.

Syntax

type Page struct { ui.Chrome GameCSS string GameJS string } func (r *Runner[E]) Page(title string) Page func (r *Runner[E]) PageLocked(title string) Page func (r *Runner[E]) Render(w http.ResponseWriter, name string, data any, status int) func Static(files fs.FS) http.Handler

Description

Embed Page in board, phone, settings, and extra-page views. It stamps Theme and the /play/static/game.css and game.js links at AssetVersion for extra pages; board and phone fragments get their assets from Assets. Render buffers the template, so a template error is a clean 500 and not half a page. Static serves the static/ folder of your embedded FS.

Examples

Mount static assets
//go:embed static/*
var staticFiles embed.FS

mux.Handle("GET /static/", runtimekit.Static(staticFiles))
runtimekittests

Tests and previews

Drives a Runner without a host, a wall clock, or waiting on the tick.

Syntax

func (r *Runner[E]) Install(e E) func (r *Runner[E]) SetClock(now func() time.Time) func (r *Runner[E]) SetRand(rng *rand.Rand) func (r *Runner[E]) Tick()

Description

Install makes e a running match with no helper and no tick, which is what Scenarios need. SetClock and SetRand fix time and shuffles. Tick runs one tick now, host calls included. All four take the lock, so do not call them while holding it.

Examples

A preview Game at a fixed moment
func fixedGame(e *engine, now time.Time) *Game {
    g := New()
    g.run.SetClock(func() time.Time { return now })
    g.run.Install(e)
    return g
}

Must not

The live package that exercises almost every hook is Testing in internal/games/testing.