Repository navigation
feat(states): add game states, run conditions and state-scoped entities - #711
Conversation
Implements design/game-states.md. - ecs: `runIf` on `addSystem` and `addSystemGroup`, checked just before the system or group runs; a gated system isn't queried. - ecs: a built-in `firstSystemGroup` that runs before every other group. Groups ordered after it run at the start of the tick, before every other group, so a state's exit and enter groups run before input and gameplay. - states: new module with `createGameState`, `inState`/`onEnter`/`onExit` and `addStateScopedComponent`. - Docs: run conditions and the first group in the ECS guides, a new Game States guide and a game states demo. - design/game-states.md: records the start-of-tick placement the exit and enter groups need, and the scoped-removal group. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…j6xxo Brings in generational entity handles (#715) and the angle convention change (#712). State-scoped component errors now name the entity with formatEntity, and design/game-states.md notes that removing an already-removed entity is a no-op now that generational ids have shipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
…j6xxo Brings in camera views (#714). The game states demo sizes its play area with getCameraView(...).size, since calculateVisibleWorldSize is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
|
|
||
| A system that should only run on some ticks gets a run condition: a | ||
| function of the world that returns whether the system runs this tick. Pass | ||
| it as `runIf` when registering the system: |
There was a problem hiding this comment.
Is this idiomatic, does Bevy do this?
There was a problem hiding this comment.
Yes, it's modelled on Bevy's run conditions. Bevy attaches the condition where the system is added to a schedule, not inside the system:
app.add_systems(Update, spawn_enemies.run_if(in_state(GameState::Playing)));
app.configure_sets(Update, GameplaySet.run_if(in_state(GameState::Playing)));runIf on addSystem/addSystemGroup is the same thing: system groups play the role of system sets. The semantics match too:
- The condition is checked just before the system or set would run, and a skipped system does no work.
- A system in a gated set runs only if the set's condition and its own are both true.
- Conditions only read state.
- Bevy's
in_state/state_changed/OnEnterare whatinState/onEnter/onExitmirror here.
Unity DOTS does the same job with RequireForUpdate and Enabled, also outside OnUpdate. Godot has no ECS equivalent.
Differences from Bevy, kept deliberately small:
- Plain functions. A Bevy condition is a read-only system with its own parameters. Here it's
(world) => boolean, which reads whatever it closes over, the same way systems getTimeor aGameStatethrough their factory. - No combinators. Bevy has
.and(),.or()andnot(). Here you write() => a(world) && !b(world). - One condition per system. Bevy ANDs several
run_ifcalls on one system. Here you combine them in the one function.
If you'd rather have combinators or several conditions per system, I can add them. I left them out because a one-line lambda covers the same cases.
Generated by Claude Code
| } | ||
|
|
||
| /** | ||
| * Creates a run condition that is true on the tick `state` enters one of |
There was a problem hiding this comment.
So this system will only run for a single tick?
There was a problem hiding this comment.
Once per entry, not once ever. A system gated with onEnter(state, 'playing') runs on the tick the state enters playing, and again every later time it's entered:
- the initial state counts as entered on the first tick;
- coming back from another state counts;
set('playing')while already playing (a restart) counts too.
state.entered is non-null only on the tick of a transition. The transition system clears it at the start of the next tick, so the condition is false again until the next entry.
This is the one-shot setup/teardown hook, like Bevy's OnEnter(GameState::Playing) schedule: spawning the player, building a screen. For something that should run on every tick while in a state, use inState(state, 'playing') instead. The demo uses both: onEnter systems in state.enterGroup build each screen, and inState gates the gameplay and input systems.
Generated by Claude Code
There was a problem hiding this comment.
Right, but now the mental model change significantly. Prior to this change, all systems in the world ran on every tick. Easy to reason about, easy to debug. Is this a valuable tradeoff?
There was a problem hiding this comment.
Fair challenge. My take: it's worth it, but the cost is real, so here it is next to the alternative.
What actually changes
- Nothing changes for a system without
runIf. It still runs on every tick, and existing code is untouched. The new model applies only to systems that opt in. - The check moves rather than appears. A system that only makes sense in some states has to check somewhere. Galactic Journey does it with 15
isInMenu/hasEntered/hasLeftcalls in 9 files, each one querying the run entity first. WithrunIf, the condition sits where the system is registered, so the registration list says when each system runs. Today you have to read each system's body to find out. - A system that doesn't run isn't queried, so it does no work instead of querying and returning early.
What it costs
- "Every registered system ran this tick" no longer holds. When a system doesn't run, the question becomes "is it registered?" and "is its condition true?". That's a debugging step that didn't exist before.
- Ordering reasoning gains start-of-tick groups.
How others land
Every ECS I know with states lands on this:
- Bevy's
run_if; - Unity DOTS'
RequireForUpdate/Enabled; - Flecs's enabled/disabled systems and phases.
None of them make every system check inside its own update.
The smaller alternative, if you'd rather keep the old model
Drop public runIf and keep:
GameState;- transitions at the start of the tick;
- the enter/exit ordering;
- scoped entities.
Systems would then check gameState.current/entered themselves. The engine could still skip its own exit/enter groups internally. That keeps "everything runs every tick" for user systems, at the price of the in-update checks and queries that run for nothing.
Debugging
If you keep runIf, a cheap mitigation is a debug hook: for example, world.update could record which systems a condition skipped that tick. I'd do that as a follow-up rather than in this PR.
Which way do you want to go?
Generated by Claude Code
There was a problem hiding this comment.
This feels very similar to the FSM already built into the engine?
There was a problem hiding this comment.
They overlap in holding "a current state that changes", but they solve different problems. The design keeps FiniteStateMachine out of scope on purpose (§2: it stays a general utility for animation controllers and AI).
FiniteStateMachine |
GameState |
|
|---|---|---|
| What changes the state | Its own transition table: predicates over an input, checked when you call update(input) |
A system calls set(next); any state can go to any other |
| When the change happens | Immediately, inside update |
At the start of the next tick, before every system, so the whole tick sees one state (DL-1) |
| Enter/exit | None; the caller compares before and after | entered/exited for one tick, plus exitGroup/enterGroup that run at the transition |
| Scheduling | None | inState/onEnter/onExit gate systems and groups |
| Entities | None | State-scoped entities are removed on the transition |
| How many | One per thing: each enemy's AI, each pilot panel | A few per game, top level |
Galactic Journey shows the gap. RunEcsComponent wraps a FiniteStateMachine of phases, then adds enteredPhase/leftPhase, request flags, "must be registered after" ordering, a loading phase and clear-run.system.ts on top. That extra layer is what GameState provides. Meanwhile pilot-panel-steps.ts uses the FSM the way it fits: a predicate-driven step machine on one component, where none of the scheduling applies.
Building GameState on the FSM would only reuse its currentState field. Its immediate, predicate-driven update is the behaviour this design avoids, so the transition table would go unused.
Bevy has the same split: States handles app-level flow and scheduling, while per-entity state machines for AI or animation live elsewhere (in crates such as seldom_state or big_brain). Unity is similar: Animator state machines on one side, scene and game-mode flow on the other.
If you'd rather have only one concept, there are two options:
- Rename for clarity: for example, call this
AppState/createAppStateso it reads as top-level flow. - Narrow the FSM's docs: state that it's for per-entity logic.
I'd keep both and do the docs line. Your call.
Generated by Claude Code
…j6xxo Brings in explicit font atlas URLs (#713) and HDR colors (#710). The game states demo loads the default font atlas from the package, as the other demos now do, since static/fonts is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
…o claude/serene-mendel-1j6xxo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
…ection aside Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
…e, drop round-specific wording Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
| Gate a whole system group the same way, with `addSystemGroup`'s `runIf`. | ||
| See [System](../ecs/system.md#run-conditions) for how run conditions work. | ||
|
|
||
| ## Setting up and tearing down a state |
There was a problem hiding this comment.
Is it the state it's self that requires setup and teardown?
There was a problem hiding this comment.
No. The state is just a value, and nothing needs to set it up. What runs on a transition is game work: systems that should run once when the game enters or leaves a state, such as spawning the player when playing is entered.
1984ba8 (pushing now) rewords the guide, the GameState JSDoc and the world.md note to match:
- The heading is now "Running systems when a state is entered or left".
- The intro says some work happens only when the game enters or leaves a state.
- The enter group's description says "what its systems create exists before any other system runs" instead of "the new state is set up".
Generated by Claude Code
…ons, not setting up a state Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
| input in an ordinary system that calls `set`, as in the demo, and the | ||
| transition follows on the next tick. | ||
|
|
||
| ## Several worlds |
There was a problem hiding this comment.
Worlds are separate concepts, not relevant to this piece of documentation.
There was a problem hiding this comment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
…al documentation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
…rence Applies the document-feature skill to the game states guide, the state-scoped entities guide, the run conditions section of the System guide, the first group section of the World guide, and the GameState and firstSystemGroup JSDoc. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
| @@ -1,165 +1,158 @@ | |||
| --- | |||
| name: document-feature | |||
| description: Write or update a conceptual guide page in documentation-site/docs/docs for a new or changed Forge feature, focused on practical usage (common use cases, gotchas, performance notes, code smells to avoid) rather than restating the API surface. Also makes sure the public API has JSDoc for the auto-generated API reference. Use when a component, system, class, or module has been added or changed and needs user-facing documentation. | |||
There was a problem hiding this comment.
I'm still not happy with this skill.
This very much is "guide page", we have a separate API reference the details each class, function, parameter, etc. The purpose of these docs is to document a complete feature. A user will consult these docs first to understand the feature and once they understand the basic usage, they can/will then consult the API reference to cater for their specific use case.
When we want to build a top down shooter, we may need to know how a raycast works, but knowing that raycasts can detect all object in a line vs a single one, or that there can be a max distance set, are details left for the API reference.
Unity has a "user manual" and "scripting API" for this distinction.
Take a look at: https://docs.unity3d.com/Manual/CamerasOverview.html and https://docs.unity3d.com/Manual/class-Camera.html
I have no issue if you include theoretical use cases, but don't assume that your example is THE use case.
The documentation is forge that is of high quality are: ECS and Input.
The rest are pretty poor, take events for example - This is the document outline for "Creating custom events"

Can you as a user, find what you're looking for when creating a custom event? The 2 main types of events aren't even listed (void and parameterized). Raising an event should be in that outline, listening for an event should be in that outline, registering and deregistering an even should be in that outline.
There was a problem hiding this comment.
Rewritten in 1d32a3e around the manual-vs-reference split:
- Guides vs reference: a guide explains a complete feature top-down: what it is, the concepts it's made of, and how to do each basic task. Option-level detail (all hits vs first hit, max distance, defaults) is left to the API reference. The skill also makes sure the JSDoc covers that detail. The exception is a default that breaks the obvious usage, like
noResetininput/actions.md. - Outline first: one heading per concept or task, named after it, so a reader finds what they need from the outline alone. Generic headings are banned ("Worked example", "Gotchas", "Common mistakes", "Performance notes", "Notes", "See also"). Caveats go inside the task's section as an admonition. Your events outline (Event types / Creating / Raising / Listening / Removing a listener) is the example.
- Use cases: can be named as possibilities, but not as the use case, and no single game runs through a page.
- Earlier rules kept: isolation, literal wording, and the true-to-the-code check.
- Models in this repo:
ecs/world.md,ecs/entity.mdandinput/actions.mdare named as the pages to follow, andevents/custom-events.mdas the outline to avoid.
The game states guide is rewritten to it as one page whose outline is the feature's tasks:
- Creating a game state
- Changing state
- Running systems in some states
- Running systems when a state is entered or left
- Removing entities when a state changes
The separate state-scoped entities page is folded in, and option defaults are left to the reference.
I couldn't open the Unity pages from this environment (its network policy blocks docs.unity3d.com), so the skill is based on the split you described.
I haven't touched events or the other weak guides in this PR. Want them rewritten to the skill in a follow-up?
Generated by Claude Code
…ber detail to the API reference The skill now separates guides (the user manual) from the generated API reference, requires an outline of headings named after the feature's concepts and tasks, and bans generic headings such as "Worked example", "Gotchas" and "Common mistakes". The game states guide is rewritten to it as a single page; the separate state-scoped entities page is folded in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
| Removal happens between `exitGroup` and `enterGroup`, so `onExit` systems | ||
| can still read the entity. | ||
|
|
||
| :::note |
There was a problem hiding this comment.
What does this have to do with the feature being documented? This is standard behaviour for the Parenting feature.
Remove a parent and children go with it, remove a child and the parent/siblings stay.
Game States are one mechanism that removes an entity from the world, there are many other ways to remove enemies from the world that would follow the same rule.
Why is this documentation in the game state feature?
There was a problem hiding this comment.
This is almost actively shit advice, because now users will add this component to every entity when they could just add it to the parent
There was a problem hiding this comment.
Agreed, it doesn't belong here. 08c571e (pushing now) removes the note. A game state is one of many ways an entity gets removed, and what removal does is the World's and parenting's behavior. I also added the rule to the skill: behavior that belongs to another feature is documented once, in that feature's guide, even when this feature triggers it.
One factual point, since the old note was answering it: on dev today, removing a parent does not remove its children. removeEntity removes only that entity; design/hierarchy-removal.md is the design for "children go with it", and it isn't implemented yet. ecs/world.md's "Removing an entity" section says so ("Entities parented to it aren't removed with it"). That sentence is in the right guide; it changes when hierarchy removal ships.
Generated by Claude Code
There was a problem hiding this comment.
Removed in 08c571e, along with the rest of that note.
The root cause is outside this PR. On dev, removeEntity doesn't remove children, so scoping only the parent leaves the children in the world today, whether the removal comes from a game state or anything else. Telling users to scope every child was a workaround for that gap, and it was the wrong place for it. The fix is design/hierarchy-removal.md: removeEntity removes descendants, and you put the component on the parent only.
I can implement hierarchy removal as its own PR next if you want. Nothing in this PR needs to change for it: scoped removal already goes through world.removeEntity, so it picks up descendants automatically once that ships.
Generated by Claude Code
The game states guide no longer describes what happens to a removed entity's children; that's removal and parenting behavior, documented where those features are. The document-feature skill gains the rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
Summary
Implements
design/game-states.md(Phases 1 and 2).Phase 1: scheduling (
src/ecs)RunConditiontype, andrunIfonaddSystemandaddSystemGroup. A group's condition is checked once, just before it runs. Then each of its systems' conditions is checked just before that system runs. A skipped system isn't queried, andcleanupis unchanged.world.firstSystemGroupthat runs before every other group. Ordering a groupbeforeit throws.registerInputs'input-update. That's because group order without edges falls back to insertion order. So a group orderedafterthe first group, or after another group that is, joins the start of the tick. The world orders it before every other group, including ones added earlier or later. Ordering a start-of-tick group after one that isn't throws, and so does the reverse. This is the layering Bevy gets from its main schedule order, built on the existing group graph rather than on-demand schedules. As a result,onEnter/onExitsystems see the previous tick's input. The guide documents this.Phase 2:
@forge-game-engine/forge/states(new module)createGameState(world, initial)returns aGameStatewithcurrent,entered,exited,exitGroup,enterGroupandset.setis applied in the first group at the start of the next tick, and the lastsetwins.current/entered/exited, and it isn't exported.inState,onEnterandonExitrun conditions.addStateScopedComponent(world, entity, { state, removeOnExit, removeOnEnter })attaches the scope; it throws if both lists are empty. Scoped entities are removed in their own start-of-tick group, which runs between the exit and enter groups.world.removeEntity, so it removes the scoped entity only. Descendants will come along oncedesign/hierarchy-removal.mdships. Until then, the guide says to give children their own scope.Docs and demo
ecs/system.mdandecs/world.md. There's a new Game States guide (states/).demos/game-statesdemo, a "Star Catcher" game with menu, playing and game-over states. No system checks the state, and nothing cleans up a round by hand.Related issue(s)
Implements
design/game-states.md. Migrating the Galactic Journey demo (§9) has to wait for a release, because it depends on the published npm package.Verification checklist
npm run check-typespasses with 0 errorsnpm testpasses (1829 tests, plus the new ones)npm run lintpasses with 0 errorsnpm run cspellpasses with 0 errorsnpm run check-exportspassesindex.ts(and/src/index.ts/package.jsonexportsif it's a new module)/documentation-site/docs/docsis updated if this change affects documented behavior/documentation-site/src/pages/demos, the demo has been updated and verified. I rannpm run build, then the docs site'stypecheckandbuild. I also loaded the new demo in Chromium and went through menu → play → game over → menu → play.Changelog
## [Unreleased]inCHANGELOG.md🤖 Generated with Claude Code
https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
Generated by Claude Code