Skip to content

Latest commit

 

History

History
1182 lines (969 loc) · 53.4 KB

AGENTS.md

File metadata and controls

1182 lines (969 loc) · 53.4 KB

AGENTS.md - Coding Agent Guide for Forge Game Engine

This document provides guidance for AI coding agents working on the Forge Game Engine codebase. It covers architecture, conventions, and best practices to help agents write code that matches the existing codebase.

Human contributors: see CONTRIBUTING.md for a shorter, human-facing summary of setup, verification steps, and commit/changelog conventions that links back here for the full detail.

Table of Contents

Project Overview

Forge is a browser-based, code-only game engine built with TypeScript. It provides core game engine functionality including:

  • ECS (Entity-Component-System): Core architecture pattern
  • Rendering: WebGL2-based rendering system
  • Physics: Native 2D physics engine (rigid bodies, collision detection/resolution, gravity)
  • Audio: Web Audio mixer with buses, sound assets, one-shot playback and entity-bound sounds
  • Animations: Robust animation system
  • Input: Keyboard, mouse, and gamepad input handling
  • Particles: Particle system
  • Asset Loading: Resource management
  • FSM: Finite state machine implementation

Important: The engine contains general-purpose game functionality. Game-specific or genre-specific code should be in separate packages.

Change Philosophy

Forge is pre-1.0 (see version in package.json) and makes no backwards compatibility guarantees. That's on purpose. It keeps the API small while it's still being shaped. Every change, and every bug fix especially, follows these rules:

  • Change behavior outright. When something is wrong, make it right and update every caller in /src, /demo, /e2e and documentation-site in the same change. Don't add deprecated aliases, compatibility wrappers, overloads that accept both the old and new shapes, fallback branches to the old code path, or "legacy" modes. The changelog bullet tells consumers what to change; that's the whole migration story.
  • Don't add options to dodge a decision. An option that switches between the old and new behavior, or that only exists to correct a wrong default, is a bug left in place for the user to find. For example, GamepadAxis1dBinding's inverted option exists because the W3C gamepad reports stick-up as -1 while the rest of the engine is Y-up. The input source should translate that at the boundary so up is positive for everyone. Only add an option when different games really need different values (a blur radius, a gravity scale). Don't add one when the user could get the variation with one line of their own code.
  • Fix the layer that owns the behavior. The engine has conventions (see "Common Patterns" below). A defect is usually one layer breaking one of them. Fix that layer, so nothing downstream has to compensate.
  • Delete what the fix made unnecessary. Workarounds, compensating code in callers, and options added by earlier band-aid fixes all go in the same change. A good fix is often net negative in /src.
  • Ask whether it's the right fix, not just a working one. A change that makes a system take on a second job, special-cases some callers, or makes behavior configurable so other code doesn't have to change is usually patching a symptom. Find the code that breaks the contract and fix that, even when the right fix is much bigger than the patch.
  • Solve it the way established engines do. Most problems in a game engine (transform hierarchies, physics/transform sync, fixed timesteps, input mapping, UI layout) have a well-understood solution in Unity, Godot, Bevy or the ECS literature. Name the problem in general terms, check how those engines solve it, and implement that solution. Deviate only for a reason you can state, and state it in the PR.
  • One writer per value. Every component field has one owner that writes it; everything else reads it. When two systems write the same field (e.g. physics and the transform system both writing position.world), that's the bug, whichever one the symptom shows up in. See "Transforms" under "Common Patterns".
  • Flag designs that don't fit instead of building on them. If a request only works by bending a core part of the engine, or a relationship doesn't make sense in an ECS engine, stop and raise it with the user before implementing. Don't stack a workaround on top of a questionable design.

Before implementing any fix or feature that's more than a one-line change, run the solution-reviewer agent (.claude/agents/solution-reviewer.md) on your plan. It checks the plan against these rules and against how other engines solve the same problem.

Once the change is done, the pr-reviewer agent (.claude/agents/pr-reviewer.md) reviews it against the same rules plus the changelog, documentation and demo requirements. It runs on every pull request when it opens (.github/workflows/claude-pr-review.yml); run it locally before opening one to catch the same findings first.

The fix-defect skill (.claude/skills/fix-defect/SKILL.md) walks through this step by step for bug fixes.

For a change big enough to need a design first, the design-author agent (.claude/agents/design-author.md) writes its design document in /design. It designs the issue it's given (the claude label run always passes the number); run locally without one, it designs the issue the current branch is working on (from a claude/issue-<n>-... branch name or the branch's pull request).

Agentic Workflow

A milestone goes from plan to merged code in these steps. The agents and workflows in this table are configured so that steps 3, 5-7 and 9 run unattended:

