Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .claude/skills/create-component/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,17 @@ Notes:
defaulted ones into a `<PascalCaseName>DefaultedOptions` interface so
`default<PascalCaseName>Options` can be typed as that (not the whole
component, which would wrongly force the required fields into the
defaults object too) — see `audio-component.ts`, `lifetime-component.ts`,
or `sprite-animation-component.ts`. `sprite-component.ts` shows the same
defaults object too) — see `lifetime-component.ts` or
`sprite-animation-component.ts`. `sprite-component.ts` shows the same
shape with more fields.
- **Has an output field that a system writes** (e.g. `hasFinished` on
`SoundEcsComponent`, which only `createSoundEcsSystem` writes): leave it
out of the factory's options, so a caller can't set it and become its
second writer. Type `options` as
`<PascalCaseName>RequiredOptions & Partial<<PascalCaseName>DefaultedOptions>`
and set the output field's starting value in the factory after
spreading the options (see `sound-component.ts`). Document the field as
output, written only by its system.
- **Has no required fields, only defaulted fields**: no interface split
needed at all — type `default<PascalCaseName>Options` as the full
`<PascalCaseName>EcsComponent` directly, as in the template above (see
Expand Down
2 changes: 2 additions & 0 deletions .cspell/project-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ undersamples
unedit
unflipped
unmarks
unmuting
unnegated
unparent
unparented
Expand All @@ -221,6 +222,7 @@ viewports
Viktor
visibilitychange
Vleugels
Vorbis
WASD
webgl
webglcontextlost
Expand Down
18 changes: 16 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Forge is a browser-based, code-only game engine built with TypeScript. It provid
- **ECS (Entity-Component-System)**: Core architecture pattern
- **Rendering**: WebGL2-based rendering system
- **Physics**: Native 2D physics engine (rigid bodies, collision detection/resolution, gravity)
- **Audio**: Sound management via Howler.js
- **Audio**: Web Audio mixer with buses, sound assets, one-shot playback and entity-bound sounds
- **Animations**: Robust animation system
- **Input**: Keyboard, mouse, and gamepad input handling
- **Particles**: Particle system
Expand Down Expand Up @@ -505,6 +505,11 @@ describe('MyClass', () => {
what it does return, since undeclared active uniforms (struct members)
are typed from it. Sampler values are `Texture`s (`new Texture(mockGl)`),
and `bindTexture` receives `texture.glTexture`
- Fakes shared by several test files go in a `test-helpers/` folder inside
the module (e.g. `src/audio/test-helpers/fake-audio-context.ts`, a
stand-in for the Web Audio API, which jsdom lacks). `tsconfig.build.json`
and the coverage config exclude `**/test-helpers/**`, so they never ship
in `/dist`

### Coverage

Expand Down Expand Up @@ -569,6 +574,15 @@ vite.config.e2e.js # dev server for fixtures/, rooted like vite.config.
not `Game.run()`'s `requestAnimationFrame` loop), instead of waiting on
real time. This is what keeps the suite flake-free.

**Audio scenes and user activation**: Playwright runs every `page.evaluate`
(including `waitForFunction`'s polling) as a user gesture, and recording a
trace does the same when it snapshots the page. Either one before a scene
creates its `AudioContext` lets the browser start audio unlocked, so a test
of the first-gesture behavior passes for the wrong reason or fails.
`audio-mixer.spec.ts` turns tracing off with `test.use({ trace: 'off' })`
and waits for a console message from the scene, not for
`window.__forgeTestHooks`, before it evaluates anything.

**Node vs. browser split**: `e2e/specs/*.spec.ts` files run under Node
(Playwright's own TS loader), not through Vite - they can `import type` from
a scene module freely (erased at compile time), but a _value_ import that
Expand Down Expand Up @@ -989,7 +1003,7 @@ export class Entity {

### Dependencies

- Peer dependencies: `howler`
- Peer dependencies: `msdf-bmfont-xml` (optional, only for generating font atlases)
- Keep dependencies minimal and well-maintained

## Additional Resources
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

#### Added

- **audio:** A sound mixer built on the Web Audio API. `createSoundMixer()` owns the game's audio output and starts it on the player's first click, tap or key press (and again after Safari interrupts it). `mixer.createBus(name, parent?)` creates nestable buses whose `volume` and `muted` apply to every sound played through them, including sounds already playing. `mixer.suspend()`/`resume()` pause all audio, and `mixer.stop()` releases it when the game is torn down
- **audio:** `SoundAssetCache` loads and decodes each sound file once and shares concurrent loads, and `createSoundAsset({ sampleRate, channels })` makes a sound from samples, for audio generated at runtime. Sounds stay usable after the world that played them stops
- **audio:** `playSound(bus, sound, { volume, rate, loop })` plays a sound without an entity and returns a handle to change its volume or stop it. Any number of sounds, including copies of the same one, can play at once
- **audio:** `SoundEcsComponent` (`addSoundComponent`, `soundId`) with `createSoundEcsSystem()` plays a sound that belongs to an entity. Changes to its `volume`, `rate`, `loop`, `bus`, `sound` and `paused` apply while it plays, removing the component or the entity stops it, and `hasFinished` reports when a non-looping sound has played to its end
- **audio:** A non-looping sound requested before the player's first interaction with the page is dropped rather than played late on the first click; looping sounds start and are heard once audio runs
- **storage:** New `@forge-game-engine/forge/storage` module for data that has to outlive the page, such as settings, achievements and unlocked levels. `await createPersistentState(name, defaults, { validators, storage })` loads a named record of numbers, strings and booleans once, takes the default for every field that isn't stored, and stores only the fields you `set`, so changing a default in an update reaches players who never changed that field. `set` and `reset` raise `onChange` and return a promise that settles once the change is stored; writes go out one at a time and in order. Nothing falls back silently: a stored value of the wrong type rejects with `PersistentStateValueError` naming the field, an entry that isn't a JSON object rejects with `PersistentStateFormatError`, and storage failures reject with `StorageError` (`StorageUnavailableError`, `StorageBlockedError`, `StorageFullError`). Records are kept through a `StorageBackend`: `createLocalStorageBackend()` (the default) or `createMemoryStorageBackend()`, or your own. See the new Storage guide and Persistent State demo
- **physics:** Continuous collision detection. Register the new `createContinuousCollisionEcsSystem()` directly after `createEulerIntegrationEcsSystem` and a fast dynamic `CircleCollider` body no longer sinks deep into, or passes through, a static collider (polygon, circle or terrain) between two ticks. A wheel coming down from a jump, for example, now stops at the terrain's surface instead of sinking 25-30 units into it in a single tick. Write teleports to `position.local` before `createTransformEcsSystem` runs, or the sweep treats the jump as motion. The sweeps it uses are public too: `sweepCircleCircle`, `sweepCirclePolygon` and `sweepCircleTerrain` return the first `SweepHit` (`point`, `normal`, `t`) of a circle moving from one position to another
- **physics:** Collision filtering. `addColliderComponent` takes a `category` (the bits a collider belongs to, default `1`) and a `mask` (the categories it collides with, default `allCollisionCategories`). Two colliders are tested only when each one's category is in the other's mask, so pairs your game would ignore, such as bullets against bullets, never reach the narrow phase
Expand Down Expand Up @@ -59,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **physics:** `AabbEcsComponent`, `aabbId` and `addAabbComponent`. A collider no longer needs a separate AABB component to take part in collision detection or raycasts: the broad phase writes its bounds to the collider's new `aabb` field. Delete your `addAabbComponent` calls, and read `collider.aabb` where you read the AABB component
- **rendering:** `calculateVisibleWorldSize`, `calculatePixelsPerUnit`, `screenToWorldSpace`, `worldToScreenSpace` and `canvasToWorldSpace` are removed; use the camera's view instead. `calculateVisibleWorldSize(width, height, verticalWorldUnits)` becomes `getCameraView(world, camera, renderContext).size`, which also accounts for zoom; `screenToWorldSpace(pointer, ...)` becomes `view.viewportToWorld(pointer)`; `worldToScreenSpace(...)` becomes `view.worldToViewport(position)`, which unlike the old function flips Y to the Y-down viewport; `calculatePixelsPerUnit(...)` becomes `view.pixelsPerUnit`. Constants that mirrored a camera's `verticalWorldUnits` for these calls can be deleted
- **rendering:** `CameraEcsComponent.scissorRect`, which nothing read, is removed
- **audio:** `AudioEcsComponent`, `audioId`, `addAudioComponent` and `createAudioEcsSystem`, along with the Howler.js peer dependency, so you can uninstall `howler` and `@types/howler`. Create one mixer with `createSoundMixer()` and buses for your volume settings, load sounds with `new SoundAssetCache(mixer).getOrLoad(url)` instead of creating `Howl`s, replace `playSound: true` one-shots with `playSound(bus, sound, options)`, and replace sounds that belong to an entity (music, loops) with `addSoundComponent(world, entity, { sound, bus, loop })` and `createSoundEcsSystem()`. Removing an entity now stops its sound, so drop any code that stopped or unloaded sounds before removing entities

#### Fixed

Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,5 @@ You can make a separate folder or package outside of the engine.

## Acknowledgements

- [Howler.js](https://howlerjs.com/)
- [Vite](https://vite.dev/)
- [Kenny](https://www.kenney.nl/)
Loading
Loading