Skip to content

Documentation

Echo Engine — a distributable WebGPU gameplay starter with a single stable sticker API.

Language

Architecture overview

Echo Engine gives third-party developers a generic WebGPU engine — scene, render, shader, compute, input, resource, and collision services — plus one stable public API for linking gameplay to physical stickers. No proprietary scanning, camera, or backend code is exposed.

A single Stage singleton owns every service. Gameplay code reaches shared services through Stage.instance() and never constructs its own. This keeps engine wiring in one place and keeps the public surface small.

import Stage from '../lib/services/stage/stage';

const stage = Stage.instance();      // lazily constructs the singleton
stage.scene_service;                 // engine services live on the stage
stage.render_service;
stage.sticker_service;               // the one public gameplay API

Stage.instance() lazily builds the singleton and exposes every service as a field.

The scanner is a swappable port

StickerService depends only on the StickerScannerAdapter interface. When the owner builds a first-party cloud version, they implement the same interface and inject it in stage.ts — gameplay code does not change.

By default Stage wires the camera scene scanner adapter: tapping scan activates an in-scene camera scanner (a Video SceneObject) that decodes a physical sticker and resolves the session. It uses no IndexedDB and no backend — session/save state is held in memory for the browser session. A leaner in-memory stub adapter is also included and is what the contract tests use.

Service lifecycle

A view brings the engine up in a fixed order, then tears it down on unmount. The demo page (democlient.tsx) is the reference: it boots the stage, resets the renderer onto a canvas, activates a scene, drives resize, and deactivates cleanly.

  • Construct — Stage.instance() lazily creates the singleton and all services (shader, resource, collision, render, compute, input, scene, sticker).
  • Initialize — await stage.init_services() detects WebGPU support and initializes the shader and compute services. It returns false on unsupported browsers.
  • Attach — stage.render_service.reset(canvas) binds the WebGPU context to the canvas; stage.scene_service.activate("echo-demo", canvas) loads a scene.
  • Resize — stage.render_service.resize(width, height, devicePixelRatio) keeps the drawing buffer matched to layout (driven by a ResizeObserver in the demo).
  • Run — stage.start() begins the requestAnimationFrame loop. Each frame ticks the scene, cycles input frames, prepares GPU state, and renders.
  • Stop — stage.stop() cancels the animation frame and detaches input; stage.scene_service.deactivate() releases the current scene.
useEffect(() => {
  const canvas = canvasRef.current;
  if (!canvas) return;
  let disposed = false;

  async function boot(target: HTMLCanvasElement) {
    const stage = Stage.instance();
    await stage.init_services();               // detect WebGPU + init shader/compute
    if (disposed) return;
    stage.render_service.reset(target);        // bind WebGPU context to the canvas
    await stage.scene_service.activate('echo-demo', target);
  }

  boot(canvas);
  return () => {
    disposed = true;
    Stage.instance().scene_service.deactivate(); // release the scene on unmount
  };
}, []);

Boot / teardown, adapted from src/app/demo/democlient.tsx.

WebGPU is required. Unsupported browsers are redirected to /unsupported. The camera is requested lazily only when a scan starts, never at construction or during server rendering.

Core services & responsibilities

Every service is constructed once by the Stage and reached as a field on Stage.instance(). The table lists the stage-owned services and what each is responsible for.

Service (field)Responsibility
shader_serviceOwns the WebGPU device and shader modules; initialized in init_services().
render_serviceBinds the canvas WebGPU context (reset), tracks render objects, and draws each frame (render); handles resize.
compute_serviceManages GPU compute pipelines and pools; initialized in init_services().
scene_serviceActivates/deactivates a scene, ticks it, and prepares GPU state. "echo-demo" loads the minimal starter scene.
input_serviceAttaches to the canvas and cycles per-frame input state (begin/end frame).
resource_serviceLoads and owns GPU resources (meshes, textures) via the WebGPU device context.
collision_serviceProvides collision volumes and raycasting, and draws collision debug geometry.
sticker_serviceThe single public gameplay API for stickers. Backed by a swappable scanner adapter (the camera scene scanner by default).

Stable vs. internal