# Step Who
1 Plan a milestone Human
2 Research it Human
3 Turn the milestone into issues Agent, with the plan-milestone skill
4 Label an issue claude Human
5 Write a design document design-author, inside the label run (.github/workflows/claude.yml)
6 Review the design solution-reviewer, inside the label run
7 Implement, with unit and e2e tests The label run, which opens the pull request
8 Review the pull request, test the demos Human
9 Review the pull request against Forge's rules pr-reviewer (.github/workflows/claude-pr-review.yml)
10 Merge Human
  • Steps 5 and 6 only run when they're needed. The label run designs a new feature, large change or refactor whose issue doesn't link a design in /design already. A one-line or clearly scoped fix skips the design and goes straight to solution-reviewer on the plan (for anything beyond one line).
  • The run stops rather than guess. If design-author reports a blocking open question, or solution-reviewer rejects or escalates the design (or rejects it again after one revision), the run commits the design on its branch, posts the questions or verdict in its tracking comment on the issue and stops without a pull request. Answer on the issue and mention @claude: the next run fetches that branch, has design-author update the design with the answers, and carries on. Otherwise the design is committed in the same pull request as the implementation.
  • Testing is part of step 7. The run installs Playwright's Chromium, runs npm run test:e2e for anything touching rendering, input or the game loop, and names the docs-site demo pages a human needs to check.
  • Step 9 runs once, when the pull request opens. Nothing reviews it again automatically: run pr-reviewer locally to review new commits. An @claude mention on a pull request starts an implementing run (claude.yml) that pushes to its branch, not a review.
  • Each agent asks only for what its workflow allows. design-author, solution-reviewer and pr-reviewer run inside the workflows above, so every command they tell themselves to run must be in that workflow's --allowedTools. When you change one, check the other.

Repository Structure

/src                        # Source code organized by feature modules
  /animations              # Animation system
  /asset-loading           # Asset loading utilities
  /audio                   # Audio system
  /common                  # Shared components and utilities
  /ecs                     # Entity-Component-System core
  /events                  # Event system
  /finite-state-machine    # FSM implementation
  /input                   # Input handling (keyboard, mouse, gamepad, hidden-DOM text entry)
  /lifecycle               # Lifecycle management
  /math                    # Math utilities
  /particles               # Particle system
  /physics                 # Physics integration
  /pooling                 # Object pooling
  /rendering               # Rendering system
  /states                  # Game states (createGameState), inState/onEnter/onExit run conditions, state-scoped entities
  /storage                 # StorageBackend (localStorage, memory) and createPersistentState: typed records kept outside the game
  /text                    # MSDF font atlas loading and text rendering
  /timer                   # Timer utilities
  /ui                      # Retained-mode UI (anchored rect tree layout, canvases, panels, labels, buttons, focus navigation, toggles, sliders, progress bars, dropdowns, text inputs, layout groups, content size/aspect ratio fitters)
  /utilities               # General utilities
  index.ts                 # Main exports

/demo                      # Demo application
/documentation-site        # Docusaurus documentation
/scripts                   # Build and utility scripts
/assets                    # Static assets (images, etc.)
/design                    # Design docs / RFCs for not-yet-built subsystems

/design holds forward-looking design documents for subsystems that don't exist yet (architecture, decision logs, phased backlogs). They are proposals, not descriptions of current behavior - never treat a /design document as a source of truth about what the engine does today, and don't cite one in user-facing documentation. Once a design ships, documentation-site/docs/docs becomes the authority and the design document stays as historical rationale.

Code comments (in /src, /demo, and /documentation-site/src) must never reference a /design document, a decision-log entry (e.g. "DL-06"), a phase number, or an implementation backlog/roadmap either. A comment should explain the why of the code it sits next to in a way that stands on its own - a reader with no access to /design (or to a since-superseded plan inside it) must still be able to follow the reasoning. If a design document's rationale is worth keeping, restate it in the comment; don't point at the document.

Module Exports

The package exports are modular. Each subsystem has its own export path:

  • @forge-game-engine/forge/ecs
  • @forge-game-engine/forge/rendering
  • @forge-game-engine/forge/physics
  • etc.

Each module has an index.ts file that exports its public API.

Architecture

Entity-Component-System (ECS)

The codebase follows a data-oriented ECS pattern built from plain objects and factory functions, not classes:

  1. Components: Plain data interfaces, not classes. Each one gets a createComponentId key (a symbol) and an add<Name>Component factory that attaches it to a caller-supplied entity (see "Component Pattern" below). Components carry no logic.

  2. Systems (EcsSystem<TQuery>): Plain objects, produced by create<Name>EcsSystem factory functions, not classes. A system declares a query (the component keys it reads, in order) and an optional set of tags, and implements update(world, queryResult). queryResult is a batch for the whole tick - entities: readonly number[] and a components array (one array per queried component type, in query order) - so update runs exactly once per tick regardless of how many entities matched (including zero), and the system iterates the batch itself. An optional cleanup(world) hook runs once when the system is removed from an EcsWorld or the world is stopped. See "System Pattern" below and /documentation-site/docs/docs/ecs/system.md.

  3. Entities: Just numeric ids (number), created with EcsWorld.createEntity(). Components are attached/detached by id via the world (addComponent/removeComponent/addTag); there is no Entity object. Parent-child relationships are kept by the world, not object containment: world.setParent(child, parent)/removeParent write the child's ParentEcsComponent (the only writer; addComponent/ removeComponent of parentId throw) and the world's children index (getChildren, in sibling order). removeEntity removes descendants too, so loops that remove entities check isAlive first.

  4. World (EcsWorld): Container for component data and registered systems. Stores component data grouped by component key, runs each registered system's update once per EcsWorld.update() tick (in registration-order), and exposes query(componentKeys, tags?) for ad-hoc lookups outside of a system's own query.

Key Patterns

  • Dependency Injection: Systems receive dependencies as arguments to their create<Name>EcsSystem factory (e.g., RenderContext); classes receive them via constructor
  • Composition over Inheritance: Favor components over deep class hierarchies
  • Immutability: Use readonly for fields that shouldn't change after construction
  • Private fields: Prefix with underscore (_fieldName)
  • Initialization: Members are initialized in the constructor body
  • Getters/Setters: Getters and setters have no access modifiers (always public by default)

Coding Conventions

