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:
- Add
internal/games/<name>. Do not name the package testing. Product Testing uses package testinggame.
- Implement
games.Game.
- Call
games.Register from init.
- 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 action | Game method | / and /board | /play/ POST | KV |
| Load | Load | Lobby | 404 | kept |
| Start | Start | game | mounted | kept |
| Pause | Pause | game | mounted | kept |
| Resume | Resume | game | mounted | kept |
| Stop | Stop | Lobby | 404 | kept |
| Shutdown | Stop if started, then Shutdown | Lobby | 404 | kept |
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.
| Method | Host calls it |
ID | catalog key, logs, KV, data dir |
Name | Lobby rail label after Load |
MinPlayers | Start gate |
MaxPlayers | Load gate and Lobby max |
Load | after Factory.New() |
Settings | after Load, inlined on /settings |
Start | operator Start or auto-start |
Board | board shell after Start |
BoardButtons | Lobby TV rail after Load, before Start |
Phone | phone shell after Start, any signed-in player |
Help | phone shell Help button, through GET /help |
Play | /play/ mux |
Pause | operator or auto-pause |
Resume | operator Resume |
Stop | round end |
Shutdown | unload |
Assets | whenever 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.
| Path | After Load | After Start |
/ | phone shell, Lobby tenant | phone shell, game Phone wrapped by host, any signed-in player |
/board | board shell, Lobby tenant plus BoardButtons | board shell, game Board |
/settings | Host operator | same, Game Settings inlined |
/settings/game/* | Settings() mux | same |
/play/* | Play() GET | Play() 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.
| Name | Who publishes | What pages do |
roster | join, leave, ready, seats, disconnect, Load, and similar | Swap roster partials |
round | host on Start, Stop, Shutdown | Shells refetch their tenant (Lobby vs game swap) |
tenant | host and Lobby when what shells show changed: Load, sign-in, admin-only board, Keep | Shells refetch their tenant |
pause | host on Pause and Resume | Swap pause chrome and paused game frames |
theme | host when the palette flips | Set html[data-theme] |
log | host log ring | Refresh the settings log tail |
notice | host via Helper.Notify | Toast 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
- Import
internal/host, internal/lobby, or internal/store from a game package.
- Write seats, name, wait, audience, or claimed-host.
- Parse the player cookie or the admin cookie.
- Register
/, /board, or /settings as mux roots.
- Clear KV on Stop or Shutdown.
- Scan a games folder at runtime.
- Treat
/docs as this contract. That URL is how to run the binary.
- Open an EventSource. The shell has the only one.
- Send a full document,
<link>, or <script> from Board or Phone. List files in Assets.
- Use
<form method="post"> or location.reload inside a shell. See No reloads.
- Add a
document or window listener, timer, or frame loop outside ctx.
- Write a second timer loop in
game.js. Use Countdown.
The live package that exercises almost every hook is Testing in internal/games/testing.