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>
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>

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 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")) {
  /* ... */
}

Methods

.start()
Begins the game. Resets round counters and scores, initializes the progression (if any), sets scene to "playing", and fires a game-lifecycle event.
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

Events Fired

game-lifecycle
Fired on every scene transition. The event is a GameLifecycleEvent with .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.