Import freely from sticker_service.ts (and the types it re-exports) and from the Stage singleton and its services. Do not import anything under lib/services/sticker/internal/*, the StickerScannerAdapter port, Firebase, or any api/* route from gameplay code. An ESLint rule and npm run check:boundaries enforce this for src/app/demo and src/app/scene.

StickerService usage

StickerService is stage-owned. Obtain the shared instance from the stage — do not construct your own. scan() never rejects for expected outcomes; inspect the discriminated ScanResult instead. Codes are opaque decimal strings and save data is free-form JSON; inputs and outputs are deep-cloned so you cannot mutate engine state by accident.

import Stage from '../lib/services/stage/stage';
import type { JsonObject, StickerSession } from '../lib/services/sticker/sticker_service';

const stickers = Stage.instance().sticker_service; // shared instance

// Bring up scanning and get an anonymous session + save game.
const result = await stickers.scan();   // optional: { signal, preferredCode }
if (result.status === 'scanned') {
  const { code, tag, linkedCodes, save } = result.session; // save is free-form JSON
  console.log(tag.id, tag.name);        // ProjectTag: e.g. "series-alpha", "Series Alpha"
  await stickers.updateSave((prev) => ({ ...prev, score: (prev.score ?? 0) + 1 }));
} else if (result.status === 'cancelled') {
  // user aborted via AbortSignal
} else {
  console.error(result.error.code, result.error.message); // typed StickerError
}

The core scan → inspect → updateSave flow.

Public methods

MethodPurpose
scan(options?)Bring up scanning; resolves { status: 'scanned' | 'cancelled' | 'error' }. Accepts { signal, preferredCode }.
getSession()Current session (with cloned save) or null.
getSave()Cloned free-form save object, or null.
updateSave(next | updater)Validate + persist save JSON; returns the stored (cloned) save. Throws not_scanning without a session.
getLinkedCodes()Codes linked to the current save (e.g. '1000000001' ↔ '1000000002').
endSession()End the runtime session. Does not delete persistent save data.
subscribe(listener)Observe session/save changes; called immediately with the current snapshot. Returns an unsubscribe function.
getDemoStickers()Fixture codes (empty for production adapters).

Reacting to changes

// subscribe() fires immediately with the current session, then on every
// scan / updateSave / endSession. It returns an unsubscribe function.
const unsubscribe = stickers.subscribe((session) => {
  if (session) {
    console.log('active code', session.code, 'save', session.save);
  } else {
    console.log('no active session');
  }
});

// later, when your view unmounts:
unsubscribe();

Sessions & fixtures

A scanned StickerSession carries sessionId, code, a persistent saveGameId, a status (not_activated | activated | used), a project tag, linkedCodes, and the free-form save. A successfully scanned code is always used, and its saveGameId is stable across scans of the same or linked codes.

The bundled in-memory stub adapter (used by the contract tests) exposes fixture codes for zero-config testing:

  • 1000000001, 1000000002 — linked to the same save (exercises linkedCodes); tagged Series Alpha.
  • 1000000003 — an independent save; tagged Series Beta.
  • 9999999999 — a fault fixture; scanning it yields { status: 'error' } with StickerError code scan_failed.

With the default camera scene scanner, a bare scan() activates the in-scene camera and resolves when a sticker stabilizes (or reports unsupported where no camera exists). It exposes no fixtures. Passing a preferredCode opens that specific code directly without the camera — this is how the unit tests exercise the full session/save flow with no hardware.

// Open a specific code directly (no camera): used by tests.
const result = await stickers.scan({ preferredCode: '1000000003' });

// Cancellable scan: abort maps to { status: 'cancelled' }.
const controller = new AbortController();
const pending = stickers.scan({ signal: controller.signal });
controller.abort();
const outcome = await pending; // { status: 'cancelled' }

preferredCode opens a code without the camera; an AbortSignal cancels a scan.

Project tags

Every sticker code belongs to a batch, and every batch belongs to a project that owns a catalog of tags. Recognition resolves that reference and returns the complete tag on every scanned session as result.session.tag. The shape is the shared ProjectTag: { id: string, name: string }. The id is stable identity — persist and compare on it — while name is display-only and may change. Tag names are trimmed and unique per project (case-insensitively).

The tag is always present. Codes with no explicit tag — including legacy data created before tags existed — normalize non-destructively to the default Unassigned tag ({ id: "unassigned", name: "Unassigned" }, exported as UNASSIGNED_TAG). The StickerService applies this default at the public boundary for any adapter result that omits a tag, so gameplay code can always read session.tag without a null check.

import type { ProjectTag } from '../lib/services/sticker/sticker_service';
import { UNASSIGNED_TAG } from '../lib/services/sticker/sticker_service';

const result = await stickers.scan({ preferredCode: '1000000001' });
if (result.status === 'scanned') {
  const tag: ProjectTag = result.session.tag; // { id: 'series-alpha', name: 'Series Alpha' }
  if (tag.id === UNASSIGNED_TAG.id) {
    // untagged / legacy code
  }
}

Every scanned session exposes a fully-resolved ProjectTag; untagged codes resolve to UNASSIGNED_TAG.

Production tag resolution (Firestore)

In a first-party cloud build the tag is resolved server-side by the /api/codes routes against Firestore. The document model is authored by the Echoprint tooling: a project document projects/{projectId} owns a tags array of { id, name }; each batch document projects/{projectId}/batches/{batchId} stores a tagId; and each activated code projects/{projectId}/activatedCodes/{code} records the batchId it was activated under. Recognition follows code.batchId → batch.tagId → project.tags[] and returns the resolved { id, name }.

Resolution is non-destructive at every hop, mirroring Echoprint's shared rules: a code with no batchId, a batch with no/empty tagId, a missing batch or project, and a tagId absent from the catalog all fall back safely — an empty tagId becomes Unassigned, while an unknown id is returned as-is so nothing is silently dropped. The project catalog is read as if the default Unassigned tag is always present (injected on read for legacy projects). All four /api/codes responses (check, use, retrieve/update save, link) carry the resolved tag alongside their existing fields.

Developer workflow

Install dependencies, run the dev server, and verify with the typecheck and build scripts. WebGPU is required to run the demo scene.

npm install
npm run dev            # http://localhost:3000  (the demo scene)
npm run typecheck      # tsc --noEmit
npm test               # sticker contract + camera adapter / scan-bridge tests
npm run check:boundaries  # gameplay uses only the public surface
npm run build

Scripts from package.json.

Where you write gameplay

  • src/app/demo/ — the demo page: a fullscreen WebGPU canvas with a small sticker-scan control. It calls Stage.instance().sticker_service.scan(...) and surfaces status through the shared footer via Stage.footer(...).
  • src/app/scene/ — engine scenes. echodemo/echodemoscene.ts is the minimal starter; add your own RenderObjects there.

The /dev tools

The /dev page is a Mantine-only developer surface for checking a sticker code and managing its save via the /api/codes endpoints: check a code’s status, create its save (use), retrieve and edit the save JSON, and link a second code to the same save. Every action also shows the code’s resolved project tag (name + id), so you can verify tagged and legacy/Unassigned codes end-to-end. It intentionally does not import the engine or WebGPU and does not touch the Stage or StickerService.

Swapping in a private adapter (owner only)

To ship a first-party build, implement StickerScannerAdapter and inject it where the service is constructed in stage.ts. Gameplay code is unchanged.

// stage.ts
this.sticker_service = new StickerService({ adapter: new CloudScannerAdapter() });

// createStickerService({ adapter }) is also available for isolated instances (e.g. tests).

Tag administration (owner handoff)

The /api/codes surface is anonymous and bearer-by-code: a caller proves nothing but possession of a sticker code, so it exposes read/resolve and save operations only — never tag mutation. Authoring and renaming tags remains Echoprint’s job via its existing direct Firestore REST client, which is unchanged. The one invariant a client cannot uphold alone is deleting a tag that is in use: every affected batch must be reassigned to another tag and the project catalog rewritten in a single atomic commit, which exceeds Firestore’s 500-writes-per-commit ceiling once a tag spans enough batches. store.ts ships an admin-only transactional helper, deleteTagWithReassign(project, tagId, reassignToTagId), that performs the reassignment and catalog rewrite atomically using the privileged Admin SDK. It is deliberately not mounted as an HTTP route: the owner wires it behind their authenticated admin channel so it never becomes an unauthenticated privilege-escalation endpoint.