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.
- Project Overview
- Change Philosophy
- Agentic Workflow
- Repository Structure
- Architecture
- Coding Conventions
- Module Organization
- Development Workflow
- Changelog
- Testing
- Documentation Site Demos
- Documentation Site Blog
- Common Patterns
- Security Considerations
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.
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,/e2eanddocumentation-sitein 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'sinvertedoption exists because the W3C gamepad reports stick-up as-1while 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).
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
/designalready. A one-line or clearly scoped fix skips the design and goes straight tosolution-revieweron the plan (for anything beyond one line). - The run stops rather than guess. If
design-authorreports a blocking open question, orsolution-reviewerrejects 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, hasdesign-authorupdate 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:e2efor 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-reviewerlocally to review new commits. An@claudemention 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-reviewerandpr-reviewerrun 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.
/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.
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.
The codebase follows a data-oriented ECS pattern built from plain objects and factory functions, not classes:
-
Components: Plain data interfaces, not classes. Each one gets a
createComponentIdkey (a symbol) and anadd<Name>Componentfactory that attaches it to a caller-supplied entity (see "Component Pattern" below). Components carry no logic. -
Systems (
EcsSystem<TQuery>): Plain objects, produced bycreate<Name>EcsSystemfactory functions, not classes. A system declares aquery(the component keys it reads, in order) and an optional set oftags, and implementsupdate(world, queryResult).queryResultis a batch for the whole tick -entities: readonly number[]and acomponentsarray (one array per queried component type, in query order) - soupdateruns exactly once per tick regardless of how many entities matched (including zero), and the system iterates the batch itself. An optionalcleanup(world)hook runs once when the system is removed from anEcsWorldor the world is stopped. See "System Pattern" below and/documentation-site/docs/docs/ecs/system.md. -
Entities: Just numeric ids (
number), created withEcsWorld.createEntity(). Components are attached/detached by id via the world (addComponent/removeComponent/addTag); there is noEntityobject. Parent-child relationships are kept by the world, not object containment:world.setParent(child, parent)/removeParentwrite the child'sParentEcsComponent(the only writer;addComponent/removeComponentofparentIdthrow) and the world's children index (getChildren, in sibling order).removeEntityremoves descendants too, so loops that remove entities checkisAlivefirst. -
World (
EcsWorld): Container for component data and registered systems. Stores component data grouped by component key, runs each registered system'supdateonce perEcsWorld.update()tick (in registration-order), and exposesquery(componentKeys, tags?)for ad-hoc lookups outside of a system's ownquery.
- Dependency Injection: Systems receive dependencies as arguments to their
create<Name>EcsSystemfactory (e.g.,RenderContext); classes receive them via constructor - Composition over Inheritance: Favor components over deep class hierarchies
- Immutability: Use
readonlyfor 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)
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:
camelCasewith leading underscore (e.g.,_components) - Constants:
camelCaseorUPPER_SNAKE_CASEdepending 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):
- Public instance fields
- Protected instance fields
- Private instance fields
- Public static fields
- Protected static fields
- Private static fields
- Constructor
- Public static methods
- Protected static methods
- Private static methods
- Public instance methods
- Protected instance methods
- 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; useunknownif 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
.jsextension in imports (even for.tsfiles) - 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
elseafterreturn(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,@throwsannotations - 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 }
- ESLint: Run
npm run lintto check,npm run lint:fixto auto-fix - Prettier: Run
npm run prettierto check,npm run prettier:writeto format - TypeScript: Run
npm run check-typesto verify types - Spell Check: Run
npm run cspellto check spelling
ESLint is configured with:
- TypeScript ESLint with type checking
- SonarJS for code quality
- Prettier integration
- Jest rules for test files
- Create a directory under
/src/your-module - Add an
index.tsthat exports the public API - Add sub-folders for organization:
/components- Component interfaces, theircreateComponentIdkeys, andadd<Name>Componentfactories (see "Component Pattern" below)/systems- Systems that operate on those components/types- Type definitions and interfaces
- Update
/src/index.tsto export the new module - Add export mapping in
package.jsonif it should be separately importable
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.
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.
Each module's index.ts should export everything from its submodules:
export * from './my-class.js';
export * from './subfolder/index.js';# 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 command:
npm run build - Compiles TypeScript to JavaScript with declarations
- Copies shader files to dist directory
- Output directory:
/dist
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.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 rootCHANGELOG.mdbydocumentation-site/scripts/sync-changelog.mjsonprestart/prebuild, and gitignored. Never edit or commit it directly; edit/CHANGELOG.mdinstead 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.
- Vitest for unit tests
- jsdom for DOM mocking
- Test files:
*.test.tsor*.spec.ts
- Place test files next to the code they test:
render-system.ts→render-system.test.ts - Use descriptive test names
- Group related tests with
describeblocks
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();
});
});- Use
beforeEachfor common setup - Test edge cases and error conditions
- Mock external dependencies when needed
- Use descriptive assertions
- For tests involving ECS, create a minimal
Worldand entities - When a test constructs a real
Materialagainst a mocked WebGL context, build a realRenderContextover a canvas whosegetContextreturns 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;):Materialtakes a uniform's type and array size from its declaration and throws for a name neither shader declares. The mockedgetActiveUniformdecides which declared uniforms are active; leave one out to test a uniform the compiler stripped.binduploads every active uniform, a zero default when the material didn't set it andrenderContext.blackTexturefor a sampler, so report only the uniforms the shaders really use, mock theuniform*vmethod for each declared type, and mock the texture-creation callsTexture's constructor makes when a sampler is active. Report the real GL type enum (e.g.0x8b5e /* SAMPLER_2D */,0x1406 /* FLOAT */) and arraysizefor what it does return, since undeclared active uniforms (struct members) are typed from it. Sampler values areTextures (new Texture(renderContext)), andbindTexturereceivestexture.glTexture. ASpriteMaterial's fragment shader must includespriteMask(createSpriteMaterialthrows otherwise), so a test's sprite fragment shader includes it too - A mocked WebGL context behind a real
RenderContextneedsgetExtension(the render context requestsEXT_color_buffer_floatwhen it's created) andisContextLost(returningfalse; GPU resources check it before touching GL).src/rendering/context-loss.test.tsshows 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.jsonand the coverage config exclude**/test-helpers/**, so they never ship in/dist
npm run test:coverageruns the unit suite with V8 coverage instrumentation (configured invite.config.base.js'stest.coverage) and writes a report to/coverage(gitignored): a terminal summary, anlcov.info, and an HTML report atcoverage/index.html.- CI's
testjob (.github/workflows/ci.yml) runstest:coverageinstead of plainnpm testand uploads/coverageas thecoverage-reportworkflow 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.
/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).
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
- Add
e2e/fixtures/scenes/<name>.tsexporting acreateScene: CreateScenethat builds a minimal world/camera/systems from/srcand returns a handle (implementingSceneHandle, extended with whatever fields the spec needs to assert against - seeCameraSceneHandleincamera-pan-zoom.ts).harness.tspicks it up automatically viaimport.meta.glob('./scenes/*.ts')- nothing else to register. - Drive real browser input against it in
e2e/specs/<name>.spec.ts(page.locator('canvas').dispatchEvent('wheel', ...),page.keyboard.down/up(...), etc.) and assert throughwindow.__forgeTestHooks. - Call
handle.step(deltaMilliseconds?)to advance exactly one frame deterministically (it drivesTime.update/EcsWorld.updatedirectly, notGame.run()'srequestAnimationFrameloop), 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 samepage.evaluatecall - the canvas isn't created withpreserveDrawingBuffer, so the browser may clear it as soon as control returns after a frame is presented. - Don't assume
gl.readPixelsand adrawImage/getImageDatareadback 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.tsdid exactly that for its pan test and it passed even thoughcreateTransformEcsSystemwas missing from the scene, so the camera'sposition.localupdated correctly while the camera never visibly moved (render-system.ts's projection matrix readsposition.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 thewrite-e2e-testskill andmeasureGreenSquareBounds()incamera-pan-zoom.tsfor the full pattern and rationale.
npm run test:e2e/npm run test:e2e:ui- runs the suite (thewebServerconfig startsnpm run dev:e2eagainstvite.config.e2e.jsautomatically).npm run check-types:e2e- type-checks/e2eon its own (npm run check-typesonly covers/srcand/demo).@playwright/testis 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-e2ejob in.github/workflows/ci.ymlon every PR intodev, alongsidelint/check-types/check-spelling. Addtest-e2eto 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,
traceandvideoare'on'for every run (not just failures) so a run can always be replayed:npx playwright show-report e2e/playwright-reportopens 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 theplaywright-reportworkflow 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:uiruns: thepretest:e2enpm script (fires before Node even starts loading Playwright) and the top ofplaywright.config.tsitself (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/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-typesandnpm testat the repo root only exercise/srcdirectly. They will pass even if a change breaks every demo.- A demo only picks up a
/srcchange afternpm run buildregenerates/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):
- Update the demo's source (the
_*.tsfiles alongside itsindex.tsx) if the change altered the API it depends on. - Run
npm run buildfrom the repo root to refresh/distwith the change. - From
documentation-site/, runnpm run typecheckandnpm 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. - Run
npm run startindocumentation-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.
/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, fromsrc/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 bothphysicsandgamessince 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/ 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, withslug,title,description,authors,tagsandimagein frontmatter. The homepage's News section lists the three newest posts with theirimageanddescription(plugins/blog-with-recent-posts.ts, which loads the blog in place of the classic preset's, writes them to a generated module). Giveimagerelative to the post (./banner.png), and writedescriptionas 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 inblog/authors.yml; tags inblog/tags.yml. - Internal links (to docs, the API reference, or demos) must include the
/ForgebaseUrlprefix (e.g./Forge/demos/physics,/Forge/docs/changelog), matching the convention already used indocs/docs/**-onBrokenLinks: 'throw'indocusaurus.config.tsfails the build on a mismatch. - Gotcha: this site opts into
future.v4: true, which defaultsmarkdown.mdx1Compat.commentstofalse, 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(fromdocumentation-site/) to catch broken links/MDX errors, the same way a demo change is per the "Documentation Site Demos" section above.
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)(seerender-system.ts). Never plainblendFunc(SRC_ALPHA, ONE_MINUS_SRC_ALPHA): it storesa * aas the destination alpha, which is how translucent UI used to reach the screen at roughlya³. - Blending a premultiplied texture (a render target's
colorTexture) over another destination usesgl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)(seepresent-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.BLENDdisabled 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.
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 againstgl_FragCoord. A render target that covers the canvas is created withcreateRenderTarget(renderContext, 'canvas'), andRenderContext.resizeresizes 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.
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.
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.
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,WebGLProgramorWebGLFramebufferacross frames, or cache one keyed by theWebGL2RenderingContext(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.
Use readonly for fields that shouldn't change after construction:
export class Example {
private readonly _config: Config;
constructor(config: Config) {
this._config = config;
}
}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 };
};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.`,
);
}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;
}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;
}
}Use getters for computed or protected values:
get children(): Set<Entity> {
return new Set(this._children); // Return a copy
}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');
}
}- Validate constructor parameters and throw early
- Check for null/undefined before accessing properties
- Validate array indices before access
- 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
- Peer dependencies:
msdf-bmfont-xml(optional, only for generating font atlases) - Keep dependencies minimal and well-maintained
- Documentation Site
- GitHub Repository
- Package exports in
package.jsonfor module structure
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✅ Correct: import { X } from './module.js'
❌ Incorrect: import { X } from './module'
Always use .js extension even when importing from .ts files.
EcsWorld- Holds component data and registered systems; entities are numeric ids created withcreateEntity()EcsSystem- The plain-object system contract produced bycreate<Name>EcsSystemfactoriescreateComponentId- Creates the key a component is stored underForgeEvent- Event systemRenderContext- WebGL context wrapper
Remember: Write minimal, focused changes. Follow existing patterns. Test your code. Document public APIs.