GameShell
GameShell
The central orchestrator for every HTMLGameKit game. <game-shell> manages state transitions, round tracking, scoring, director integration, and group play. All game state is exposed as TC39 Signal.State properties directly on the shell element. Descendant components access them via this.shell.
Import
import { GameShell } from "htmlgamekit";
Usage
<game-shell
game-id="reaction-time"
storage-key="reaction-v1"
rounds="10"
score-order="asc"
between-delay="800"
demo
>
<div when-some-scene="intro" data-overlay>...</div>
<div when-some-scene="playing between paused">...</div>
<div when-some-scene="result" data-overlay>...</div>
</game-shell>
Static Methods
- static gameScores(gameId, options?)
-
Factory that creates a score service instance. See the Scoring guide for full details.
const scores = GameShell.gameScores("my-game", { baseUrl: "https://scores.htmlgamekit.dev", }); - static noopScores
-
A no-op score service stub. Every method is a no-op that returns
nullorfalse. Used internally as the default when noscore-urlis configured. - static toBase64Url(buf)
-
Encodes a
Uint8ArrayorArrayBufferinto a URL-safe Base64 string (no padding,+/replaced with-_). Useful for encoding game results into shareable URLs viaencodeResult.const encoded = GameShell.toBase64Url(new Uint8Array([1, 2, 3])); - static fromBase64Url(str)
-
Decodes a URL-safe Base64 string back into a
Uint8Array. The inverse oftoBase64Url. Useful for decoding challenge results indecodeResult.const bytes = GameShell.fromBase64Url(encoded); - static encodeUint16WithBitmask(scale?)
-
Returns an
encodeResultfunction. Packs the shell score and round pass/fail history into a compact binary URL token.Byte layout:
[score:uint16be] [correct:uint8] [total:uint8] [bitmask:⌈total/8⌉bytes]Use when
roundScoresare pass/fail (0 or non-zero) andscoreis a fixed-point integer viascore-scale. Pair withdecodeUint16WithBitmaskusing the same scale.shell.encodeResult = GameShell.encodeUint16WithBitmask(100000); shell.decodeResult = GameShell.decodeUint16WithBitmask(100000); // Decoded: { score: number, strip: boolean[] } - static decodeUint16WithBitmask(scale?)
-
Returns a
decodeResultfunction matchingencodeUint16WithBitmask. The decoded object contains{ score, strip }wherestripis a boolean array of per-round pass/fail results. Returnsnullon invalid input. - static encodeUint16Array(scale?, roundCount?)
-
Returns an
encodeResultfunction. Packs the shell score and all round scores into a compact binary URL token.Byte layout:
[score:uint16be] [round0:uint16be] ... [roundN:uint16be]Use when
roundScoresare continuous values (e.g. per-round reaction times stored as integers). Pair withscore-scaleso values are pre-scaled. PassroundCountto lock the encoded length for games with a fixed round count.shell.encodeResult = GameShell.encodeUint16Array(1, 10); shell.decodeResult = GameShell.decodeUint16Array(1, 10); // Decoded: { score: number, roundScores: number[] } - static decodeUint16Array(scale?, roundCount?)
-
Returns a
decodeResultfunction matchingencodeUint16Array. The decoded object contains{ score, roundScores }. Returnsnullon invalid input.
Attributes
All attributes reflect as IDL properties (e.g. game-id reflects as .gameIdAttr, score-order as .scoreOrderAttr). Setting the property writes the attribute and vice versa. All attributes are reactive -- changing them via setAttribute() or the IDL property triggers the component to update.
- game-id
.gameIdAttr -
string-- Unique identifier for the game. Used as the key when talking to the score service and when persisting data tolocalStorage. - sprite-sheet
.spriteSheetAttr -
string?-- URL to an SVG sprite sheet. Mirrors to thespriteSheetsignal, which is read by all descendant<game-icon>elements. See the Icon component for details.<game-shell id="game" game-id="my-game" sprite-sheet="/assets/sprites.svg" ></game-shell> - storage-key
.storageKeyAttr -
string?-- Optional override for thelocalStoragekey. Defaults togame-idif omitted. Useful when you want multiple games to share a storage namespace, or when you version your storage schema. - rounds
.roundsAttr -
long-- Total number of rounds in the game. The shell uses this to know when to transition fromplayingtoresult. If a progression is attached, the progression's round count takes precedence. Defaults to0. - score-order
.scoreOrderAttr -
"asc" | "desc"-- Enumerated. Sort direction for scores. Use"asc"when lower is better (e.g. reaction time in ms) and"desc"when higher is better (e.g. points). Missing and invalid values default to"asc". - between-delay
.betweenDelayAttr -
string-- Milliseconds to stay in thebetweenstate between rounds, or"manual"to disable auto-advance (the player must dispatch agame-next-roundevent or click acommand="--next-round"button). Defaults to"500". - demo
.demo -
boolean-- When present, the shell enters thedemoscene on connection instead ofready. Useful for showing an animated preview on the intro screen. - score-url
.scoreUrl -
string?-- Base URL for the score service API. When set and no scores service has been assigned programmatically, the shell automatically creates one viaGameShell.gameScores(gameId, { baseUrl: scoreUrl }). This is the declarative alternative to setting.scoresin JavaScript.<game-shell game-id="reaction-time" score-url="https://scores.example.com" ></game-shell> - trophy-url
.trophyUrl -
string?-- Base URL for the trophy persistence API. When set, the shell syncs trophies with the remote server — fetching on load and pushing each unlock immediately. Players are identified by an anonymous UUID stored in localStorage and sent as anAuthorization: Bearerheader.<game-shell game-id="reaction-time" trophy-url="https://trophies.htmlgamekit.dev" ></game-shell>If the server is unreachable, trophies degrade gracefully to localStorage-only.
- scenes
.scenes -
string?-- Optional custom scene names recognised by the slot assignment logic, in addition to built-ins. Accepts a space- or comma-separated list.Built-in scenes are:
init,demo,ready,practice,playing,between,result,paused.<game-shell scenes="practice tutorial,review"></game-shell>This lets children use
when-some-scene="tutorial"and be slotted when your game setsshell.scene.set("tutorial"). - group
.group -
string?-- When set, the shell callsinitGroup()during initialisation to join or create a multiplayer group. The group identifier and name are exposed via thegroupIdandgroupNamesignals. - score-scale
.scoreScale -
long-- Integer multiplier applied to the progression's computed threshold before storing asscore. Use when the threshold is a small float (e.g. a JND of0.02) and you want to store it as a compact integer (0.02 × 100000 = 2000). Pair withshell.formatScorethat divides by the same scale for display. Defaults to1.<game-shell game-id="jnd-test" progression="staircase" score-scale="100000" ></game-shell> - session-save
.sessionSave -
boolean-- When present, the shell serializes game state tosessionStorageafter every signal change during active play (playingandbetweenstates). On page load, if a session exists, the shell restores to the saved state instead of going toinit.The session is cleared automatically on game completion (
result) or explicit restart. This is useful for mobile devices where the browser may kill a tab mid-game.<game-shell id="game" game-id="my-game" session-save></game-shell> - save-stats
.saveStats -
"persist" | "daily" | "auto"-- Controls whether and how the shell persists thestatssignal tolocalStorageunder the key{storageKey}-stats. IDL property returnsnullwhen absent,"auto"for a bare/unrecognised value,"persist", or"daily".Bare attribute (
save-stats) -- Stats are persisted and restored on page load. Cleared when.start()is called. Useful for per-session game state (current room, story progress).<game-shell id="game" game-id="my-adventure" save-stats></game-shell>"persist"-- Stats survive.start()calls and are never cleared by the shell. Useful for lifetime stats (total games played, best streak, unlocks).<game-shell id="game" game-id="my-game" save-stats="persist"></game-shell>"daily"-- Day-aware persistence for daily puzzle games:- The shell exposes a
daycomputed signal (see Signals) with today's day number (day 1 = 2026-01-01). - A
_dayfield is auto-stamped into persisted stats and result data. Game code does not need to manage this. - On page load, stale stats and results from a previous day are discarded automatically.
- If in-progress stats exist for today (but no result), the shell skips the intro and calls
.start()automatically.
<game-shell id="game" game-id="wordnt" save-stats="daily" rounds="0" ></game-shell> - The shell exposes a
Commands
The shell supports the Invoker Commands API for declarative button actions. Use commandfor and command on buttons to invoke shell methods without JavaScript:
<game-shell id="game" game-id="my-game">
<div when-some-scene="intro" data-overlay>
<button commandfor="game" command="--start">Play</button>
</div>
<div when-some-scene="result" data-overlay>
<button commandfor="game" command="--restart">Play Again</button>
</div>
</game-shell>
| Command | Action |
|---|---|
--start |
Calls shell.start() -- starts a new game |
--restart |
Calls shell.start() -- restarts (same as start) |
--practice |
Sets scene signal to "practice" |
--pause |
Calls shell.pause() |
--resume |
Calls shell.resume() |
--next-round |
Advances immediately when in between |
--stat |
Sets a stat. value="key:value" on the button |
--collect |
Adds to a collection. value="collection:itemId" |
--uncollect |
Removes from a collection. value="collection:itemId" |
--toggle-mute |
Toggles the sound <game-preference>, muting/unmuting audio |
The --stat, --collect, and --uncollect commands read their data from the value attribute on the invoking button, using key:value syntax where everything before the first : is the key/collection name and everything after is the value/item ID.
<button commandfor="game" command="--stat" value="room:lobby">
Go to lobby
</button>
<button commandfor="game" command="--collect" value="inventory:sword">
Take sword
</button>
<button commandfor="game" command="--uncollect" value="inventory:sword">
Drop sword
</button>
--toggle-mute finds the first <game-preference key="sound"> inside the shell and calls its .toggle() method. The preference handles persistence and auto-wiring to <game-audio>. Pair with a <game-icon> to show the current state:
<game-preference key="sound" default="true"></game-preference>
<button commandfor="game" command="--toggle-mute" aria-label="Toggle sound">
<game-icon name="volume-2">
<option when-some-muted value="volume-x"></option>
</game-icon>
</button>
Custom commands use the -- prefix per the Invoker Commands spec. The shell requires an id attribute so buttons can reference it via commandfor.
Signals
The shell exposes all game state as public Signal.State properties directly on the element. Descendant components access them via this.shell.
| Signal | Type | Description |
| ------------------- | ------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------- |
| scene | Signal.State<string> | Current game scene |
| round | Signal.State<number> | Current round (1-indexed) |
| rounds | Signal.State<number> | Total rounds configured |
| score | Signal.State<number> | Accumulated score |
| roundScores | Signal.State<number[]> | Per-round scores |
| roundScore | Signal.Computed<number> | Score from the most recent round |
| bestRoundScore | Signal.Computed<number> | Highest individual round score |
| worstRoundScore | Signal.Computed<number> | Lowest individual round score |
| scoreOrder | Signal.State<string> | "asc" or "desc" |
| lastRoundPassed | Signal.State<boolean | null> | Did last round pass? |
| lastFeedback | Signal.State<string | null> | Feedback from last round |
| passStreak | Signal.State<number> | Current consecutive pass streak |
| failStreak | Signal.State<number> | Current consecutive fail streak |
| peakPassStreak | Signal.State<number> | Highest pass streak reached this game |
| peakFailStreak | Signal.State<number> | Highest fail streak reached this game |
| difficulty | Signal.State<object> | Current difficulty from director |
| stats | Signal.State<object> | Arbitrary stats map |
| storageKey | Signal.State<string> | localStorage key |
| gameId | Signal.State<string> | Game identifier |
| betweenDelay | Signal.State<number> | Ms between rounds |
| encodedResult | Signal.State<string | null> | Encoded result for sharing |
| groupId | Signal.State<string | null> | Group identifier |
| groupName | Signal.State<string | null> | Group display name |
| challenge | Signal.State<object | null> | Challenge data |
| formatScoreSignal | Signal.State<function | null> | Score formatting function (set via .formatScore) |
| spriteSheet | Signal.State<string> | Sprite sheet URL from sprite-sheet attribute |
| muted | Signal.State<boolean> | Whether sound is muted; synced by <game-preference key="sound"> |
| day | Signal.Computed<number> | Today's day number (day 1 = 2026-01-01). Useful with the daily attribute |
Components access these via effectCallback:
effectCallback({ scene, score }) {
// re-runs whenever scene or score changes
}
Properties
- .scores
-
get | set-- Get or set the scores service instance. Typically created bygameScores().shell.scores = gameScores("reaction-time", { baseUrl: "/api" }); - .progressionSet
-
get | set-- Get or set the progression object that controls round difficulty and progression. Accepts any object implementing the Director interface. Note: this is distinct from theprogressionIDL property, which reflects theprogressionstring attribute (e.g."fixed","staircase").shell.progressionSet = new StaircaseProgression({ levels: 8 }); - .encodeResult
-
set(function) -- Assign a function(state) => stringto encode the final game result into a compact string for sharing or storage. Thestateargument is a plain snapshot object.shell.encodeResult = (state) => GameShell.toBase64Url(new Uint8Array(state.scores)); - .decodeResult
-
set(function) -- Assign a function(encoded) => objectto decode a previously encoded result string back into a result object.shell.decodeResult = (str) => ({ scores: GameShell.fromBase64Url(str) }); - .formatScore
-
set(function) -- Assign a function(score) => stringto format raw numeric scores for display throughout the UI. This sets theformatScoreSignalsignal internally, so all components that read it will react.shell.formatScore = (ms) => `${ms}ms`; - .gameUrl
-
get(string) -- The current page URL without query string (location.origin + location.pathname). Used by<game-share>to build shareable links. - .gameTitle
-
get(string) -- The game title, resolved from the first<h1>inside an intro overlay ([when-some-scene~=intro]or[when-some-scene~=init]), falling back todocument.title. Used by<game-share>. - .trophyCount
-
get(number) -- The number of trophies unlocked this session. Equivalent to readingtrophyCountfrom thewhen-*condition system or<game-signal key="trophyCount">. - .isTrophyUnlocked(id)
-
Returns
trueif the trophy with the givenidhas been unlocked. Used internally by the condition system forwhen-some-trophy/when-no-trophy.if (shell.isTrophyUnlocked("hat-trick")) { /* ... */ } - .addToCollection(name, id)
-
Add an item to a named collection. Returns
trueif the item was newly added,falseif already present. Collections are persisted tolocalStorageunder{storageKey}-collection-{name}.shell.addToCollection("inventory", "sword"); // true shell.addToCollection("inventory", "sword"); // false (already present) - .removeFromCollection(name, id)
-
Remove an item from a named collection. Returns
trueif the item was present and removed.shell.removeFromCollection("inventory", "sword"); - .hasInCollection(name, id)
-
Check if an item exists in a named collection. Returns
boolean.if (shell.hasInCollection("inventory", "key")) { // player has the key } - .collectionSize(name)
-
Returns the number of items in a named collection.
const itemCount = shell.collectionSize("inventory"); - .collectionEntries(name)
-
Returns an array of all item IDs in a named collection.
const items = shell.collectionEntries("inventory"); // ["sword", "shield", "potion"] - .clearCollection(name)
-
Removes all items from a named collection.
shell.clearCollection("inventory"); - .isCollection(name)
-
Check whether a named collection has been registered (even if empty). Used by the condition system to distinguish collection keys from signal/stat keys.
shell.isCollection("inventory"); // true if any item was ever added
Methods
- .start()
-
Begins the game. First dispatches a
game-lifecycleevent with action"setup"synchronously -- use this to initialise game state (set stats, collections, etc.) before play begins. Stats set during"setup"survive into the"playing"phase. After that, resets round counters and scores, initializes the progression (if any), and setssceneto"playing".shell.addEventListener("game-lifecycle", (e) => { if (e.action === "setup") { // Initialise stats, collections, etc. before "playing" begins shell.addToCollection("visited", "start"); } }); shell.start(); - .pause()
-
Pauses the game if currently playing. Sets
sceneto"paused". - .resume()
-
Resumes a paused game. Sets
sceneback to"playing". - static define(tag?, registry?)
-
Registers the custom element. Call without arguments to register as
<game-shell>, or pass a custom tag name and/or aCustomElementRegistry.GameShell.define(); // <game-shell> GameShell.define("my-shell"); // <my-shell> GameShell.define("my-shell", registry); // scoped registry
Events Caught
The shell listens for the following events bubbling up from descendant components:
| Event | Trigger | Effect |
|---|---|---|
game-round-pass |
A round was completed successfully | Records the score, advances the round counter, transitions to between then next round or result |
game-round-fail |
A round was failed | If retry is false, consumes a round. Optionally records a penalty. |
game-timer-expired |
The round timer ran out | Equivalent to a fail with no retry -- ends the current round |
game-stat-update |
A child wants to update a stat | Merges { key: value } into the stats signal |
game-start-request |
User clicked a start button | Calls .start() |
game-restart-request |
User clicked a restart button | Resets state and calls .start() |
game-practice-start |
User entered practice mode | Sets scene signal to "practice" |
game-complete |
Game mechanics signal completion directly | Sets scene to "result", optionally records a final score |
game-pause-request |
A child requests a pause | Calls .pause() |
game-resume-request |
A child requests a resume | Calls .resume() |
game-next-round |
A child requests immediate round advance | Advances to the next round immediately when in between |
game-trophy-unlock |
A <game-trophy> was unlocked |
Records the trophy id in the shell's internal set and persists to localStorage |
game-collection-add |
A child wants to add to a collection | Calls addToCollection(collection, itemId) and persists to localStorage |
game-collection-remove |
A child wants to remove from a collection | Calls removeFromCollection(collection, itemId) and persists to localStorage |
Events Fired
- game-lifecycle
-
Fired on every scene transition and at the start of
.start()(with action"setup"). The event is aGameLifecycleEventwith.action(the new scene name or"setup"),.state(a plain snapshot object), and.scene(the current scene name).The
"setup"action fires synchronously at the start of.start()before stats are wiped and the scene changes to"playing". Use this to initialise game state (set stats, collections, etc.) on game start/restart.shell.addEventListener("game-lifecycle", (e) => { if (e.action === "setup") { // Initialise game state before playing begins } console.log(e.action, e.state.scene); });
CSS Custom States
The shell mirrors the current scene as a CSS custom state via ElementInternals. This lets you style the shell (or elements below it) based on the active scene using the :state() pseudo-class:
| State | Active when |
|---|---|
:state(init) |
Scene is init |
:state(demo) |
Scene is demo |
:state(ready) |
Scene is ready |
:state(practice) |
Scene is practice |
:state(playing) |
Scene is playing |
:state(between) |
Scene is between |
:state(paused) |
Scene is paused |
:state(result) |
Scene is result |
Custom scenes added via the scenes attribute also get corresponding custom states.
game-shell:state(playing) {
--game-bg: #1a1a2e;
}
game-shell:state(result) {
--game-bg: #0f3460;
}
Context Provided
The shell does not use the Context Protocol for any of its own data. All game state — including the sprite sheet URL — is exposed as Signal.State properties directly on the element, accessible via this.shell.
The Context Protocol is still available for custom components that need to distribute their own data to descendants. See the Context Protocol reference for when to use it.
Scene Visibility (Slot Assignment)
The shell creates a shadow root with slotAssignment: "manual" containing a
single <slot>. When the scene signal changes, the shell assigns matching
children to the slot. Unassigned children are not rendered.
Children declare which scenes they belong to via when-some-scene:
| Value | Visible during |
|---|---|
"intro" |
init, demo, ready |
"playing" |
playing |
"playing between paused" |
Any of those three |
"result" |
result |
(no when-some-scene) |
Always visible |
<game-shell id="game" ...>
<div when-some-scene="intro" data-overlay>
<h1>Welcome!</h1>
<button commandfor="game" command="--start" autofocus>Play</button>
</div>
<div when-some-scene="playing between paused" data-hud>
<game-round-counter></game-round-counter>
</div>
<game-timer
when-some-scene="playing between paused"
duration="10"
></game-timer>
<game-toast when-some-scene="playing between paused" trigger="pass"
>Nice!</game-toast
>
<div when-some-scene="playing between demo practice paused">
<my-game></my-game>
</div>
<div when-some-scene="result" data-overlay>
<game-result-stat label="Score"></game-result-stat>
<button commandfor="game" command="--restart" autofocus>Again</button>
</div>
<!-- No when-* attrs: always slotted -->
<game-audio>...</game-audio>
</game-shell>
When a when-some-scene element with [autofocus] becomes visible, the shell
focuses that element automatically.
Why when-some-scene instead of slot?
The shell uses manual slot assignment internally, so slot would be a natural
fit. But slot is a single token in the platform spec -- it names one
<slot> element. when-some-scene is a space-separated list
("playing between paused"), which slot doesn't support. Overloading slot
with multi-value semantics would conflict with its platform meaning and
confuse anyone who knows how shadow DOM slotting works.
The data-overlay Attribute
A boolean attribute that opts a <div> into fixed-position overlay styling
(centred layout, backdrop blur, safe-area padding). It does not control
visibility -- that is handled by when-some-scene.
The styling comes from game-base.css:
| Property | Default | Description |
|---|---|---|
--game-overlay-bg |
rgba(0, 0, 0, 0.8) |
Overlay background |
--game-text |
#eee |
Text colour inside overlays |
Content inside [data-overlay] gets default typographic styling from
game-base.css: headings, paragraphs, buttons, links. Games can override
these freely.