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> - 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>
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 |
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 |
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")) { /* ... */ }
Methods
- .start()
-
Begins the game. Resets round counters and scores, initializes the progression (if any), sets
sceneto"playing", and fires agame-lifecycleevent.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 |
Events Fired
- game-lifecycle
-
Fired on every scene transition. The event is a
GameLifecycleEventwith.action(the new scene name),.state(a plain snapshot object), and.scene(the current scene name).shell.addEventListener("game-lifecycle", (e) => { 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.