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 null or false. Used internally as the default when no score-url is configured.
static toBase64Url(buf)
Encodes a Uint8Array or ArrayBuffer into a URL-safe Base64 string (no padding, +/ replaced with -_). Useful for encoding game results into shareable URLs via encodeResult.
const encoded = GameShell.toBase64Url(new Uint8Array([1, 2, 3]));
static fromBase64Url(str)
Decodes a URL-safe Base64 string back into a Uint8Array. The inverse of toBase64Url. Useful for decoding challenge results in decodeResult.
const bytes = GameShell.fromBase64Url(encoded);
static encodeUint16WithBitmask(scale?)
Returns an encodeResult function. 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 roundScores are pass/fail (0 or non-zero) and score is a fixed-point integer via score-scale. Pair with decodeUint16WithBitmask using the same scale.

shell.encodeResult = GameShell.encodeUint16WithBitmask(100000);
shell.decodeResult = GameShell.decodeUint16WithBitmask(100000);
// Decoded: { score: number, strip: boolean[] }
static decodeUint16WithBitmask(scale?)
Returns a decodeResult function matching encodeUint16WithBitmask. The decoded object contains { score, strip } where strip is a boolean array of per-round pass/fail results. Returns null on invalid input.
static encodeUint16Array(scale?, roundCount?)
Returns an encodeResult function. Packs the shell score and all round scores into a compact binary URL token.

Byte layout: [score:uint16be] [round0:uint16be] ... [roundN:uint16be]

Use when roundScores are continuous values (e.g. per-round reaction times stored as integers). Pair with score-scale so values are pre-scaled. Pass roundCount to 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 decodeResult function matching encodeUint16Array. The decoded object contains { score, roundScores }. Returns null on 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 to localStorage.
sprite-sheet .spriteSheetAttr
string? -- URL to an SVG sprite sheet. Mirrors to the spriteSheet signal, 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 the localStorage key. Defaults to game-id if 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 from playing to result. If a progression is attached, the progression's round count takes precedence. Defaults to 0.
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 the between state between rounds, or "manual" to disable auto-advance (the player must dispatch a game-next-round event or click a command="--next-round" button). Defaults to "500".
demo .demo
boolean -- When present, the shell enters the demo scene on connection instead of ready. 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 via GameShell.gameScores(gameId, { baseUrl: scoreUrl }). This is the declarative alternative to setting .scores in 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 an Authorization: Bearer header.
<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 sets shell.scene.set("tutorial").

group .group
string? -- When set, the shell calls initGroup() during initialisation to join or create a multiplayer group. The group identifier and name are exposed via the groupId and groupName signals.
score-scale .scoreScale
long -- Integer multiplier applied to the progression's computed threshold before storing as score. Use when the threshold is a small float (e.g. a JND of 0.02) and you want to store it as a compact integer (0.02 × 100000 = 2000). Pair with shell.formatScore that divides by the same scale for display. Defaults to 1.
<game-shell
  game-id="jnd-test"
  progression="staircase"
  score-scale="100000"
></game-shell>
session-save .sessionSave
boolean -- When present, the shell serializes game state to sessionStorage after every signal change during active play (playing and between states). On page load, if a session exists, the shell restores to the saved state instead of going to init.

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 the stats signal to localStorage under the key {storageKey}-stats. IDL property returns null when 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 day computed signal (see Signals) with today's day number (day 1 = 2026-01-01).
  • A _day field 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>

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 by gameScores().
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 the progression IDL property, which reflects the progression string attribute (e.g. "fixed", "staircase").
shell.progressionSet = new StaircaseProgression({ levels: 8 });
.encodeResult
set (function) -- Assign a function (state) => string to encode the final game result into a compact string for sharing or storage. The state argument is a plain snapshot object.
shell.encodeResult = (state) =>
  GameShell.toBase64Url(new Uint8Array(state.scores));
.decodeResult
set (function) -- Assign a function (encoded) => object to 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) => string to format raw numeric scores for display throughout the UI. This sets the formatScoreSignal signal 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 to document.title. Used by <game-share>.
.trophyCount
get (number) -- The number of trophies unlocked this session. Equivalent to reading trophyCount from the when-* condition system or <game-signal key="trophyCount">.
.isTrophyUnlocked(id)
Returns true if the trophy with the given id has been unlocked. Used internally by the condition system for when-some-trophy / when-no-trophy.
if (shell.isTrophyUnlocked("hat-trick")) {
  /* ... */
}
.addToCollection(name, id)
Add an item to a named collection. Returns true if the item was newly added, false if already present. Collections are persisted to localStorage under {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 true if 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-lifecycle event 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 sets scene to "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 scene to "paused".
.resume()
Resumes a paused game. Sets scene back 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 a CustomElementRegistry.
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 a GameLifecycleEvent with .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.