TypeScript Style

Naming Conventions (enforced by ESLint):

  • Classes: PascalCase (e.g., RenderSystem, Entity)
  • Interfaces: PascalCase (e.g., EntityOptions)
  • Types: PascalCase (e.g., ComponentCtor)
  • Public members: camelCase (e.g., getComponent)
  • Private members: camelCase with leading underscore (e.g., _components)
  • Constants: camelCase or UPPER_SNAKE_CASE depending on context
  • Files: kebab-case.ts (e.g., render-system.ts)

Member Visibility:

  • Always use explicit access modifiers (public, private, protected)
  • Exception: Constructors don't need public
  • Exception: Getters and setters have no access modifiers
  • Private fields must have leading underscore

Member Ordering (enforced by ESLint):

  1. Public instance fields
  2. Protected instance fields
  3. Private instance fields
  4. Public static fields
  5. Protected static fields
  6. Private static fields
  7. Constructor
  8. Public static methods
  9. Protected static methods
  10. Private static methods
  11. Public instance methods
  12. Protected instance methods
  13. Private instance methods

Type Safety:

  • Use strict TypeScript mode (already enabled in tsconfig.base.json)
  • Always specify return types for functions: update(world: EcsWorld, queryResult: QueryResult<T>): void
  • Avoid any; use unknown if necessary
  • Avoid null assertions and casting
  • Types should be narrowed and nullish values should be handled appropriately (usually by throwing an error if it makes sense to do so)
  • No unused locals or parameters (enforced)

Imports:

  • Use ES module syntax: import { X } from './path.js'
  • Important: Always include .js extension in imports (even for .ts files)
  • Sort imports alphabetically (case-insensitive, enforced by ESLint)
  • Group imports: external libraries first, then internal modules

Code Style:

  • Use single quotes for strings
  • Semicolons required
  • 2-space indentation
  • Trailing commas in multi-line structures
  • Max 7 parameters per function
  • Use curly braces for all control structures
  • Prefer early returns over nested conditionals
  • Execute/return default behavior after all special cases are handled
  • No else after return (early return pattern)
  • No switch statements - use polymorphic dispatch or strategy functions instead
  • Blank lines before and after block-like statements

JSDoc Comments:

  • Document all public classes, methods, and properties
  • Use @param, @returns, @throws annotations
  • Document complex private methods
  • Example:
    /**
     * Adds components to the entity.
     * @param components - The components to add.
     * @throws An error if a component with the same name already exists on the entity.
     */
    public addComponents(...components: Component[]): void {
      // implementation
    }

Linting and Formatting

  • ESLint: Run npm run lint to check, npm run lint:fix to auto-fix
  • Prettier: Run npm run prettier to check, npm run prettier:write to format
  • TypeScript: Run npm run check-types to verify types
  • Spell Check: Run npm run cspell to check spelling

ESLint is configured with:

  • TypeScript ESLint with type checking
  • SonarJS for code quality
  • Prettier integration
  • Jest rules for test files

Module Organization

Creating a New Module

  1. Create a directory under /src/your-module
  2. Add an index.ts that exports the public API
  3. Add sub-folders for organization:
    • /components - Component interfaces, their createComponentId keys, and add<Name>Component factories (see "Component Pattern" below)
    • /systems - Systems that operate on those components
    • /types - Type definitions and interfaces
  4. Update /src/index.ts to export the new module
  5. Add export mapping in package.json if it should be separately importable

Component Pattern

Components are plain data interfaces, not classes. Each one gets a createComponentId key and an add<Name>Component factory that attaches it to a caller-supplied entity, all colocated in the same file. This lets a caller build up a composite entity by calling several add<Name>Component functions against the same entity (position + rotation + scale + sprite + ...), which is how entities are assembled throughout this codebase. add<Name>Component never creates its own entity; a handful of components that are always the root of their own entity in practice (currently just camera) additionally get a create<Name> aggregate factory that creates an entity and calls the relevant add<Name>Component functions against it. See the create-component skill for the full pattern, including the default-options-object convention and create-camera.ts as the aggregate-factory example.

System Pattern

Systems are plain objects produced by a create<Name>EcsSystem factory, not classes. update receives the whole tick's batch of matches at once (entities plus one components array per queried component type, in query order) and iterates it itself:

import { EcsSystem } from '../../ecs/index.js';
import { MyComponent, myComponentId } from '../components/my-component.js';

/**
 * Creates a system that processes every entity with a `MyComponent`.
 * @returns The ECS system.
 */
export const createMyEcsSystem = (): EcsSystem<[MyComponent]> => ({
  query: [myComponentId],
  update: (world, { components: [myComponents] }) => {
    for (const myComponent of myComponents) {
      // Process component data
    }
  },
});

See /documentation-site/docs/docs/ecs/system.md for the full contract, including the optional tags and cleanup fields.

A system's query and tags are fixed: a create<Name>EcsSystem factory never takes options that change which components or tags it matches. A system processes every entity that has its components, which is what makes a component mean the same thing everywhere. If a system matches entities it shouldn't touch, the entities' components are wrong, or another system is writing a value this one owns. Fix that instead.

A system holds no state. Its factory takes dependencies (Time, RenderContext, resolved tuning options) that it only reads, and the only state update changes is component data. Nothing declared outside update (a counter, a Map of per-entity bookkeeping, a snapshot from last tick) is changed inside it; that information goes in a component instead. See the create-system skill (.claude/skills/create-system/SKILL.md) for where each kind of state belongs. A few existing systems still keep closure state; don't copy them.

Index Files

Each module's index.ts should export everything from its submodules:

export * from './my-class.js';
export * from './subfolder/index.js';

Development Workflow

Setup

# Install dependencies
npm install

# Run demo in development mode
npm run dev

# Build the project
npm run build

# Run tests
npm test

# Run tests with UI
npm run test:ui

Build Process

  • Build command: npm run build
  • Compiles TypeScript to JavaScript with declarations
  • Copies shader files to dist directory
  • Output directory: /dist

Git Workflow

Commit Messages (Conventional Commits):

  • Format: <type>(<scope>): <subject>
  • Types: feat, fix, perf, docs, style, refactor, test, build, ci, chore
  • Example: feat(ecs): add component removal event
  • Max length: 200 characters
  • Enforced by commitlint with husky pre-commit hooks

Changelog

/CHANGELOG.md follows Keep a Changelog and Semantic Versioning. Feature branches merge into dev via squash merge, so a PR's title becomes its permanent commit message and changelog source.

When you must add an entry: if a change's Conventional Commits type is anything other than chore, style, refactor, test, ci, docs, or build (i.e. it's a feat, fix, perf, or similar release-note-worthy type), add one bullet under the ## [Unreleased] heading, in the matching Keep a Changelog category (#### Added, #### Changed, #### Deprecated, #### Removed, #### Fixed, #### Security — only include the categories that have entries). Write it for the consumer of the package, not as a restatement of the commit message. This is enforced by CI (.github/workflows/changelog.yml): a PR whose title isn't an excluded type fails the check-changelog job unless CHANGELOG.md gained a new bullet under [Unreleased].

What agents should never hand-edit:

  • Released version sections (## [x.y.z] - date) — these are historical record. Fix a factual error if you find one, but don't add new entries to a past release.
  • documentation-site/docs/changelog.md — generated from the root CHANGELOG.md by documentation-site/scripts/sync-changelog.mjs on prestart/prebuild, and gitignored. Never edit or commit it directly; edit /CHANGELOG.md instead and the docs site picks it up on the next build.

What's automated: scripts/changelog/promote-unreleased.mjs, run by .github/workflows/create-release.yml during the "Create Release" workflow, moves everything under [Unreleased] into a new ## [x.y.z] - date section and leaves a fresh empty [Unreleased] behind. Don't run this manually or preemptively rename [Unreleased] yourself — the release workflow owns that step.

Testing

Testing Framework

  • Vitest for unit tests
  • jsdom for DOM mocking
  • Test files: *.test.ts or *.spec.ts

Test File Organization

  • Place test files next to the code they test: render-system.ts → render-system.test.ts
  • Use descriptive test names
  • Group related tests with describe blocks

Test Patterns

import { describe, it, expect, beforeEach } from 'vitest';
import { MyClass } from './my-class.js';

describe('MyClass', () => {
  let instance: MyClass;

  beforeEach(() => {
    instance = new MyClass();
  });

  it('should do something', () => {
    const result = instance.doSomething();
    expect(result).toBe(expected);
  });

  it('should throw error on invalid input', () => {
    expect(() => instance.method(invalid)).toThrow();
  });
});

Test Conventions

  • Use beforeEach for common setup
  • Test edge cases and error conditions
  • Mock external dependencies when needed
  • Use descriptive assertions
  • For tests involving ECS, create a minimal World and entities
  • When a test constructs a real Material against a mocked WebGL context, build a real RenderContext over a canvas whose getContext returns the mock (new Material(renderContext, vertex, fragment)): the render context caches the linked program, so materials from the same shader sources in one context share it. Declare each uniform the test sets in the shader source it passes (uniform vec4 u_color;): Material takes a uniform's type and array size from its declaration and throws for a name neither shader declares. The mocked getActiveUniform decides which declared uniforms are active; leave one out to test a uniform the compiler stripped. bind uploads every active uniform, a zero default when the material didn't set it and renderContext.blackTexture for a sampler, so report only the uniforms the shaders really use, mock the uniform*v method for each declared type, and mock the texture-creation calls Texture's constructor makes when a sampler is active. Report the real GL type enum (e.g. 0x8b5e /* SAMPLER_2D */, 0x1406 /* FLOAT */) and array size for what it does return, since undeclared active uniforms (struct members) are typed from it. Sampler values are Textures (new Texture(renderContext)), and bindTexture receives texture.glTexture. A SpriteMaterial's fragment shader must include spriteMask (createSpriteMaterial throws otherwise), so a test's sprite fragment shader includes it too
  • A mocked WebGL context behind a real RenderContext needs getExtension (the render context requests EXT_color_buffer_float when it's created) and isContextLost (returning false; GPU resources check it before touching GL). src/rendering/context-loss.test.ts shows a mock that can be lost and restored
  • Fakes shared by several test files go in a test-helpers/ folder inside the module (e.g. src/audio/test-helpers/fake-audio-context.ts, a stand-in for the Web Audio API, which jsdom lacks). tsconfig.build.json and the coverage config exclude **/test-helpers/**, so they never ship in /dist

Coverage

  • npm run test:coverage runs the unit suite with V8 coverage instrumentation (configured in vite.config.base.js's test.coverage) and writes a report to /coverage (gitignored): a terminal summary, an lcov.info, and an HTML report at coverage/index.html.
  • CI's test job (.github/workflows/ci.yml) runs test:coverage instead of plain npm test and uploads /coverage as the coverage-report workflow artifact on every run, so coverage is checked without needing a local run.
  • There's no enforced coverage threshold yet - the report is a visibility tool, not a gate. Modules with thin test suites (check the per-file breakdown in the terminal summary or coverage/index.html) are good candidates for new tests.

Integration & E2E Testing

/e2e holds real-browser tests (Playwright) for cross-system behavior that unit tests can't see: a real WebGL2 canvas, real DOM input events run through the actual input pipeline, and a real (but manually stepped, not requestAnimationFrame-driven) game loop. Unlike /src's unit tests - which mock the WebGL context and drive systems directly - these exercise the real rendering and input code paths end-to-end.

These tests depend only on /src, never on /demo or /documentation-site. Each scenario gets its own minimal, purpose-built scene under e2e/fixtures/scenes/, built directly against the engine's public API, so e2e stays unaffected by unrelated changes to the demo app or docs site (and vice versa).

Layout

e2e/
  fixtures/
    index.html           # single HTML shell (a sized #app container + harness.ts)
    harness.ts            # reads `?scene=`, loads the matching scene, exposes window.__forgeTestHooks
    scenes/
      scene.ts             # the SceneHandle/CreateScene contract every scene implements
      camera-pan-zoom.ts   # imports straight from '../../../src/index.js'
  specs/
    camera-pan-zoom.spec.ts
  playwright.config.ts
  tsconfig.json
vite.config.e2e.js          # dev server for fixtures/, rooted like vite.config.demo.js is for /demo

Adding a new scenario

  1. Add e2e/fixtures/scenes/<name>.ts exporting a createScene: CreateScene that builds a minimal world/camera/systems from /src and returns a handle (implementing SceneHandle, extended with whatever fields the spec needs to assert against - see CameraSceneHandle in camera-pan-zoom.ts). harness.ts picks it up automatically via import.meta.glob('./scenes/*.ts') - nothing else to register.
  2. Drive real browser input against it in e2e/specs/<name>.spec.ts (page.locator('canvas').dispatchEvent('wheel', ...), page.keyboard.down/up(...), etc.) and assert through window.__forgeTestHooks.
  3. Call handle.step(deltaMilliseconds?) to advance exactly one frame deterministically (it drives Time.update/EcsWorld.update directly, not Game.run()'s requestAnimationFrame loop), instead of waiting on real time. This is what keeps the suite flake-free.

Audio scenes and user activation: Playwright runs every page.evaluate (including waitForFunction's polling) as a user gesture, and recording a trace does the same when it snapshots the page. Either one before a scene creates its AudioContext lets the browser start audio unlocked, so a test of the first-gesture behavior passes for the wrong reason or fails. audio-mixer.spec.ts turns tracing off with test.use({ trace: 'off' }) and waits for a console message from the scene, not for window.__forgeTestHooks, before it evaluates anything.

Node vs. browser split: e2e/specs/*.spec.ts files run under Node (Playwright's own TS loader), not through Vite - they can import type from a scene module freely (erased at compile time), but a value import that transitively pulls in /src (e.g. anything importing shader .glsl?raw sources) will crash Node's loader, which can't parse those. If a spec needs a plain constant a scene also uses (see clearColorRgb in camera-pan-zoom-clear-color.ts), give it its own tiny module with zero /src imports, and have both the scene and the spec import from that.

Be wary of pixel-level rendering assertions. camera-pan-zoom.spec.ts originally included a test that read back the canvas's clear color to prove a frame actually rendered. It was removed: the read was correct and reproducible locally (verified via gl.readPixels, then again via a context2d.drawImage(canvas, 0, 0) + getImageData readback - a completely different code path, to rule out a readback-API bug), but deterministically returned a wrong, uniform color for the entire canvas in one CI environment's specific SwiftShader build, on every attempt, regardless of worker concurrency. That points at a genuine SwiftShader/GL instancing compatibility issue in that environment, not a bug in the test. If you add a pixel-reading assertion:

  • Do the step() and the read in the same page.evaluate call - the canvas isn't created with preserveDrawingBuffer, so the browser may clear it as soon as control returns after a frame is presented.
  • Don't assume gl.readPixels and a drawImage/getImageData readback disagreeing means the bug is in the readback method - both can (and did) agree while still being wrong, if the actual rendered frame is wrong.
  • Avoid absolute pixel assertions (an exact coordinate, an exact color byte value) - they're the kind of assertion the SwiftShader discrepancy above breaks for reasons unrelated to your feature. But don't swing to asserting ECS/logic state alone either: camera-pan-zoom.spec.ts did exactly that for its pan test and it passed even though createTransformEcsSystem was missing from the scene, so the camera's position.local updated correctly while the camera never visibly moved (render-system.ts's projection matrix reads position.world, which nothing was writing). Numeric-only assertions can't catch a bug like that. The pattern that catches it without reintroducing the SwiftShader problem is a relative, same-run measurement: read the actual rendered canvas for a landmark's on-screen bounds before and after the action under test, and assert the change matches what the logic state predicts (e.g. the landmark's on-screen width scaled by the zoom ratio). See the write-e2e-test skill and measureGreenSquareBounds() in camera-pan-zoom.ts for the full pattern and rationale.

Running

  • npm run test:e2e / npm run test:e2e:ui - runs the suite (the webServer config starts npm run dev:e2e against vite.config.e2e.js automatically).
  • npm run check-types:e2e - type-checks /e2e on its own (npm run check-types only covers /src and /demo).
  • @playwright/test is pinned to an exact version (not ^), matched to whatever Chromium revision is available in this repo's dev/CI environments, since the browser binary and the library version are tightly coupled - bumping it means also fetching the matching browser build (npx playwright install chromium), not just a version bump.
  • Runs as the test-e2e job in .github/workflows/ci.yml on every PR into dev, alongside lint/check-types/check-spelling. Add test-e2e to the repository's required status checks (Settings → Branches) if it isn't already, so a PR can't merge with a red e2e suite.
  • Locally, trace and video are 'on' for every run (not just failures) so a run can always be replayed: npx playwright show-report e2e/playwright-report opens the HTML report, which links each test's trace (full DOM/network/console timeline) and video recording. CI only keeps these for failures ('retain-on-failure'), uploaded as the playwright-report workflow artifact, to avoid uploading a trace/video per test on every green run.
  • Two things print an immediate, unambiguous timestamp the moment test:e2e/test:e2e:ui runs: the pretest:e2e npm script (fires before Node even starts loading Playwright) and the top of playwright.config.ts itself (fires once Playwright's own startup has finished). A run that looks "stuck" with a large gap between those two lines and nothing else is Playwright/Node startup itself being slow (common on Windows/WSL - antivirus real-time-scanning newly-touched files the first time, or generally slower process-spawn/filesystem overhead through the WSL2 VM boundary), not a bug in the suite; excluding the WSL distro's filesystem from real-time AV scanning is the usual fix.

Documentation Site Demos

documentation-site/src/pages/demos/<name>/ holds interactive, in-browser demos of engine features (physics, ecs, particles, rendering, ...), each rendered through DemoPage (documentation-site/src/components/demo-page/): the running game with its controls and a short "How it works" list beside it, and a code explorer for the demo's source below. Its title and summary come from the demo's entry in documentation-site/src/data/demos.ts. The add-feature-demo skill shows how to write a page. The code explorer only shows engine code a reader can reuse: never list React or other framework code (.tsx/.jsx) in it, such as a settings panel that only drives the site's page.

Critical gotcha: these demos import the engine as a published package (e.g. import { addRigidBodyComponent } from '@forge-game-engine/forge/physics'), resolved via documentation-site/node_modules/@forge-game-engine/forge, a file:.. link back to this repo, satisfied through this repo's package.json exports, which point at /dist, not /src. This means:

  • npm run check-types and npm test at the repo root only exercise /src directly. They will pass even if a change breaks every demo.
  • A demo only picks up a /src change after npm run build regenerates /dist.
  • Demos are a runtime integration surface (canvas rendering, input, the game loop) with no automated test coverage. A change can be fully type-safe and unit-tested and still crash or misbehave in a demo.

When a change touches a module that has a demo (check which demos import it, e.g. grep -rl "/physics" documentation-site/src/pages/demos):

  1. Update the demo's source (the _*.ts files alongside its index.tsx) if the change altered the API it depends on.
  2. Run npm run build from the repo root to refresh /dist with the change.
  3. From documentation-site/, run npm run typecheck and npm run build (docusaurus build). This catches broken imports/exports and type errors that the root-level checks never see, since they never compile against the published package surface.
  4. Run npm run start in documentation-site/ (or reuse an already-running dev server) and open the affected demo page(s) in a browser. Confirm they render and behave correctly with a full page reload, since demos are stateful and fast refresh does not guarantee a clean re-initialization.

See also step 9 of CLAUDE.md's verification checklist, which makes this mandatory before marking a task complete.

Demo Catalogue and Categories

/demos is a landing page (documentation-site/src/pages/demos/index.tsx) showing every demo category as a card (image, title, description) plus a search box that filters demos by title. The navbar's "Demos" item links straight to it - it used to be a dropdown listing every demo, which stopped scaling once the catalogue passed a couple dozen entries. Each category also gets its own listing page under documentation-site/src/pages/demos/category/<slug>/index.tsx, a thin wrapper around the shared CategoryDemosPage component.

The catalogue's data lives separately from the demo pages themselves:

  • documentation-site/src/data/demo-categories.ts - the fixed list of categories (slug, title, description, and the line icon its card shows, from src/components/ModuleIcon.tsx, the same set the homepage's feature grid uses).
  • documentation-site/src/data/demos.ts - one entry per demo (slug, title, short description, and the category slugs it belongs to). A demo can belong to more than one category - e.g. the car demo is tagged both physics and games since it's a physics showcase that's also a composite, multi-system demo.

When adding a new demo, also add an entry to demos.ts with the category slugs it belongs to (adding a new category itself means adding to demo-categories.ts with a ModuleIcon name, drawing a new icon in ModuleIcon.tsx in the same style if none fits, plus a new src/pages/demos/category/<slug>/index.tsx wrapper). A demo that combines many engine features into a complete mini-game (like the space shooter, brick breaker and car demos) belongs in the games category regardless of which other categories also apply.

Documentation Site Blog

documentation-site/blog/ is a Docusaurus blog used for marketing/announcement content (release roundups, feature spotlights) aimed at consumers of the engine - distinct from docs/docs, which is reference/conceptual documentation. Enabled via the blog key in the classic preset options in docusaurus.config.ts, with a Blog navbar/footer link pointing at /blog.

Conventions:

  • One file per post: blog/YYYY-MM-DD-slug.md, with slug, title, description, authors, tags and image in frontmatter. The homepage's News section lists the three newest posts with their image and description (plugins/blog-with-recent-posts.ts, which loads the blog in place of the classic preset's, writes them to a generated module). Give image relative to the post (./banner.png), and write description as a one-sentence summary; without it Docusaurus uses the post's first line, which is usually its banner image's alt text. Authors are defined once in blog/authors.yml; tags in blog/tags.yml.
  • Internal links (to docs, the API reference, or demos) must include the /Forge baseUrl prefix (e.g. /Forge/demos/physics, /Forge/docs/changelog), matching the convention already used in docs/docs/** - onBrokenLinks: 'throw' in docusaurus.config.ts fails the build on a mismatch.
  • Gotcha: this site opts into future.v4: true, which defaults markdown.mdx1Compat.comments to false, so the classic HTML-comment truncate marker (<!-- truncate -->) fails MDX compilation with an "Unexpected character !" error. Use the MDX-native marker instead: {/* truncate */}.
  • A new post should be verified with npm run build (from documentation-site/) to catch broken links/MDX errors, the same way a demo change is per the "Documentation Site Demos" section above.

Common Patterns

Alpha Blending (Premultiplied Alpha)

Every render destination - each RenderTarget and the canvas itself - holds premultiplied alpha, while sprite/text fragment shaders output straight alpha. Keep both halves of that contract when touching the rendering pipeline:

  • Drawing straight-alpha fragments uses gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA) (see render-system.ts). Never plain blendFunc(SRC_ALPHA, ONE_MINUS_SRC_ALPHA): it stores a * a as the destination alpha, which is how translucent UI used to reach the screen at roughly a³.
  • Blending a premultiplied texture (a render target's colorTexture) over another destination uses gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA) (see present-system.ts).
  • Clear colors are straight-alpha Colors and get premultiplied when written (RenderContext.clear, createTerrainRenderEcsSystem).
  • Light that should only brighten what's beneath it (bloom's glow) is added to the color and leaves alpha untouched (see bloom-composite.frag.glsl). Giving it alpha of its own makes it cover, and dim, the layers presented under it.
  • Leave gl.BLEND disabled when a system finishes drawing - it's global GL state, and the next system to draw would otherwise inherit it.

e2e/specs/translucent-ui-compositing.spec.ts and e2e/specs/bloom-over-background.spec.ts check this end to end on a real canvas.

Device Pixels vs. CSS Pixels

A RenderContext renders at the display's device pixel ratio, so it has two sizes that differ on any HiDPI display (and in Playwright only when a test sets deviceScaleFactor, since it defaults to 1):

  • width/height - the drawing buffer, in device pixels. Use these for anything GL sees: the viewport, shader uniforms compared against gl_FragCoord. A render target that covers the canvas is created with createRenderTarget(renderContext, 'canvas'), and RenderContext.resize resizes it with the canvas; never resize a camera's target by hand to follow the canvas.
  • cssWidth/cssHeight - the canvas's on-page size, in CSS pixels. Use these for anything the DOM measures: MouseInputSource.position, getSafeAreaInsets(), and any UI size meant to stay the same physical size (constantPixelSize, 'screenPixels').

pixelRatio is the ratio between them. Mixing the pairs (e.g. converting a mouse position against width/height) is off by pixelRatio, and Vitest won't catch it because jsdom's devicePixelRatio is 1. Test such code with a mocked RenderContext whose two sizes differ, or a deviceScaleFactor: 2 e2e test (see e2e/specs/high-dpi-canvas.spec.ts).

Screen-space effect sizes (blur radii, bloom spread) are sized in CSS pixels too, so they look the same on every display: the Gaussian blur system averages the scene down to CSS-pixel resolution before blurring, and bloom's downsample block is 4 * pixelRatio render-target texels. Don't step a kernel pixelRatio texels apart on the full-resolution texture instead - it skips the texels in between and stripes thin details.

Transforms

PositionEcsComponent, RotationEcsComponent and ScaleEcsComponent each have a local and a world value. local is the input: game code, physics integration (createEulerIntegrationEcsSystem), UI layout and every other system that moves an entity writes local. world is output: only createTransformEcsSystem writes it, composing local with the parent's world. Everything that draws, collides or measures reads world. Never write world anywhere else, including demos, e2e scenes and docs samples. The transform system overwrites it on its next pass. The add{Position,Rotation,Scale}Component factories only take local, and start world as a copy of it.

The transform system runs once per frame, after the systems that write local and before physics and rendering, which read world. documentation-site/docs/docs/common/transforms.md covers the ordering and following other entities.

Angles and Directions

The world is Y-up, and every angle follows one convention: radians, angle 0 points along +X, and a positive angle turns +X towards +Y (counter-clockwise on screen). Vec2.up is (0, 1), radiansToVector(θ) is (cos θ, sin θ) and vectorToRadians is its inverse. Particle emitter directionRange/rotationRange, RotationEcsComponent, Vec2.rotate and physics all use it. Don't add an API that measures angles from up, clockwise, or in degrees; callers who author degrees use degreesToRadians.

Something that emits or aims relative to an entity (a particle emitter's spawn shape and direction) works in that entity's frame, turned by its rotation.world, the way Unity and Godot emitters follow their transform. documentation-site/docs/docs/math/angles-and-rotation.md covers the convention and which way a sprite faces.

GPU Resources and Context Loss

The browser can take the WebGL context away at any time, and every GL object goes with it. RenderContext survives that by rebuilding every GPU resource when the context is restored, so every GL object has to belong to an engine wrapper that can recreate it from data it keeps on the CPU side: Texture (its last source), ShaderProgram (its shader sources, cached by the render context), RenderTarget (its size and format) and Geometry (its vertex data). Each registers itself with its render context when it's created (gpu-resource-registry.ts, which the package doesn't export) and unregisters in dispose.

  • Don't keep a raw WebGLTexture, WebGLBuffer, WebGLProgram or WebGLFramebuffer across frames, or cache one keyed by the WebGL2RenderingContext (that object survives a restore, so the cache would hand out dead handles). Read it from its wrapper when drawing, and put shared resources on the render context (quadGeometry, instanceBuffer, whiteTexture).
  • While renderContext.isContextLost, creating or resizing a resource records what to create and touches no GL, and checks that read GL state (compile/link status, framebuffer completeness) are skipped, since a lost context fails all of them.
  • Engine draw functions (the render system's batches, drawFullscreenQuad, the terrain render system) return early while the context is lost, so game systems that draw through them need no guard of their own.

e2e/specs/webgl-context-loss.spec.ts loses and restores a real context with WEBGL_lose_context.

Readonly Fields

Use readonly for fields that shouldn't change after construction:

export class Example {
  private readonly _config: Config;

  constructor(config: Config) {
    this._config = config;
  }
}

Optional Parameters with Defaults

Before adding an optional parameter at all, check it against "Change Philosophy" above. Most bug fixes shouldn't add one.

Defaults should be stored in an object with the word "default" in its name, and applied with withDefaults(defaults, options) (src/utilities/with-defaults.ts). Defaults should not be added when reading the value. Types should be narrowed and nullish values should be handled appropriately.

import { withDefaults } from '../utilities/with-defaults.js';

interface MoveOptions {
  speed?: number;
  direction?: Vector2;
}

const defaultMoveOptions = { speed: 5 };

const move = (options: MoveOptions) => {
  const { speed, direction } = withDefaults(defaultMoveOptions, options);

  if (!direction) {
    throw new Error('Needs a direction');
  }

  doMoveLogic(speed, direction);
};

withDefaults treats an option passed as undefined like one left out, the way a JavaScript default parameter does. Don't spread the options over the defaults ({ ...defaultMoveOptions, ...options }): an explicit undefined replaces the default, so a caller forwarding its own optional parameter (move({ speed })) breaks. For the same reason, pass an optional value straight through ({ anchoredPosition }) rather than guarding it with ...(anchoredPosition && { anchoredPosition }).

Incorrect patterns (do not use):

const move = (options: MoveOptions) => {
  doMoveLogic(options.speed ?? 5, options.direction ?? Vector2.Left);
};

const move = (options: MoveOptions) => {
  const { speed } = { ...defaultMoveOptions, ...options };
};

Error Handling

Throw descriptive errors with context:

if (this._components.has(key)) {
  throw new Error(
    `Unable to add component "${key.toString()}" to entity "${this.name}", it already exists on the entity.`,
  );
}

Type Narrowing

Narrow types appropriately and handle nullish values:

public getComponent<C extends ComponentCtor>(
  componentType: C,
): InstanceType<C> | null {
  return (this._components.get(componentType.id) as InstanceType<C>) ?? null;
}

public getComponentRequired<C extends ComponentCtor>(
  componentType: C,
): InstanceType<C> {
  const component = this.getComponent(componentType);

  if (component === null) {
    throw new Error(
      `Required component "${componentType.id.toString()}" not found on entity "${this.name}"`,
    );
  }

  return component;
}

Static Counters

Use static fields for ID generation:

export class Entity {
  private static _idCounter: number = 0;
  private readonly _id: number;

  constructor() {
    this._id = Entity._generateId();
  }

  private static _generateId(): number {
    return Entity._idCounter++;
  }

  get id(): number {
    return this._id;
  }
}

Getters/Setters

Use getters for computed or protected values:

get children(): Set<Entity> {
  return new Set(this._children); // Return a copy
}

Events

Use the event system for decoupled communication:

import { ForgeEvent } from '../events/forge-event.js';

export class Entity {
  public onRemovedFromWorld: ForgeEvent;

  constructor() {
    this.onRemovedFromWorld = new ForgeEvent('entityRemovedFromWorld');
  }
}

Security Considerations

Validation

  • Validate constructor parameters and throw early
  • Check for null/undefined before accessing properties
  • Validate array indices before access

Browser Security

  • The engine runs in the browser - be aware of browser security model
  • No server-side code or Node.js-specific APIs
  • Be cautious with user-generated content in WebGL

Dependencies

  • Peer dependencies: msdf-bmfont-xml (optional, only for generating font atlases)
  • Keep dependencies minimal and well-maintained

Additional Resources

Quick Reference

Common Commands

npm run dev          # Run demo app
npm run build        # Build the project
npm test             # Run tests
npm run lint         # Run ESLint
npm run lint:fix     # Fix linting issues
npm run prettier     # Check formatting
npm run prettier:write  # Format code
npm run check-types  # Type check without emit
npm run check-exports   # Verify package exports
npm run cspell       # Spell check

File Extensions in Imports

✅ Correct: import { X } from './module.js'
❌ Incorrect: import { X } from './module'

Always use .js extension even when importing from .ts files.

Key Classes to Know

  • EcsWorld - Holds component data and registered systems; entities are numeric ids created with createEntity()
  • EcsSystem - The plain-object system contract produced by create<Name>EcsSystem factories
  • createComponentId - Creates the key a component is stored under
  • ForgeEvent - Event system
  • RenderContext - WebGL context wrapper

Remember: Write minimal, focused changes. Follow existing patterns. Test your code. Document public APIs.