Skip to content

feat(states): add game states, run conditions and state-scoped entities - #711

Merged
stormmuller merged 20 commits into
devfrom
claude/serene-mendel-1j6xxo
Oct 6, 2026
Merged

stormmuller merged 20 commits into
devfrom
claude/serene-mendel-1j6xxo

Conversation

@stormmuller

Copy link
Copy Markdown
Member

Summary

Implements design/game-states.md (Phases 1 and 2).

Phase 1: scheduling (src/ecs)

  • RunCondition type, and runIf on addSystem and addSystemGroup. 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, and cleanup is unchanged.
  • A built-in world.firstSystemGroup that runs before every other group. Ordering a group before it throws.
  • Deviation from the design, recorded in §4.2/DL-2: "every group runs after first" doesn't put a state's exit and enter groups ahead of a group added earlier, such as registerInputs' input-update. That's because group order without edges falls back to insertion order. 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 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/onExit systems see the previous tick's input. The guide documents this.

Phase 2: @forge-game-engine/forge/states (new module)

  • createGameState(world, initial) returns a GameState with current, entered, exited, exitGroup, enterGroup and set.
    • A transition requested with set is applied in the first group at the start of the next tick, and the last set wins.
    • The initial state is entered on the first tick.
    • Setting the current state again re-enters it.
    • The transition system is the only writer of current/entered/exited, and it isn't exported.
  • inState, onEnter and onExit run 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.
  • Scoped removal calls world.removeEntity, so it removes the scoped entity only. Descendants will come along once design/hierarchy-removal.md ships. Until then, the guide says to give children their own scope.

Docs and demo

  • Run conditions and the first group are covered in ecs/system.md and ecs/world.md. There's a new Game States guide (states/).
  • New demos/game-states demo, 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-types passes with 0 errors
  • npm test passes (1829 tests, plus the new ones)
  • npm run lint passes with 0 errors
  • npm run cspell passes with 0 errors
  • npm run check-exports passes
  • Any new/changed public API is exported from the module's index.ts (and /src/index.ts / package.json exports if it's a new module)
  • Documentation under /documentation-site/docs/docs is updated if this change affects documented behavior
  • If this change touches a module with a demo under /documentation-site/src/pages/demos, the demo has been updated and verified. I ran npm run build, then the docs site's typecheck and build. I also loaded the new demo in Chromium and went through menu → play → game over → menu → play.

Changelog

  • A bullet has been added under ## [Unreleased] in CHANGELOG.md

🤖 Generated with Claude Code

https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE


Generated by Claude Code

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

codecov Bot commented Oct 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

claude added 2 commits October 6, 2026 17:00
…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:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this idiomatic, does Bevy do this?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/OnEnter are what inState/onEnter/onExit mirror 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 get Time or a GameState through their factory.
  • No combinators. Bevy has .and(), .or() and not(). Here you write () => a(world) && !b(world).
  • One condition per system. Bevy ANDs several run_if calls 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this system will only run for a single tick?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/hasLeft calls in 9 files, each one querying the run entity first. With runIf, 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels very similar to the FSM already built into the engine?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/createAppState so 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

claude and others added 3 commits October 6, 2026 18:55
…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
Comment thread documentation-site/docs/docs/states/index.md Outdated
Comment thread documentation-site/docs/docs/states/index.md Outdated
Comment thread documentation-site/docs/docs/states/index.md Outdated
Comment thread documentation-site/docs/docs/states/index.md Outdated
Comment thread documentation-site/docs/docs/states/index.md Outdated
Comment thread documentation-site/docs/docs/states/index.md Outdated
Comment thread documentation-site/docs/docs/states/index.md Outdated
Comment thread documentation-site/docs/docs/states/index.md Outdated
Comment thread documentation-site/docs/docs/states/index.md Outdated
claude added 2 commits October 6, 2026 19:21
…e, drop round-specific wording

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE
Comment thread documentation-site/docs/docs/states/index.md Outdated
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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it the state it's self that requires setup and teardown?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worlds are separate concepts, not relevant to this piece of documentation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, b7909eb (pushing now) removes the "Several worlds" section.


Generated by Claude Code

claude added 3 commits October 6, 2026 19:23
…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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"
Image

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 noReset in input/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.md and input/actions.md are named as the pages to follow, and events/custom-events.md as 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@stormmuller
stormmuller enabled auto-merge (squash) October 6, 2026 20:14
@stormmuller
stormmuller merged commit 8572376 into dev Oct 6, 2026
12 checks passed
@stormmuller
stormmuller deleted the claude/serene-mendel-1j6xxo branch October 6, 2026 20:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants