GameComponent

Base class for building custom game UI elements. Extends HTMLElement with shadow DOM, scoped styles, declarative templates, signal-based reactivity, and an automatic abort signal for cleanup.

Import

import { GameComponent, css } from "htmlgamekit";

Usage

class RoundDisplay extends GameComponent {
  static styles = css`
    :host {
      display: block;
      font-size: 1.5rem;
    }
    .round {
      color: var(--game-accent, #0af);
    }
  `;

  static template = `
    <span class="round">
      Round <span id="current">1</span> / <span id="total">?</span>
    </span>
  `;

  connectedCallback() {
    // Use this.signal for any work that should stop on disconnect
    someAsyncTask({ signal: this.signal });
    super.connectedCallback();
  }

  effectCallback({ round, rounds }) {
    this.shadowRoot.getElementById("current").textContent = round.get();
    this.shadowRoot.getElementById("total").textContent = rounds.get();
  }
}

RoundDisplay.define("round-display");

Attribute Reflection (static attrs)

Declare static attrs on your class to get automatic attribute observation, property reflection, and typed coercion. The define() method wires everything up.

class MyComponent extends GameComponent {
  static attrs = {
    duration: { type: "number", default: 10 },
    mode: {
      type: "enum",
      values: ["easy", "hard"],
      missing: "easy",
      invalid: "easy",
    },
    label: { type: "string" },
    description: { type: "string?" },
    disabled: { type: "boolean" },
    "max-score": { type: "long", default: 100 },
  };
}

MyComponent.define("my-component");
// Now:
//   el.duration    -> 10 (reads attribute, coerces to number)
//   el.duration = 5  -> setAttribute("duration", "5")
//   el.mode        -> "easy" (enumerated with missing/invalid defaults)
//   el.disabled    -> false (boolean, presence-based)
//   el.maxScore    -> 100 (kebab-case attribute, camelCase property)
//   el.description -> null (string?, null when attribute absent)

Supported Types

Type Behavior Default when absent
string Non-nullable string "" (or spec.default)
string? Nullable string null
long Integer via parseInt 0 (or spec.default)
number Float via parseFloat 0 (or spec.default)
number? Nullable float via parseFloat null (or spec.default)
boolean Presence-based (true if attribute present) false
enum Must match one of spec.values spec.default (absent and invalid cases)

Spec Fields

Field Required Description
type Yes One of the type strings above
default No Fallback when the attribute is absent or has an invalid value
values Enum only Array of valid canonical values
missing Enum only Overrides default for the absent-attribute case only
invalid Enum only Overrides default for the unrecognised-value case only
prop No Override the IDL property name (default: camelCase of the attribute name)

For enums, default is the simplest option when missing and invalid should behave the same. Use missing and/or invalid only when they need to differ:

static attrs = {
  // same fallback for both missing and invalid:
  mode: { type: "enum", values: ["a", "b", "c"], default: "a" },

  // different fallbacks:
  mode: { type: "enum", values: ["a", "b", "c"], default: "a", missing: "b" },
  //   absent → "b", invalid → "a"
};

Property Naming

Attribute names are converted to camelCase for the IDL property: when-min-score becomes whenMinScore, start-bpm becomes startBpm. Use the prop field to override this when the natural name would conflict with an existing field.

Reactivity

All declared attributes are automatically added to observedAttributes. When an attribute changes, the base attributeChangedCallback fires. Subclasses can define an attributeChanged(name, oldValue, newValue) method to react:

class MyTimer extends GameComponent {
  static attrs = {
    duration: { type: "number", default: 10 },
  };

  attributeChanged(name) {
    if (name === "duration") this.#restart();
  }
}

For non-GameComponent Elements

Elements that extend HTMLElement directly can use initAttrs():

import { initAttrs } from "htmlgamekit";

class MyDataElement extends HTMLElement {
  static attrs = {
    key: { type: "string" },
  };

  static define(tag, registry = customElements) {
    initAttrs(this);
    registry.define(tag, this);
  }
}

Static Properties

static styles
CSSStyleSheet -- A constructed stylesheet to adopt into the shadow root. Create one with the css tagged template literal.
static styles = css`
  :host { display: block; }
  p { margin: 0; }
`;

If not set, the shadow root receives no adopted stylesheets.

static template
string -- HTML string used as the shadow root's initial innerHTML. Defaults to "<slot></slot>", which projects all light DOM children.
static template = `
  <div class="wrapper">
    <slot></slot>
  </div>
`;

Instance Properties

.shell
object -- The nearest ancestor <game-shell> element, found via this.closest("game-shell"). The shell exposes all game state as Signal.State properties directly on the element. Lazily resolved on first access and cached for the component's lifetime.
this.shell.scene.get(); // "playing"
this.shell.score.get(); // 42

Returns null if the component is not a descendant of a <game-shell>.

.abort
AbortController -- The component's AbortController. Created lazily on first access. Aborted when the component disconnects from the DOM. Exposed so subclasses can use it for lifecycle management.
.signal
AbortSignal -- Shorthand for this.abort.signal. An AbortSignal that is aborted when the component disconnects from the DOM. Use it with addEventListener, fetch, or any API that accepts an abort signal.
connectedCallback() {
  window.addEventListener("resize", this.onResize, { signal: this.signal });
}

Methods

.effectCallback(shell)
Override this method to react to shell signal changes. It is called automatically when the component connects and re-called whenever any signal read inside it changes. The effect is automatically disposed when the component disconnects.

The shell object is passed as the argument. Destructure only the signals you need:

effectCallback({ scene, round }) {
  // Re-runs whenever scene or round changes
  this.#updateDisplay(scene.get(), round.get());
}

If you also need to do one-time setup (DOM queries, event listeners), do that in connectedCallback and call super.connectedCallback() at the end:

connectedCallback() {
  this.#el = this.shadowRoot.querySelector(".value");
  this.addEventListener("click", this.#onClick, { signal: this.signal });
  super.connectedCallback();
}

effectCallback({ score }) {
  this.#el.textContent = score.get();
}

Effects are batched via microtasks -- multiple signal writes in the same synchronous block coalesce into a single effect run.

.subscribe(context, callback)
Subscribe to a context value. The callback fires immediately with the current value and again whenever the provider updates it. The subscription is automatically cleaned up when the component disconnects (tied to this.signal).

This is the right choice for custom contexts or non-signal use cases. For game state signals, use effectCallback instead.

import { gameWordContext } from "htmlgamekit/words";

// inside connectedCallback:
this.subscribe(gameWordContext, (word) => {
  this.#target = word;
});

Parameters:

  • context -- A context object created by createContext().
  • callback -- (value) => void -- Called with the current and every subsequent value.
triggerCallback(name, event)
Override this method to react to trigger lifecycle events. The trigger system is automatically initialised when this method is present — no manual setup needed.

Fires for all state triggers (start, round, pass, fail, timeout, complete, tier-up) and DOM triggers (click, keydown, etc.) during the playing state.

triggerCallback(name, event) {
  if (name === "pass") this.#playSound();
}

See Triggers for the full list of trigger names.

timeoutCallback(event)
Override this method to handle timeouts separately from failures. When present, the system will fire timeoutCallback instead of triggerCallback("fail") when a round times out. Its presence also signals to the trigger system that this component distinguishes timeout from fail, enabling the timeout trigger to fire (rather than folding into fail).
timeoutCallback(event) {
  this.#showTimeoutMessage();
}
resultCallback(shell)
Override this method to run logic exactly once when the game enters the result scene. Automatically resets after the scene leaves result, so it fires once per game. Automatically disposed on disconnect.
resultCallback(shell) {
  const score = shell.score.get();
  this.#renderFinalChart(score);
}
static define(tag, registry?)
Register the custom element with the given tag name. Optionally pass a CustomElementRegistry for scoped registries.
RoundDisplay.define("round-display");

css Tagged Template

The css tagged template literal creates a CSSStyleSheet using new CSSStyleSheet() and replaceSync. This is the recommended way to define component styles because adopted stylesheets are shared across all instances of the component rather than duplicated per element.

import { css } from "htmlgamekit";

const sheet = css`
  :host {
    display: block;
    padding: 1rem;
  }
  .highlight {
    color: var(--game-accent, gold);
  }
`;
// sheet instanceof CSSStyleSheet -> true

The template literal is evaluated once at class definition time, producing a single CSSStyleSheet object reused by every instance.


Lifecycle

Constructor

The constructor (called via super()) performs the following setup:

  1. Attaches an open shadow root (this.attachShadow({ mode: "open" })).
  2. If static styles is defined, sets this.shadowRoot.adoptedStyleSheets = [this.constructor.styles].
  3. Sets this.shadowRoot.innerHTML to static template (default: "<slot></slot>").

disconnectedCallback()

Aborts the internal AbortController, which:

Subclasses may override disconnectedCallback() for additional cleanup, but should call super.disconnectedCallback().