diff --git a/design/angle-conventions.md b/design/angle-conventions.md
deleted file mode 100644
index 07b54009..00000000
--- a/design/angle-conventions.md
+++ /dev/null
@@ -1,276 +0,0 @@
-# Design: One Angle and Direction Convention
-
-| | |
-| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
-| **Status** | Implemented (both phases; open questions resolved as proposed) |
-| **Kind** | Defect |
-| **Found in** | Galactic Journey demo: `src/engine-flame/create-engine-flames.ts` (exhaust direction converted by hand, fixed at spawn) |
-| **Engine version at time of writing** | `0.25.8` |
-| **Related** | [`demo-findings.md`](./demo-findings.md) |
-
-## 0. Targeted modules
-
-| Path | Change | Notes |
-| ------------------------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------- |
-| `src/math/vector2.ts` | Modified | `Vec2.up` is `(0, 1)`, `Vec2.down` is `(0, -1)` |
-| `src/math/radians-to-vector.ts` | Modified | `(cos θ, sin θ)`: angle `0` is `+X`, the inverse of `vectorToRadians` |
-| `src/particles/components/particle-emitter.ts` | Modified | `directionRange` and `rotationRange` in radians, counter-clockwise, `0` along `+X` |
-| `src/particles/utilities/spawn-particle.ts` | Modified | Direction and spawn shape follow the emitter entity's world rotation; full-turn check with a tolerance |
-| `src/physics/colliders/terrain-collider.ts`, `src/rendering/terrain/create-terrain-mesh.ts` | Modified (Phase 2) | The terrain slab extends below the surface in a Y-up world |
-| `documentation-site/docs/docs/math/*.md`, `particles/`, `physics/terrain.md` | Modified | Y-up throughout; the forward axis stated; the "not inverses" caution and the y-down section are deleted |
-| `documentation-site/src/pages/demos/particles`, `car`, `rolling-ball` | Modified | Particle ranges in radians; the car's suspension axes; terrain workarounds removed |
-
----
-
-## 1. Summary
-
-Forge's world is Y-up, and its rotations are radians, positive
-counter-clockwise: `RotationEcsComponent`, `Vec2.rotate`, physics and the
-sprite renderer all agree. A handful of APIs don't:
-
-| API | Convention today |
-| ------------------------------------------- | ----------------------------------------------------------------------------------- |
-| `ParticleEmitter.directionRange` | Degrees, `0` is up, **clockwise** |
-| `ParticleEmitter.rotationRange` | **Degrees**, counter-clockwise |
-| `ParticleEmitter.rotationSpeedRange` | Radians per second, counter-clockwise |
-| `Vec2.up` / `Vec2.down` | `(0, -1)` / `(0, 1)`: **down** / **up** in the world |
-| `radiansToVector(θ)` | `(sin θ, -cos θ)`: `0` points **down** |
-| `vectorToRadians(v)` | `atan2(y, x)`: `0` points along `+X` |
-| `math/angles-and-rotation.md`, `vectors.md` | Say the y axis points down the screen; the turret sample's "above" offset is below |
-| `TerrainCollider`, `createTerrainMesh` | The solid slab grows toward **+Y**, under a surface that's above it in a Y-up world |
-
-On top of that, an emitter's direction and spawn shape ignore the
-rotation of the entity it's on: particles spawn at the entity's world
-position, but always in the same world direction. And both terrain docs
-demos work around the Y-down terrain slab by rotating the terrain entity
-by π and negating and reversing its points.
-
-The demo's engine flames show both problems. Each flame is a child of its
-ship, rotated to point backwards, and emits exhaust. The demo converts the
-flame's rotation by hand (`-radiansToDegrees(shipRotation +
-engine.rotation)`, with a comment explaining the two conventions), and
-does it once when the flame is created, so the exhaust direction would no
-longer match if the ship turned.
-
-This design picks the convention the rest of the engine already uses and
-applies it to the stragglers: radians, counter-clockwise, `0` along `+X`,
-Y-up. Emitters emit in their entity's frame.
-
----
-
-## 2. Scope
-
-### In scope
-
-- `Vec2.up`/`Vec2.down`, `radiansToVector`, `vectorToRadians`.
-- Particle emitter angle units and direction.
-- Emitter direction and spawn shape following the emitter entity's world
- rotation.
-- The math and particles guides, including which local axis is a
- sprite's "forward".
-- Phase 2: the terrain slab, if it's folded in here (open question 1).
-
-### Out of scope
-
-- **Emitter scale.** Spawn shapes don't scale with the entity's world
- scale. Nothing has asked for it yet.
-- **Local-space simulation** (particles that move with their emitter after
- spawning). Particles stay in world space, as today.
-- **The renderer's internal Y flips.** The sprite renderer negates
- position, rotation and pivot Y on the way to a Y-down projection. That's
- internal and invisible to callers (see
- [`camera-views.md`](./camera-views.md)'s scope).
-
----
-
-## 3. How established engines handle this
-
-- **Bevy** (Y-up, like Forge): angles are radians, counter-clockwise.
- `Vec2::from_angle(0)` is `(1, 0)`, and `Vec2::to_angle` is
- `atan2(y, x)`, its inverse. `Vec2::Y` is up.
-- **Godot 2D** (Y-down): `Vector2.from_angle(0)` is `(1, 0)` and
- `Vector2.angle()` is its inverse. Angles increase towards `+Y`, which is
- clockwise on a Y-down screen. Particle direction is a vector in the
- node's local space, so it turns with the node.
-- **Unity**: particle shapes and emission direction follow the particle
- system's transform; the simulation space (local or world) is a separate
- setting.
-
-Their vector helpers agree on angle `0` along `+X`, and their emitters
-emit relative to their own transform. Units differ (Unity's API is mostly
-degrees, and Godot's particle spread and angle are degrees); Forge uses
-radians because its own guide already says rotation is "radians
-everywhere".
-
----
-
-## 4. Design
-
-### 4.1 The convention
-
-One sentence, stated in the math guide and relied on everywhere: **angles
-are radians, positive turns `+X` towards `+Y` (counter-clockwise, since
-`+Y` is up), and angle `0` points along `+X`.**
-
-- `Vec2.up` is `(0, 1)` and `Vec2.down` is `(0, -1)`.
-- `radiansToVector(θ)` returns `(cos θ, sin θ)`, and
- `vectorToRadians(radiansToVector(θ))` is `θ` (normalized to `(-π, π]`).
-- A sprite's "forward" is the direction its art faces. The guide states
- the convention (art facing `+X` has forward `0`, so facing a direction is
- `rotation = vectorToRadians(direction)`) and shows the offset for art
- that faces up (`- Math.PI / 2` in a Y-up world). The guide's current
- `+ Math.PI / 2` is that offset with the wrong sign, not just the
- round-trip workaround, and its turret sample's offset to "above" the
- entity (`{ x: 0, y: -20 }`) is below it.
-
-### 4.2 Particle emitters
-
-- `directionRange`: radians, the convention above, measured in the
- emitter's frame. Default `{ min: 0, max: 2π }`.
-- `rotationRange`: radians. Default `{ min: 0, max: 0 }`. It stays a world
- rotation for the particle's sprite (not relative to the emitter), as in
- Godot's CPU particles.
-- `rotationSpeedRange`: unchanged (already radians per second).
-- When spawning, the sampled spawn-shape offset and direction are rotated
- by the emitter entity's `rotation.world` (`0` if it has no rotation).
- `emitOutward` uses the offset's angle in the emitter's frame, so it's
- rotated the same way.
-- `acceleration` and `getVelocityOffset` stay in world space: they model
- forces like gravity and wind, which don't turn with the emitter.
-- `emitParticleBurst` emits at a position with no entity, so its frame is
- the world's; a caller wanting a rotated burst passes the rotation, as
- Godot's `emit_particle` takes a transform.
-- A range spanning a full turn picks from the whole circle. In radians a
- computed span such as `π/2 ± π` can miss `2π` by a rounding step, so the
- check is `span >= 2π - ε`, not exact equality.
- _Implementation note:_ no check was needed. The old special case only
- existed because `(max - min) % 360` turned a full-turn span into `0`;
- picking uniformly from `[min, max]` already covers the whole circle for
- any full-turn span, so it was deleted rather than replaced.
-
-The demo's flame then sets `directionRange` to the backwards direction in
-the flame's own frame plus or minus the spread, once, and the exhaust
-follows the ship.
-
-### 4.3 Physics fallbacks and the car demo
-
-`applyExplosiveForce` and `detectCircleCircleCollision` fall back to
-`Vec2.up` when two centers coincide. Their code doesn't change; with the
-fix, the fallback points up, as the code intends.
-
-The car docs demo builds its suspension axes by rotating `Vec2.up`. A
-prismatic joint's axis is a line, so its sign doesn't change the joint,
-but the demo also spawns each wheel at `axis * -wheelSpawnDrop` from its
-anchor, and that offset flips. Today the wheels start above and inboard
-of their anchors, against what the demo's comments describe; after the
-fix they start below and outboard. The migration picks `Vec2.down` (keeps
-today's geometry) or `Vec2.up` with retuned spring rest lengths (matches
-the comments), checked in the browser. The `joints.md` samples need no
-edit: their axes slide up, as their prose already says.
-
-### 4.4 Terrain (Phase 2)
-
-`TerrainCollider` and `createTerrainMesh` treat the solid side of the
-surface as `+Y`, so a terrain is built upside down in a Y-up world, and
-both terrain docs demos rotate the entity by π and negate and reverse
-their points to get ground below the surface. The fix makes the slab
-extend toward `-Y`, and the demos drop the workaround.
-
----
-
-## 5. Phases
-
-### Phase 1: Convention fix
-
-| # | Task | Size |
-| --- | -------------------------------------------------------------------------------------------------------------------------------------- | ---- |
-| 1.1 | `Vec2.up`/`down`; `radiansToVector` as the inverse of `vectorToRadians`; tests | S |
-| 1.2 | Particle angle ranges in radians, counter-clockwise from `+X`; defaults; tests | S |
-| 1.3 | Rotate spawn offset and direction by the emitter's world rotation; tests | S |
-| 1.4 | Migrate the particles docs demo; the car demo's spawn offset per §4.3, checked in the browser | S |
-| 1.5 | Rewrite `math/angles-and-rotation.md` (convention, forward axis, turret sample) and the y-down section of `math/vectors.md`; changelog | S |
-
-**Definition of done:** every angle in the public API follows §4.1; the
-particles demos look as they did; a rotating emitter's particles leave in
-its facing direction; the car demo's wheels start where its comments say.
-
-### Phase 2: Terrain below its surface
-
-| # | Task | Size |
-| --- | -------------------------------------------------------------------------------------------------------- | ---- |
-| 2.1 | Terrain collider and mesh: the slab extends toward `-Y`; tests | M |
-| 2.2 | Car and rolling-ball terrains without the π rotation and negated points; `physics/terrain.md`; changelog | S |
-
-**Definition of done:** a terrain built from surface points in world
-coordinates has its ground below the surface, with no rotation.
-
----
-
-## 6. Decision log
-
-### DL-1: Angle `0` points along `+X`, not up
-
-**Options.** (a) `+X` (Bevy, Godot, `Math.atan2`). (b) Up, as particles
-and `radiansToVector` use today.
-
-**Decision: (a).**
-
-**Rationale.** It's what `vectorToRadians`, `Vec2.rotate` and the
-rotation matrices already assume, so only the stragglers change. (b)
-would need a quarter-turn offset in every conversion, which is the
-"not inverses" caution the guide has today.
-
-### DL-2: Particle angles in radians
-
-**Rationale.** Forge's guide already says rotation is "radians
-everywhere", and the emitter mixes the two today (`rotationRange` in
-degrees, `rotationSpeedRange` in radians). Callers who author in degrees
-use `degreesToRadians`, as they do for every other angle.
-
-### DL-3: Emitters emit in their entity's frame
-
-**Options.** (a) Direction and shape rotate with the entity. (b) Keep
-world space and let callers rotate the range.
-
-**Decision: (a).**
-
-**Rationale.** It's what Unity and Godot do, and it's the only way an
-emitter on a child entity (a flame, a muzzle, a thruster) can follow its
-parent without a system rewriting its range every frame. An emitter that
-should ignore rotation goes on an entity without one.
-
----
-
-## 7. Open questions
-
-1. **Fold the terrain fix in here, or give it its own design?** It's the
- same Y-up convention applied to one more API, but it touches physics
- and rendering rather than math and particles.
- - (a) Phase 2 of this design (proposed). (b) A separate design.
-2. **Should spawn shapes also scale with the entity's world scale?**
- Unity has a scaling-mode setting for this. Nothing in the demo or the
- docs demos needs it.
- - (a) Not now (proposed). (b) Scale with the entity.
-
----
-
-## 8. Testing considerations
-
-- `radiansToVector`/`vectorToRadians` round trip at the four axes and a
- few angles in between.
-- A full-turn range computed in radians (`π/2 ± π`) still covers the
- whole circle.
-- Spawning with a rotated emitter: velocity and spawn offset are rotated;
- `emitOutward` too; no rotation component behaves as rotation `0`.
-- The circle-collision test that expects `Vec2.up` as the fallback normal
- keeps passing with the new value; its geometry assertions are checked.
-
-## 9. Documentation and demo follow-up
-
-- `math/angles-and-rotation.md`: states §4.1 and the forward-axis
- convention; the round-trip caution goes; the facing example uses the
- correct offset for art that faces up.
-- `math/vectors.md`: the y-down section becomes a Y-up one.
-- `particles/emitters.md`: radians, and emitters following their entity.
-- Demo: `create-engine-flames.ts` sets the exhaust range in the flame's
- frame; the conversion and its comment are deleted.
diff --git a/design/audio-mixer.md b/design/audio-mixer.md
index d69a860a..b01e999a 100644
--- a/design/audio-mixer.md
+++ b/design/audio-mixer.md
@@ -6,7 +6,7 @@
| **Kind** | Missing feature |
| **Found in** | Galactic Journey demo: `src/audio/audio-mixer.ts`, `src/speed/create-speed-sounds.ts`, `src/explosions/create-explosions.ts`, `src/gun/gun.system.ts`, `src/enemy/enemy.system.ts`, `src/music/create-music.ts`, `src/main-menu/create-settings-panel.ts` |
| **Engine version at time of writing** | `0.25.8` |
-| **Related** | [`demo-findings.md`](./demo-findings.md), [`persistent-preferences.md`](./persistent-preferences.md), [`generational-entity-ids.md`](./generational-entity-ids.md) |
+| **Related** | [`demo-findings.md`](./demo-findings.md), [`persistent-preferences.md`](./persistent-preferences.md) |
## 0. Targeted modules
diff --git a/design/camera-views.md b/design/camera-views.md
deleted file mode 100644
index 60d1c299..00000000
--- a/design/camera-views.md
+++ /dev/null
@@ -1,307 +0,0 @@
-# Design: Camera Views and View Culling
-
-| | |
-| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Status** | Draft, for review |
-| **Kind** | Feature and defect |
-| **Found in** | Galactic Journey demo: `src/constants.ts`, `calculateVisibleWorldSize` called in 16 files, `src/speed/create-hud.ts` and `src/health/health.system.ts` (`hudUnitsPerWorldUnit`), `src/journey/planet.system.ts` and `finish-line.system.ts` (hidden by hand while off screen), `src/shockwave/refraction.system.ts` |
-| **Engine version at time of writing** | `0.25.8` |
-| **Related** | [`render-resolution.md`](./render-resolution.md), [`post-processing-effects.md`](./post-processing-effects.md), [`sprite-textures.md`](./sprite-textures.md) |
-
-## 0. Targeted modules
-
-| Path | Change | Notes |
-| --------------------------------------------------------------------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------- |
-| `src/rendering/camera-view.ts` | **New** | `computeCameraView` and `getCameraView`: what a camera sees, and conversions between its world and its viewport |
-| `src/rendering/transforms/*` | Removed | `worldToScreenSpace`, `screenToWorldSpace`, `canvasToWorldSpace` |
-| `src/rendering/utilities/calculate-visible-world-size.ts`, `calculate-pixels-per-unit.ts` | Removed / internal | Replaced by the view |
-| `src/rendering/systems/render-system.ts` | Modified | Builds each camera's projection from its view (and its destination's size); skips sprites and text outside it |
-| `src/rendering/terrain/create-terrain-render-ecs-system.ts` | Modified | Builds its projection from the view instead of its own copy of the maths |
-| `src/ui/systems/ui-layout-system.ts`, `ui-safe-area-system.ts`, `src/ui/utilities/resolve-canvas-pointer-position.ts` | Modified | Use the view for pixels per unit and pointer conversion |
-| `src/rendering/components/camera-component.ts` | Modified | `scissorRect`, which nothing reads, is removed |
-| `documentation-site/docs/docs/rendering/world-units-and-cameras.md`, `physics/forces.md`, `ecs/game.md`, demos, e2e | Modified | One way to convert positions |
-
----
-
-## 1. Summary
-
-A camera's view (the world area it shows) is computed inside the render
-system from `verticalWorldUnits`, `zoom`, the camera's world position and
-the canvas size. The terrain system repeats that maths for its own
-projection, and the UI computes pixels per unit itself. Game code that
-needs the same information recomputes it from pieces:
-
-- **Visible size.** `calculateVisibleWorldSize(width, height,
-verticalWorldUnits)` needs the camera's `verticalWorldUnits`, but game
- systems don't have the camera, so the demo copies the default (`10`)
- into its own constant, noting that Forge doesn't export it. It calls
- `calculateVisibleWorldSize` in 16 files. The function ignores `zoom` and
- the camera's position, so it's only right for a camera at the origin
- with a zoom of `1`.
-- **Converting between cameras.** The demo's speed HUD and health bars are
- drawn by a second camera whose units are 1080p pixels. To place a ring
- around the ship, it multiplies the ship's position by
- `hudUnitsPerWorldUnit`, a constant derived from both cameras' settings,
- again assuming both sit at the origin with a zoom of `1`.
-- **Pointer conversion.** Every docs demo that reads the mouse in world
- space calls `calculatePixelsPerUnit` and `screenToWorldSpace` with the
- CSS canvas size, a copy of its camera's `verticalWorldUnits`, and a zero
- position and zoom of `1`. The UI's `resolveCanvasPointerPosition` does
- the same with the real camera.
-- **`worldToScreenSpace` is wrong.** It doesn't flip Y from the Y-up world
- to the Y-down page, so it isn't the inverse of `screenToWorldSpace`,
- which does.
-- **No culling.** The render system draws every enabled sprite, on screen
- or not. The demo's planet and finish line wait off screen for most of a
- run, so their systems hide them until they reach the screen edge.
-- **A camera rendering into a target of a different shape** is projected
- with the canvas's size anyway, so it renders stretched.
-
-This design gives cameras a view that game code can ask for, with
-conversions to and from the viewport, derives it from where the camera
-actually draws, and makes the render system cull against it.
-
----
-
-## 2. Scope
-
-### In scope
-
-- `computeCameraView` (pure, over a camera's components and its
- destination size) and `getCameraView(world, camera, renderContext)` (the
- entity lookup) returning the camera's visible bounds, size, scale, and
- conversions between world and viewport.
-- The render, terrain and UI systems using it.
-- Removing the free conversion functions, `calculateVisibleWorldSize`,
- and the unused `scissorRect`.
-- Culling sprites and text whose bounds are outside the view.
-
-### Out of scope
-
-- **Camera rotation.** Cameras don't rotate today.
-- **Camera follow, shake and other camera controllers.** Game feel, and
- the demo's shake works without engine help.
-- **The camera's built-in pan and zoom input** (`zoomInput`, `panInput`).
- Unchanged here.
-- **Render target sizing.** See [`render-resolution.md`](./render-resolution.md).
-- **The renderer's internal Y-down space.** The sprite renderer negates
- position, rotation and pivot Y on the way to a Y-down projection, and
- images are uploaded top row first, which that space relies on. It's
- invisible to callers and not why `worldToScreenSpace` is wrong; changing
- it would flip every image unless uploads or the quad's texture
- coordinates changed too, and it would change the contract custom vertex
- shaders rely on. If it's ever worth doing, it needs its own design.
-
----
-
-## 3. How established engines handle this
-
-- **Unity**: `Camera.orthographicSize` and `aspect` give the view;
- `WorldToScreenPoint`, `ScreenToWorldPoint`, `WorldToViewportPoint` and
- `ViewportToWorldPoint` convert (Unity's "viewport" is normalized,
- bottom-left). Renderers outside the frustum are culled.
-- **Godot**: `Viewport.get_visible_rect()` and the canvas transform give
- the view; `get_global_mouse_position()` converts the pointer. Canvas items
- outside the viewport are culled.
-- **Bevy**: `Camera::world_to_viewport` and `viewport_to_world_2d` convert
- in logical pixels from the top-left, using the camera's computed
- projection, which Bevy stores because it depends on the size of the
- camera's render target. `VisibilitySystems` cull entities whose `Aabb` is
- outside the view frustum; `NoFrustumCulling` opts an entity out.
-
-The camera is the object that answers "what do I see and where is this
-point on screen", derived from where it draws, and the renderer culls
-against the same answer. Forge's naming follows Bevy's (viewport positions
-in CSS pixels from the top-left).
-
----
-
-## 4. Design
-
-### 4.1 The view
-
-```ts
-interface CameraView {
- /** The world-space area the camera shows. */
- readonly bounds: Rect;
- /** `bounds`' size, in world units. */
- readonly size: Vector2;
- /** CSS pixels per world unit on the canvas. */
- readonly pixelsPerUnit: number;
- /** A world position as CSS pixels from the canvas's top-left (Y-down, like pointer positions). */
- worldToViewport(worldPosition: Vector2): Vector2;
- /** The inverse of `worldToViewport`. */
- viewportToWorld(viewportPosition: Vector2): Vector2;
-}
-
-/** Pure: for systems that already have the camera's components. */
-function computeCameraView(
- camera: CameraEcsComponent,
- position: PositionEcsComponent,
- renderContext: RenderContext,
-): CameraView;
-
-/** Looks the camera's components up; for game code. */
-function getCameraView(
- world: EcsWorld,
- camera: number,
- renderContext: RenderContext,
-): CameraView;
-```
-
-The view's aspect comes from the camera's destination: its render
-target's size if it has one, the canvas otherwise. Its scale comes from
-`zoom` and `verticalWorldUnits`, and its center from `position.world`.
-Viewport positions are in CSS pixels of the canvas, because that's what
-pointer input, the DOM and the safe area use (see "Device Pixels vs. CSS
-Pixels" in `AGENTS.md`); `worldToViewport` flips Y, which is the step
-`worldToScreenSpace` misses.
-
-It's computed whenever it's called, so nothing is stored and nothing owns
-it. It reflects the camera's state when called: a system that runs before
-the transform system or the UI layout system (which writes UI cameras'
-`verticalWorldUnits`) sees last tick's view, like any reader of
-`position.world`. `getCameraView` throws if `camera` has no
-`CameraEcsComponent` or position.
-
-### 4.2 Who uses it
-
-- The render system builds each camera's projection from its view, using
- the destination's size, so a camera rendering into a target of another
- shape is no longer stretched.
-- The terrain system builds its projection from the same view instead of
- repeating the maths.
-- The UI layout and safe-area systems and `resolveCanvasPointerPosition`
- take pixels per unit and pointer conversion from it.
-
-That leaves one place where a view is derived, so `calculatePixelsPerUnit`
-becomes internal.
-
-### 4.3 What it replaces
-
-| Today | With views |
-| ------------------------------------------------------- | -------------------------------------------------- |
-| `calculateVisibleWorldSize(w, h, verticalWorldUnits)` | `getCameraView(world, camera, renderContext).size` |
-| `screenToWorldSpace(p, position, zoom, w, h, ppu)` | `view.viewportToWorld(p)` |
-| `worldToScreenSpace(...)` | `view.worldToViewport(p)` |
-| World position on camera A to camera B (the demo's HUD) | `viewB.viewportToWorld(viewA.worldToViewport(p))` |
-
-`calculateVisibleWorldSize`, `worldToScreenSpace`, `screenToWorldSpace` and
-`canvasToWorldSpace` are removed. So is `CameraEcsComponent.scissorRect`,
-which nothing reads.
-
-### 4.4 View culling
-
-The render system skips a sprite whose world bounds don't overlap the
-camera's `bounds`, before building its draw command:
-
-- **Sprites**: the quad's bounds from size, pivot, rotation and scale.
- Nine-slice sprites use their whole rect. With
- [`sprite-textures.md`](./sprite-textures.md), every sprite material uses
- `sprite.vert`, so the quad is what's drawn.
-- **Text**: the mesh's glyph bounds, placed at the text's world position
- and widened by its outline and shadow, which extend past the glyph
- quads.
-- **Terrain** isn't culled in this design.
-
-Systems that hide sprites only because they're off screen (the demo's
-planet and finish line) delete that code.
-
----
-
-## 5. Phases
-
-### Phase 1: Camera views
-
-| # | Task | Size |
-| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- |
-| 1.1 | `computeCameraView`, `getCameraView`: bounds, size, scale and conversions from the camera's destination; tests including zoom, position, a target of another shape, HiDPI | M |
-| 1.2 | Render, terrain, UI layout, safe-area and pointer resolution use it; `calculatePixelsPerUnit` internal | M |
-| 1.3 | Remove the conversion functions, `calculateVisibleWorldSize` and `scissorRect`; migrate docs demos (31 files), Forge's own `/demo`, `physics/forces.md`, `ecs/game.md` and the `high-dpi-canvas` e2e scene | M |
-| 1.4 | `world-units-and-cameras.md` rewritten around views; changelog under `#### Changed` | S |
-
-**Definition of done:** no public conversion function outside the view;
-the docs demos read the pointer through their camera's view; a camera
-rendering into a non-canvas-shaped target isn't stretched.
-
-### Phase 2: View culling
-
-| # | Task | Size |
-| --- | ------------------------------------------------------------------------------------------ | ---- |
-| 2.1 | Sprite and text bounds (text including outline and shadow); skip commands outside the view | M |
-| 2.2 | Tests: rotated and scaled sprites at the edges; nine-slice; text with effects | S |
-| 2.3 | Stress-test demo before and after; changelog under `#### Changed` | S |
-
-**Definition of done:** sprites and text outside every camera's view
-produce no instances, and nothing on screen changes.
-
----
-
-## 6. Decision log
-
-### DL-1: Computed on demand, not stored on the camera
-
-**Options.** (a) Functions computing the view from components and the
-destination size. (b) A `view` field on `CameraEcsComponent`, written by a
-camera system each frame.
-
-**Decision: (a).**
-
-**Rationale.** The computation is a few multiplications, and computing it
-on demand means it can't disagree with the camera's components. (b) adds
-a system that has to run after anything moving the camera and before
-anything reading the view, and a field with an owner to respect. What
-Bevy's stored values teach is the dependency, not the storage: the view
-depends on the camera's destination, so (a) reads it.
-
-### DL-2: Viewport positions in CSS pixels
-
-**Rationale.** Every consumer of a viewport position in Forge is in CSS
-pixels: pointer input, the DOM, safe-area insets, UI sized in screen
-pixels. Device pixels only matter to GL, which the render system handles.
-
-### DL-3: Culling in the render system, not a visibility component
-
-**Options.** (a) The render system tests bounds while building commands.
-(b) A visibility system writing a per-entity "visible in view" component
-(Bevy's `ViewVisibility`).
-
-**Decision: (a).**
-
-**Rationale.** Only rendering uses the result today. (b) is the right
-shape once something else needs it (audio, AI), and can be extracted then.
-
-### DL-4: Two functions, not overloads
-
-**Rationale.** Systems that already query cameras (render, terrain) call
-the pure `computeCameraView` and skip the lookups; game code calls
-`getCameraView` with an entity. Two named functions, rather than one
-overloaded one, follow the "no overloads" rule.
-
----
-
-## 7. Open questions
-
-None.
-
----
-
-## 8. Testing considerations
-
-- View: bounds at zoom `1` and `2`, a moved camera, a non-square canvas, a
- camera with a render target of a different shape, and `pixelRatio` `2`
- (the CSS size is what counts); `viewportToWorld(worldToViewport(p))`
- round trip.
-- Culling: a sprite just outside the edge is skipped, one overlapping it
- by a pixel is drawn, rotation and scale are respected, text with a
- shadow just off screen still draws its visible part.
-- e2e: `camera-pan-zoom` asserts the pointer-to-world conversion through
- the view.
-
-## 9. Documentation and demo follow-up
-
-- `rendering/world-units-and-cameras.md`: "Converting screen and world
- positions" uses the view; culling is mentioned.
-- Demo: `constants.ts`'s mirrored `verticalWorldUnits`, the
- `calculateVisibleWorldSize` calls, `hudUnitsPerWorldUnit` and the planet
- and finish line hiding are replaced or deleted.
diff --git a/design/collision-events.md b/design/collision-events.md
deleted file mode 100644
index 90e47dbe..00000000
--- a/design/collision-events.md
+++ /dev/null
@@ -1,297 +0,0 @@
-# Design: Collision Filtering, Sensors and Per-Entity Contacts
-
-| | |
-| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Status** | Draft, for review |
-| **Kind** | Feature |
-| **Found in** | Galactic Journey demo: `src/asteroids/asteroid-collision.system.ts`, `src/enemy/enemy-collision.system.ts`, `src/grabber/grabber-collision.system.ts`, `src/power-ups/power-up-collision.system.ts`, `src/systems/register-collision-systems.ts`, every `addAabbComponent` call |
-| **Engine version at time of writing** | `0.25.8` |
-| **Related** | [#559](https://github.com/Forge-Game-Engine/Forge/issues/559) (sensors), [#560](https://github.com/Forge-Game-Engine/Forge/issues/560) (layers and masks), [`generational-entity-ids.md`](./generational-entity-ids.md) |
-
-## 0. Targeted modules
-
-| Path | Change | Notes |
-| ------------------------------------------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `src/physics/components/collider-component.ts` | Modified | `category`, `mask`, `sensor`; `aabb` as an output field |
-| `src/physics/components/aabb-component.ts` | Removed | The AABB moves onto the collider |
-| `src/physics/components/contacts-component.ts` | **New** | `ContactsEcsComponent`: who this entity touches, who started and who stopped this tick |
-| `src/physics/systems/broad-phase-system.ts` | Modified | Skips pairs whose categories and masks don't match |
-| `src/physics/systems/narrow-phase-system.ts` | Modified | Fills `ContactsEcsComponent`s; writes sensor overlaps only there, never as manifolds |
-| `src/physics/raycast/raycast.ts` | Modified | Reads the collider's `aabb`; filters by mask; skips sensors unless asked |
-| `documentation-site/docs/docs/physics/`, docs demos, `/demo` | Modified | Filtering, sensors, contacts; `addAabbComponent` calls removed (28 in the docs site, 2 in `/demo`, 7 in `/src` tests); five manifold scanners move to contacts |
-
----
-
-## 1. Summary
-
-The physics module finds collisions and writes them, as manifolds, into
-one array for the whole world. Game code that wants to know what an entity
-touched reads that array. The demo has four systems that each loop over
-their own entities and, for each, scan every manifold of the tick for one
-involving it, then check the other entity's components to see what it hit:
-
-```ts
-for (let i = 0; i < entities.length; i++) {
- for (const { entityA, entityB } of collisionManifolds) {
- if (entityA !== asteroidEntity && entityB !== asteroidEntity) continue;
- const otherEntity = entityA === asteroidEntity ? entityB : entityA;
- if (world.getComponent(otherEntity, bulletId)) { /* ... */ }
-```
-
-Around that:
-
-- **Everything is tested against everything.** Bullets against bullets,
- asteroids against asteroids: the broad phase has no way to know which
- pairs matter, so the narrow phase runs on all of them and the game
- ignores most of the results.
-- **There are no sensors.** The demo never registers the resolution
- system, since nothing should bounce. A game that wants physical bodies
- and pickups or triggers in the same world can't have both.
-- **An entity is in the broad phase only if it has an
- `AabbEcsComponent`**, which callers add by hand next to every collider
- (28 calls in the docs site, 2 in Forge's own `/demo`, one per collider in
- the Galactic Journey demo). Leaving it out makes the collider silently do
- nothing.
-- **"What did I touch?" has no per-entity answer**, so every consumer is
- quadratic in its entity count times the tick's collisions, and repeats
- the "which end is me" dance. Forge's own docs demos do the same scan
- (the space shooter, brick breaker, rolling ball and car) and so does
- `/demo/src/game.ts`.
-
-This design adds the three things every 2D physics engine has: collision
-categories and masks, sensor colliders, and per-entity contact lists with
-begin and end.
-
----
-
-## 2. Scope
-
-### In scope
-
-- `category`/`mask` filtering in the broad phase (#560).
-- `sensor` colliders: detected, never resolved (#559).
-- `ContactsEcsComponent`: current, started and ended contacts per entity,
- opt-in.
-- The AABB as an output field of the collider; `AabbEcsComponent` removed.
-
-### Out of scope
-
-- **A faster broad phase** (sweep and prune, a spatial hash). Filtering
- saves the narrow phase work; the all-pairs AABB loop is its own change.
-- **Contact callbacks.** Forge communicates through components that
- systems read; a system iterating its entities' contacts replaces a
- callback.
-- **Group indices** (Box2D's "never collide within this group"). Masks
- cover the demo.
-- **Bounds that are current after integration.** `collider.aabb` is
- computed by the broad phase, before integration moves bodies; a raycast
- later in the tick tests those bounds, and a collider added this tick has
- none until the next broad phase. Same as `AabbEcsComponent` today; the
- guide says so.
-
----
-
-## 3. How established engines handle this
-
-- **Box2D v3**: `b2Filter` with `categoryBits` and `maskBits`; two shapes
- collide when each one's category is in the other's mask. Sensor shapes
- report begin/end overlap events, computed separately from contacts, and
- never get contact points or forces. Since v3.1, contact and sensor
- events are opt-in per shape.
-- **Godot**: `collision_layer`/`collision_mask` bitmasks; `Area2D`
- detects overlaps without collision response and emits `body_entered`/
- `body_exited`; `get_overlapping_bodies()` lists current ones.
-- **Avian (Bevy)**: `CollisionLayers` (memberships and filters); a
- `Sensor` component; `CollidingEntities`, a component listing who an
- entity currently touches; `CollisionStart`/`CollisionEnd` events
- (opt-in with `CollisionEventsEnabled`).
-- **Rapier**: interaction groups with a symmetric (`And`) test by default
- and a one-sided (`Or`) mode, which is Godot's model.
-- **Unity**: layers and the collision matrix; `isTrigger` colliders;
- enter/stay/exit callbacks.
-
----
-
-## 4. Design
-
-### 4.1 Collider fields
-
-```ts
-interface ColliderDefaultedOptions {
- friction: number;
- restitution: number;
- /** Bits this collider belongs to (32 bits, as JavaScript's bitwise operators allow). Default `1`. */
- category: number;
- /** Bits of the categories it collides with. Default every bit. */
- mask: number;
- /** Detected and reported, never resolved. Default `false`. */
- sensor: boolean;
-}
-
-interface ColliderEcsComponent {
- // ...
- /** World-space bounds, written by the broad phase each tick. Output only. */
- readonly aabb: Aabb;
-}
-```
-
-Two colliders are tested only if `(a.category & b.mask) !== 0` and
-`(b.category & a.mask) !== 0`, Box2D's rule. The broad phase checks this
-before the AABB test.
-
-The broad phase queries `[position, collider]` and writes `collider.aabb`,
-its only writer; `aabb` isn't accepted by `addColliderComponent`'s
-options. `addAabbComponent` and `AabbEcsComponent` are removed; raycasts
-read `collider.aabb`. `continuous-collision-detection.md` plans to reuse
-`AabbEcsComponent`; whichever design lands second reads `collider.aabb`
-instead.
-
-### 4.2 Sensors
-
-When either collider of an overlapping pair is a sensor, the narrow phase
-reports the overlap through contacts only (§4.3) and adds nothing to
-`collisionManifolds`, as Box2D keeps sensor overlaps apart from contacts.
-`collisionManifolds` keeps one meaning, contacts to resolve, and the
-resolution system needs no change.
-
-Raycasts skip sensors unless the ray asks for them (Godot's
-`collide_with_areas` defaults the same way), since a ray aimed at walls
-shouldn't stop at a trigger zone.
-
-### 4.3 Contacts
-
-```ts
-interface ContactsEcsComponent {
- /** Entities this one is touching this tick. */
- readonly touching: readonly Entity[];
- /** Entities it started touching this tick. */
- readonly started: readonly Entity[];
- /** Entities it stopped touching this tick (some may no longer exist). */
- readonly ended: readonly Entity[];
-}
-```
-
-An entity with a collider and a `ContactsEcsComponent` gets it filled by
-the narrow phase, its only writer, every tick. The narrow phase queries
-`[contactsId]` so it visits every such component, including one whose
-entity just lost its collider (its contacts then end). Entities without
-one pay nothing. `touching` lists each other entity once, though a pair
-can produce several manifolds (one per terrain edge it touches). `started`
-and `ended` come from comparing with the previous tick's `touching`.
-
-The demo's asteroid system becomes:
-
-```ts
-for (let i = 0; i < entities.length; i++) {
- for (const other of contacts[i].touching) {
- if (!world.isAlive(other)) continue;
- if (world.getComponent(other, bulletId)) {
- /* ... */
- }
- }
-}
-```
-
-`isAlive` (from [`generational-entity-ids.md`](./generational-entity-ids.md))
-covers a bullet that another system removed earlier in the tick, which is
-what the demo's `usedBullets` and `removed` sets handle today.
-
-`collisionManifolds` stays as the resolution system's input and for
-anything that needs contact points and normals (the car docs demo counts
-its wheels' ground manifolds; it moves to contacts with a count of
-touching ground entities).
-
----
-
-## 5. Phases
-
-### Phase 1: Filtering and the AABB on the collider
-
-| # | Task | Size |
-| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- |
-| 1.1 | `category`/`mask` on colliders; broad-phase filter; tests | S |
-| 1.2 | `collider.aabb` written by the broad phase; `AabbEcsComponent` removed; raycast | M |
-| 1.3 | Raycasts take an optional mask, like the colliders they hit | S |
-| 1.4 | Migrate the docs site (3 guides, 25 demo files), `/demo` and `/src` tests; changelog: the AABB component's removal under `#### Removed`, filtering under `#### Added` | M |
-
-**Definition of done:** a collider works without any extra component; two
-colliders whose masks exclude each other are never tested.
-
-### Phase 2: Sensors and contacts
-
-| # | Task | Size |
-| --- | ------------------------------------------------------------------------------------------------------------------ | ---- |
-| 2.1 | `sensor`: overlaps reported through contacts only; raycasts skip sensors unless asked; tests | S |
-| 2.2 | `ContactsEcsComponent` filled by the narrow phase (`[contactsId]` query, deduplicated); started/ended across ticks | M |
-| 2.3 | Move the space shooter, brick breaker, rolling ball and car docs demos and `/demo/src/game.ts` to contacts | M |
-| 2.4 | Physics guide: filtering, sensors, contacts; a docs demo with a trigger zone; changelog under `#### Added` | M |
-
-**Definition of done:** a sensor in a world with resolved bodies reports
-overlaps without pushing anything; no docs demo scans `collisionManifolds`
-to find what an entity touched.
-
-Phase 2 depends on [`generational-entity-ids.md`](./generational-entity-ids.md)
-for `isAlive`.
-
----
-
-## 6. Decision log
-
-### DL-1: Contacts as a component, not an event list or callbacks
-
-**Options.** (a) A per-entity component (Avian's `CollidingEntities`).
-(b) A world-wide list of begin/end events (Box2D, Avian's events). (c)
-Callbacks (Unity, Godot signals).
-
-**Decision: (a).**
-
-**Rationale.** It's the shape the demo's systems need: each iterates its
-own entities and asks what they touch. (b) brings back the global scan;
-(c) runs game code inside the physics step, outside any system.
-
-### DL-2: Opt-in contacts
-
-**Rationale.** Most colliders (walls, debris) never ask what they touch.
-Filling a list for each costs memory and time for nothing; Avian makes
-the same choice, and Box2D made its events opt-in in v3.1 for the same
-reason.
-
-### DL-3: The AABB is part of the collider
-
-**Options.** (a) A field on the collider written by the broad phase. (b)
-`addColliderComponent` also adds an `AabbEcsComponent`.
-
-**Decision: (a).**
-
-**Rationale.** The bounds have no meaning without the collider, and (b)
-leaves a component that can be removed separately, or forgotten when the
-collider is removed.
-
----
-
-## 7. Open questions
-
-1. **Should masks be symmetric** (Box2D: both must accept) or one-sided
- (Godot: either one's mask can detect the other)? One-sided lets a
- sensor see bodies that don't see it.
- - (a) Symmetric (proposed; simplest to reason about). (b) One-sided.
-
----
-
-## 8. Testing considerations
-
-- Filter matrix: matching and non-matching categories both ways.
-- Sensors: reported through contacts, never in `collisionManifolds`,
- including sensor against static.
-- Contacts: started on the first overlapping tick, touching while
- overlapping (once per entity, even against terrain with several edges),
- ended on the first separated tick, and ended when the other entity is
- removed or loses its collider.
-- Raycast against `collider.aabb`, with a mask, and past a sensor.
-
-## 9. Documentation and demo follow-up
-
-- `physics/` guides: collision filtering, sensors, reading contacts.
-- Demo: the four collision systems read `ContactsEcsComponent`; bullets,
- enemies, asteroids, grabbers and power-ups get categories and masks; the
- `addAabbComponent` calls and the `usedBullets`/`removed` sets go.
diff --git a/design/continuous-collision-detection.md b/design/continuous-collision-detection.md
deleted file mode 100644
index cfa84d7b..00000000
--- a/design/continuous-collision-detection.md
+++ /dev/null
@@ -1,287 +0,0 @@
-# Design: Continuous Collision Detection (CCD) for Fast-Moving Bodies
-
-| | |
-| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Status** | Implemented (Phases 1-2) |
-| **Target module** | `/src/physics` → `@forge-game-engine/forge/physics` |
-| **Engine version at time of writing** | `0.25.4` |
-| **Modules** | See §1 table below |
-
-## 0. Targeted modules
-
-| Path | Change | Notes |
-| ------------------------------------------------------------ | -------- | --------------------------------------------------------------------------------------------------- |
-| `src/physics/ccd/` | **New** | Pure sweep math: `sweep-circle-polygon.ts`, `sweep-circle-terrain.ts`, `sweep-circle-circle.ts`, `index.ts` |
-| `src/physics/systems/continuous-collision-system.ts` | **New** | `createContinuousCollisionEcsSystem` - the ECS system wiring sweeps into the simulation loop |
-| `src/physics/types/sweep-hit.ts` | **New** | `SweepHit` return type for the sweep functions |
-| `src/physics/components/rigidbody-component.ts` | Modified | Adds `continuousDetection` to `RigidBodyDefaultedOptions` |
-| `src/physics/systems/index.ts`, `src/physics/components/index.ts`, `src/physics/index.ts` | Modified | Export the new system/module |
-| `documentation-site/docs/docs/physics/` | Modified | New `continuous-collision-detection.md` doc page; cross-links from `terrain.md`/`rigid-bodies.md` |
-| `documentation-site/src/pages/demos/car/_create-game.ts` | Modified | Registers the new system, once implemented (Phase 2's definition of done) |
-
-Nothing is removed.
-
----
-
-## 1. Summary
-
-A fast-moving `CircleCollider` body (a car's wheel, a thrown/launched projectile) can pass partway - or, at high enough speed, entirely - through a static `TerrainCollider`/`PolygonCollider` within a single physics tick. The engine's discrete-time narrow-phase only tests for overlap at each tick's *start* position; if a body's velocity carries it past a thin obstacle before the *next* tick's test runs, the collision either resolves late (as a large, already-established penetration) or is missed outright.
-
-This was diagnosed directly, not inferred: driving the Car demo (`documentation-site/src/pages/demos/car`) at highish speed and landing wheel-first (chassis pitched ~40-55°, not flipped) produces a measured, repeatable ~25-30 unit penetration of the rear wheel into the terrain (`wheelRadius` is `100`), lasting several ticks before the solver's normal penetration correction clears it. Two solver-tuning knobs were tried and empirically ruled out as fixes: quadrupling `CollisionResolutionOptions.maxBiasSpeed` (300 → 1200) changed peak penetration by less than 3 units; quadrupling `contactHertz` (30 → 120) similarly made no meaningful difference. Neither is the bottleneck - the penetration already exists by the time either knob gets a chance to correct it, because it was created in a single discrete step of motion that outran detection. Only preventing that step from ever landing inside the terrain in the first place fixes it, which is what CCD does.
-
-This document proposes adding **continuous collision detection for dynamic bodies against static bodies**, implemented as a swept-shape query that runs once per tick, per qualifying fast body, between the existing collision-resolution step and Euler integration. It reuses this engine's existing `src/physics/raycast/` primitives (specifically the ray-vs-convex-polygon math already used by `raycastPolygon`/`raycastTerrain`) via the standard technique of inflating the target shape by the moving circle's radius (a Minkowski sum), rather than building an unrelated geometry pipeline from scratch.
-
-**Explicitly excluded from this design: substep integration.** Substepping (running the solver at a higher internal frequency than the rest of the engine's tick) improves general solver stiffness, joint convergence, and stacking stability - a different problem from tunneling, and a much larger architectural change, since this engine's physics systems currently run at the same fixed frequency as every other system in an `EcsWorld` (there is no independent physics clock to subdivide). CCD, as implemented by this design, does not require it: it is a self-contained swept query inserted into the existing single-rate tick, matching how Box2D itself implements CCD - as a post-integration-candidate TOI (time-of-impact) pass for fast bodies, independent of and shipped long before Box2D ever gained substepping. See §7 (Decision Log, DL-1) for the full reasoning, and §9 for how a future substepping design would compose with this one rather than compete with it.
-
----
-
-## 2. Scope
-
-### In scope
-
-- Swept collision detection for a **dynamic `CircleCollider` body** moving against a **static `PolygonCollider` or `TerrainCollider` body** (matching `RigidBodyEcsComponent.type !== 'dynamic'`, i.e. `'static'`/`'kinematic'`, and the pre-existing convention that a collider entity with no `RigidBodyEcsComponent` at all is static).
-- A new `createContinuousCollisionEcsSystem`, registered between `createCollisionResolutionEcsSystem` and `createEulerIntegrationEcsSystem`, that:
- - Identifies which dynamic bodies are moving fast enough this tick to risk tunneling.
- - For each, sweeps against nearby static bodies and finds the earliest time of impact (TOI) in `[0, 1]` across this tick's translation.
- - If a TOI is found, clamps that body's effective translation for this tick's Euler integration step to stop at (fractionally before) the surface, leaving a small, correctly-signed contact for the *next* tick's ordinary broad/narrow-phase and resolution to pick up and solve normally (including warm-starting).
-- An automatic, threshold-based decision for "is this body moving fast enough to need a sweep this tick," so existing demos (including the Car demo) benefit without any per-entity opt-in - with a documented, overridable option for a demo/game author who wants to force it on or off for a specific body.
-- Unit tests for the sweep primitives themselves (`src/physics/ccd/*.test.ts`), and an integration regression test reproducing the exact wheel-through-terrain scenario measured during diagnosis (mirroring `src/physics/systems/terrain-resting-contact.test.ts`'s existing full-pipeline style).
-- Updating the Car demo to register the new system, and documenting the feature under `documentation-site/docs/docs/physics/`.
-
-### Out of scope
-
-- **Substep integration.** See §1 and §7 (DL-1).
-- **Dynamic-vs-dynamic CCD** (e.g. two fast circles colliding with each other). Box2D itself defaults to the same restriction ("bullets" don't sweep against other bullets) because a two-body sweep is materially more expensive (both endpoints move during the query) and rarer in practice; nothing in the diagnosed bug or this engine's existing demos needs it. A future phase could add it once a real need appears - see §8, Open Question 4.
-- **Swept polygon-vs-polygon or polygon-vs-terrain CCD** (e.g. the car's chassis itself tunneling through terrain, as opposed to its wheels). The diagnosed bug is specifically the wheels (circles); the chassis is a `PolygonCollider`. A correct polygon sweep needs full conservative advancement (or a GJK-based swept test), not the inflate-and-raycast trick this design uses for circles - a materially larger effort that does not block fixing the reported bug. Flagged as a natural Phase 3 in §6, not designed here.
-- **Speculative contacts / predictive margins** as an alternative strategy (used by some engines, e.g. Rapier, instead of true TOI sweeps). Considered and rejected in §7 (DL-2).
-- Any change to `createCollisionResolutionEcsSystem`'s existing solver (`maxBiasSpeed`, `contactHertz`, `contactDampingRatio`, `slop`) - already empirically shown not to fix this class of bug (§1).
-- Broad-phase spatial partitioning (the engine's broad-phase is currently an all-pairs `O(n²)` AABB scan with no spatial structure - see §8, Open Question 2). CCD's own cost-control is handled independently via the fast-body threshold in §5.3, not by fixing broad-phase's own complexity, which is a pre-existing, separate concern.
-
----
-
-## 3. Background: why this happens
-
-The current per-tick pipeline (see e.g. `documentation-site/src/pages/demos/car/_create-game.ts`'s system registration order) is:
-
-1. Apply forces (`createGravityEcsSystem`, springs, motors) - mutates `velocity`.
-2. `createBroadPhaseEcsSystem` - recomputes AABBs from *this tick's* (i.e. last tick's already-integrated) position/rotation, all-pairs overlap test.
-3. `createNarrowPhaseEcsSystem` - exact SAT test on broad-phase's candidate pairs, using the same pre-integration position.
-4. `createCollisionResolutionEcsSystem` - resolves impulses from this tick's manifolds, further mutating `velocity`.
-5. Joint/motor systems.
-6. `createEulerIntegrationEcsSystem` - `position.world += velocity * deltaTimeInSeconds` (semi-implicit Euler: position integrates using the *already-resolved* velocity).
-
-Steps 2-4 see the body's position from *before* this tick's motion is applied - they cannot know where the body is about to end up. If a body's velocity is large enough that `velocity * deltaTimeInSeconds` exceeds the gap between its current position and a thin obstacle, step 6 can move it clean through - or deep into - that obstacle in one step, and nothing catches it until *next* tick's step 3, by which point the manifold already reports a large, "pre-existing" penetration.
-
-Measured directly in the Car demo: a wheel (`CircleCollider`, `radius: 100`) traveling at roughly 1200-1500 world units/second covers 20-25 units per 60Hz tick (`1200 / 60 ≈ 20`, `1500 / 60 ≈ 25`) - consistent with the ~25-30 unit penetrations actually observed (the chassis's own rotational momentum, still settling from an asymmetric landing, added a few more ticks before the contact fully arrested). This is not a solver-strength problem; it is a **missed-detection-window** problem, and no amount of tuning the resolution step's correction rate changes when detection first happens.
-
----
-
-## 4. Prior art
-
-| Engine | Technique | Notes |
-| --- | --- | --- |
-| Box2D | Per-body "bullet" opt-in *and* an automatic fast-body heuristic; conservative-advancement TOI solver against the broad-phase's dynamic tree; clamps position at TOI, leaves final resolution to the next normal step | This engine already follows Box2D's precedent elsewhere (`b2FindMaxSeparation`'s tie-break bias, `contactPushMaxSpeed` → `maxBiasSpeed`, the soft-constraint solver's `biasRate`/`massScale`/`impulseScale` formulation) - this design continues that precedent rather than inventing an unrelated scheme |
-| Bullet Physics | Conservative advancement + GJK, opt-in via `CCD_MOTION_THRESHOLD`/`CCD_SWEPT_SPHERE_RADIUS` per body | Confirms the "opt-in with an automatic distance threshold" shape is a well-worn API, not novel to Box2D |
-| Rapier | Nonlinear CCD via bisection search on predicted trajectories, opt-in per rigid body (`.ccd_enabled`) | An alternative "predictive/speculative" family (§7 DL-2) rather than a true swept-shape TOI; considered and rejected here for reasons in that entry |
-| PhysX | Both speculative contacts (default, cheap, approximate) and true swept CCD (opt-in, exact) | Two-tier approach; this design's threshold-gated sweep (§5.3) is closer in spirit to PhysX's swept-CCD tier, applied automatically rather than requiring an explicit flag |
-
----
-
-## 5. Algorithm and pipeline integration
-
-### 5.1 Swept circle vs. convex polygon (the core primitive)
-
-A circle of radius `r` sweeping from point `A` to point `B` first touches a convex polygon at the same parametric time as an infinitely thin ray from `A` to `B` first touches that polygon **inflated by `r`** - each edge offset outward along its own normal by `r`, each vertex rounded into an arc of radius `r`. This is the standard circle-vs-convex-shape Minkowski sweep technique, and it lets this design reuse rather than replace the existing `raycastConvexPolygon` (`src/physics/raycast/raycast-convex-polygon.ts`) - the same function `raycastPolygon`/`raycastTerrain` already call - by:
-
-1. Offsetting each of the target polygon's edges outward along its own `normals[i]` by `r` (matching how `TerrainSegment`'s `vertices`/`normals` are already structured for `detectCircleTerrainCollision`/`raycastTerrain`).
-2. Additionally testing the swept ray against a circle of radius `r` centered on each of the polygon's original vertices (to correctly round the Minkowski sum's corners) - reusing `raycastCircle`'s own math, called once per vertex.
-3. Taking the earliest (smallest-`t`) hit across both.
-
-`sweepCirclePolygon(circleBody, staticPolygonBody, startPosition, endPosition): SweepHit | null` in `src/physics/ccd/sweep-circle-polygon.ts` implements this. `sweepCircleTerrain` (`src/physics/ccd/sweep-circle-terrain.ts`) is the terrain-collider equivalent, mirroring `raycastTerrain`'s own segment-scan structure (iterate `terrainCollider.segments` whose local x-range overlaps the swept path, delegate each to the same inflated-edge test, keep the earliest hit) - including the same fixed, ascending-segment-index iteration order `detectCircleTerrainCollision`/`raycastTerrain` already use, for the reason in §8, Open Question 3.
-
-```
-SweepHit {
- point: Vector2; // world-space point of first contact
- normal: Vector2; // outward surface normal at point
- t: number; // 0..1, the fraction of the swept translation completed at first contact
-}
-```
-
-### 5.2 Pipeline placement
-
-```mermaid
-flowchart TB
- A["Force systems
(gravity, springs, motors)"] --> B[createBroadPhaseEcsSystem]
- B --> C[createNarrowPhaseEcsSystem]
- C --> D[createCollisionResolutionEcsSystem]
- D --> E["Joint / motor systems"]
- E --> F["createContinuousCollisionEcsSystem
(new)"]
- F --> G[createEulerIntegrationEcsSystem]
-
- style F fill:#2d5,stroke:#141,color:#000
-```
-
-`createContinuousCollisionEcsSystem` runs **after** every system that can change `velocity` this tick and **before** `createEulerIntegrationEcsSystem`, so it sees the tick's final, fully-resolved velocity - the same value integration is about to use - and can act on exactly what's about to happen, not a stale prediction.
-
-It does not itself move anything or apply impulses. For each qualifying fast dynamic body (§5.3), it computes the *candidate* end position integration is about to produce (`position.world + velocity * deltaTimeInSeconds`, the same expression `createEulerIntegrationEcsSystem` uses), sweeps from the current position to that candidate against nearby static bodies, and - only if a hit is found - writes a **per-tick translation clamp** that `createEulerIntegrationEcsSystem` respects instead of the body's full velocity-derived translation (§5.4 covers precisely how that hand-off works without modifying the existing integration system's own logic).
-
-This mirrors the same design choice this session already validated for `TerrainCollider` narrow-phase's own segment tie-breaking: keep the *existing*, already-tested system (`createEulerIntegrationEcsSystem`, in that case `detectCircleTerrainCollision`'s core loop) untouched wherever possible, and add new behavior as a bounded, single-purpose addition around it, rather than reworking a system that many other things already depend on.
-
-### 5.3 The fast-body threshold
-
-Sweeping every dynamic body every tick against every nearby static body would add cost with no benefit for the overwhelming majority of ticks, where ordinary discrete detection is already correct (a body only needs a sweep when its own tick's translation is large relative to its own size). `createContinuousCollisionEcsSystem` only sweeps a body when:
-
-```
-translationDistance = |velocity * deltaTimeInSeconds|
-translationDistance > continuousDetectionThreshold * collider's own bounding radius
-```
-
-`continuousDetectionThreshold` defaults to `0.5` (a body moving more than half its own radius in one tick) - the same order of magnitude Bullet Physics's `CCD_MOTION_THRESHOLD` and Box2D's own automatic fast-body heuristic use, tightened or loosened via §8 Open Question 1's tuning pass. This keeps CCD's added cost proportional to how many bodies are actually moving fast, not to the world's total entity count.
-
-### 5.4 Applying the clamp without touching `createEulerIntegrationEcsSystem`
-
-Two options were considered for how `createContinuousCollisionEcsSystem`'s finding reaches integration; see §7 DL-3 for the comparison. The chosen approach: the new system writes a **temporary, single-tick-lived clamp** onto the body's own `RigidBodyEcsComponent` (a new, system-managed field, not caller-facing configuration - the same pattern `GroundContactEcsComponent.groundContacts` and `AirControlEcsComponent.airborneDuration` already establish for "state a system writes and a different system reads, both scoped to the current or a bounded recent window"). `createEulerIntegrationEcsSystem` gains a small, additive check: if that clamp is set this tick, integrate the clamped translation instead of the full `velocity * deltaTimeInSeconds`, then clear it. This is the one small, mechanical touch point `createEulerIntegrationEcsSystem` needs (a few added lines, not a rewrite) - everything else about CCD lives entirely in the new module.
-
-### 5.5 Why "stop at the surface, let next tick resolve it" instead of resolving the impact immediately
-
-Clamping translation at the TOI leaves the body just touching (or a hair's breadth from touching) the static surface, with its *velocity* still unresolved for that contact. The very next tick's ordinary broad-phase/narrow-phase/resolution pipeline then sees a small, ordinary, correctly-signed overlap - exactly the case it's already built, tested, and (as of this session's earlier work) warm-starting-stable for. This avoids duplicating any part of the existing contact-resolution logic (Coulomb friction, restitution, warm-started accumulated impulses) inside the new CCD system; CCD's only job is to prevent the *geometric* miss, not to re-implement the *physical* response to it.
-
----
-
-## 6. Phases
-
-Each phase is independently completable and releasable, per this engine's own `feat`/`fix`-per-PR, squash-merge-to-`dev` convention.
-
-### Phase 1: Sweep primitives (pure functions, no simulation wiring)
-
-**Goal:** prove the core geometry is correct in isolation before touching the live tick. Definition of done: `sweepCirclePolygon`/`sweepCircleTerrain`/`sweepCircleCircle` exist, are unit-tested (including edge cases: sweep starting already overlapping, sweep that grazes a vertex, sweep entirely missing, sweep exactly reaching but not passing the surface), and are exported from `@forge-game-engine/forge/physics`, but nothing in the engine calls them yet.
-
-| Task | Description | Size |
-| --- | --- | --- |
-| `SweepHit` type | `src/physics/types/sweep-hit.ts`, mirroring `RaycastShapeHit`'s shape (`point`, `normal`, plus `t` in place of `distance`) | S |
-| `sweepCirclePolygon` | Inflated-edge + rounded-vertex sweep against a static `PolygonCollider`, reusing `raycastConvexPolygon`/`raycastCircle` | M |
-| `sweepCircleTerrain` | Segment-scan wrapper over `sweepCirclePolygon`, mirroring `raycastTerrain`'s structure | M |
-| `sweepCircleCircle` | Swept-circle-vs-static-circle (simpler closed form: shrink the sweep to a ray from the moving circle's center, inflate the static circle's radius by the moving one's) - included since it's a small addition once the polygon case exists, and static circle colliders already exist in the engine (e.g. `Newton's Cradle`) | S |
-| Unit tests | Exhaustive per-function cases per the "definition of done" above, plus a couple of tests replaying the exact velocity/geometry measured in the Car demo diagnosis (§1) as a fixed regression case at the pure-math level | M |
-
-### Phase 2: Wire into the simulation loop
-
-**Goal:** the diagnosed bug no longer reproduces in the Car demo. Definition of done: `createContinuousCollisionEcsSystem` exists and is registered in the Car demo between resolution and integration; a full-pipeline integration test (mirroring `terrain-resting-contact.test.ts`) reproduces the exact wheel-through-terrain scenario and asserts bounded, small penetration instead of the previously-measured ~25-30 units; the Car demo is driven in a real browser (per this repo's standard demo-verification process) under the same sustained-throttle, highish-speed, wheel-first-landing conditions that originally reproduced the bug, confirming no visible embedding.
-
-| Task | Description | Size |
-| --- | --- | --- |
-| `RigidBodyEcsComponent.continuousDetection` | New field on `RigidBodyDefaultedOptions` (default `true` for dynamic bodies against static bodies - matches "the reported bug is an ordinary wheel, not something a demo author should have to know to flag," §7 DL-4); settable `false` per body to opt out | S |
-| `createContinuousCollisionEcsSystem` | The system itself: threshold check (§5.3), candidate-end-position computation, nearby-static-body query (reusing `ColliderEcsComponent.aabb`/`aabbsOverlap` over a swept AABB spanning start→end), sweep dispatch, clamp write-back | L |
-| `createEulerIntegrationEcsSystem` clamp support | The small addition in §5.4 | S |
-| Integration regression test | Full-pipeline test reproducing the diagnosed scenario | M |
-| Car demo wiring + manual verification | Register the system; browser-verify per AGENTS.md's "Documentation Site Demos" process | S |
-| Changelog + docs | `CHANGELOG.md` entry; new `documentation-site/docs/docs/physics/continuous-collision-detection.md` page, cross-linked from `terrain.md` and the rigid-bodies guide | S |
-
-### Phase 3 (future, not designed here): swept polygon-vs-static shapes
-
-Extending CCD to a fast-moving `PolygonCollider` (e.g. the car's chassis itself, or any other polygon body) against static `PolygonCollider`/`TerrainCollider` bodies. Needs full conservative advancement or a GJK-based swept test rather than the inflate-and-raycast trick this design uses for circles, since a rotating, translating polygon's swept volume isn't a simple offset of its target. Not designed here because it isn't needed to fix the reported bug (the wheels are circles) and deserves its own focused design once a concrete need for it appears - see §8, Open Question 5.
-
----
-
-## 7. Decision log
-
-| # | Decision | Options considered | Chosen | Rationale / tradeoffs |
-| --- | --- | --- | --- | --- |
-| DL-1 | CCD vs. substep integration | (a) Substep the whole solver at a higher internal rate; (b) swept-shape CCD at the existing tick rate; (c) both | (b), CCD only | Substepping solves a broader class of problems (general stiffness, stacking, joint convergence) but requires decoupling the physics tick from the rest of the engine's single shared frequency - a materially larger architectural change with its own design surface (how do render-visible transforms interpolate between substeps? do all systems substep, or only physics?). CCD alone directly and fully addresses the diagnosed bug (confirmed by ruling out solver-tuning fixes in §1) without that dependency. Requested explicitly in scope discussion; (a)/(c) may be worth a future, separate design once a concrete need beyond tunneling appears. |
-| DL-2 | True swept TOI vs. speculative/predictive contacts | (a) True swept-shape TOI (this design); (b) Rapier-style speculative contacts: generate a contact constraint slightly before actual touching, let the solver's own velocity constraint prevent penetration reactively | (a) | Speculative contacts are cheaper (no sweep math) but only prevent penetration the solver can react to *before* it's already deep - they still fundamentally rely on detecting the contact before integration, which is only reliable if the speculative margin exceeds the body's own per-tick travel, i.e. they still need a distance-based threshold to be effective at all, at which point the complexity saved is marginal while the exactness lost (a true TOI, vs. an approximate margin) is not. A true sweep also composes more simply with existing warm-starting (§5.5) since it hands off a clean, ordinary contact to the next tick rather than an early/approximate one this tick. |
-| DL-3 | Where the CCD-found clamp is applied | (a) `createContinuousCollisionEcsSystem` mutates `position.world` directly, bypassing integration entirely for the clamped body this tick; (b) it mutates `velocity` for this tick only, letting the existing integration formula naturally land at the right spot; (c) it writes a small, explicit, system-managed clamp field that `createEulerIntegrationEcsSystem` checks | (c) | (a) duplicates integration's own math in a second place (rotation integration, kinematic-body handling) - a maintenance hazard. (b) is tempting but wrong: scaling velocity down to avoid tunneling this tick would also feed a smaller, incorrect value into anything else reading `velocity` this same tick (friction, next tick's resting-contact expectations) before it's restored. (c) keeps `velocity` as the single source of truth for "how fast is this body actually going," touches `createEulerIntegrationEcsSystem` with a small additive check instead of a rewrite, and matches this codebase's established pattern of small, system-managed state fields (`GroundContactEcsComponent.groundContacts`, `AirControlEcsComponent.airborneDuration`) read by a different system than the one that writes them. |
-| DL-4 | Opt-in flag vs. automatic threshold | (a) Off by default, a Box2D-"bullet"-style explicit per-body flag; (b) on by default for all dynamic-vs-static pairs, gated only by the speed threshold (§5.3); (c) on by default, with a per-body override in either direction | (c) | The diagnosed bug is an *ordinary wheel* on an *ordinary* demo - not a specially-flagged "fast projectile" a demo author would think to mark. An opt-in-only design (a) would have shipped this exact bug unfixed by default. Fully unconditional CCD for every dynamic body regardless of speed (dropping §5.3's threshold entirely) would cost more than needed for the common case of slow-moving bodies. (c) defaults to safe (fixes the reported class of bug for everyone automatically) while still letting a game opt a specific body out (e.g. a deliberately fast "ghost" trigger volume that should be allowed to pass through geometry) or force it on below the automatic threshold. |
-| DL-5 | Scope: dynamic-vs-static only, not dynamic-vs-dynamic | (a) Support both from the start; (b) static only for v1 | (b) | Matches Box2D's own default restriction (§4) for the same reason: a two-moving-body sweep is materially more expensive and more complex (both endpoints move during the query window), and every occurrence of the diagnosed bug, and every existing demo in this repository, is a fast body against a static one (terrain, a wall, a floor). Deferred to a future phase per §8, Open Question 4, rather than speculatively built now. |
-
----
-
-## 8. Open questions
-
-1. **What `continuousDetectionThreshold` (§5.3) is right?** `0.5` (half the collider's own bounding radius per tick) is a reasonable starting point drawn from other engines' defaults, but needs empirical tuning against both the Car demo (confirm the diagnosed scenario is caught) and a synthetic slow-body benchmark (confirm normal resting/rolling contacts are *not* needlessly swept every tick). Should be resolved during Phase 2 implementation, not blocking Phase 1.
-2. **How does the nearby-static-body query for a sweep avoid duplicating the broad-phase's own `O(n²)` all-pairs cost?** The current broad-phase (`createBroadPhaseEcsSystem`) is already an all-pairs AABB scan with no spatial structure. A naive CCD implementation that re-queries "all static bodies" per fast dynamic body would compound that cost. The most direct option is extending each qualifying body's *own* AABB to cover its swept path for one extra broad-phase-style pass restricted to bodies already found to need a sweep (typically a small subset of the world) - bounding the added cost to "fast bodies × static bodies," not "all bodies × all bodies." Whether that's sufficient at scale, or whether this motivates finally adding a spatial structure to broad-phase generally (a separate, pre-existing concern per §2's Out of Scope), should be settled with a benchmark early in Phase 2, before the system's query shape is finalized.
-3. **Should terrain sweeps use the same fixed-order, tolerance-biased tie-break this session already had to add to `detectCircleTerrainCollision`?** That fix (topology-keyed feature ids, `DEPTH_TIE_TOLERANCE`) exists because floating-point noise could otherwise flip which of two near-coplanar segments "wins" a discrete contact from tick to tick, breaking warm-starting. A sweep's TOI result feeds into *which* segment's contact gets resolved next tick, so the same class of noise-driven instability is plausible here too. Needs a dedicated pass at the same rigor once `sweepCircleTerrain` exists, using the same reproduction technique (a wide body swept across many near-coplanar segments, perturbed by sub-pixel jitter) - likely reuses the existing `DEPTH_TIE_TOLERANCE` constant and reasoning directly rather than inventing a parallel one.
-4. **Is dynamic-vs-dynamic CCD (§2 Out of Scope, §7 DL-5) worth a future phase, and if so, when?** No current demo or reported bug needs it. Worth revisiting if a future feature (e.g. fast projectiles that can hit each other, not just terrain) makes it concrete rather than speculative.
-5. **When does swept polygon-vs-polygon/terrain CCD (§6 Phase 3) become worth designing?** The car's chassis (a `PolygonCollider`) can in principle tunnel the same way its wheels can, just less often in practice (it's larger, and the wheels take the brunt of ground contact in normal play). Worth a dedicated design once either a concrete reproduction of chassis tunneling appears, or another polygon-heavy use case (e.g. a fast melee weapon hitbox) surfaces the same need independently.
-6. **Should `SweepHit`/the sweep functions be part of the public `@forge-game-engine/forge/physics` API surface (usable directly by game code, the way `raycast` already is), or purely an internal implementation detail of `createContinuousCollisionEcsSystem`?** Leaning toward public, mirroring `raycast`'s own precedent (useful standalone for "will this fast-moving thing hit that wall if I don't do anything" queries, e.g. AI or gameplay-scripted dodges) - but should be confirmed before Phase 1 locks in the module's exported shape.
-
----
-
-## 9. Relationship to a possible future substepping design
-
-Nothing in this design forecloses substepping being added later. If it is, the two compose rather than conflict: substepping would change how many times *all* physics systems run per rendered frame (a broader, orthogonal change to the engine's tick model), while CCD's sweep would simply run once per *sub-tick* instead of once per tick - the sweep math in §5.1 and the pipeline placement in §5.2 are expressed in terms of "this tick's" start/end position and don't assume anything about how long a tick is or how many run per frame. A future substepping design should treat this document's Phase 1 primitives as reusable as-is.
-
----
-
-## 10. Testing considerations
-
-- **Pure-math level** (Phase 1): exhaustive unit tests per sweep function, including the exact velocity/geometry combination measured in the Car demo diagnosis, asserting the correct TOI and normal.
-- **Full-pipeline level** (Phase 2): an integration test in the style of `src/physics/systems/terrain-resting-contact.test.ts` - real `EcsWorld`, real gravity/broad-phase/narrow-phase/resolution/CCD/integration systems, a `CircleCollider` body given a velocity and starting position chosen to reproduce the diagnosed tunneling geometry against a `TerrainCollider`, asserting the body's final penetration stays bounded (near the solver's existing `slop`) rather than reaching the previously-measured tens of units.
-- **Demo-level** (Phase 2, manual): per AGENTS.md's "Documentation Site Demos" process - rebuild, browser-verify the Car demo under the same sustained-throttle/highish-speed conditions that originally reproduced the bug, confirming no visible wheel/chassis embedding on a hard, wheel-first landing.
-- **Regression guard for §8 Open Question 3** (once resolved): a wide-body-across-many-segments jitter test mirroring `detect-circle-terrain-collision.test.ts`'s own "should never flip feature ids for a wide body..." test, applied to the sweep path.
-
----
-
-## 11. Implementation notes
-
-Phases 1 and 2 shipped with these deviations from the plan above. The physics
-guide (`documentation-site/docs/docs/physics/continuous-collision-detection.md`)
-describes the shipped behavior.
-
-- **DL-3 replaced: rewind after integration instead of a clamp field.**
- `createContinuousCollisionEcsSystem` registers directly *after*
- `createEulerIntegrationEcsSystem`, sweeps from `position.world` (where this
- tick's broad/narrow phase saw the body) to `position.local` (where
- integration moved it), and on a hit moves `position.local` back to the time
- of impact. No field is added to `RigidBodyEcsComponent` and
- `createEulerIntegrationEcsSystem` is unchanged. A field written by one
- system and cleared by another has two writers; the rewind matches how
- Box2D's `b2SolveContinuous` (after body finalization) and Avian's swept CCD
- (after the solver) are structured.
-- **The body is left slightly inside the surface, not just short of it** (1%
- of its radius). Forge has no speculative contacts, so a body stopped short
- would give the next tick's narrow phase nothing to report and would be
- swept and stopped again every tick.
-- **DL-4 replaced: no per-body flag and no configurable threshold.** The
- sweep follows the same rules as discrete collision: it skips sensors and
- pairs whose `category`/`mask` exclude each other. Any other opt-out could
- only let a body tunnel, and "force it on below the threshold" is by
- definition a case discrete detection handles.
-- **Threshold (§8 Q1): a tenth of the radius, not half.** The diagnosed wheel
- moves 0.2-0.25 of its radius per tick, so `0.5` would never have fired for
- the reported bug. A hit is acted on only when the unclamped step would end
- more than `0.1 * radius` inside the surface; the same value is the
- per-body speed pre-filter. This doubles as Box2D's "prevent pausing" rule:
- grazes and slight bends in the ground are left to the solver rather than
- cutting short every tick of a fast roll.
-- **Targets are static colliders only.** §2 counted `'kinematic'` as static;
- a kinematic body moves during the tick, so a sweep against its start pose
- gives the wrong time of impact. Box2D's non-bullet CCD likewise only sweeps
- against static bodies.
-- **Dedicated sweep math instead of reusing `raycastConvexPolygon`/
- `raycastCircle`.** Those test both crossing directions, and `raycastCircle`
- returns the exit point for a ray starting inside. The sweeps only count
- entering hits on the true boundary of the Minkowski sum (front faces
- within their span, corner rounds within their normal cone).
-- **Terrain is swept against the surface chain, not the slab** (§8 Q3). Each
- edge is tested on its own and corners only exist where the surface bends
- away from the circle, so a fast wheel rolling along the ground doesn't
- catch on the tops of neighboring columns. The sweep only decides where to
- stop the body; the next tick's contacts (and their feature ids) still come
- from narrow phase, so no extra tie-break was needed.
-- **§8 Q6: the sweeps are public** (`sweepCircleCircle`,
- `sweepCirclePolygon`, `sweepCircleTerrain`), mirroring `raycast`.
-- **Registered in every pipeline**, not only the Car demo: every demo that
- registers `createEulerIntegrationEcsSystem`, `/demo`, and the physics
- guides' system listings.
-- **Car demo finding: most of its visible wheel embedding is not
- tunneling.** Probing the live demo under sustained throttle, CCD removes
- every "no contact to deep" landing (without it: 11-19 units at 750-1500
- units/second downward, matching §1). But the demo's deepest penetrations
- (50-80 units) come from wheels that are *already* in contact sinking
- further over several ticks, which CCD deliberately leaves alone. The
- cause is solver ordering: the prismatic/revolute joint systems run after
- `createCollisionResolutionEcsSystem` and get the last word on velocity,
- so they drive a wheel back into the ground after its contact was solved.
- Running contact resolution after the joints, as an experiment, dropped
- the worst depth to about 12 units. The fix is solving contacts and joints
- in one iteration loop, as Box2D does, which needs its own design.
diff --git a/design/demo-findings.md b/design/demo-findings.md
index b4153abf..cb8b306a 100644
--- a/design/demo-findings.md
+++ b/design/demo-findings.md
@@ -23,8 +23,10 @@ Godot, Bevy (or, for browser-specific problems, web engines) solve the
same problem. Each was then reviewed against those rules and against the
code, and revised (§3).
-These are proposals. None of them describes current behavior, and each
-needs review before it's implemented.
+These are proposals. Findings marked "implemented" have shipped: their
+design documents were removed, and `documentation-site/docs/docs`
+describes the current behavior. The rest don't describe current behavior,
+and each needs review before it's implemented.
---
@@ -35,24 +37,24 @@ needs review before it's implemented.
| 1 | [Shader uniform declarations](./shader-uniform-declarations.md) | Defect | Keeps uniforms alive with `highp` so `setUniform` doesn't throw on mobile | Materials only know the uniforms the driver kept |
| 2 | [Sound mixer](./audio-mixer.md) | Feature | Its own mixer, buses, one-shots on entities with guessed lifetimes, synthesized WAVs as data URLs, a Howl per system | No buses, volumes, one-shots or procedural sounds; removal doesn't stop sounds; no completion report |
| 3 | [Text input field](./text-input-field.md) | Feature | Its own text field on a hidden DOM input, keys swallowed while typing | No text field; the keyboard source reads keys typed into inputs |
-| 4 | [Generational entity ids](./generational-entity-ids.md) | Defect | "Id can be reused" checks, `removed`/`usedBullets` sets | Ids reused immediately with no generation; double removal; removing the last component removes the entity |
+| 4 | Generational entity ids (implemented) | Defect | "Id can be reused" checks, `removed`/`usedBullets` sets | Ids reused immediately with no generation; double removal; removing the last component removes the entity |
| 5 | [Hierarchy removal](./hierarchy-removal.md) | Defect | Orphaned flame cleanup; `removeWithHealthBar`, `removePowerUp` | Removing a parent leaves its children; no children index |
| 6 | [Input action state](./input-action-state.md) | Defect | `noReset` on every axis; `shootInput.endHold()` after a group switch | Axes reset every frame by default; holds carried across group switches; sources overwrite each other |
-| 7 | [Angle conventions](./angle-conventions.md) | Defect | Converts the exhaust direction by hand, once | Particles use degrees clockwise from up; `Vec2.up` points down; emitters ignore rotation; Y-down terrain slab |
-| 8 | [HDR colors](./hdr-colors.md) | Defect | Buttons dimmed at rest so hover can brighten | `Color` clamps to `1` |
+| 7 | Angle conventions (implemented) | Defect | Converts the exhaust direction by hand, once | Particles use degrees clockwise from up; `Vec2.up` points down; emitters ignore rotation; Y-down terrain slab |
+| 8 | HDR colors (implemented) | Defect | Buttons dimmed at rest so hover can brighten | `Color` clamps to `1` |
| 9 | [Sprite textures](./sprite-textures.md) | Defect and feature | Renderable swaps, raw GL textures, a white SVG, fresh `uvOffset`s, hand-built renderables | A sprite is a whole pipeline per image; textures have no owner; shared vectors |
-| 10 | [Camera views](./camera-views.md) | Feature and defect | A mirrored `verticalWorldUnits`, visible-size calls in 16 files, unit conversion constants, manual culling | No camera view or conversions; `worldToScreenSpace` doesn't flip Y; no culling |
+| 10 | Camera views (implemented) | Feature and defect | A mirrored `verticalWorldUnits`, visible-size calls in 16 files, unit conversion constants, manual culling | No camera view or conversions; `worldToScreenSpace` doesn't flip Y; no culling |
| 11 | [Render resolution](./render-resolution.md) | Defect and feature | Writes `maxPixelRatio` through a cast; resizes camera targets every frame | `maxPixelRatio` is read-only; camera targets don't follow the canvas |
| 12 | [Post-processing effects](./post-processing-effects.md) | Feature and defect | Scratch targets and copy-backs in each effect; bloom off via intensity | Each pass copies back; bloom at `passes: 0` still draws |
| 13 | [WebGL context loss](./webgl-context-loss.md) | Feature | Reloads the page | Context loss isn't handled; GPU resources can't be rebuilt |
| 14 | [Sprite draw order](./sprite-draw-order.md) | Defect | `sortDepth = ship.y - offset` every frame; `-1e6`-style depths | Absolute, Y-based, quantized sorting; nothing relative to the parent |
| 15 | [Sprite fill](./sprite-fill.md) | Feature | 32 pre-rendered drain images; a progress fill held at a minimum width | No linear or radial reveal |
| 16 | [Hierarchical visibility](./hierarchical-visibility.md) | Feature | `setShown` (alpha, interactable, raycasts) in five files; buttons moved by hand | No subtree hide affecting rendering, layout and input |
-| 17 | [Collision events](./collision-events.md) | Feature | Four systems scanning every manifold; `addAabbComponent` everywhere | No layers, masks, sensors or per-entity contacts |
+| 17 | Collision events (implemented) | Feature | Four systems scanning every manifold; `addAabbComponent` everywhere | No layers, masks, sensors or per-entity contacts |
| 18 | [Polygon collider local space](./polygon-collider-local-space.md) | Defect | A symmetric collider so re-centering doesn't move it | Polygons re-centered because the solver assumes origin = center of mass |
-| 19 | [Game states](./game-states.md) | Feature | A phase machine with entered/left flags, 15 state checks in 9 files, a clear-run system | No states, run conditions or state-scoped entities |
+| 19 | Game states (implemented) | Feature | A phase machine with entered/left flags, 15 state checks in 9 files, a clear-run system | No states, run conditions or state-scoped entities |
| 20 | [Text cap-height centering](./text-cap-height-centering.md) | Defect | Baselines computed from cap height; a rebuilt button | `'middle'` centers each string's own ink; the stored cap height includes glyph padding |
-| 21 | [Font atlas loading](./font-atlas-loading.md) | Defect | Fonts served from `public/` | The atlas image is resolved next to the JSON, which bundlers rename |
+| 21 | Font atlas loading (implemented) | Defect | Fonts served from `public/` | The atlas image is resolved next to the JSON, which bundlers rename |
| 22 | [Persistent state](./persistent-preferences.md) | Feature | Two copies of load/validate/save around `localStorage` | No persistent state |
---
@@ -104,8 +106,6 @@ These are recorded as open questions in their designs, with a proposal:
emissive maps on the sprite rather than the material.
- **Polygon collider local space:** derive rigid-body mass data from the
collider instead of copying it in.
-- **Angle conventions:** fold the terrain fix in, or give it its own
- design.
- **Text input field:** keep hover-driven focus with a separate editing
state, or remove hover focus from the UI.
- **Hierarchy removal:** wait before adding a lifetime link that isn't a
@@ -117,41 +117,33 @@ These are recorded as open questions in their designs, with a proposal:
Most designs are independent. These aren't:
-- [Hierarchy removal](./hierarchy-removal.md) needs
- [generational entity ids](./generational-entity-ids.md).
- [Sprite draw order](./sprite-draw-order.md) needs hierarchy removal's
children index and sibling order;
[hierarchical visibility](./hierarchical-visibility.md) needs draw
order's per-frame resolution pass.
-- [Game states](./game-states.md) relies on generational ids and
- hierarchy removal for scoped removal.
- [Sprite textures](./sprite-textures.md) needs
[shader uniform declarations](./shader-uniform-declarations.md);
[WebGL context loss](./webgl-context-loss.md) needs both;
[sprite fill](./sprite-fill.md) Phase 2 lands after sprite textures.
- [Render resolution](./render-resolution.md) lands after
[post-processing effects](./post-processing-effects.md).
-- [Collision events](./collision-events.md) Phase 2 uses `isAlive` from
- generational entity ids.
- [Text input field](./text-input-field.md) Phase 1 and
[input action state](./input-action-state.md) both change the keyboard
source; either can land first.
-- Polygon collider local space and collision events both touch what
- `continuous-collision-detection.md` plans; whichever lands second
- adapts.
+- [Polygon collider local space](./polygon-collider-local-space.md)
+ changes where continuous collision detection sweeps circles from.
A suggested order, small and independent first:
-1. **Small defects**: HDR colors, text cap-height centering, font atlas
- loading, angle conventions, shader uniform declarations.
-2. **ECS foundations**: generational entity ids, hierarchy removal, run
- conditions (game states Phase 1), then game states.
+1. **Small defects**: text cap-height centering, shader uniform
+ declarations.
+2. **ECS foundations**: hierarchy removal.
3. **Input**: input action state, then the text input field.
-4. **Rendering**: sprite textures (Phase 0 can land any time), camera
- views, post-processing effects, render resolution, sprite draw order,
+4. **Rendering**: sprite textures (Phase 0 can land any time),
+ post-processing effects, render resolution, sprite draw order,
hierarchical visibility, sprite fill, then WebGL context loss.
-5. **Features**: the sound mixer, collision events, polygon collider
- local space, persistent state.
+5. **Features**: the sound mixer, polygon collider local space,
+ persistent state.
---
diff --git a/design/font-atlas-loading.md b/design/font-atlas-loading.md
deleted file mode 100644
index 7a29c97e..00000000
--- a/design/font-atlas-loading.md
+++ /dev/null
@@ -1,202 +0,0 @@
-# Design: Font Atlases Load From Two Explicit URLs
-
-| | |
-| ------------------------------------- | ------------------------------------------------------------------------------------------------ |
-| **Status** | Implemented. `documentation-site/docs/docs/text` describes current behavior |
-| **Kind** | Defect |
-| **Found in** | Galactic Journey demo: `src/ui/create-ui.ts` (fonts served from `public/` to avoid hashed names) |
-| **Engine version at time of writing** | `0.25.8` |
-| **Related** | [`sprite-textures.md`](./sprite-textures.md) |
-
-## 0. Targeted modules
-
-| Path | Change | Notes |
-| -------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
-| `src/text/font-atlas/font-atlas-cache.ts` | Modified | `getOrLoad({ metricsUrl, imageUrl })`; in-flight loads shared; no path resolution |
-| `src/text/font-atlas/font-atlas-file-data.ts`, `font-atlas-data.ts`, `validate-font-atlas-data.ts` | Modified | `atlasImage` no longer part of the data |
-| `scripts/generate-font-atlas.mjs` | Modified | Stops writing `atlasImage` |
-| `assets/fonts/default/default.json`, `documentation-site/static/fonts/default/default.json` | Modified | `atlasImage` removed |
-| `package.json` | Modified | An `exports` subpath for the shipped default font's files |
-| `documentation-site/docs/docs/text/*.md`, `ui/labels-and-text.md`, `asset-loading/index.md`, demos | Modified | Both URLs; importing them through a bundler |
-
----
-
-## 1. Summary
-
-`FontAtlasCache.getOrLoad(jsonUrl)` fetches the atlas's metrics JSON, reads
-the image file name stored in it (`atlasImage`), and loads the image from
-the JSON's directory. That only works if the image is served next to the
-JSON under its original name.
-
-Bundlers don't do that: Vite, webpack and others give imported assets
-content-hashed names, so the image the JSON names doesn't exist in a
-build. The demo keeps its fonts out of the bundle, in `public/`, with a
-comment explaining why. The resolution is also a hand-rolled string
-slice of the JSON URL, which breaks on a `/` inside a query string or
-fragment, on `data:` and `blob:` URLs, and on an absolute `atlasImage`.
-
-The engine's own default font has the same problem from the other side:
-the package ships its files, but `package.json`'s `exports` has no path to
-them, so a bundler can't import them either. The text guide tells users to
-copy them out of `node_modules` by hand.
-
-This design has the caller pass both URLs, which any bundler can produce,
-and exports the default font's files so they can be imported like any
-other asset.
-
----
-
-## 2. Scope
-
-### In scope
-
-- `FontAtlasCache` taking both URLs.
-- Dropping `atlasImage` from the loaded data, the generator's output and
- the committed default font JSONs.
-- An `exports` subpath for the default font.
-- Docs: importing font atlases through a bundler.
-
-### Out of scope
-
-- **A single-file atlas format** (image embedded in the JSON). Larger
- downloads, and the two-file output works with every bundler once the
- loader stops guessing.
-- **Loading fonts as `Texture`s.** That follows from
- [`sprite-textures.md`](./sprite-textures.md).
-
----
-
-## 3. How established engines handle this
-
-- **Phaser**: `load.bitmapFont(key, textureURL, fontDataURL)`: the caller
- passes both.
-- **Babylon.js**: `new FontAsset(definitionData, textureUrl)`: the font
- definition (the JSON's contents) and the texture's URL, separately.
-- **three.js** with `three-bmfont-text`: the definition and the atlas
- texture are loaded by the caller with the usual loaders.
-- **PixiJS** (v8 `loadBitmapFont`) does what Forge does today: it
- resolves the page image next to the font file. It handles hashed names
- through its own asset pipeline (AssetPack), which rewrites the files
- together. Forge has no asset pipeline and relies on the game's bundler,
- which can only rewrite URLs it sees, so both files have to be imported
- by the game.
-- **Unity, Godot**: fonts are imported assets with their atlas inside;
- there's no runtime URL to resolve.
-
----
-
-## 4. Design
-
-```ts
-class FontAtlasCache {
- /** Loads (once) the atlas whose metrics are at `metricsUrl`. */
- getOrLoad(urls: { metricsUrl: string; imageUrl: string }): Promise;
- /** The atlas loaded from `metricsUrl`. */
- get(metricsUrl: string): FontAtlas;
-}
-```
-
-- **Keying and concurrency.** The cache is keyed by `metricsUrl`. It
- records `{ imageUrl, promise }` when a load starts, so concurrent calls
- for the same atlas (the guide's `Promise.all` pattern) share one load,
- and a call naming a different image URL for the same metrics throws,
- whether the first load has finished or not. The comparison is against
- the requested string, not the image's `src`, which the browser rewrites
- to an absolute URL.
-- **Pairing check.** The caller now pairs the two files, so the cache
- checks the loaded image's natural size against the JSON's `atlasSize`
- (which the shaders depend on) and throws on a mismatch, naming both
- URLs.
-- **Encapsulation.** `getOrLoad` and `get` are the public API; `load` and
- the `assets` map become private. `FontAtlasCache` no longer implements
- `AssetCache`, whose single-key `load` doesn't fit two URLs. Nothing uses
- `AssetCache` polymorphically, but two guides describe it as the font
- cache's contract (`asset-loading/index.md`, `text/index.md`) and are
- updated.
-- **Data.** `atlasImage` is removed from `FontAtlasData`, its validation,
- the generator's output and the two committed JSON files. Files that
- still contain it load normally: the validator ignores fields it doesn't
- know. `formatVersion` stays `2`; bumping it would reject every existing
- file for no benefit.
-
-With Vite, a game imports both files and passes the results:
-
-```ts
-import fontMetricsUrl from './fonts/my-font.json?url';
-import fontImageUrl from './fonts/my-font.png';
-
-const font = await fontAtlasCache.getOrLoad({
- metricsUrl: fontMetricsUrl,
- imageUrl: fontImageUrl,
-});
-```
-
-The default font is imported the same way, through a new `exports`
-subpath (`@forge-game-engine/forge/fonts/default/default.json` and
-`.png`), which `check-exports` must accept. The "copy these files out of
-`node_modules`" instructions in the text guide are deleted.
-
----
-
-## 5. Phases
-
-### Phase 1: Explicit URLs
-
-| # | Task | Size |
-| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- |
-| 1.1 | `getOrLoad({ metricsUrl, imageUrl })`; in-flight sharing and conflict check; size check; `load`/`assets` private; `resolveRelativeToKey` deleted | M |
-| 1.2 | `atlasImage` removed from data, validation, the generator and the two committed JSONs | S |
-| 1.3 | `exports` subpath for the default font; `check-exports` passes | S |
-| 1.4 | Unit tests: the six test files that build `atlasImage`; new tests in §8 | S |
-| 1.5 | Migrate the 13 docs demos (`_create-game.ts` and their `index.tsx` URL builders) and the stale synthetic atlas in `e2e/fixtures/scenes/text-effects-overlap.ts` | S |
-| 1.6 | Guides: `text/index.md`, `text/loading-a-font-atlas.md` (with a bundler example), `text/rendering-text.md`, `text/generating-a-font-atlas.md`, `ui/labels-and-text.md`, `asset-loading/index.md`; changelog under `#### Changed` | M |
-
-**Definition of done:** a font atlas, including the engine's default
-font, imported through Vite loads in a production build with hashed asset
-names.
-
----
-
-## 6. Decision log
-
-### DL-1: Always both URLs, not an optional image URL
-
-**Options.** (a) Both URLs, always. (b) The image URL optional, falling
-back to the JSON-relative name.
-
-**Decision: (a).**
-
-**Rationale.** (b) keeps the path that breaks under a bundler as the
-default and adds a second way to load the same thing. Passing two URLs is
-one extra line for a game that serves files unbundled.
-
-### DL-2: Validate the pairing
-
-**Rationale.** With the image named by the caller, a mismatched PNG is a
-new possible mistake, and its symptom (garbled glyphs) is far from its
-cause. Comparing the image's size with `atlasSize` catches it at load time
-for the cost of one comparison.
-
----
-
-## 7. Open questions
-
-None.
-
----
-
-## 8. Testing considerations
-
-- Loads with unrelated URLs for metrics and image (different directories,
- query strings, `blob:` URLs).
-- Two concurrent `getOrLoad` calls fetch once; a concurrent call with a
- different image URL throws.
-- An image whose size doesn't match `atlasSize` throws.
-- JSON with and without `atlasImage` validates.
-
-## 9. Documentation and demo follow-up
-
-- The guides in task 1.6: both URLs, with a Vite example; the default
- font imported through its `exports` subpath.
-- Demo: fonts move from `public/` into `src/` and are imported; the
- comment goes.
diff --git a/design/form-layout-columns.md b/design/form-layout-columns.md
deleted file mode 100644
index 138d101c..00000000
--- a/design/form-layout-columns.md
+++ /dev/null
@@ -1,528 +0,0 @@
-# Design: Column-Aligned Form Layout
-
-| | |
-| --- | --- |
-| **Status** | Draft |
-| **Engine version at time of writing** | `0.24.2` |
-
-| Module | Change |
-| --- | --- |
-| `src/ui/components/layout-element-component.ts` | Modified — new `sizeToText` override field |
-| `src/ui/components/layout-group-component.ts` | Modified — new `columnWidthMode`/`rowHeightMode`/`cellAlignment` fields on `GridLayoutGroupEcsComponent` |
-| `src/ui/systems/ui-layout-group-system.ts` | Modified — `createMeasure`'s plain-entity fallback reads `TextMeshEcsComponent.bounds` when `sizeToText` is set; `measureGridContent`/`arrangeGrid` gain content-driven column/row sizing |
-| `documentation-site/docs/docs/ui/index.md` | Modified — document both additions, update "Known limitations" |
-| `documentation-site/src/pages/demos/ui-nested-resize/_create-options-content.ts` | Modified — replace the hand-computed `labelWidth`/`controlX` offsets with the new grid mode |
-| `documentation-site/src/pages/demos/layout-groups/*` | Modified — the inventory grid demo already exercises `GridLayoutGroupEcsComponent`; extend it (or add a sibling demo) to show a content-sized column, so the feature has a live example beyond `ui-nested-resize` |
-
-## 1. Summary
-
-Every existing Forge layout group either measures a child's own natural size
-(`HorizontalLayoutGroupEcsComponent`/`VerticalLayoutGroupEcsComponent`) or
-ignores it entirely in favor of a fixed `cellSize`
-(`GridLayoutGroupEcsComponent`). Neither combination can produce the single
-most common two-dimensional form pattern: a label column and a control
-column, where every row's control should start at the same x position, but
-the column has to be exactly as wide as the longest label actually is - a
-width that isn't known until the labels' own text is measured, and that
-should keep sibling rows' widest label as the shared value.
-
-Today, building that layout means hand-computing pixel offsets per entity -
-exactly what `documentation-site/src/pages/demos/ui-nested-resize/_create-options-content.ts`
-does (`labelWidth = 140`, `controlX = labelX + labelWidth + 16`, both magic
-numbers, both silently wrong the moment a label's text or font size
-changes). This design closes that gap with two additions:
-
-1. **Label text auto-sizing** (`LayoutElementEcsComponent.sizeToText`) - lets
- the layout/measure pipeline read a label's actual shaped-text bounds as
- its preferred size, instead of whatever `sizeOrMargin` the caller
- hardcoded at creation time.
-2. **Content-sized grid columns and rows** (two new
- `GridLayoutGroupEcsComponent` fields) - lets a grid derive each column's
- width (and/or each row's height) from the largest measured cell in that
- column/row, the same way an HTML `` or a Unity/Unreal table layout
- auto-sizes its columns, rather than only supporting `GridLayoutGroupEcsComponent`'s
- current fixed, uniform `cellSize`.
-
-Together, these let a "Music" label and a "Fullscreen" label size themselves
-to their own text, and let the grid derive one shared column width from
-whichever is wider - so every row's control aligns on the same left edge
-automatically, with no per-demo pixel math.
-
-## 2. Scope
-
-### In scope
-
-- A new `sizeToText` override on `LayoutElementEcsComponent`, read by the
- existing measurement pipeline in `ui-layout-group-system.ts`.
-- Two new fields on `GridLayoutGroupEcsComponent` -
- `columnWidthMode`/`rowHeightMode` (`'fixed' | 'content'`, both defaulting
- to `'fixed'` - fully backward compatible, every existing grid keeps its
- current fixed-`cellSize` behavior unchanged) - plus a `cellAlignment`
- field controlling where a cell's own content sits within a
- content-derived column/row that's larger than that specific cell.
-- Updating `measureGridContent`/`arrangeGrid` to compute per-column widths
- and per-row heights when either mode is `'content'`, reusing the same
- recursive `Measure` function axis groups already use - no new measurement
- concept, just a new consumer of the existing one.
-- Validating the `'content'` mode against `GridLayoutGroupEcsComponent.constraint`
- (see Decision Log, DL-02) and throwing a descriptive error for the
- unsupported combination.
-- Updating `documentation-site/docs/docs/ui/index.md`'s "Layout groups" and
- "Known limitations" sections.
-- Migrating `ui-nested-resize`'s options content to the new grid mode,
- removing its hardcoded `labelWidth`/`controlX`.
-- Adding or extending a documentation-site demo that shows a content-sized
- grid independent of `ui-nested-resize` (which is about live-resizing
- nested anchors, not about form layout - the feature needs its own,
- clearer example).
-
-### Out of scope
-
-- **A distinct "table layout group" component.** Everything this design
- needs is an extension of the existing `GridLayoutGroupEcsComponent`'s
- placement model (row/column index, `constraint`, `startCorner`,
- `startAxis`); introducing a second, largely-overlapping component for
- "arrange children into rows and columns" would be a worse API surface for
- consumers than one component with an additional sizing mode. See DL-01.
-- **`'flexible'` constraint combined with content sizing.** Deriving column
- count from available width, when column width itself depends on which
- cells land in which column, is circular; `'content'` mode requires
- `'fixedColumnCount'` or `'fixedRowCount'`. Revisit only if a concrete use
- case needs it.
-- **Per-cell stretch-to-fill for a content-sized axis.** A cell on a
- `'content'` axis keeps its own measured size and is positioned via
- `cellAlignment`; there's no force-expand equivalent (axis groups' own
- `childForceExpandWidth`/`Height`) to stretch a narrower cell to fill its
- column. Nothing in the motivating use case needs it, and it can be added
- later without a breaking change if a real one comes up.
-- **Auto-sizing anything other than labels.** `sizeToText` only ever reads
- `TextMeshEcsComponent.bounds`; it has no bearing on sprites, panels, or
- any other UI element - those already have `LayoutElementEcsComponent`'s
- existing `preferredWidth`/`preferredHeight` overrides for a manual
- equivalent.
-- **Dynamic runtime text changes.** `sizeToText` reads whatever
- `TextMeshEcsComponent.bounds` currently holds every frame (the same
- recompute-every-frame model the rest of this module already uses - see
- DL-03), so a label whose text changes at runtime keeps sizing correctly
- automatically. No new API is needed for this; noting it here only because
- it's a natural question, not because there's design work behind it.
-
-## 3. Phases
-
-Each phase ships and is useful independently - Phase 1 has value with no
-labels involved (e.g. a content-sized grid of variously-sized panels/icons),
-and Phase 0 has value with no grid involved (any layout group already
-benefits from a label reporting its true measured width instead of a
-hardcoded one).
-
-### Phase 0 — Label text auto-sizing
-
-Lets a label's preferred size, for measurement purposes only, come from its
-own shaped text rather than a hand-set `sizeOrMargin`.
-
-| Task | Description | Size |
-| --- | --- | --- |
-| Add `sizeToText` field | New optional `boolean` field on `LayoutElementEcsComponent`, defaulting to `false` via `addLayoutElementComponent`'s existing defaulting pattern | S |
-| Wire into `createMeasure` | In `ui-layout-group-system.ts`, when an entity's `LayoutElementEcsComponent.sizeToText` is `true`, read `TextMeshEcsComponent.bounds.width`/`height` as that axis's `preferred` (and `min`, since a label shouldn't shrink below its own ink) instead of falling back to `RectTransformEcsComponent.sizeOrMargin`; throw a descriptive error if the entity has no `TextMeshEcsComponent` (see DL-04, `TextEcsComponent` vs `TextMeshEcsComponent` timing) | M |
-| `createLabel` convenience | Add a `sizeToText?: boolean` option to `CreateLabelOptions` that, when `true`, attaches a `LayoutElementEcsComponent` with `sizeToText: true` for the caller, so the common case doesn't need two separate calls | S |
-| Unit tests | Cover: a plain label with `sizeToText` reports its shaped bounds; a label without it keeps today's `sizeOrMargin` fallback; the error path for `sizeToText` on a non-text entity; `sizeToText` composing with an explicit `preferredWidth`/`Height` override (the explicit override should still win, per `LayoutElementEcsComponent`'s existing per-field precedence) | M |
-
-**Definition of done:** a label created via `createLabel(..., { sizeToText: true })`
-inside any existing layout group (axis or grid) sizes itself to its own text
-with zero manual `sizeOrMargin`, verified by a unit test asserting the
-group's arrangement reflects two labels of different text lengths differently.
-
-### Phase 1 — Content-sized grid columns and rows
-
-The core of this design: lets `GridLayoutGroupEcsComponent` derive column
-width / row height from measured content instead of only a fixed `cellSize`.
-
-| Task | Description | Size |
-| --- | --- | --- |
-| New component fields | `columnWidthMode`/`rowHeightMode: 'fixed' \| 'content'` (default `'fixed'`) and `cellAlignment: UiAlignment` (default `uiAlignments.topLeft`) on `GridLayoutGroupEcsComponent`/`addGridLayoutGroupComponent`, following the file's existing default-options-object convention | S |
-| Constraint validation | `addGridLayoutGroupComponent` throws a descriptive error when either mode is `'content'` and `constraint` is `'flexible'` (see Scope/Out of scope and DL-02) | S |
-| Column/row measurement | Extend `measureGridContent` to accept the same `Measure` function `measureAxisGroupContent` already receives, compute each column's/row's size as the max of its cells' measured preferred size (for a `'fixed'` axis, keep exactly today's `cellSize`-only math) - see Design sub-section 6.1 for the exact algorithm | L |
-| Arrangement | Extend `arrangeGrid` to size/position each cell using the computed column widths/row heights: a `'fixed'` axis keeps today's forced full-cell resize; a `'content'` axis leaves the cell at its own measured size, offset within its column/row by `cellAlignment` - see Design sub-section 6.2 | L |
-| `startCorner`/`startAxis` correctness | Verify (and add regression tests for) every `startCorner`/`startAxis` combination against content-sized columns/rows specifically - the existing fixed-`cellSize` grid's corner/axis flip is index-only and cheap to get right by construction, but a flip that also has to relabel which *physical* column a *logical* column's measured width belongs to is a new failure mode this design introduces (see Design sub-section 6.2's prefix-sum approach) | M |
-| Unit tests | Cover: two-column content-sized grid with rows of unequal label width, shared column width equals the max; mixed mode (one fixed axis, one content axis); `cellAlignment` centering/right-aligning a narrower cell within its column; the `'flexible'` + `'content'` validation error; nesting (a content-sized grid inside a `ContentSizeFitterEcsComponent`, or inside another layout group, measuring correctly) | L |
-
-**Definition of done:** a two-column, `fixedColumnCount: 2`,
-`columnWidthMode: 'content'` grid, given a "Music"-length label + a wide
-control in row 1 and a "Fullscreen"-length label + a narrow control in row
-2, places both controls at the same x position, verified by a unit test
-reading both cells' resolved `RectTransformEcsComponent.rect`.
-
-### Phase 2 — Documentation and demo migration
-
-| Task | Description | Size |
-| --- | --- | --- |
-| `documentation-site/docs/docs/ui/index.md` | Document `sizeToText` under "Labels", the two new grid fields under "Layout groups", following the file's existing terminology and cross-linking conventions (`UiAnchor`-style prose, links to the generated API reference) | M |
-| Migrate `ui-nested-resize` | Replace `_create-options-content.ts`'s hardcoded `labelWidth`/`controlX` with a `columnWidthMode: 'content'` grid hosting the "Music"/slider and "Fullscreen"/toggle rows, using `sizeToText` labels | M |
-| New/extended demo | Add a content-sized-grid example to `documentation-site/src/pages/demos/layout-groups/` (its existing `_create-inventory-grid.ts` already shows `GridLayoutGroupEcsComponent` - either extend it with a labeled options panel, or add a sibling `_create-options-form.ts`), since `ui-nested-resize` is the wrong place to be the feature's primary illustration | M |
-| Full demo verification | Per `AGENTS.md`'s "Documentation Site Demos" section: rebuild `/dist`, `documentation-site`'s `typecheck`/`build`, and a manual browser check of both demos | S |
-
-**Definition of done:** `ui-nested-resize`'s options content has no
-hardcoded column-alignment constants left, and a browser check confirms the
-Music/Fullscreen rows still align identically to today's hand-tuned result.
-
-## 4. Decision log
-
-### DL-01 — Extend `GridLayoutGroupEcsComponent`, don't add a `TableLayoutGroupEcsComponent`
-
-**Options considered:**
-
-- A new, standalone `TableLayoutGroupEcsComponent` modeled directly on
- HTML ``/Unity `TableLayoutPanel` semantics.
-- Extend the existing `GridLayoutGroupEcsComponent` with a per-axis sizing
- mode.
-
-**Decision:** Extend `GridLayoutGroupEcsComponent`.
-
-**Rationale:** The two concepts already share their entire placement model -
-row/column index derivation, `constraint`, `startCorner`, `startAxis`,
-`childAlignment` for the whole block. The only real difference is *where a
-column's width comes from* (a fixed value vs. measured content), which is a
-narrower, additive change to one component rather than a second component
-that would force every consumer to learn "grid vs. table" as a first
-decision, when the actual decision is one field.
-
-**Tradeoff:** `GridLayoutGroupEcsComponent`'s interface grows two fields and
-a validation rule, and `measureGridContent`/`arrangeGrid` grow real branching
-logic instead of staying the simple, cheap functions they are today - a
-maintenance cost this decision accepts in exchange for a single, coherent
-"arrange into rows and columns" primitive.
-
-**Assumption:** No use case needs *both* a fixed-`cellSize` grid and a
-content-sized grid to compose in ways that would be cleaner as two
-components than as one with a mode flag. If one turns up, it would be a
-signal to revisit this decision, not a signal that this decision was
-premature.
-
-### DL-02 — `'content'` sizing requires a fixed row or column count
-
-**Options considered:**
-
-- Support `'flexible'` by iterating: guess a column count, measure, refit,
- repeat until stable.
-- Require `'fixedColumnCount'`/`'fixedRowCount'` when either axis is
- `'content'`; throw for `'flexible'`.
-
-**Decision:** Require a fixed count; throw for the unsupported combination.
-
-**Rationale:** `'flexible'`'s column count depends on how much content-box
-width is available once column widths are known - and column widths depend
-on which cells fall into which column, which depends on the column count.
-An iterate-to-convergence approach is solvable, but adds real complexity
-(how many iterations, does it always converge, what happens under
-`createUiLayoutGroupEcsSystem`'s existing one-frame-stale-rect model) for a
-combination nothing in this design's motivating use case needs - a two- or
-three-column form always knows its column count up front.
-
-**Tradeoff:** A consumer who genuinely wants both flexible wrapping and
-content-sized columns has no path today; they get a clear error pointing at
-`fixedColumnCount`/`fixedRowCount` instead, rather than either silently
-wrong behavior or unbounded per-frame iteration cost.
-
-### DL-03 — `sizeToText` re-reads `TextMeshEcsComponent.bounds` every frame, not once at creation
-
-**Options considered:**
-
-- Measure once when the label is created (or once when `sizeToText` is
- first added) and freeze the resulting `sizeOrMargin`.
-- Re-read `TextMeshEcsComponent.bounds` every frame `createMeasure` runs,
- same as every other field in `createMeasure`.
-
-**Decision:** Re-read every frame.
-
-**Rationale:** This module already recomputes layout in full every frame
-rather than tracking dirty state (`createUiLayoutEcsSystem`'s own
-documented model, and `createUiLayoutGroupEcsSystem`'s doc comment above).
-A one-shot measurement would be the only place in the whole layout pipeline
-that special-cases "compute once," and would silently go stale the moment a
-label's text changes at runtime (localization, a dynamic value like a
-volume percentage) - exactly the kind of surprising behavior this design
-should avoid.
-
-**Tradeoff:** None functionally; `TextMeshEcsComponent.bounds` is already
-computed every frame `text-shaping-system.ts` reshapes a changed string
-(and is a plain field read otherwise), so this adds no new per-frame cost
-beyond the measurement pipeline's existing per-entity work.
-
-### DL-04 — `sizeToText` requires a `TextMeshEcsComponent`, not just a `TextEcsComponent`
-
-**Options considered:**
-
-- Accept `sizeToText` on any entity with a `TextEcsComponent`, falling back
- to `0`/`sizeOrMargin` if shaping hasn't produced a `TextMeshEcsComponent`
- yet.
-- Require `TextMeshEcsComponent` to already exist; throw otherwise.
-
-**Decision:** Require `TextMeshEcsComponent`.
-
-**Rationale:** `TextMeshEcsComponent` (glyph quads + bounds) is only added
-by `text-shaping-system.ts` once shaping actually runs; a `TextEcsComponent`
-alone doesn't have bounds to read yet. `createTextShapingEcsSystem` is
-already required by every demo using labels and runs every frame, so in
-practice this component exists by the time layout groups run except on the
-very first frame an entity is created - the same one-frame lag every other
-freshly-created entity in this system already has (see this file's own doc
-comment on `createUiLayoutGroupEcsSystem` about one-frame-stale rects).
-Silently falling back to `0` would hide a genuine misconfiguration (missing
-`createTextShapingEcsSystem` registration, or `sizeToText` on a non-label
-entity) behind a confusing "everything's zero-width" symptom instead of a
-clear error at the point of misuse.
-
-**Tradeoff:** A brand-new label with `sizeToText: true` measures as `0` for
-exactly one frame before `text-shaping-system.ts` first runs, rather than
-never. This matches the one-frame convergence behavior every other part of
-this module already accepts by design (see `design/ui-system.md`'s DL-12,
-"Full recompute per frame; no dirty tracking in v1") rather than
-introducing a new kind of inconsistency.
-
-## 5. Open questions
-
-1. **Should `sizeToText` live on `LayoutElementEcsComponent`, or become a
- `CreateLabelOptions`-only convenience with no queryable component at
- all?** This design puts it on `LayoutElementEcsComponent` so it composes
- with every other override field (`preferredWidth` still wins if both are
- set) and so it can be toggled at runtime, not just at creation. Confirm
- this is the right home before implementation - it's the one part of this
- design without a precedent to lean on (`preferredWidth`/`Height` are
- plain numeric overrides; `sizeToText` is the first override whose value
- depends on *another component*).
-2. **Does `cellAlignment` need independent horizontal/vertical values, or is
- one `UiAlignment` (the existing `Vector2`-shaped preset) enough?** This
- design reuses `UiAlignment` directly, matching `childAlignment`'s
- existing shape on both group types - flag if a real layout needs, say,
- left-aligned labels but centered controls in the same grid, which would
- need per-column (not per-grid) alignment instead.
-3. **Should the new demo (Phase 2) replace `layout-groups`' existing
- inventory grid, or sit alongside it as a fourth panel?** The existing
- three-panel structure (Menu/Toolbar/Inventory) is a deliberate one-panel-
- per-feature split (see `6b9c85da`'s commit splitting the old monolithic
- demo) - confirm whether a fourth "Options form" panel fits that pattern
- or deserves its own demo page.
-4. **Is a validation error the right failure mode for `'flexible'` +
- `'content'` (DL-02), or should it just be silently disallowed by the
- type system** (e.g. a discriminated union making the combination
- unrepresentable)? A type-level fix is more idiomatic for this codebase's
- "narrow types, handle nullish values" convention, but
- `GridLayoutGroupDefaultedOptions` is a flat, mutable-in-place interface
- today (every field independently settable after creation via the
- returned component reference) - worth confirming a discriminated union
- doesn't fight that pattern before committing to the runtime-check
- version this design assumes.
-
-## 6. Design sub-sections
-
-### 6.1 Column/row measurement algorithm
-
-`measureGridContent` already exists and, for a `'fixed'` grid, simply
-multiplies `cellSize` by the derived column/row count (see
-`ui-layout-group-system.ts`'s current implementation). This design extends
-it to also accept the same `measure: Measure` function
-`measureAxisGroupContent` already receives, and to branch per axis:
-
-```
-function measureGridContent(world, entity, grid, childrenByParent, measure): Measured {
- const children = arrangeableChildrenOf(world, childrenByParent, entity)
- const { columns, rows } = gridDimensions(grid, children.length, undefined)
-
- const columnWidths = new Array(columns).fill(grid.cellSize.x)
- const rowHeights = new Array(rows).fill(grid.cellSize.y)
-
- if (grid.columnWidthMode === 'content' || grid.rowHeightMode === 'content') {
- for (let i = 0; i < children.length; i++) {
- const { column, row } = logicalCellOf(grid, i, columns, rows) // startAxis only, no corner flip - see 6.2
- const measured = measure(children[i])
-
- if (grid.columnWidthMode === 'content') {
- columnWidths[column] = Math.max(columnWidths[column], measured.width.preferred)
- }
- if (grid.rowHeightMode === 'content') {
- rowHeights[row] = Math.max(rowHeights[row], measured.height.preferred)
- }
- }
- }
-
- const width = sum(columnWidths) + grid.spacing.x * (columns - 1) + grid.padding.left + grid.padding.right
- const height = sum(rowHeights) + grid.spacing.y * (rows - 1) + grid.padding.top + grid.padding.bottom
-
- return {
- width: { min: width, preferred: width, flexible: 0 },
- height: { min: height, preferred: height, flexible: 0 },
- }
-}
-```
-
-`logicalCellOf` is the existing `row`/`column` derivation `arrangeGrid`
-already computes from `i`, `columns`, `rows`, and `grid.startAxis` - *before*
-`startCorner`'s flip is applied. Sizing is computed in logical (unflipped)
-space; only physical placement (6.2) needs the flip.
-
-### 6.2 Arrangement algorithm
-
-`arrangeGrid` currently places cell `i` at a corner-flipped
-`(actualColumn, actualRow)` and unconditionally resizes it to `cellSize`.
-This design changes the resize to be per-axis-conditional, and changes the
-cell's physical offset from `actualColumn * cellSize.x` to a prefix sum over
-the (possibly non-uniform) column widths - in *physical*, not logical,
-order, since a flipped grid's physical column `0` holds whichever logical
-column ends up there:
-
-```
-function arrangeGrid(world, entity, grid, childrenByParent, measure): void {
- // columnWidths, rowHeights computed exactly as in 6.1
-
- const flipColumn = grid.startCorner === 'upperRight' || grid.startCorner === 'lowerRight'
- const flipRow = grid.startCorner === 'lowerLeft' || grid.startCorner === 'lowerRight'
-
- const physicalColumnWidths = columnWidths.map((_, physical) =>
- columnWidths[flipColumn ? columns - 1 - physical : physical]
- )
- const physicalRowHeights = rowHeights.map((_, physical) =>
- rowHeights[flipRow ? rows - 1 - physical : physical]
- )
-
- const columnOffsets = prefixSum(physicalColumnWidths, grid.spacing.x)
- const rowOffsets = prefixSum(physicalRowHeights, grid.spacing.y)
-
- for (let i = 0; i < children.length; i++) {
- const { column, row } = logicalCellOf(grid, i, columns, rows)
- const actualColumn = flipColumn ? columns - 1 - column : column
- const actualRow = flipRow ? rows - 1 - row : row
-
- const columnWidth = columnWidths[column] // logical index - this cell's own column's measured width
- const rowHeight = rowHeights[row]
-
- const childMeasured = measure(children[i])
- const cellWidth = grid.columnWidthMode === 'content' ? childMeasured.width.preferred : columnWidth
- const cellHeight = grid.rowHeightMode === 'content' ? childMeasured.height.preferred : rowHeight
-
- childRect.sizeOrMargin = { x: cellWidth, y: cellHeight }
-
- const offsetX = (columnWidth - cellWidth) * grid.cellAlignment.x
- const offsetY = (rowHeight - cellHeight) * grid.cellAlignment.y
-
- childRect.anchoredPosition = {
- x: contentLeft + columnOffsets[actualColumn] + offsetX,
- y: contentBottom + gridContentHeight - rowOffsets[actualRow] - rowHeight + offsetY,
- }
- }
-}
-```
-
-Note `cellAlignment` needs no special-casing per mode: on a `'fixed'` axis,
-`cellWidth === columnWidth` always (today's forced-fill behavior,
-unchanged), so `offsetX` is always `0` regardless of `cellAlignment` - the
-same formula produces both behaviors.
-
-### 6.3 API sketch
-
-```ts
-export type UiGridSizingMode = 'fixed' | 'content';
-
-export interface GridLayoutGroupDefaultedOptions {
- padding: UiLayoutGroupPadding;
- cellSize: Vector2; // still consulted per-axis when that axis's mode is 'fixed'
- spacing: Vector2;
- childAlignment: UiAlignment;
- startCorner: UiGridLayoutGroupCorner;
- startAxis: UiGridLayoutGroupAxis;
- constraint: UiGridLayoutGroupConstraint;
- constraintCount: number;
-
- /** New: derives column width from measured cell content instead of `cellSize.x`. */
- columnWidthMode: UiGridSizingMode; // default 'fixed'
-
- /** New: derives row height from measured cell content instead of `cellSize.y`. */
- rowHeightMode: UiGridSizingMode; // default 'fixed'
-
- /**
- * New: where a cell's own content sits within its column/row when that
- * axis's mode is 'content' and this specific cell is narrower/shorter
- * than the shared column/row size. No effect on a 'fixed' axis, where a
- * cell always fills `cellSize` exactly.
- */
- cellAlignment: UiAlignment; // default uiAlignments.topLeft
-}
-
-export interface LayoutElementEcsComponent extends LayoutElementDefaultedOptions {
- minWidth?: number;
- minHeight?: number;
- preferredWidth?: number;
- preferredHeight?: number;
- flexibleWidth?: number;
- flexibleHeight?: number;
-
- /**
- * New: this entity's preferred (and min) size, on whichever axis this is
- * relevant to, comes from its own `TextMeshEcsComponent.bounds` instead of
- * `RectTransformEcsComponent.sizeOrMargin`. Requires a `TextMeshEcsComponent`
- * on the same entity - throws otherwise. An explicit `preferredWidth`/
- * `preferredHeight` still overrides this, same precedence as every other
- * `LayoutElementEcsComponent` field.
- */
- sizeToText?: boolean;
-}
-```
-
-Usage, replacing `_create-options-content.ts`'s hardcoded offsets:
-
-```ts
-const optionsGrid = world.createEntity();
-// ... addPositionComponent, addParentComponent, addRectTransformComponent ...
-
-addGridLayoutGroupComponent(world, optionsGrid, {
- constraint: 'fixedColumnCount',
- constraintCount: 2,
- columnWidthMode: 'content',
- rowHeightMode: 'fixed',
- cellSize: { x: 0, y: 40 }, // x ignored (content mode); y is every row's fixed height
- spacing: { x: 16, y: 12 },
- cellAlignment: uiAlignments.middleLeft,
-});
-
-createLabel(world, optionsGrid, { text: 'Music', fontAtlas, size: 20, sizeToText: true, /* ... */ });
-createSlider(world, optionsGrid, { /* ... */ }); // no anchoredPosition/sizeOrMargin math needed
-createLabel(world, optionsGrid, { text: 'Fullscreen', fontAtlas, size: 20, sizeToText: true, /* ... */ });
-createToggle(world, optionsGrid, { /* ... */ });
-```
-
-### 6.4 Performance considerations
-
-Both additions reuse the existing `Measure` function, which is already
-memoized per entity per frame (`createMeasure`'s `cache`) - a content-sized
-grid's per-cell `measure()` call inside the new loops in 6.1/6.2 is a cache
-hit for every entity the outer `createUiLayoutGroupEcsSystem.update` loop
-already warmed. The added cost is O(children) extra array writes for
-`columnWidths`/`rowHeights`/prefix sums, on top of work `arrangeGrid`
-already does per cell - no new asymptotic complexity, and no cost at all
-for the (unchanged, default) `'fixed'` mode path.
-
-### 6.5 Testing considerations
-
-Per `AGENTS.md`'s testing conventions, new coverage lands in
-`layout-element-component.test.ts`, `layout-group-component.test.ts`, and
-`ui-layout-group-system.test.ts` alongside the existing tests for each
-component/system, not in new files - these are additive fields on existing
-components/systems, not new modules. Phase 1's `startCorner`/`startAxis`
-correctness task (see Phase 1 table) specifically needs a test matrix over
-all four corners crossed with both `startAxis` values, asserting resolved
-`rect` positions - the existing grid tests only assert this for uniform
-`cellSize`, which can't catch a column-width/physical-index mismatch; the
-logical-vs-physical indexing in section 6.2 is the one part of this design
-with real room for an off-by-one.
-
-### 6.6 Documentation considerations
-
-`documentation-site/docs/docs/ui/index.md`'s existing "Labels" section gets
-a short `sizeToText` paragraph near its existing size-related prose; its
-"Layout groups" section's existing `GridLayoutGroupEcsComponent` paragraph
-(see the file's current line ~530) gets extended with `columnWidthMode`/
-`rowHeightMode`/`cellAlignment`, following the same
-"`[api link]` does X - `field` controls Y" prose pattern the rest of that
-section already uses. "Known limitations" doesn't need a new bullet removed
-(this design doesn't touch scroll views/clipping), but should gain a note
-if Open Question 2 (per-column alignment) or 4 (flexible + content) is
-resolved as "not supported yet" rather than "not applicable."
diff --git a/design/game-states.md b/design/game-states.md
deleted file mode 100644
index b11471f6..00000000
--- a/design/game-states.md
+++ /dev/null
@@ -1,401 +0,0 @@
-# Design: Game States, Run Conditions and State-Scoped Entities
-
-| | |
-| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **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` |
-| **Related** | [`generational-entity-ids.md`](./generational-entity-ids.md), [`hierarchy-removal.md`](./hierarchy-removal.md), [`input-action-state.md`](./input-action-state.md), [`hierarchical-visibility.md`](./hierarchical-visibility.md) |
-
-## 0. Targeted modules
-
-| Path | Change | Notes |
-| ------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------ |
-| `src/ecs/ecs-world.ts`, `ecs-system-group.ts` | Modified | `runIf` on `addSystem` and `addSystemGroup`; a built-in first group that every group runs after |
-| `src/states/` | **New** | `createGameState`, `GameState`, `inState`/`onEnter`/`onExit`, the state-scoped component and the transition system |
-| `src/index.ts`, `package.json` exports | Modified | New module |
-| `documentation-site/docs/docs/ecs/`, new `states/` guide, docs demo | Modified | Run conditions, states |
-
----
-
-## 1. Summary
-
-Most games move between a few top-level states (loading, menu, playing,
-paused, game over) and most of their systems only make sense in some of
-them. Forge has no notion of this. Its finite state machine is a general
-predicate-driven utility, and systems run every tick. A game can run
-several `EcsWorld`s, but `Game` updates every one of them every frame, and
-worlds keep separate content apart rather than switch between states
-(DL-4).
-
-The demo builds the missing pieces itself:
-
-- **A state with enter and exit.** `RunEcsComponent` holds a
- `FiniteStateMachine` of phases plus `enteredPhase` and `leftPhase`, which
- `run.system.ts` sets for one tick after each transition, and which only
- systems registered after it may read. Moving to another phase means
- setting one of five request flags that the run system turns into a
- transition. A `loading` phase exists only so the menu gets an "entered"
- tick at startup.
-- **Systems that only run in some states.** Systems call `isInMenu(run)`,
- `hasEntered(run, ...)` or `hasLeft(run, ...)` at the top of their
- `update` (15 calls in 9 files), each querying the run entity first. Some
- of those calls are gates; others sit in callbacks or in fades that must
- run every tick, and stay as checks.
-- **Entities that belong to a state.** `clear-run.system.ts` removes
- asteroids, bullets, enemies, grabbers, warnings, power-ups, particles and
- the player, kind by kind, when a run starts or the menu comes up.
-
-Bevy has all three as engine features: states with `OnEnter`/`OnExit`,
-`run_if(in_state(...))`, and entities despawned when their state is left
-or entered. This design gives Forge the same.
-
----
-
-## 2. Scope
-
-### In scope
-
-- Run conditions on systems and system groups.
-- A built-in first system group, so state transitions happen before any
- other system of the tick.
-- `createGameState`: a named state with transitions applied at the start
- of the tick, enter and exit groups, and `inState`, `onEnter` and
- `onExit` conditions.
-- A state-scoped component that removes entities on a transition.
-
-### Out of scope
-
-- **Sub-states and computed states.** One flat state per `GameState`; a
- game can have several.
-- **Changing the existing `FiniteStateMachine`.** It stays as a general
- utility (animation controllers, AI).
-- **Pausing time.** A paused state stops the systems it gates; `Time`
- itself is unchanged.
-
----
-
-## 3. How established engines handle this
-
-- **Bevy**: a `States` type, `NextState` to request a transition, applied
- in a dedicated `StateTransition` schedule (after `PreUpdate`, before
- `Update`); `OnExit`, `OnTransition` and `OnEnter` schedules run there,
- once per transition, so enter and exit logic happens before any `Update`
- system; the initial state's `OnEnter` runs at startup; setting the
- current state again re-runs exit and enter (`set_if_different` skips
- that); `run_if(in_state(...))` gates systems; entities with
- `DespawnOnExit`/`DespawnOnEnter` (formerly `StateScoped`) are despawned,
- with their descendants, on the matching transition.
-- **Godot**: no built-in state. Games change scenes
- (`change_scene_to_file`), which frees the current scene's node tree and
- loads the next, and pause subtrees with `SceneTree.paused` and each
- node's `process_mode`. Content shared between scenes goes in an
- autoload, which outlives scene changes.
-- **Unity**: scenes play the same role. Loading a scene destroys the
- previous scene's objects, unless they're marked to survive scene loads
- or the new scene is loaded additively. In DOTS, systems are gated with
- `RequireForUpdate` or `Enabled`, and several `World`s keep separate
- simulations apart (Netcode's client and server worlds) rather than
- switch between states.
-
-A Godot node or a Unity object carries its own logic, so swapping the
-content swaps the logic with it. In an ECS, systems are separate from
-entities, so a state needs both halves: which systems run (run
-conditions) and which entities go (state-scoped entities). Bevy, whose
-states work inside an app's one main world, is the ECS reference and the
-model here.
-
----
-
-## 4. Design
-
-### 4.1 Run conditions
-
-```ts
-type RunCondition = (world: EcsWorld) => boolean;
-
-world.addSystem(system, { runIf: inState(runState, 'flying') });
-world.addSystemGroup(gameplayGroup, { runIf: inState(runState, 'flying') });
-```
-
-A system runs if its group's condition and its own are true, evaluated
-each tick just before the group or system would run. A gated system's
-query isn't computed when it doesn't run. Run conditions are scheduling
-only: a system's `query` and `tags` stay fixed, and its `cleanup` still
-runs when it's removed.
-
-### 4.2 The first group
-
-`EcsWorld` gets a built-in `firstSystemGroup` that runs before every other
-group of the tick, whatever order groups were added in. Ordering another
-group before it throws. Today `addSystemGroup`'s `before` only promises an
-order relative to the groups named, so a game's group placed before the
-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
-interface GameState {
- /** The current state. */
- readonly current: TName;
- /** The state entered at the start of this tick, if any. */
- readonly entered: TName | null;
- /** The state left at the start of this tick, if any. */
- readonly exited: TName | null;
- /** Systems that run once when a state is left (with `onExit` conditions). */
- readonly exitGroup: EcsSystemGroup;
- /** Systems that run once when a state is entered (with `onEnter` conditions). */
- readonly enterGroup: EcsSystemGroup;
- /**
- * Requests a transition, applied at the start of the next tick.
- * Requesting the current state re-enters it (exit, then enter).
- */
- set(next: TName): void;
-}
-
-function createGameState(
- world: EcsWorld,
- initial: TName,
-): GameState;
-
-function inState(
- state: GameState,
- ...names: T[]
-): RunCondition;
-function onEnter(
- state: GameState,
- ...names: T[]
-): RunCondition;
-function onExit(
- state: GameState,
- ...names: T[]
-): RunCondition;
-```
-
-`createGameState` registers its transition system in the world's first
-group. It's the only writer of `current`, `entered` and `exited`. At the
-start of each tick, in this order:
-
-1. A requested transition is applied (the last `set` of the previous tick
- 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), 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.
-
-Re-entering lets a game restart without a detour through another state:
-`set('playing')` while playing runs the exit and the enter of `playing`
-again, with everything they trigger.
-
-### 4.4 State-scoped entities
-
-```ts
-addStateScopedComponent(world, entity, {
- state: runState,
- removeOnExit: ['flying'],
-});
-```
-
-The transition removes every entity whose state left one of its
-`removeOnExit` states, or entered one of its `removeOnEnter` states, at
-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
-`removeOnEnter: ['flying', 'menu']`. Particles are created by emitters, so
-their scope is added in the emitter's `onParticleSpawned` callback. With
-that, `clear-run.system.ts` is deleted. The demo's `removeWithHealthBar`
-and `removePowerUp` helpers stay for kills during a run, until
-[`hierarchy-removal.md`](./hierarchy-removal.md)'s open question 1 is
-settled.
-
-### 4.5 Where it lives
-
-Run conditions and the first group are scheduling, so they're in
-`src/ecs`. States, their component and their transition system are a new
-`src/states` module with the usual `components`/`systems` layout, as Bevy
-keeps `bevy_state` apart from `bevy_ecs`.
-
-### 4.6 Several worlds
-
-A `GameState` belongs to the world it's created with, which applies its
-transitions and runs its enter and exit groups. Systems in another world,
-such as a UI overlay world, can still be gated on it with `inState`,
-since a run condition only reads the state. A world that `Game` updates
-after the owning one sees each transition in the same frame.
-
----
-
-## 5. Phases
-
-### Phase 1: Run conditions and the first group
-
-| # | Task | Size |
-| --- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ---- |
-| 1.1 | `runIf` on `addSystem` and `addSystemGroup`; the update loop checks the group's condition, then the system's, and skips the query of a gated system | S |
-| 1.2 | `firstSystemGroup`; ordering a group before it throws | S |
-| 1.3 | Tests; `ecs/system.md` section; changelog under `#### Added` | S |
-
-**Definition of done:** a system whose condition is false doesn't run,
-and its `cleanup` still runs when it's removed; the first group runs first
-however groups were added.
-
-### Phase 2: States and scoped entities
-
-| # | Task | Size |
-| --- | -------------------------------------------------------------------------------------------------------------------------- | ---- |
-| 2.1 | `src/states`: `createGameState`, transition system, `entered`/`exited`, initial entry, re-entry | M |
-| 2.2 | `exitGroup`/`enterGroup` ordering; `inState`, `onEnter`, `onExit` | S |
-| 2.3 | State-scoped component and removal at the transition | S |
-| 2.4 | Module wiring; guide `states/`; a docs demo (menu, play, game over) with scoped entities and a `demos.ts` entry; changelog | M |
-
-**Definition of done:** the docs demo switches between three states with
-no state checks inside its systems and no manual cleanup.
-
-Phase 2 depends on Phase 1, and on
-[`generational-entity-ids.md`](./generational-entity-ids.md) and
-[`hierarchy-removal.md`](./hierarchy-removal.md) for scoped removal.
-
----
-
-## 6. Decision log
-
-### DL-1: Transitions apply at the start of the tick
-
-**Options.** (a) At the start of the next tick. (b) Immediately when
-`set` is called.
-
-**Decision: (a).**
-
-**Rationale.** With (b), systems earlier in the tick saw the old state
-and later ones the new, which is the ordering constraint the demo
-documents on its run systems ("must be registered after"). (a) is Bevy's
-model, and the first group makes it hold for every system.
-
-### DL-2: Enter and exit systems run at the transition point
-
-**Options.** (a) `onEnter`/`onExit` as conditions on ordinary systems,
-wherever they're registered. (b) Groups that run right after the
-transition, holding systems with those conditions.
-
-**Decision: (b).**
-
-**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 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
-
-**Rationale.** "Remove when this state is left" is the common case. The
-demo's run shows the other one: what a run leaves behind stays visible on
-the end screens and goes when the next state that starts fresh is
-entered. Bevy has both for the same reason.
-
-### DL-4: States inside one world, not a world per state
-
-**Options.** (a) States, run conditions and state-scoped entities inside
-one world. (b) A world per state, the ECS form of a Unity or Godot scene
-change: `Game` updates only the current state's worlds, and changing
-state swaps them.
-
-**Decision: (a).**
-
-**Rationale.** A game's states share most of what's on screen. The
-demo's background keeps streaming past behind the main menu, its journey
-HUD fades out when the menu comes up and back in when a run starts, and a
-run's asteroids and enemies stay on screen behind the end-of-run panels.
-With (b), that content would have to live in a world updated in several
-states, or be rebuilt in each, and entity ids are per world: a parent or
-a joint's other body is always looked up in the entity's own world. Each
-world also needs its own cameras and its own transform, render, UI and
-input systems, and worlds that share a canvas don't compose today: each
-world's render system clears the canvas before its first camera draws,
-so a second world erases the first one's frame unless the render
-context's `clearStrategy` is `'none'`. And (b) still needs what this
-design adds: something has to decide when to swap, and the work done on
-entering and leaving a state (spawning the player, logging the flight)
-still has to run once, at the switch.
-
-Several worlds stay the tool for content that doesn't interact, like the
-UI overlay world in the `Game` guide (§4.6 covers gating its systems on a
-state). Loading and unloading a level's content as a unit, as Unity's
-scenes and Godot's packed scenes do, is a separate feature that this
-design neither needs nor blocks.
-
----
-
-## 7. Open questions
-
-1. **Should `GameState` live on a component** (queryable, like the demo's
- run entity) rather than as an object passed to systems? An object
- matches how `Time` and `InputManager` are passed today.
- - (a) An object (proposed). (b) A component on a world entity.
-
----
-
-## 8. Testing considerations
-
-- Run conditions: system and group, false skips (and skips the query),
- true runs, both combined.
-- First group: runs before a group added earlier and ordered before the
- default group; ordering a group before it throws.
-- States: transition applied next tick; `entered`/`exited` for exactly
- one tick; initial state entered on the first tick; last request wins;
- re-entry exits and enters; exit group, removal and enter group in
- order.
-- Scoped entities: removed on exit and on enter, with descendants, after
- the exit group and before the enter group.
-
-## 9. Documentation and demo follow-up
-
-- `ecs/system.md`: run conditions and the first group. New `states/`
- guide.
-- Demo: `RunEcsComponent`'s phase machine, request flags,
- `enteredPhase`/`leftPhase` and the `loading` phase become a `GameState`;
- the gate checks become run conditions (the callback and fade checks
- read `runState.current`); `clear-run.system.ts` goes.
diff --git a/design/generational-entity-ids.md b/design/generational-entity-ids.md
deleted file mode 100644
index f3aaf5cf..00000000
--- a/design/generational-entity-ids.md
+++ /dev/null
@@ -1,404 +0,0 @@
-# Design: Generational Entity Handles
-
-| | |
-| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| **Status** | Implemented (Phase 1) |
-| **Kind** | Defect |
-| **Found in** | Galactic Journey demo: `src/engine-flame/engine-flame.system.ts`, `src/engine-flame/engine-flame.component.ts`, `src/enemy/enemy-collision.system.ts`, `src/grabber/grabber-collision.system.ts` |
-| **Engine version at time of writing** | `0.25.8` |
-| **Related** | [`demo-findings.md`](./demo-findings.md), [`hierarchy-removal.md`](./hierarchy-removal.md) (builds on this), [`collision-events.md`](./collision-events.md) (uses `isAlive`) |
-
-## 0. Targeted modules
-
-| Path | Change | Notes |
-| -------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------- |
-| `src/ecs/entity.ts` | **New** | Handle packing (`entityIndex`, `entityGeneration`), `formatEntity`; entities stay `number` |
-| `src/ecs/ecs-world.ts` | Modified | Slot table with generations, `isAlive`, idempotent `removeEntity`, explicit entity lifetime |
-| `src/utilities/sparse-set.ts` | Modified | Indexes its sparse array by the handle's index; membership compares the full handle |
-| `src/common/systems/transform-system.ts` | Modified | Prunes its frozen set when entities are removed |
-| `src/physics/systems/euler-integration-system.ts` | Modified | Error messages name entities with `formatEntity` |
-| `documentation-site/docs/docs/ecs/entity.md`, `world.md` | Modified | Entity lifetime, stale handles, `isAlive` |
-
----
-
-## 1. Summary
-
-An entity in Forge is a plain number, and `EcsWorld` reuses a removed
-entity's number for the next entity it creates. Reusing numbers is
-normal, and every ECS does it, but Forge has nothing to tell the old
-entity and the new one apart. Any reference to an entity that outlives
-it, such as a component field holding another entity's id, a collision
-pair, or a closure, silently starts pointing at an unrelated entity, and
-whatever holds it can't find out that its entity is gone.
-
-Four behaviors in `src/ecs/ecs-world.ts` combine into this:
-
-1. **Immediate, last-in-first-out reuse.** `removeEntity` pushes the id
- onto `_freeEntityIds`, and `createEntity` pops from it (lines 225-232,
- 433-442). The most recently removed id is the next one handed out, so
- reuse can happen within the same tick, often within the same system.
- Reuse stays (§4.2); this order is why a stale reference turns into a
- wrong one almost at once.
-2. **No generation.** Nothing distinguishes the old entity from the new
- one: `getComponent(oldId, key)` returns the new entity's component.
-3. **Double removal frees an id twice.** `removeEntity` doesn't check that
- the entity exists. Removing an entity twice (a bullet that hit two
- targets in one tick) pushes its id onto the free list twice, so two
- future entities get the _same_ id and share component storage.
-4. **Removing an entity's last component removes the entity.**
- `removeComponent` (lines 315-330) frees the id if nothing else is left,
- while the caller still holds it. This is documented in `ecs/world.md`.
-
-`addComponent` also writes to any id, alive or not (lines 234-243), and
-`SparseSet.has` compares bare ids.
-
-The demo works around this in several places:
-
-- Engine flames hold their ship's id. The flame system removes a flame
- when its ship is gone, but checks the ship's `EnginesEcsComponent.flames`
- list for the flame's own id, "since a removed ship's id can be reused by
- a new entity" (`engine-flame.component.ts`, `engine-flame.system.ts`).
-- The enemy and grabber collision systems keep `removed` and `usedBullets`
- sets so an entity already removed this tick isn't removed (and freed)
- again, or hit again, by another collision pair it was in.
-
-This design makes an entity a **generational handle**: a slot index plus
-a generation counter that increases every time the slot is freed, packed
-into the same `number`. A stale handle no longer matches anything: it has
-no components, `isAlive` is false, removing it again does nothing.
-Entities also get an explicit lifetime: they exist from `createEntity`
-until `removeEntity`, whatever components they have.
-
----
-
-## 2. Scope
-
-### In scope
-
-- Packing an index and a generation into an entity handle that's still a
- plain `number`, small enough to stay a small integer in V8.
-- `EcsWorld.isAlive(entity)`.
-- `removeEntity` on a stale or already-removed handle is a no-op.
-- `removeComponent` never removes the entity.
-- `addComponent`/`addTag` on a stale handle throws.
-- `SparseSet` comparing full handles, so component lookups with a stale
- handle return `null` without any extra bookkeeping.
-- Reusing the least recently freed slot first.
-- Documenting how game code should hold references.
-
-### Out of scope
-
-- **Removing children with their parent.** That's
- [`hierarchy-removal.md`](./hierarchy-removal.md).
-- **Deferred structural changes (command buffers).** Query results are
- already snapshots (see `ecs/system.md`, "Atomicity"), so removing
- entities while iterating is safe. What wasn't safe was reusing their
- ids; this design fixes that without deferring anything.
-- **A branded `Entity` type** that stops arithmetic on handles. Open
- question 1.
-- **Saving and loading worlds** ([#567](https://github.com/Forge-Game-Engine/Forge/issues/567)).
- Handles are remapped on load either way.
-
----
-
-## 3. How established engines handle this
-
-Every mainstream ECS uses generational handles for exactly this reason:
-
-- **Bevy**: `Entity` is an index plus a generation. When an entity is
- despawned, its index's generation increments, and the index is reused
- by a later spawn. Every lookup compares generations, so
- `World::get_entity` and `Query::get` on the old handle find nothing
- rather than the new entity. `Entity` is meant to be stored (Bevy's own
- `ChildOf` holds one), and Bevy's docs advise dropping a handle once you
- know its entity was despawned, since generations eventually wrap. The
- generation is how the holder finds out.
-- **Unity DOTS**: `Entity` is `Index` plus `Version`; `EntityManager.Exists`
- checks both.
-- **flecs**: entity ids carry a generation in their upper bits;
- `ecs_is_alive` checks it.
-- **EnTT**: entity identifiers combine an index and a version; the
- default identifier is 32 bits, with a 20-bit index and a 12-bit version.
-- **bitECS**, the most used JavaScript ECS, packs a version into its
- 32-bit entity ids (12 bits by default).
-
-All of them also treat an entity with no components as alive: lifetime is
-`create` to `destroy`, not "has at least one component".
-
----
-
-## 4. Design
-
-### 4.1 Handle layout
-
-A handle stays a JavaScript `number`, so every existing `entity: number`
-signature, `Map`/`Set` keyed by entity, and component field holding an
-entity keeps working.
-
-```
-handle = (generation << 20) | index
-index = handle & 0xfffff (0 .. 1,048,575 live slots)
-generation = handle >>> 20 (0 .. 1,023, then wraps)
-```
-
-```ts
-export const entityIndex = (entity: number): number => entity & 0xfffff;
-export const entityGeneration = (entity: number): number => entity >>> 20;
-/** "12v3": index 12, generation 3. For error messages and debugging. */
-export const formatEntity = (entity: number): string =>
- `${entityIndex(entity)}v${entityGeneration(entity)}`;
-```
-
-Handles stay below 2^30, the top of V8's small-integer range. Above it, a
-number is stored on the heap, and every `Set`/`Map` keyed by entity that
-systems rebuild each frame (the transform cache, the UI layout's maps)
-would allocate on insert. That's why Forge uses 30 bits rather than
-EnTT's or bitECS's 32. A million live entities is far above what a
-browser game holds at once.
-
-A slot's first entity has generation 0, so until the first removal a
-world's entities have exactly the numbers they have today (0, 1, 2, ...);
-existing tests that expect the first entity to be `0` keep passing. Only a
-_reused_ slot produces a different number than before.
-
-### 4.2 `EcsWorld`
-
-```ts
-class EcsWorld {
- createEntity(): Entity;
- /** Whether `entity` was created and hasn't been removed since. */
- isAlive(entity: Entity): boolean;
- /**
- * Removes `entity` and all of its components. Does nothing (and returns
- * `false`) if it isn't alive, e.g. it was already removed this tick.
- */
- removeEntity(entity: Entity): boolean;
- /** Removes one component. The entity stays alive, with or without others. */
- removeComponent(entity: Entity, key: ComponentKey): void;
- /** @throws if `entity` isn't alive. */
- addComponent(entity: Entity, key: ComponentKey, data: T): T;
-}
-```
-
-Internally the world keeps one generation per slot and a queue of free
-slot indices:
-
-- `createEntity`: take the least recently freed slot (or a new one) and
- return its handle for the slot's current generation.
-- `removeEntity`: if the handle's generation doesn't match its slot's, or
- the slot is free, return `false`. Otherwise mark the slot dead first
- (increment its generation), then remove every component and tag, queue
- the slot, and raise `onEntityRemoved`. Queuing it before the event means
- a listener that throws can't leak the slot; the queued handle is already
- the next generation, so a listener that creates an entity in it can't be
- confused with the removed one. Marking it dead before the event
- means a listener that removes the same entity again (directly, or
- through a hierarchy) gets `false` instead of recursing or freeing the
- slot twice.
-- `isAlive`: the slot is in use and its generation matches the handle's.
-
-Removing the same entity twice in a tick is now harmless, so the demo's
-`removed` and `usedBullets` sets become `isAlive` checks or go away. A
-second removal can't free the slot twice, so two live entities can never
-share a handle.
-
-### 4.3 `SparseSet`
-
-The sparse array is indexed by `entityIndex(handle)`, and the dense array
-stores full handles, as it does today. Membership is
-`dense[sparse[entityIndex(handle)]] === handle`, so a stale handle (same
-index, older generation) is simply not a member. `getComponent` with a
-stale handle returns `null`, and no other code needs to know about
-generations.
-
-### 4.4 What changes for code holding entity references
-
-Storing another entity's handle in a component (`ParentEcsComponent.parent`,
-the demo's `EngineFlameEcsComponent.ship`, `HealthEcsComponent.bar`) is
-now safe: once the referenced entity is removed, `getComponent` on the
-handle returns `null` and `isAlive` returns `false`, until its slot's
-generation wraps (§4.1, open question 2). The demo's flame system can check
-`world.isAlive(flame.ship)` instead of searching its ship's flame list.
-As Bevy advises, code that finds its entity gone should drop the handle
-rather than keep it. [`hierarchy-removal.md`](./hierarchy-removal.md)
-takes the most common holders, children and their parent, off game
-code's hands.
-
-The transform system keeps a set of frozen (static) entities. Its comment
-says it re-checks `isStatic` "in case the entity's id was recycled";
-with generations that reason goes, but the check stays, because it's also
-how an entity whose `isStatic` was cleared gets unfrozen. What changes is
-cleanup: today slot reuse bounds the set's size, and with fresh handles a
-removed static entity would stay in it forever. The set holds the
-entities' `PositionEcsComponent` objects in a `WeakSet` rather than their
-handles, so removing the entity, or just its position, drops it with no
-subscription to keep in step. That also covers a case an
-`onEntityRemoved` subscription would miss now that `removeComponent` keeps
-the entity alive: a static position removed and re-added under a parent is
-a new object, so it starts unfrozen and gets composed with its parent.
-
-### 4.5 Errors
-
-`addComponent` and `addTag` throw on a handle that isn't alive, naming it
-with `formatEntity` (`Unable to add component "sprite" to entity 12v3: it
-was removed.`). Adding to a removed entity is always a bug, and today it
-silently resurrects the id while the slot may already be on the free list.
-
-`getComponent` returns `null` for a stale handle, and
-`getComponentRequired` throws for it as it does for any missing
-component. `removeComponent` and `removeEntity` don't throw on stale
-handles: removing something that may already be gone is normal (collision
-pairs from earlier in the tick, a target that was destroyed). Every error
-message that prints an entity (`getComponentRequired`, the Euler
-integration system's parent check) uses `formatEntity`.
-
----
-
-## 5. Phases
-
-### Phase 1: Generational handles and explicit lifetime
-
-| # | Task | Size |
-| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- |
-| 1.1 | `entity.ts`: `Entity`, `entityIndex`, `entityGeneration`, `formatEntity` | S |
-| 1.2 | `SparseSet` indexing by `entityIndex`, membership by full handle | S |
-| 1.3 | `EcsWorld` slot table: `createEntity` (oldest free slot first), `isAlive`, idempotent `removeEntity` marking the slot dead before its event, `removeComponent` keeps the entity, `addComponent`/`addTag` throw on dead handles | M |
-| 1.4 | Transform system: prune the frozen set on `onEntityRemoved`; its comment states the remaining reason for the `isStatic` check | S |
-| 1.5 | Error messages use `formatEntity` | S |
-| 1.6 | Tests: reuse gets a new handle, stale lookups return `null`, double removal, re-entrant removal from a listener, last-component removal, generation wrap; rewrite `ecs-world.test.ts`'s last-component test and the transform system's recycled-id test | M |
-| 1.7 | Benchmark: a particle-heavy scene (the particles stress test) before and after, for creation/removal churn and frame time | S |
-| 1.8 | Update `ecs/entity.md` and `ecs/world.md`; changelog under `#### Changed` | S |
-
-**Definition of done:** a handle to a removed entity never refers to
-another entity (until its slot has been reused 1,024 times); removing an
-entity twice is harmless; an entity stays alive until `removeEntity`,
-whatever components it has; the particle benchmark shows no regression;
-every existing test passes with at most the expected changes to
-`removeComponent`'s behavior.
-
-The changelog bullet says: entity ids of reused slots are now different
-numbers, `removeComponent` no longer removes an entity whose last
-component it removed (call `removeEntity`), and `addComponent` on a
-removed entity now throws.
-
----
-
-## 6. Decision log
-
-### DL-1: Pack the handle into a `number`
-
-**Options.** (a) An object `{ index, generation }`. (b) A bigint. (c) A
-`number` with both parts packed.
-
-**Decision: (c).**
-
-**Rationale.** Every API, component and test in Forge takes and stores
-entities as numbers; (c) changes none of their types. (a) allocates per
-entity and breaks identity comparison (`===`) and `Map` keys. (b) is slow
-in hot loops and can't index arrays.
-
-### DL-2: 20 bits of index, 10 of generation
-
-**Options.** (a) A wide layout using the 53 bits a double holds exactly
-(24-bit index, 29-bit generation). (b) 32 bits, as EnTT and bitECS use.
-(c) 30 bits.
-
-**Decision: (c).**
-
-**Rationale.** (a) leaves V8's small-integer range as soon as a slot's
-generation reaches 64, which particles reach quickly, and every handle
-after that is a heap number. (b) has the same problem above 2^30. (c)
-keeps every handle a small integer, at the cost of generations wrapping
-after 1,024 reuses of one slot, which DL-6 makes rare.
-
-### DL-3: `removeEntity` on a dead handle is a no-op, not an error
-
-**Options.** (a) Throw. (b) No-op, returning `false`.
-
-**Decision: (b).**
-
-**Rationale.** Removing something that may already be gone is a normal
-gameplay situation (two collisions in one tick involving the same bullet).
-Bevy treats it the same way (a warning, not a panic, for a despawn
-command on a missing entity, and `try_despawn` is silent). Adding
-components to a dead entity, on the other hand, is always a bug, so that
-throws (DL-5).
-
-### DL-4: An entity is alive from `createEntity` to `removeEntity`
-
-**Options.** (a) Keep "an entity exists while it has components". (b)
-Explicit lifetime.
-
-**Decision: (b).**
-
-**Rationale.** (a) frees a handle out from under code that still holds
-it, which is the defect this design removes. Every ECS listed in §3 uses
-(b). Nothing in `/src`, the demo or the e2e scenes relies on (a).
-
-### DL-5: `addComponent` on a dead handle throws
-
-**Rationale.** Today it silently writes into a free slot, which a later
-`createEntity` then hands out with that component already attached. A
-descriptive error at the faulty call is the engine's convention for
-programming errors.
-
-### DL-6: Reuse the least recently freed slot
-
-**Options.** (a) Reuse the most recently freed slot (today). (b) Reuse the
-least recently freed slot.
-
-**Decision: (b).**
-
-**Rationale.** Reuse order now decides how fast a slot's generation
-climbs toward wrapping. With last-in-first-out reuse, an entity created
-and removed every frame (a one-frame effect) takes the same slot every
-time and wraps its generation in 1,024 frames, about 17 seconds at 60
-frames per second. First-in-first-out spreads reuse over every free slot.
-The benchmark in task 1.7 checks the cost to locality.
-
----
-
-## 7. Open questions
-
-1. **Brand the type?** `type Entity = Brand` would make
- `entity * 1.7` (the demo seeds a flame's flicker that way) and passing a
- plain number a type error. Forge already brands `ComponentKey`, so
- there's precedent. It also touches every signature in the engine and in
- games.
- - (a) Plain alias now, brand later if misuse shows up (proposed).
- (b) Brand in Phase 1.
- - Resolved in implementation: neither. A plain alias adds no type
- safety (the linter flags it as redundant), so entities stay
- `number`. Branding stays open for later.
-2. **Generation overflow.** After 1,024 reuses of one slot, its generation
- wraps and a handle held across all of them would match again. With
- oldest-first reuse that takes 1,024 times as many removals as there are
- free slots.
- - (a) Wrap (proposed; the same trade-off EnTT and bitECS make). (b)
- Retire the slot permanently (never reuse it again).
-
----
-
-## 8. Testing considerations
-
-- Unit tests in `ecs-world.test.ts` for every rule in §4.2 and §4.5.
-- A regression test reproducing the double-free: remove the same entity
- twice, create two entities, and assert they have different handles and
- separate components.
-- An `onEntityRemoved` listener that removes the same entity returns
- `false` and doesn't free the slot twice.
-- The existing transform, physics and UI tests run unchanged; the
- transform system's recycled-id test becomes a test that a removed static
- entity leaves the frozen set.
-
-## 9. Documentation and demo follow-up
-
-- `ecs/entity.md`: what a handle is, that it's opaque, `isAlive`, and that
- holding handles of other entities in components is safe.
-- `ecs/world.md`: replace "If this was the last component on the entity,
- the entity will be removed from the world" with the explicit lifetime
- rule.
-- Demo: `engine-flame.system.ts` checks `world.isAlive(flame.ship)`; the
- collision systems' `removed`/`usedBullets` sets become `isAlive` checks.
- With [`hierarchy-removal.md`](./hierarchy-removal.md) the flame check
- goes away entirely.
diff --git a/design/hdr-colors.md b/design/hdr-colors.md
deleted file mode 100644
index 75e52ece..00000000
--- a/design/hdr-colors.md
+++ /dev/null
@@ -1,151 +0,0 @@
-# Design: Colors Brighter Than White
-
-| | |
-| ------------------------------------- | ---------------------------------------------------------------------------------------------------- |
-| **Status** | Draft, for review |
-| **Kind** | Defect |
-| **Found in** | Galactic Journey demo: `src/ui/create-menu-button.ts` (buttons dimmed at rest so hover can brighten) |
-| **Engine version at time of writing** | `0.25.8` |
-| **Related** | [`demo-findings.md`](./demo-findings.md) |
-
-## 0. Targeted modules
-
-| Path | Change | Notes |
-| --------------------------------------------------------------------- | -------- | ---------------------------------------------------------------- |
-| `src/rendering/color.ts`, `color.test.ts` | Modified | Red, green and blue have no upper bound; alpha stays in `[0, 1]` |
-| `documentation-site/docs/docs/rendering/bloom.md`, `hdr-rendering.md` | Modified | Tints above `1` as a way to bloom on an HDR camera |
-| `documentation-site/docs/docs/ui/` (transitions) | Modified | Hover brightening with a tint above `1` |
-| `documentation-site/src/pages/demos/easing-functions/index.tsx` | Modified | Uses `toRGBAString`; checked for colors above `1` |
-
----
-
-## 1. Summary
-
-`Color`'s constructor clamps every channel to `[0, 1]`. Everything
-downstream could take more: the sprite shader multiplies the texture by
-the tint and the text shader outputs its color, neither clamping,
-instance tints are uploaded as floats, and HDR render targets (`RENDER_TARGET_FORMAT.hdr`) keep values
-above `1` for bloom and tone mapping. The clamp in `Color` is the only
-thing stopping a tint from brightening a sprite.
-
-The demo's menu buttons hit this. A button's color transition should
-brighten the art on hover. With tints capped at `1`, the art's own
-brightness is the most hover can show, so the demo tints every button to
-`0.8` at rest and `1` on hover, and has to author the art brighter than it
-should look. The rendering guides have the same gap: the bloom and HDR
-guides present an emissive map as the only way to push a sprite past `1`
-on an HDR camera, because a tint can't.
-
-Every engine's color type is unclamped for exactly these uses. This design
-removes the upper bound on red, green and blue.
-
----
-
-## 2. Scope
-
-### In scope
-
-- `Color` keeping red, green and blue above `1`.
-- Updating the guides that describe the clamp.
-
-### Out of scope
-
-- **A UI-specific brightness multiplier** (Unity's `ColorBlock` has one).
- With unclamped colors, a hover color of `(1.2, 1.2, 1.2)` does the same
- thing.
-- **Color spaces.** Forge's colors are used as-is in the shaders. Whether
- they should be treated as sRGB and linearized is a separate question.
-
----
-
-## 3. How established engines handle this
-
-- **Unity**: `Color` is four unclamped floats. HDR colors are a normal use
- (`ColorUsage(hdr: true)` in the inspector) for emission and bloom.
-- **Godot**: `Color` channels can exceed `1`, and
- `modulate` above `1` brightens a sprite, which is the usual way to make a
- 2D sprite glow with the glow effect.
-- **Bevy**: `LinearRgba` is unclamped; brightening a sprite past its
- texture with `Sprite::color` and blooming it is in the 2D bloom example.
-
----
-
-## 4. Design
-
-`new Color(r, g, b, a)`:
-
-- `r`, `g` and `b` are clamped to `0` at the bottom and have no upper
- bound. The lower clamp stays: negative light has no meaning, and an
- easing curve that overshoots below `0` shouldn't make a sprite render
- negative colors on an HDR target.
-- `a` stays clamped to `[0, 1]`. Alpha above `1` breaks premultiplied
- blending: on a float render target, `1 - a` goes negative.
-
-What a value above `1` does depends on where it's drawn, which is how GPUs
-work and what the guides will say:
-
-- **On an 8-bit target or the canvas**, each channel of the result is
- clamped when it's written: a tint of `1.2` brightens the art until a
- channel reaches full brightness.
-- **On an HDR target**, the value survives to bloom and tone mapping, so a
- sprite tinted `(3, 3, 3)` blooms more than one tinted `(1, 1, 1)`.
-
-`toRGBAString` clamps red, green and blue to `255` when it formats them,
-since CSS colors can't be brighter than white.
-
----
-
-## 5. Phases
-
-### Phase 1: Unclamped colors
-
-| # | Task | Size |
-| --- | ------------------------------------------------------------------------------- | ---- |
-| 1.1 | Remove the upper clamp on red, green and blue; keep alpha and the lower clamp | S |
-| 1.2 | Tests: values above `1` kept; negatives clamped; `toRGBAString` stays valid CSS | S |
-| 1.3 | Guides: tinting, color transitions, bloom tip; changelog under `#### Changed` | S |
-
-**Definition of done:** a sprite tinted `(1.5, 1.5, 1.5)` draws brighter
-than its texture, and blooms more than a white-tinted one on an HDR
-camera.
-
----
-
-## 6. Decision log
-
-### DL-1: No upper bound, rather than a larger one
-
-**Rationale.** Any fixed ceiling is arbitrary: the right maximum depends
-on the target format and tone mapping, which `Color` knows nothing about.
-
-### DL-2: Alpha stays clamped
-
-**Rationale.** Premultiplied blending (see "Alpha Blending" in
-`AGENTS.md`) assumes alpha in `[0, 1]`; outside it, the blend factors go
-negative on float targets.
-
----
-
-## 7. Open questions
-
-None.
-
----
-
-## 8. Testing considerations
-
-- `Color` unit tests for the bounds above.
-- An existing translucent-UI or bloom e2e scene gains a sprite tinted above
- `1` on an HDR camera, compared against a white-tinted twin with the
- relative-measurement pattern from `AGENTS.md`.
-
-## 9. Documentation and demo follow-up
-
-- `rendering/bloom.md`: the tip about 8-bit targets clamping stays (it's
- still true on LDR targets) and gains the HDR case: on an `hdr` camera, a
- tint above `1` blooms more. The emissive-map section and the opening of
- `rendering/hdr-rendering.md` stop presenting emissive maps as the only
- way past `1`.
-- `Color`'s JSDoc stops saying each channel is `0-1`.
-- Demo: buttons rest at white and brighten on hover; the art is authored at
- its intended brightness and the comment goes.
diff --git a/design/hierarchical-visibility.md b/design/hierarchical-visibility.md
index e1419a6e..2af5db2b 100644
--- a/design/hierarchical-visibility.md
+++ b/design/hierarchical-visibility.md
@@ -6,7 +6,7 @@
| **Kind** | Feature |
| **Found in** | Galactic Journey demo: `setShown` helpers in `src/game-over/create-stats-panel.ts`, `create-flight-history-page.ts`, `pilot-panel.system.ts`, `src/leaderboard/create-leaderboard-page.ts`, `src/main-menu/create-main-menu.ts`; the stats panel's button repositioned by hand when the one above it is hidden; `src/speed/create-hud.ts` (the speed HUD's ring hidden segment by segment) |
| **Engine version at time of writing** | `0.25.8` |
-| **Related** | [`ui-system.md`](./ui-system.md), [`hierarchy-removal.md`](./hierarchy-removal.md), [`sprite-draw-order.md`](./sprite-draw-order.md) (prerequisite), [`game-states.md`](./game-states.md) |
+| **Related** | [`ui-system.md`](./ui-system.md), [`hierarchy-removal.md`](./hierarchy-removal.md), [`sprite-draw-order.md`](./sprite-draw-order.md) (prerequisite) |
## 0. Targeted modules
@@ -78,7 +78,8 @@ groups for what they're for: fading and disabling interaction.
- **Disabling a subtree's logic** (Unity's inactive GameObjects stop their
scripts). Systems decide for themselves what a hidden entity means;
- pausing whole groups of systems is [`game-states.md`](./game-states.md).
+ pausing whole groups of systems is what run conditions and game states
+ (`/src/states`) are for.
- **Hiding across canvases.** A canvas is a root, so a canvas shown along
with a page on another canvas (the demo's title) is hidden with its own
`visible`, one line instead of a group.
diff --git a/design/hierarchy-removal.md b/design/hierarchy-removal.md
index ef289549..cd9c7855 100644
--- a/design/hierarchy-removal.md
+++ b/design/hierarchy-removal.md
@@ -6,7 +6,7 @@
| **Kind** | Defect |
| **Found in** | Galactic Journey demo: `src/engine-flame/engine-flame.system.ts`, `src/engine-flame/engine-flame.component.ts`, `src/health/health.component.ts`, `src/power-ups/power-up.component.ts`, `src/run/clear-run.system.ts` |
| **Engine version at time of writing** | `0.25.8` |
-| **Related** | [`generational-entity-ids.md`](./generational-entity-ids.md) (prerequisite), [`sprite-draw-order.md`](./sprite-draw-order.md), [`game-states.md`](./game-states.md) |
+| **Related** | [`sprite-draw-order.md`](./sprite-draw-order.md) |
## 0. Targeted modules
@@ -27,9 +27,9 @@
An entity can have a parent (`ParentEcsComponent`), and the transform
system composes a child's transform with its parent's. But removing a
parent does nothing to its children: they stay in the world, still
-pointing at the removed parent's id. With ids reused immediately (see
-[`generational-entity-ids.md`](./generational-entity-ids.md)), that id
-soon belongs to an unrelated entity, and the orphan starts following it.
+pointing at the removed parent's id. Generational handles stop that stale id from matching a new entity, but
+the orphan is still left behind, following a parent that no longer
+exists.
The demo has to clean up after this, or avoid parenting altogether:
@@ -88,9 +88,8 @@ through world methods so the index can't go stale.
`removeParent` keep the child's local transform (DL-4). A helper that
converts the local transform so the child stays put on screen belongs in
the transform module, not the ECS, and can be added when needed.
-- **Removing entities when a game state ends.** That's
- [`game-states.md`](./game-states.md), which relies on this design for
- the subtrees of what it removes.
+- **Removing entities when a game state ends.** Game states (`/src/states`) already remove state-scoped entities; with
+ this design they take those entities' subtrees with them.
---
@@ -233,8 +232,8 @@ is small except for very wide UI lists.
code outside the world writes `ParentEcsComponent`; sibling order survives
unrelated removals; every docs demo works with `setParent`.
-Depends on [`generational-entity-ids.md`](./generational-entity-ids.md):
-its explicit lifetime (today, removing an entity's last component frees
+Builds on generational entity handles, which have landed: their explicit
+lifetime (today, removing an entity's last component frees
it without touching anything that refers to it), its idempotent
`removeEntity` (a descendant removed earlier in the same tick), and
`isAlive`, which `setParent` checks.
@@ -300,7 +299,7 @@ in the world.
1. **Linked lifetime without transform inheritance.** The demo's health
bars live in another camera's units, so they can't be children of their
enemy, and its halos copy their orb's position instead of being
- children. [`camera-views.md`](./camera-views.md) and
+ children. Camera views (`getCameraView`) and
[`sprite-draw-order.md`](./sprite-draw-order.md) remove those reasons;
cases may remain. Unity DOTS (`LinkedEntityGroup`) and Bevy
(`linked_spawn` relationships) both offer a lifetime link separate from
diff --git a/design/input-action-state.md b/design/input-action-state.md
index e106e8d1..00cda14b 100644
--- a/design/input-action-state.md
+++ b/design/input-action-state.md
@@ -6,7 +6,7 @@
| **Kind** | Defect |
| **Found in** | Galactic Journey demo: `src/input/create-inputs.ts` (three `actionResetTypes.noReset` axes), `src/run/run-input.system.ts` (`shootInput.endHold()`) |
| **Engine version at time of writing** | `0.25.8` |
-| **Related** | [`text-input-field.md`](./text-input-field.md) (keyboard source changes), [`game-states.md`](./game-states.md) |
+| **Related** | [`text-input-field.md`](./text-input-field.md) (keyboard source changes) |
## 0. Targeted modules
diff --git a/design/polygon-collider-local-space.md b/design/polygon-collider-local-space.md
index 43f56dd3..000e098b 100644
--- a/design/polygon-collider-local-space.md
+++ b/design/polygon-collider-local-space.md
@@ -6,7 +6,6 @@
| **Kind** | Defect |
| **Found in** | Galactic Journey demo: `src/grabber/create-grabbers.ts` (collider drawn symmetric about the image center so re-centering doesn't move it); in this repository, the triangle sprite-pivot workaround in `demo/src/game.ts` and `documentation-site/src/pages/demos/physics/_spawn-shapes.ts` |
| **Engine version at time of writing** | `0.25.8` |
-| **Related** | [`collision-events.md`](./collision-events.md), `continuous-collision-detection.md` |
## 0. Targeted modules
@@ -154,9 +153,9 @@ shape stays where it was authored.
### 4.5 Other designs
-`continuous-collision-detection.md` also edits integration and sweeps
-circles from the entity's position; whichever lands second sweeps from
-the world center of mass and the circle's rotated `center`.
+Continuous collision detection (`createContinuousCollisionEcsSystem`)
+sweeps circles from the entity's position; this design changes it to
+sweep from the world center of mass and the circle's rotated `center`.
---
diff --git a/design/post-processing-effects.md b/design/post-processing-effects.md
index d20f7e60..244aca59 100644
--- a/design/post-processing-effects.md
+++ b/design/post-processing-effects.md
@@ -1,12 +1,12 @@
# Design: Post-Processing Passes Without Copy-Back
-| | |
-| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
-| **Status** | Draft, for review |
-| **Kind** | Feature and defect |
-| **Found in** | Galactic Journey demo: `src/shockwave/refraction.system.ts`, `src/glitch/glitch.system.ts`, `src/graphics/graphics-quality.system.ts` |
-| **Engine version at time of writing** | `0.25.8` |
-| **Related** | [`render-resolution.md`](./render-resolution.md), [`camera-views.md`](./camera-views.md), [`sprite-textures.md`](./sprite-textures.md) |
+| | |
+| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
+| **Status** | Draft, for review |
+| **Kind** | Feature and defect |
+| **Found in** | Galactic Journey demo: `src/shockwave/refraction.system.ts`, `src/glitch/glitch.system.ts`, `src/graphics/graphics-quality.system.ts` |
+| **Engine version at time of writing** | `0.25.8` |
+| **Related** | [`render-resolution.md`](./render-resolution.md), [`sprite-textures.md`](./sprite-textures.md) |
## 0. Targeted modules
@@ -61,7 +61,7 @@ buffer and writes the other, with no copy back.
- **Shared per-view uniforms** (resolution, view bounds, time) bound to
every effect material automatically. An effect calls
- [`getCameraView`](./camera-views.md) and sets what it needs.
+ `getCameraView` and sets what it needs.
- **An effect stack object or volume system** (Unity's Volume framework).
Effects stay components on the camera, ordered by system registration.
- **Effects that need their own intermediate resolutions** (bloom's
diff --git a/design/render-resolution.md b/design/render-resolution.md
index 83c44cb8..adb7a86f 100644
--- a/design/render-resolution.md
+++ b/design/render-resolution.md
@@ -6,7 +6,7 @@
| **Kind** | Defect and feature |
| **Found in** | Galactic Journey demo: `src/graphics/graphics-quality.system.ts` (`maxPixelRatio` written through a cast), `src/rendering/resize-render-targets.system.ts`, `src/systems/register-draw-systems.ts` |
| **Engine version at time of writing** | `0.25.8` |
-| **Related** | [`post-processing-effects.md`](./post-processing-effects.md) (lands first), [`camera-views.md`](./camera-views.md), [`webgl-context-loss.md`](./webgl-context-loss.md) |
+| **Related** | [`post-processing-effects.md`](./post-processing-effects.md) (lands first), [`webgl-context-loss.md`](./webgl-context-loss.md) |
## 0. Targeted modules
diff --git a/design/rich-text-tags.md b/design/rich-text-tags.md
index b213b738..5ebdd5ab 100644
--- a/design/rich-text-tags.md
+++ b/design/rich-text-tags.md
@@ -48,8 +48,8 @@ that are each individually non-trivial:
This document exists to answer these questions before backlog 5.7 is
implemented, not to implement it - no code changes ship with this
-document, matching how `design/msdf-text-rendering.md` and
-`design/form-layout-columns.md` were written before their own
+document, matching how the MSDF text rendering and
+column-aligned form layout designs were written before their own
implementation phases.
---
diff --git a/design/sprite-fill.md b/design/sprite-fill.md
index 0e710fce..51089d8d 100644
--- a/design/sprite-fill.md
+++ b/design/sprite-fill.md
@@ -6,7 +6,7 @@
| **Kind** | Feature |
| **Found in** | Galactic Journey demo: `src/speed/create-hud.ts` (32 pre-rendered "drain" images for one ring segment), `src/game-over/create-stats-panel.ts` (progress fill held at a minimum width, hidden at zero) |
| **Engine version at time of writing** | `0.25.8` |
-| **Related** | [`ui-system.md`](./ui-system.md) backlog 3.7 (radial fill deferred), [`sprite-textures.md`](./sprite-textures.md) (Phase 2 lands after it), [`angle-conventions.md`](./angle-conventions.md) |
+| **Related** | [`ui-system.md`](./ui-system.md) backlog 3.7 (radial fill deferred), [`sprite-textures.md`](./sprite-textures.md) (Phase 2 lands after it) |
## 0. Targeted modules
@@ -96,7 +96,7 @@ type SpriteFill =
}
| {
method: 'radial';
- /** Where the fill starts, in radians (angle-conventions.md: counter-clockwise from +X). */
+ /** Where the fill starts, in radians (counter-clockwise from +X). */
startAngle: number;
/** The full extent at `amount: 1`, in radians; negative fills clockwise. A full turn is `2π`. */
sweep: number;
diff --git a/design/sprite-textures.md b/design/sprite-textures.md
index 6b2c45d9..32263b6c 100644
--- a/design/sprite-textures.md
+++ b/design/sprite-textures.md
@@ -6,7 +6,7 @@
| **Kind** | Defect and feature |
| **Found in** | Galactic Journey demo: `src/ui/create-qr-code.ts`, `src/main-menu/create-how-to-play-panel.ts`, `src/speed/create-hud.ts`, `src/engine-flame/*`, `src/shockwave/create-displacement-map.ts`, `src/ui/create-ui.ts`, `src/background/create-background.ts`, `src/explosions/create-explosions.ts` |
| **Engine version at time of writing** | `0.25.8` |
-| **Related** | [`shader-uniform-declarations.md`](./shader-uniform-declarations.md) (prerequisite), [`hdr-colors.md`](./hdr-colors.md), [`sprite-fill.md`](./sprite-fill.md), [`webgl-context-loss.md`](./webgl-context-loss.md), [`post-processing-effects.md`](./post-processing-effects.md) |
+| **Related** | [`shader-uniform-declarations.md`](./shader-uniform-declarations.md) (prerequisite), [`sprite-fill.md`](./sprite-fill.md), [`webgl-context-loss.md`](./webgl-context-loss.md), [`post-processing-effects.md`](./post-processing-effects.md) |
## 0. Targeted modules
@@ -264,8 +264,7 @@ interface SpriteEmissive {
texture: Texture;
/**
* Multiplies the map. Values above `1` push into HDR, which replaces
- * today's separate `intensity` once [`hdr-colors.md`](./hdr-colors.md)
- * lets colors exceed `1`.
+ * today's separate `intensity` now that colors can exceed `1`.
*/
color: Color;
}
diff --git a/design/text-cap-height-centering.md b/design/text-cap-height-centering.md
index 5ef02d73..5ef708b9 100644
--- a/design/text-cap-height-centering.md
+++ b/design/text-cap-height-centering.md
@@ -6,7 +6,7 @@
| **Kind** | Defect |
| **Found in** | Galactic Journey demo: `src/ui/create-menu-button.ts` (label placed by hand "centered on its cap height"), `src/main-menu/create-controls-diagram.ts` (`textCenteredAt`), `titleBaseline` formulas in six panels (stats, flight history, pilot, leaderboard, how-to-play, settings) |
| **Engine version at time of writing** | `0.25.8` |
-| **Related** | [`ui-system.md`](./ui-system.md), [`font-atlas-loading.md`](./font-atlas-loading.md) |
+| **Related** | [`ui-system.md`](./ui-system.md) |
## 0. Targeted modules
diff --git a/design/text-input-field.md b/design/text-input-field.md
index 16175082..83cca1aa 100644
--- a/design/text-input-field.md
+++ b/design/text-input-field.md
@@ -1,12 +1,12 @@
# Design: Text Input Field
-| | |
-| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Status** | Draft, for review |
-| **Kind** | Missing feature |
-| **Found in** | Galactic Journey demo: `src/ui/create-text-input.ts`, `src/ui/text-input.component.ts`, `src/ui/text-input.system.ts`, `src/game-over/pilot-panel*.ts` |
-| **Engine version at time of writing** | `0.25.8` |
-| **Related** | [#586](https://github.com/Forge-Game-Engine/Forge/issues/586) (this design answers its open questions), `ui-system.md` DL-10, [#583](https://github.com/Forge-Game-Engine/Forge/issues/583) (clipping), [`input-action-state.md`](./input-action-state.md), [`camera-views.md`](./camera-views.md), [`hierarchical-visibility.md`](./hierarchical-visibility.md) |
+| | |
+| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| **Status** | Draft, for review |
+| **Kind** | Missing feature |
+| **Found in** | Galactic Journey demo: `src/ui/create-text-input.ts`, `src/ui/text-input.component.ts`, `src/ui/text-input.system.ts`, `src/game-over/pilot-panel*.ts` |
+| **Engine version at time of writing** | `0.25.8` |
+| **Related** | [#586](https://github.com/Forge-Game-Engine/Forge/issues/586) (this design answers its open questions), `ui-system.md` DL-10, [#583](https://github.com/Forge-Game-Engine/Forge/issues/583) (clipping), [`input-action-state.md`](./input-action-state.md), [`hierarchical-visibility.md`](./hierarchical-visibility.md) |
## 0. Targeted modules
@@ -336,9 +336,8 @@ canvas parent):
and writes `isEditing`.
2. **Placement.** Every tick, the editing field's resolved rect is
converted from UI world space to a `CssRect` through the canvas's
- camera (the inverse of `resolveCanvasPointerPosition`, and
- `worldToViewport` once [`camera-views.md`](./camera-views.md) lands).
- Not `worldToScreenSpace`, which doesn't flip Y.
+ camera (the inverse of `resolveCanvasPointerPosition`, through the camera
+ view's `worldToViewport`).
3. **Value.** On `changed` outside a composition (or on `compositionend`),
the entry's value is run through the font filter (drop characters the
field's `FontAtlas` has no glyph for), then `filter`, then `maxLength`;
diff --git a/design/ui-system.md b/design/ui-system.md
index cdf12d50..73c26faa 100644
--- a/design/ui-system.md
+++ b/design/ui-system.md
@@ -2,7 +2,7 @@
| | |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Status** | In progress — Phase 0 (prerequisites), Phase 1 (layout core: `RectTransformEcsComponent`, `resolveRect`, `UiAnchor`, `CanvasEcsComponent`/`createUiCanvas`, `createUiLayoutEcsSystem`, `createPanel`/`createLabel`), Phase 2 (interaction: `UiInteractableEcsComponent`, `createUiRaycastEcsSystem`, `createUiInteractionEcsSystem`, `UiFocusEcsComponent`/`createUiNavigationEcsSystem`, `createButton`, `UiColorTransitionEcsComponent`/`createUiTransitionEcsSystem`), most of Phase 3 (controls: `UiToggleEcsComponent`/`UiToggleGroupEcsComponent`/`createUiToggleEcsSystem`/`createToggle`, `UiSliderEcsComponent`/`createUiSliderEcsSystem`/`createSlider`, `UiProgressBarEcsComponent`/`createUiProgressBarEcsSystem`/`createProgressBar`, `UiDropdownEcsComponent`/`createDropdown`), and all of Phase 4 (layout groups: `LayoutElementEcsComponent`, `createUiLayoutGroupEcsSystem`, horizontal/vertical/grid layout groups, `ContentSizeFitterEcsComponent`, `AspectRatioFitterEcsComponent` - [#622](https://github.com/Forge-Game-Engine/Forge/pull/622) - plus the column-aligned form layout extras from `design/form-layout-columns.md`: `LayoutElementEcsComponent.sizeToText` and `GridLayoutGroupEcsComponent.columnWidthMode`/`rowHeightMode`/`cellAlignment` - [#630](https://github.com/Forge-Game-Engine/Forge/pull/630)) have landed on `dev`, documented at `documentation-site/docs/docs/ui`. [#631](https://github.com/Forge-Game-Engine/Forge/pull/631) subsequently replaced `RectTransformEcsComponent`'s `anchorMin`/`anchorMax`/`pivot`/`sizeOrMargin` fields with typed per-axis `UiAxis` (`x`/`y`, each a `UiPointAxis` or `UiStretchAxis` built via `UiAxis.point`/`UiAxis.stretch`) and `UiAnchor`'s presets with factory functions taking the size/margin their own axes need - a breaking change; §6's API sketch and the examples below reflect the current shape. Phase 5 (polish) is in progress - see the backlog table in §8 for per-item status. Backlog 3.3 (`RectMaskEcsComponent`) and 3.5 (`TextInputEcsComponent`) remain out of scope per the table below; 3.4 (`ScrollRectEcsComponent`) remains blocked on rect clipping; radial fill (part of backlog 3.7) was deferred alongside clipping - see that item's note. Two of the three external dependencies have landed: text rendering ([#584](https://github.com/Forge-Game-Engine/Forge/issues/584)) and the sprite pivot convention ([#585](https://github.com/Forge-Game-Engine/Forge/issues/585)). Rect clipping ([#583](https://github.com/Forge-Game-Engine/Forge/issues/583)) and text input ([#586](https://github.com/Forge-Game-Engine/Forge/issues/586)) remain open, and continue to block only `ScrollRectEcsComponent`/`TextInputEcsComponent` (backlog 3.4/3.5). |
+| **Status** | In progress — Phase 0 (prerequisites), Phase 1 (layout core: `RectTransformEcsComponent`, `resolveRect`, `UiAnchor`, `CanvasEcsComponent`/`createUiCanvas`, `createUiLayoutEcsSystem`, `createPanel`/`createLabel`), Phase 2 (interaction: `UiInteractableEcsComponent`, `createUiRaycastEcsSystem`, `createUiInteractionEcsSystem`, `UiFocusEcsComponent`/`createUiNavigationEcsSystem`, `createButton`, `UiColorTransitionEcsComponent`/`createUiTransitionEcsSystem`), most of Phase 3 (controls: `UiToggleEcsComponent`/`UiToggleGroupEcsComponent`/`createUiToggleEcsSystem`/`createToggle`, `UiSliderEcsComponent`/`createUiSliderEcsSystem`/`createSlider`, `UiProgressBarEcsComponent`/`createUiProgressBarEcsSystem`/`createProgressBar`, `UiDropdownEcsComponent`/`createDropdown`), and all of Phase 4 (layout groups: `LayoutElementEcsComponent`, `createUiLayoutGroupEcsSystem`, horizontal/vertical/grid layout groups, `ContentSizeFitterEcsComponent`, `AspectRatioFitterEcsComponent` - [#622](https://github.com/Forge-Game-Engine/Forge/pull/622) - plus the column-aligned form layout extras: `LayoutElementEcsComponent.sizeToText` and `GridLayoutGroupEcsComponent.columnWidthMode`/`rowHeightMode`/`cellAlignment` - [#630](https://github.com/Forge-Game-Engine/Forge/pull/630)) have landed on `dev`, documented at `documentation-site/docs/docs/ui`. [#631](https://github.com/Forge-Game-Engine/Forge/pull/631) subsequently replaced `RectTransformEcsComponent`'s `anchorMin`/`anchorMax`/`pivot`/`sizeOrMargin` fields with typed per-axis `UiAxis` (`x`/`y`, each a `UiPointAxis` or `UiStretchAxis` built via `UiAxis.point`/`UiAxis.stretch`) and `UiAnchor`'s presets with factory functions taking the size/margin their own axes need - a breaking change; §6's API sketch and the examples below reflect the current shape. Phase 5 (polish) is in progress - see the backlog table in §8 for per-item status. Backlog 3.3 (`RectMaskEcsComponent`) and 3.5 (`TextInputEcsComponent`) remain out of scope per the table below; 3.4 (`ScrollRectEcsComponent`) remains blocked on rect clipping; radial fill (part of backlog 3.7) was deferred alongside clipping - see that item's note. Two of the three external dependencies have landed: text rendering ([#584](https://github.com/Forge-Game-Engine/Forge/issues/584)) and the sprite pivot convention ([#585](https://github.com/Forge-Game-Engine/Forge/issues/585)). Rect clipping ([#583](https://github.com/Forge-Game-Engine/Forge/issues/583)) and text input ([#586](https://github.com/Forge-Game-Engine/Forge/issues/586)) remain open, and continue to block only `ScrollRectEcsComponent`/`TextInputEcsComponent` (backlog 3.4/3.5). |
| **Target module** | `/src/ui` → `@forge-game-engine/forge/ui` |
| **Engine version at time of writing** | `0.24.2` |
| **Model** | Retained **anchored rect tree** (canvas → rect transforms → graphics + event routing) — _not_ immediate-mode, _not_ markup-and-stylesheet |
@@ -1476,7 +1476,7 @@ focusable buttons, where clicking a button does not also fire the player's weapo
### Phase 4 — Layout groups
**Landed in full** ([#622](https://github.com/Forge-Game-Engine/Forge/pull/622)), plus the
-column-aligned form layout extras from `design/form-layout-columns.md`
+column-aligned form layout extras
([#630](https://github.com/Forge-Game-Engine/Forge/pull/630)):
`LayoutElementEcsComponent.sizeToText` and `GridLayoutGroupEcsComponent.columnWidthMode`/
`rowHeightMode`/`cellAlignment`, letting a label/control grid align every row's control to the