Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fbad967
feat(states): add game states, run conditions and state-scoped entities
claude Oct 6, 2026
2ce83df
Merge remote-tracking branch 'origin/dev' into claude/serene-mendel-1…
claude Oct 6, 2026
8617f15
Merge remote-tracking branch 'origin/dev' into claude/serene-mendel-1…
claude Oct 6, 2026
15b3eb9
Merge remote-tracking branch 'origin/dev' into claude/serene-mendel-1…
claude Oct 6, 2026
2f2b02e
Merge remote-tracking branch 'origin/dev' into claude/serene-mendel-1…
claude Oct 6, 2026
5c35496
Merge branch 'dev' into claude/serene-mendel-1j6xxo
stormmuller Oct 6, 2026
e4f76bb
docs(states): link the demo where the guide uses it, not in the intro
claude Oct 6, 2026
d5aca86
Merge remote-tracking branch 'origin/claude/serene-mendel-1j6xxo' int…
claude Oct 6, 2026
de59ff7
docs(states): name the example state type GameStateName, not Screen
claude Oct 6, 2026
18ccb23
docs(states): say what happens without runIf, drop the dependency-inj…
claude Oct 6, 2026
e3697d1
docs(states): describe run conditions plainly instead of as gates
claude Oct 6, 2026
8bcadf4
docs(states): describe paused systems without implying they hold stat…
claude Oct 6, 2026
86703cc
docs(states): drop the Time aside from the run conditions section
claude Oct 6, 2026
f96f06e
docs(states): drop the input section from the game states guide
claude Oct 6, 2026
1984ba8
docs(states): describe enter and exit systems as reacting to transiti…
claude Oct 6, 2026
b7909eb
docs(states): drop the several-worlds section from the game states guide
claude Oct 6, 2026
1f35bd5
docs(skills): make document-feature produce isolated, literal technic…
claude Oct 6, 2026
9648819
docs(states): rewrite the game states docs as isolated technical refe…
claude Oct 6, 2026
1d32a3e
docs(skills): document-feature writes feature guides, leaving per-mem…
claude Oct 6, 2026
08c571e
docs(states): leave entity removal behavior to the World guide
claude Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
354 changes: 188 additions & 166 deletions .claude/skills/document-feature/SKILL.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ this step by step for bug fixes.
/physics # Physics integration
/pooling # Object pooling
/rendering # Rendering system
/states # Game states (createGameState), inState/onEnter/onExit run conditions, state-scoped entities
/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, layout groups, content size/aspect ratio fitters)
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

#### Added

- **ecs:** Systems and system groups can run conditionally. Pass a `runIf` function of the world to `addSystem` or `addSystemGroup`, and the world checks it each tick just before the system or group would run, skipping it (and its query) when it returns `false`. `EcsWorld` also gains a built-in `firstSystemGroup` that runs before every other group of the tick. A group ordered `after` it joins the start of the tick, before every other group. Ordering a group `before` the first group throws, and so does ordering a group before a start-of-tick group, or a start-of-tick group after one that isn't
- **states:** New `@forge-game-engine/forge/states` module for a game's top-level states (menu, playing, paused, game over). `createGameState(world, initial)` returns a `GameState` whose `set` switches state at the start of the next tick. Its `exitGroup` and `enterGroup` run once per transition, before any other system, holding systems that use `onExit`/`onEnter` as their `runIf`; `inState` runs a system only in some states. The initial state is entered on the first tick, and setting the current state again restarts it. `addStateScopedComponent` removes an entity when its state leaves one of `removeOnExit` or enters one of `removeOnEnter`. See the new Game States guide and demo
- **rendering:** `getCameraView(world, camera, renderContext)` (and `computeCameraView(camera, position, renderContext)` for systems that already hold the camera's components) returns what a camera sees: the world area it shows (`bounds`, `size`, accounting for its position and zoom), its `pixelsPerUnit` in CSS pixels, and `worldToViewport`/`viewportToWorld` conversions to and from CSS pixels on the canvas. To place something drawn by one camera over something drawn by another, convert through the viewport: `hudView.viewportToWorld(gameView.worldToViewport(position))`
- **text:** The engine's default font can be imported through a bundler from `@forge-game-engine/forge/fonts/default/default.json` and `@forge-game-engine/forge/fonts/default/default.png`, so you no longer need to copy it out of `node_modules`

Expand Down
16 changes: 8 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@ section has the reasoning.
Project skills live in `.claude/skills/`. Use the matching one before you
start the task:

| Task | Skill |
| ---------------------------------------------------------- | ------------------------ |
| Fixing a bug, defect, regression or wrong behavior | `fix-defect` |
| Adding an ECS component | `create-component` |
| Adding a major feature (needs a docs-site demo) | `add-feature-demo` |
| Writing or updating a guide in `documentation-site/docs` | `document-feature` |
| Adding a Playwright test for rendering, input or game loop | `write-e2e-test` |
| Designing or planning a new feature or large change | `create-design-document` |
| Task | Skill |
| -------------------------------------------------------------- | ------------------------ |
| Fixing a bug, defect, regression or wrong behavior | `fix-defect` |
| Adding an ECS component | `create-component` |
| Adding a major feature (needs a docs-site demo) | `add-feature-demo` |
| Writing or updating documentation in `documentation-site/docs` | `document-feature` |
| Adding a Playwright test for rendering, input or game loop | `write-e2e-test` |
| Designing or planning a new feature or large change | `create-design-document` |

## Before implementing

Expand Down
50 changes: 40 additions & 10 deletions design/game-states.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

| | |
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Status** | Draft, for review |
| **Status** | Implemented (Phases 1 and 2); the Galactic Journey migration (§9) follows the next release |
| **Kind** | Feature |
| **Found in** | Galactic Journey demo: `src/run/*` (`run.component.ts`'s `enteredPhase`/`leftPhase`, `run.system.ts`, `run-phases.ts`, `clear-run.system.ts`, `run-reset.system.ts`, `run-screens.system.ts`), and 15 `isInMenu`/`hasEntered`/`hasLeft` calls in 9 files |
| **Engine version at time of writing** | `0.25.8` |
Expand Down Expand Up @@ -135,6 +135,26 @@ default group (as `registerInputs` does) could run before a state
transition. The first group makes "every system of the tick sees the same
state" true.

"Every group runs after the first group" isn't enough for the state's exit
and enter groups (§4.3), though. They have to run right after the
transition and before every other group, but a group's order among groups
with no edge between them is insertion order, so `registerInputs`'
`input-update` group, added before the state, would run before the enter
group. So a group ordered `after` the first group (or after another group
that is) joins the **start of the tick**: the world orders it before every
group that isn't there, including groups added earlier or later. A
start-of-tick group ordered after a group that isn't at the start of the
tick throws, and so does a group ordered before a start-of-tick group it
isn't part of. That's the same layering Bevy gets from its fixed list of
main schedules, expressed with the group graph Forge already has.

One consequence: start-of-tick groups run before `input-update`, so
`onEnter`/`onExit` systems read the previous tick's input. In Bevy,
`PreUpdate` (input) runs before `StateTransition`. Here the transition
comes first so that every system of the tick, input systems included, sees
the same state; a transition is requested by a system reacting to input,
so it applies on the next tick either way.

### 4.3 States

```ts
Expand Down Expand Up @@ -183,11 +203,16 @@ start of each tick, in this order:
wins), setting `entered` and `exited` for this tick.
2. The `exitGroup` runs, so `onExit` systems can still read what the state
is about to tear down.
3. State-scoped entities are removed (§4.4).
3. State-scoped entities are removed (§4.4), in a start-of-tick group of
their own between the exit and enter groups, so an exit system added
later can't end up after the removal.
4. The `enterGroup` runs, so `onEnter` systems set the new state up
before any gameplay system sees it.
5. The rest of the tick.

The exit, removal and enter groups are start-of-tick groups (§4.2), gated
so they only run on ticks with a transition.

On the first tick, the initial state counts as entered: `entered` is
`initial` and `onEnter` systems run, as Bevy runs the initial state's
`OnEnter` at startup. The demo's `loading` phase goes.
Expand All @@ -205,13 +230,14 @@ addStateScopedComponent(world, entity, {
});
```

The transition system removes every entity whose state left one of its
The transition removes every entity whose state left one of its
`removeOnExit` states, or entered one of its `removeOnEnter` states, at
step 3 above. Removal takes the entity's descendants with it
([`hierarchy-removal.md`](./hierarchy-removal.md)); removing a descendant
that was already removed is a no-op
([`generational-entity-ids.md`](./generational-entity-ids.md)). At least
one of the two lists must be non-empty.
step 3 above, with `world.removeEntity`. Removing an entity that was
already removed is a no-op
([`generational-entity-ids.md`](./generational-entity-ids.md)). Once
[`hierarchy-removal.md`](./hierarchy-removal.md) ships, removal takes the
entity's descendants with it. Until then, it takes only the scoped entity,
as `removeEntity` does everywhere else. At least one of the two lists must be non-empty.

The demo's run leftovers stay on screen behind the end-of-run panels and
are cleared when a new run starts or the menu comes up, so they use
Expand Down Expand Up @@ -296,8 +322,12 @@ transition, holding systems with those conditions.
**Rationale.** With (a), setup for a new state would interleave with
gameplay systems in the same tick (a player spawned after the systems
that should see it). Bevy runs its enter and exit schedules at the
transition, before any `Update` system, for that reason. Groups give
Forge the same order without a second scheduling concept.
transition, before any `Update` system, for that reason. Groups placed at
the start of the tick (§4.2) give Forge the same order without a second
scheduling concept. Bevy runs `OnEnter`/`OnExit` as schedules run on
demand from the transition; Forge's groups run in the world's normal order
and are skipped by a run condition on ticks without a transition, so no
"registered but not run by the loop" kind of group is needed.

### DL-3: Scoped removal on enter as well as on exit

Expand Down
26 changes: 26 additions & 0 deletions documentation-site/docs/docs/ecs/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,32 @@ const system: EcsSystem<[Sprite]> = {
`createRenderEcsSystem` uses this for a sprite's optional rotation, scale,
and flip components.

## Run conditions

A run condition is a function `(world: EcsWorld) => boolean`. Passed as
`runIf` to `addSystem`, it decides on each tick whether the system runs:

```ts
world.addSystem(spawnerSystem, { runIf: () => !settings.isPaused });
```

- The world calls the condition each tick, immediately before the system
would run, so it reads values written by earlier systems in the same tick.
- When it returns `false`, the system isn't queried and `update` isn't
called.
- A system registered without `runIf` runs on every tick.

`addSystemGroup` also takes `runIf`. The group's condition is called once
per tick, before the group runs. When it returns `false`, none of the
group's systems run and their own conditions aren't called. A system in a
group runs when both conditions return `true`.

A run condition doesn't change a system's `query` or `tags`. `cleanup` runs
when the system is removed or the world stops, whatever its run condition.

`inState`, `onEnter` and `onExit` create run conditions from a
[game state](../states/index.md).

## Atomicity

Treat each call to `update(world, queryResult)` as a single, focused update for the tick's batch of matched entities. Systems should perform short, deterministic operations and avoid long-running or blocking work inside `update`.
Expand Down
31 changes: 30 additions & 1 deletion documentation-site/docs/docs/ecs/world.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ world.removeEntity(entity);
This removes every component/tag the entity had, then raises `onEntityRemoved`
with the entity. The world reuses the entity's slot for a later entity, under a
new handle, so the removed entity's handle never refers to the new one.
Entities parented to it (with `addParentComponent`) aren't removed with it.

Removing an entity that's already been removed does nothing, and
`removeEntity` returns `false` instead of `true`. Use `isAlive(entity)` to check
Expand Down Expand Up @@ -207,6 +208,33 @@ used to serve.
group; ordering systems across different groups is done by ordering their
groups against each other instead.

### The first group

`world.firstSystemGroup` runs before every other group on every tick.
Ordering a group `before` it throws.

A group registered with `after` containing `firstSystemGroup`, or containing
another group registered that way, is a start-of-tick group. Start-of-tick
groups run after the first group and before every other group, including
groups registered earlier or later. A `GameState`'s `exitGroup` and
`enterGroup` are start-of-tick groups.

```ts
const loadLevelGroup = createSystemGroup('load-level');

world.addSystemGroup(loadLevelGroup, { after: [world.firstSystemGroup] });
```

Registering a start-of-tick group with `after` containing a group that isn't
a start-of-tick group throws. Registering any other group with `before`
containing a start-of-tick group throws.

### Run conditions

`addSystem` and `addSystemGroup` take a `runIf` function that decides, each
tick, whether the system or group runs. See
[System](./system.md#run-conditions).

## Remove a system

Remove a system with `removeSystem(system)`.
Expand All @@ -225,7 +253,8 @@ it will still run as part of the current tick. The removal is only committed at
Call `world.update()` to run the registered systems for a single frame. For
each registered system, the world queries `query` (and `tags`) and invokes the
system's `update` exactly once with the batch of matches, regardless of how
many entities matched (including zero).
many entities matched (including zero). A system or group whose `runIf`
returns `false` is skipped, and the skipped system isn't queried.

In normal usage you don't call `update()` manually. The main loop in `Game` calls it for you every frame. Calling `update()` directly is useful for unit tests.

Expand Down
8 changes: 8 additions & 0 deletions documentation-site/docs/docs/states/_category_.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"label": "Game States",
"position": 13,
"link": {
"type": "doc",
"id": "docs/states/index"
}
}
112 changes: 112 additions & 0 deletions documentation-site/docs/docs/states/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
---
sidebar_position: 1
---

# Game States

A game state is one value from a fixed set of state names, such as `menu`,
`playing` and `paused`, that changes at the start of a tick. Systems can be
registered to run only in some states, or only on the tick a state is
entered or left, and entities can be removed when a state changes.

## Creating a game state

[`createGameState`](/Forge/docs/api/functions/createGameState) creates a
[`GameState`](/Forge/docs/api/interfaces/GameState) for a world, starting in
the given state:

```ts
import { createGameState } from '@forge-game-engine/forge/states';

type GameStateName = 'menu' | 'playing' | 'paused';

const gameState = createGameState<GameStateName>(world, 'menu');
```

`gameState.current` is the current state.

## Changing state

`set` requests a transition:

```ts
gameState.set('playing');
```

The transition is applied at the start of the next tick, before any other
system runs, so every system in a tick reads the same `current`. Calling
`set` with the current state leaves and re-enters it.

## Running systems in some states

[`inState`](/Forge/docs/api/functions/inState) creates a run condition that
is true while the game state is one of the given states. Pass it as `runIf`
when registering a system:

```ts
import { inState } from '@forge-game-engine/forge/states';

world.addSystem(enemyAiSystem, { runIf: inState(gameState, 'playing') });
```

The system runs only on ticks where `current` is `playing`. A system
registered without `runIf` runs in every state. See
[Run conditions](../ecs/system.md#run-conditions).

## Running systems when a state is entered or left

[`onEnter`](/Forge/docs/api/functions/onEnter) and
[`onExit`](/Forge/docs/api/functions/onExit) create run conditions that are
true only on the tick the game state enters or leaves one of the given
states. Register these systems in the game state's `enterGroup` and
`exitGroup`:

```ts
import { onEnter, onExit } from '@forge-game-engine/forge/states';

world.addSystem(spawnPlayerSystem, {
group: gameState.enterGroup,
runIf: onEnter(gameState, 'playing'),
});

world.addSystem(saveHighScoreSystem, {
group: gameState.exitGroup,
runIf: onExit(gameState, 'playing'),
});
```

On the tick of a transition, the world runs:

1. the transition: `current` changes, `exited` is the state left and
`entered` the state entered;
2. `exitGroup`;
3. the removal of state-scoped entities (see below);
4. `enterGroup`;
5. every other group.

On the first tick, the initial state is entered, so its `onEnter` systems
run.

## Removing entities when a state changes

[`addStateScopedComponent`](/Forge/docs/api/functions/addStateScopedComponent)
marks an entity to be removed when the game state leaves or enters given
states:

```ts
import { addStateScopedComponent } from '@forge-game-engine/forge/states';

addStateScopedComponent(world, enemy, {
state: gameState,
removeOnExit: ['playing'],
});
```

This entity is removed when the game state leaves `playing`.
`removeOnEnter` removes an entity when the game state enters one of the
listed states instead. With `removeOnEnter: ['playing', 'menu']`, an entity
created while playing is kept after `playing` is left, and removed when
`playing` or `menu` is next entered.

Removal happens between `exitGroup` and `enterGroup`, so `onExit` systems
can still read the entity.
7 changes: 7 additions & 0 deletions documentation-site/src/data/demos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ export const demos: Demo[] = [
'A drivable car built from rigid bodies, joints, springs and motors.',
categories: ['physics', 'games'],
},
{
slug: 'game-states',
title: 'Game States',
description:
'A menu, a round and a game-over screen, switched with a game state, run conditions and state-scoped entities.',
categories: ['ecs', 'games'],
},
{
slug: 'ecs',
title: 'ECS',
Expand Down
Loading
Loading