diff --git a/README.md b/README.md
index db9dc83..e5db57d 100644
--- a/README.md
+++ b/README.md
@@ -401,6 +401,12 @@ TypeScript consumer with `skipLibCheck: false`.
## Examples
+The [platformer statechart example](./examples/platformer) is a playable SVG
+demo centered on a schema-first character machine. It demonstrates nested
+compound locomotion, parallel airborne motion and air-jump regions, independent
+facing and wall-contact regions, typed protocol events, state-scoped timers,
+and state-driven SVG transforms.
+
The [Pokémon statechart example](./examples/pokemon) is a standalone React and
Vite project demonstrating compound and parallel states, state-scoped invokes,
invoked child statecharts, typed emissions, and Atom reactivity. It uses a local
diff --git a/examples/platformer/.gitignore b/examples/platformer/.gitignore
new file mode 100644
index 0000000..de4d1f0
--- /dev/null
+++ b/examples/platformer/.gitignore
@@ -0,0 +1,2 @@
+dist
+node_modules
diff --git a/examples/platformer/README.md b/examples/platformer/README.md
new file mode 100644
index 0000000..9075310
--- /dev/null
+++ b/examples/platformer/README.md
@@ -0,0 +1,73 @@
+# Platformer statechart example
+
+A small, standalone SVG and Vite demo in which `@typeonce/effect-machine` owns
+a platformer character's legal behavior. One compact adapter provides keyboard
+input, gravity, a floor, and visible SVG transforms.
+
+```sh
+pnpm install --frozen-lockfile
+pnpm dev
+```
+
+Run the production verification with:
+
+```sh
+pnpm check
+```
+
+## Controls
+
+- **A/D** or **arrow keys** — move
+- **W**, **up**, or **Space** — jump; press again once in the air for a double jump
+- Touch either wall and jump — turn and kick away; repeat after returning to a wall
+- **S** or **down** — duck while grounded; dive while airborne
+- **R** — reset
+
+## Statechart
+
+`Character` is parallel: `locomotion`, `facing`, and `contact` update
+independently. The locomotion region is compound and makes `Grounded` and
+`Airborne` mutually exclusive. Each branch is compound again:
+
+```text
+Character (parallel)
+├─ locomotion
+│ ├─ Grounded: Standing | Running | Ducking | Landing
+│ └─ Airborne (parallel)
+│ ├─ motion: Jumping | Falling | Diving
+│ └─ airJump: GroundLock | WallLock | Ready | Spent
+├─ facing: Left | Right
+└─ contact: NoWall | LeftWall | RightWall
+```
+
+State payloads live only where they are valid: `Landing` owns impact and resume
+direction, while `Airborne` owns only the jump origin. Air-jump availability is
+modeled entirely as state: lock states own cancellable readiness timers,
+`Ready` is the only state that authorizes a double jump, and `Spent` makes a
+second one unrepresentable. Entering `Airborne` exercises a complete nested
+parallel target by selecting both `motion` and `airJump` regions.
+
+Wall contact is an independent top-level region, so the live chart can show
+`Grounded + LeftWall` at a floor corner without confusing that combination with
+an airborne wall jump. The `Grounded` handler always produces an ordinary jump;
+only `Airborne` interprets the wall sample as a wall jump. It turns and pushes
+away, refreshes the air jump through `WallLock`, and the same wall may be used
+again after physically returning to it. Movement phases own their timestamps,
+and both landing and capability locks demonstrate state-scoped
+`Machine.after` timers.
+
+Keyboard commands and physics facts share a typed `Schema.TaggedUnion`
+protocol. The adapter executes velocity and floor collision, then reports
+`ApexReached`, `Landed`, and `WallContact`. `JumpPressed` includes the current
+wall sample, but the active `Grounded` or `Airborne` branch decides its meaning.
+Typed internal events coordinate orthogonal regions: `TryAirJump` is accepted
+only by `Ready`, while `DoubleJump` and `WallJump` update motion, capability,
+and facing without shared flags. The SVG box only reflects the active snapshot;
+it never decides behavior.
+
+## Visuals
+
+The character is a few inline SVG shapes. Each state maps to one typed transform
+and body color in `src/game.ts`; after the double jump is spent, a purple accent
+persists across falling and diving. This keeps the example focused on the
+machine rather than an art or rendering pipeline.
diff --git a/examples/platformer/index.html b/examples/platformer/index.html
new file mode 100644
index 0000000..555ce7d
--- /dev/null
+++ b/examples/platformer/index.html
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+ Orbit Courier — Effect Machine Platformer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A D move
+ W /Space jump ×2
+ S duck / dive
+ R reset
+
+
+
+
+
+ live statechart
+
+
+
+
Character parallel
+
+
+ locomotion
+
+
Grounded
+
+ Standing
+ Running
+ Ducking
+ Landing
+
+
+
+
Airborne
+
parallel regions
+
+
motion
+
+ Jumping
+ Falling
+ Diving
+
+
+
+
air jump
+
+ ground lock
+ wall lock
+ ready
+ spent
+
+
+
+
+
+ facing
+
+ Left
+ Right
+
+
+
+ wall contact
+
+ No wall
+ Left wall
+ Right wall
+
+
+
+
+
+
+
+
active
+ Standing · NoWall · Right
+
+
+
state-local data
+ {}
+
+
+
last protocol event
+ machine started
+
+
+
+
+
+
+ A small adapter owns coordinates and gravity. The machine owns legal behavior, and the box simply transforms to
+ show its active state. Wall contact is tracked explicitly, so floor-corner jumps stay ordinary.
+
+
+
+
+
diff --git a/examples/platformer/package.json b/examples/platformer/package.json
new file mode 100644
index 0000000..a885a3d
--- /dev/null
+++ b/examples/platformer/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@typeonce/effect-machine-example-platformer",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Playable platformer statechart example using @typeonce/effect-machine",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "check": "pnpm build"
+ },
+ "dependencies": {
+ "@typeonce/effect-machine": "file:../..",
+ "effect": "4.0.0-beta.102"
+ },
+ "devDependencies": {
+ "typescript": "6.0.3",
+ "vite": "8.1.5"
+ },
+ "packageManager": "pnpm@10.17.1",
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+}
diff --git a/examples/platformer/pnpm-lock.yaml b/examples/platformer/pnpm-lock.yaml
new file mode 100644
index 0000000..4846d66
--- /dev/null
+++ b/examples/platformer/pnpm-lock.yaml
@@ -0,0 +1,745 @@
+lockfileVersion: "9.0"
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+overrides:
+ effect: 4.0.0-beta.102
+
+importers:
+ .:
+ dependencies:
+ "@typeonce/effect-machine":
+ specifier: file:../..
+ version: file:../..(effect@4.0.0-beta.102)
+ effect:
+ specifier: 4.0.0-beta.102
+ version: 4.0.0-beta.102
+ devDependencies:
+ typescript:
+ specifier: 6.0.3
+ version: 6.0.3
+ vite:
+ specifier: 8.1.5
+ version: 8.1.5(yaml@2.9.0)
+
+packages:
+ "@emnapi/core@1.11.1":
+ resolution:
+ { integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ== }
+
+ "@emnapi/runtime@1.11.1":
+ resolution:
+ { integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw== }
+
+ "@emnapi/wasi-threads@1.2.2":
+ resolution:
+ { integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA== }
+
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4":
+ resolution:
+ { integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ== }
+ cpu: [arm64]
+ os: [darwin]
+
+ "@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4":
+ resolution:
+ { integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w== }
+ cpu: [x64]
+ os: [darwin]
+
+ "@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4":
+ resolution:
+ { integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw== }
+ cpu: [arm64]
+ os: [linux]
+
+ "@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4":
+ resolution:
+ { integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw== }
+ cpu: [arm]
+ os: [linux]
+
+ "@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4":
+ resolution:
+ { integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ== }
+ cpu: [x64]
+ os: [linux]
+
+ "@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4":
+ resolution:
+ { integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ== }
+ cpu: [x64]
+ os: [win32]
+
+ "@napi-rs/wasm-runtime@1.2.1":
+ resolution:
+ { integrity: sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ== }
+ engines: { node: ^20.19.0 || ^22.13.0 || >=23.5.0 }
+ peerDependencies:
+ "@emnapi/core": ^1.7.1 || ^2.0.0-alpha.3
+ "@emnapi/runtime": ^1.7.1 || ^2.0.0-alpha.3
+
+ "@oxc-project/types@0.139.0":
+ resolution:
+ { integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw== }
+
+ "@rolldown/binding-android-arm64@1.1.5":
+ resolution:
+ { integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ== }
+ engines: { node: ^20.19.0 || >=22.12.0 }
+ cpu: [arm64]
+ os: [android]
+
+ "@rolldown/binding-darwin-arm64@1.1.5":
+ resolution:
+ { integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw== }
+ engines: { node: ^20.19.0 || >=22.12.0 }
+ cpu: [arm64]
+ os: [darwin]
+
+ "@rolldown/binding-darwin-x64@1.1.5":
+ resolution:
+ { integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g== }
+ engines: { node: ^20.19.0 || >=22.12.0 }
+ cpu: [x64]
+ os: [darwin]
+
+ "@rolldown/binding-freebsd-x64@1.1.5":
+ resolution:
+ { integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA== }
+ engines: { node: ^20.19.0 || >=22.12.0 }
+ cpu: [x64]
+ os: [freebsd]
+
+ "@rolldown/binding-linux-arm-gnueabihf@1.1.5":
+ resolution:
+ { integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw== }
+ engines: { node: ^20.19.0 || >=22.12.0 }
+ cpu: [arm]
+ os: [linux]
+
+ "@rolldown/binding-linux-arm64-gnu@1.1.5":
+ resolution:
+ { integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q== }
+ engines: { node: ^20.19.0 || >=22.12.0 }
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ "@rolldown/binding-linux-arm64-musl@1.1.5":
+ resolution:
+ { integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA== }
+ engines: { node: ^20.19.0 || >=22.12.0 }
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ "@rolldown/binding-linux-ppc64-gnu@1.1.5":
+ resolution:
+ { integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg== }
+ engines: { node: ^20.19.0 || >=22.12.0 }
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ "@rolldown/binding-linux-s390x-gnu@1.1.5":
+ resolution:
+ { integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA== }
+ engines: { node: ^20.19.0 || >=22.12.0 }
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ "@rolldown/binding-linux-x64-gnu@1.1.5":
+ resolution:
+ { integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ== }
+ engines: { node: ^20.19.0 || >=22.12.0 }
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ "@rolldown/binding-linux-x64-musl@1.1.5":
+ resolution:
+ { integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg== }
+ engines: { node: ^20.19.0 || >=22.12.0 }
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ "@rolldown/binding-openharmony-arm64@1.1.5":
+ resolution:
+ { integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw== }
+ engines: { node: ^20.19.0 || >=22.12.0 }
+ cpu: [arm64]
+ os: [openharmony]
+
+ "@rolldown/binding-wasm32-wasi@1.1.5":
+ resolution:
+ { integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA== }
+ engines: { node: ^20.19.0 || >=22.12.0 }
+ cpu: [wasm32]
+
+ "@rolldown/binding-win32-arm64-msvc@1.1.5":
+ resolution:
+ { integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw== }
+ engines: { node: ^20.19.0 || >=22.12.0 }
+ cpu: [arm64]
+ os: [win32]
+
+ "@rolldown/binding-win32-x64-msvc@1.1.5":
+ resolution:
+ { integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA== }
+ engines: { node: ^20.19.0 || >=22.12.0 }
+ cpu: [x64]
+ os: [win32]
+
+ "@rolldown/pluginutils@1.0.1":
+ resolution:
+ { integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw== }
+
+ "@standard-schema/spec@1.1.0":
+ resolution:
+ { integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== }
+
+ "@tybys/wasm-util@0.10.3":
+ resolution:
+ { integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg== }
+
+ "@typeonce/effect-machine@file:../..":
+ resolution: { directory: ../.., type: directory }
+ engines: { node: ">=20" }
+ peerDependencies:
+ effect: 4.0.0-beta.102
+
+ detect-libc@2.1.2:
+ resolution:
+ { integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== }
+ engines: { node: ">=8" }
+
+ effect@4.0.0-beta.102:
+ resolution:
+ { integrity: sha512-z8Y+Q76Hh/kjLFZrXu8tGn6e+tDsg45R+UHhxd190pXxD53OGwf/G/zDxXTkse4HJ5mobNZfitLfUCp4fMvu6w== }
+
+ fast-check@4.9.0:
+ resolution:
+ { integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg== }
+ engines: { node: ">=12.17.0" }
+
+ fdir@6.5.0:
+ resolution:
+ { integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== }
+ engines: { node: ">=12.0.0" }
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ find-my-way-ts@0.1.6:
+ resolution:
+ { integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA== }
+
+ fsevents@2.3.3:
+ resolution:
+ { integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== }
+ engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 }
+ os: [darwin]
+
+ ini@7.0.0:
+ resolution:
+ { integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w== }
+ engines: { node: ^22.22.2 || ^24.15.0 || >=26.0.0 }
+
+ kubernetes-types@1.30.0:
+ resolution:
+ { integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q== }
+
+ lightningcss-android-arm64@1.33.0:
+ resolution:
+ { integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg== }
+ engines: { node: ">= 12.0.0" }
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.33.0:
+ resolution:
+ { integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg== }
+ engines: { node: ">= 12.0.0" }
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.33.0:
+ resolution:
+ { integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ== }
+ engines: { node: ">= 12.0.0" }
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.33.0:
+ resolution:
+ { integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg== }
+ engines: { node: ">= 12.0.0" }
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.33.0:
+ resolution:
+ { integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ== }
+ engines: { node: ">= 12.0.0" }
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.33.0:
+ resolution:
+ { integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg== }
+ engines: { node: ">= 12.0.0" }
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-arm64-musl@1.33.0:
+ resolution:
+ { integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ== }
+ engines: { node: ">= 12.0.0" }
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-linux-x64-gnu@1.33.0:
+ resolution:
+ { integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg== }
+ engines: { node: ">= 12.0.0" }
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-x64-musl@1.33.0:
+ resolution:
+ { integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw== }
+ engines: { node: ">= 12.0.0" }
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-win32-arm64-msvc@1.33.0:
+ resolution:
+ { integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA== }
+ engines: { node: ">= 12.0.0" }
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.33.0:
+ resolution:
+ { integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA== }
+ engines: { node: ">= 12.0.0" }
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.33.0:
+ resolution:
+ { integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA== }
+ engines: { node: ">= 12.0.0" }
+
+ msgpackr-extract@3.0.4:
+ resolution:
+ { integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw== }
+ hasBin: true
+
+ msgpackr@2.0.5:
+ resolution:
+ { integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA== }
+
+ multipasta@0.2.8:
+ resolution:
+ { integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q== }
+
+ nanoid@3.3.16:
+ resolution:
+ { integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== }
+ engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 }
+ hasBin: true
+
+ node-gyp-build-optional-packages@5.2.2:
+ resolution:
+ { integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw== }
+ hasBin: true
+
+ picocolors@1.1.1:
+ resolution:
+ { integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== }
+
+ picomatch@4.0.5:
+ resolution:
+ { integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== }
+ engines: { node: ">=12" }
+
+ postcss@8.5.25:
+ resolution:
+ { integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw== }
+ engines: { node: ^10 || ^12 || >=14 }
+
+ pure-rand@8.4.2:
+ resolution:
+ { integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng== }
+
+ rolldown@1.1.5:
+ resolution:
+ { integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA== }
+ engines: { node: ^20.19.0 || >=22.12.0 }
+ hasBin: true
+
+ source-map-js@1.2.1:
+ resolution:
+ { integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== }
+ engines: { node: ">=0.10.0" }
+
+ tinyglobby@0.2.17:
+ resolution:
+ { integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g== }
+ engines: { node: ">=12.0.0" }
+
+ toml@4.3.0:
+ resolution:
+ { integrity: sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A== }
+ engines: { node: ">=20" }
+
+ tslib@2.8.1:
+ resolution:
+ { integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== }
+
+ typescript@6.0.3:
+ resolution:
+ { integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== }
+ engines: { node: ">=14.17" }
+ hasBin: true
+
+ uuid@14.0.1:
+ resolution:
+ { integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew== }
+ hasBin: true
+
+ vite@8.1.5:
+ resolution:
+ { integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw== }
+ engines: { node: ^20.19.0 || >=22.12.0 }
+ hasBin: true
+ peerDependencies:
+ "@types/node": ^20.19.0 || >=22.12.0
+ "@vitejs/devtools": ^0.3.0
+ esbuild: ^0.27.0 || ^0.28.0
+ jiti: ">=1.21.0"
+ less: ^4.0.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: ">=0.54.8"
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ "@types/node":
+ optional: true
+ "@vitejs/devtools":
+ optional: true
+ esbuild:
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ yaml@2.9.0:
+ resolution:
+ { integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA== }
+ engines: { node: ">= 14.6" }
+ hasBin: true
+
+snapshots:
+ "@emnapi/core@1.11.1":
+ dependencies:
+ "@emnapi/wasi-threads": 1.2.2
+ tslib: 2.8.1
+ optional: true
+
+ "@emnapi/runtime@1.11.1":
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ "@emnapi/wasi-threads@1.2.2":
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4":
+ optional: true
+
+ "@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4":
+ optional: true
+
+ "@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4":
+ optional: true
+
+ "@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4":
+ optional: true
+
+ "@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4":
+ optional: true
+
+ "@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4":
+ optional: true
+
+ "@napi-rs/wasm-runtime@1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)":
+ dependencies:
+ "@emnapi/core": 1.11.1
+ "@emnapi/runtime": 1.11.1
+ "@tybys/wasm-util": 0.10.3
+ optional: true
+
+ "@oxc-project/types@0.139.0": {}
+
+ "@rolldown/binding-android-arm64@1.1.5":
+ optional: true
+
+ "@rolldown/binding-darwin-arm64@1.1.5":
+ optional: true
+
+ "@rolldown/binding-darwin-x64@1.1.5":
+ optional: true
+
+ "@rolldown/binding-freebsd-x64@1.1.5":
+ optional: true
+
+ "@rolldown/binding-linux-arm-gnueabihf@1.1.5":
+ optional: true
+
+ "@rolldown/binding-linux-arm64-gnu@1.1.5":
+ optional: true
+
+ "@rolldown/binding-linux-arm64-musl@1.1.5":
+ optional: true
+
+ "@rolldown/binding-linux-ppc64-gnu@1.1.5":
+ optional: true
+
+ "@rolldown/binding-linux-s390x-gnu@1.1.5":
+ optional: true
+
+ "@rolldown/binding-linux-x64-gnu@1.1.5":
+ optional: true
+
+ "@rolldown/binding-linux-x64-musl@1.1.5":
+ optional: true
+
+ "@rolldown/binding-openharmony-arm64@1.1.5":
+ optional: true
+
+ "@rolldown/binding-wasm32-wasi@1.1.5":
+ dependencies:
+ "@emnapi/core": 1.11.1
+ "@emnapi/runtime": 1.11.1
+ "@napi-rs/wasm-runtime": 1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
+ optional: true
+
+ "@rolldown/binding-win32-arm64-msvc@1.1.5":
+ optional: true
+
+ "@rolldown/binding-win32-x64-msvc@1.1.5":
+ optional: true
+
+ "@rolldown/pluginutils@1.0.1": {}
+
+ "@standard-schema/spec@1.1.0": {}
+
+ "@tybys/wasm-util@0.10.3":
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ "@typeonce/effect-machine@file:../..(effect@4.0.0-beta.102)":
+ dependencies:
+ effect: 4.0.0-beta.102
+
+ detect-libc@2.1.2: {}
+
+ effect@4.0.0-beta.102:
+ dependencies:
+ "@standard-schema/spec": 1.1.0
+ fast-check: 4.9.0
+ find-my-way-ts: 0.1.6
+ ini: 7.0.0
+ kubernetes-types: 1.30.0
+ msgpackr: 2.0.5
+ multipasta: 0.2.8
+ toml: 4.3.0
+ uuid: 14.0.1
+ yaml: 2.9.0
+
+ fast-check@4.9.0:
+ dependencies:
+ pure-rand: 8.4.2
+
+ fdir@6.5.0(picomatch@4.0.5):
+ optionalDependencies:
+ picomatch: 4.0.5
+
+ find-my-way-ts@0.1.6: {}
+
+ fsevents@2.3.3:
+ optional: true
+
+ ini@7.0.0: {}
+
+ kubernetes-types@1.30.0: {}
+
+ lightningcss-android-arm64@1.33.0:
+ optional: true
+
+ lightningcss-darwin-arm64@1.33.0:
+ optional: true
+
+ lightningcss-darwin-x64@1.33.0:
+ optional: true
+
+ lightningcss-freebsd-x64@1.33.0:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.33.0:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.33.0:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.33.0:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.33.0:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.33.0:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.33.0:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.33.0:
+ optional: true
+
+ lightningcss@1.33.0:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.33.0
+ lightningcss-darwin-arm64: 1.33.0
+ lightningcss-darwin-x64: 1.33.0
+ lightningcss-freebsd-x64: 1.33.0
+ lightningcss-linux-arm-gnueabihf: 1.33.0
+ lightningcss-linux-arm64-gnu: 1.33.0
+ lightningcss-linux-arm64-musl: 1.33.0
+ lightningcss-linux-x64-gnu: 1.33.0
+ lightningcss-linux-x64-musl: 1.33.0
+ lightningcss-win32-arm64-msvc: 1.33.0
+ lightningcss-win32-x64-msvc: 1.33.0
+
+ msgpackr-extract@3.0.4:
+ dependencies:
+ node-gyp-build-optional-packages: 5.2.2
+ optionalDependencies:
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64": 3.0.4
+ "@msgpackr-extract/msgpackr-extract-darwin-x64": 3.0.4
+ "@msgpackr-extract/msgpackr-extract-linux-arm": 3.0.4
+ "@msgpackr-extract/msgpackr-extract-linux-arm64": 3.0.4
+ "@msgpackr-extract/msgpackr-extract-linux-x64": 3.0.4
+ "@msgpackr-extract/msgpackr-extract-win32-x64": 3.0.4
+ optional: true
+
+ msgpackr@2.0.5:
+ optionalDependencies:
+ msgpackr-extract: 3.0.4
+
+ multipasta@0.2.8: {}
+
+ nanoid@3.3.16: {}
+
+ node-gyp-build-optional-packages@5.2.2:
+ dependencies:
+ detect-libc: 2.1.2
+ optional: true
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.5: {}
+
+ postcss@8.5.25:
+ dependencies:
+ nanoid: 3.3.16
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ pure-rand@8.4.2: {}
+
+ rolldown@1.1.5:
+ dependencies:
+ "@oxc-project/types": 0.139.0
+ "@rolldown/pluginutils": 1.0.1
+ optionalDependencies:
+ "@rolldown/binding-android-arm64": 1.1.5
+ "@rolldown/binding-darwin-arm64": 1.1.5
+ "@rolldown/binding-darwin-x64": 1.1.5
+ "@rolldown/binding-freebsd-x64": 1.1.5
+ "@rolldown/binding-linux-arm-gnueabihf": 1.1.5
+ "@rolldown/binding-linux-arm64-gnu": 1.1.5
+ "@rolldown/binding-linux-arm64-musl": 1.1.5
+ "@rolldown/binding-linux-ppc64-gnu": 1.1.5
+ "@rolldown/binding-linux-s390x-gnu": 1.1.5
+ "@rolldown/binding-linux-x64-gnu": 1.1.5
+ "@rolldown/binding-linux-x64-musl": 1.1.5
+ "@rolldown/binding-openharmony-arm64": 1.1.5
+ "@rolldown/binding-wasm32-wasi": 1.1.5
+ "@rolldown/binding-win32-arm64-msvc": 1.1.5
+ "@rolldown/binding-win32-x64-msvc": 1.1.5
+
+ source-map-js@1.2.1: {}
+
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.5)
+ picomatch: 4.0.5
+
+ toml@4.3.0: {}
+
+ tslib@2.8.1:
+ optional: true
+
+ typescript@6.0.3: {}
+
+ uuid@14.0.1: {}
+
+ vite@8.1.5(yaml@2.9.0):
+ dependencies:
+ lightningcss: 1.33.0
+ picomatch: 4.0.5
+ postcss: 8.5.25
+ rolldown: 1.1.5
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ fsevents: 2.3.3
+ yaml: 2.9.0
+
+ yaml@2.9.0: {}
diff --git a/examples/platformer/pnpm-workspace.yaml b/examples/platformer/pnpm-workspace.yaml
new file mode 100644
index 0000000..4167189
--- /dev/null
+++ b/examples/platformer/pnpm-workspace.yaml
@@ -0,0 +1,10 @@
+packages:
+ - "."
+
+overrides:
+ effect: 4.0.0-beta.102
+
+minimumReleaseAge: 1440
+minimumReleaseAgeExclude:
+ - effect@4.0.0-beta.102
+ - vite@8.1.5
diff --git a/examples/platformer/src/game.ts b/examples/platformer/src/game.ts
new file mode 100644
index 0000000..8ad601f
--- /dev/null
+++ b/examples/platformer/src/game.ts
@@ -0,0 +1,162 @@
+import type { Axis, CharacterEvent, CharacterSnapshot, LocomotionMode } from "./machine.ts"
+import { airJumpMode, facingDirection, locomotionState } from "./machine.ts"
+
+const FLOOR = 324
+const SIZE = 30
+
+const pose = {
+ Standing: "translate(0 0)",
+ Running: "rotate(6 15 30)",
+ Jumping: "rotate(-12 15 15)",
+ Falling: "rotate(12 15 15)",
+ Ducking: "translate(0 15) scale(1 .5)",
+ Diving: "rotate(90 15 15) scale(.8 1.15)",
+ Landing: "translate(-3 12) scale(1.2 .6)"
+} as const satisfies Record
+
+export class GameAdapter {
+ private readonly position = { x: 84, y: FLOOR - SIZE, vy: 0 }
+ private readonly held = new Set()
+ private snapshot: CharacterSnapshot | undefined
+ private previousMode: LocomotionMode = "Standing"
+ private lastJumpAt = -1
+ private wallPushSpeed = 0
+ private wallPushUntil = 0
+ private reportedWall: Axis | undefined
+ private grounded = true
+ private apexReported = false
+
+ constructor(
+ private readonly send: (event: CharacterEvent) => void,
+ private readonly player: SVGGElement,
+ private readonly playerPose: SVGGElement
+ ) {
+ window.addEventListener("keydown", this.onKeyDown)
+ window.addEventListener("keyup", this.onKeyUp)
+ }
+
+ setSnapshot(snapshot: CharacterSnapshot) {
+ this.snapshot = snapshot
+ }
+
+ step(seconds: number) {
+ if (this.snapshot === undefined) return
+ const dt = Math.min(seconds, 1 / 30)
+ const state = locomotionState(this.snapshot)
+ const mode = state._tag
+ const p = this.position
+
+ if (state._tag === "Jumping" && state.startedAt !== this.lastJumpAt) {
+ p.vy = -430
+ this.wallPushSpeed = state.push * 330
+ this.wallPushUntil = state.push === 0 ? 0 : state.startedAt + 240
+ this.grounded = false
+ this.apexReported = false
+ this.lastJumpAt = state.startedAt
+ }
+ if (mode === "Diving" && this.previousMode !== "Diving") p.vy = 610
+
+ const inputSpeed = mode === "Ducking" || mode === "Landing" ? 0 : this.axis * (this.grounded ? 190 : 135)
+ const speed = performance.now() < this.wallPushUntil ? this.wallPushSpeed : inputSpeed
+ if (!this.grounded) p.vy += 1_180 * dt
+ p.x = Math.max(0, Math.min(640 - SIZE, p.x + speed * dt))
+ p.y += p.vy * dt
+
+ if (this.jumpWall !== this.reportedWall) {
+ this.reportedWall = this.jumpWall
+ this.send({ _tag: "WallContact", wall: this.reportedWall })
+ }
+
+ if (!this.apexReported && p.vy >= 0 && mode === "Jumping") {
+ this.apexReported = true
+ this.send({ _tag: "ApexReached", y: Math.round(p.y) })
+ }
+ if (!this.grounded && p.y >= FLOOR - SIZE) {
+ const impact = Math.round(p.vy)
+ Object.assign(p, { y: FLOOR - SIZE, vy: 0 })
+ this.grounded = true
+ this.send({ _tag: "Landed", impact, axis: this.axis, at: performance.now() })
+ }
+
+ const x = facingDirection(this.snapshot) === "Left" ? p.x + SIZE : p.x
+ const flip = facingDirection(this.snapshot) === "Left" ? " scale(-1 1)" : ""
+ this.player.setAttribute("transform", `translate(${x.toFixed(1)} ${p.y.toFixed(1)})${flip}`)
+ this.playerPose.setAttribute("transform", pose[mode])
+ this.player.dataset.mode = mode
+ this.player.dataset.airJump = airJumpMode(this.snapshot) === "AirJumpSpent" ? "spent" : "ready"
+ if (state._tag === "Jumping") this.player.dataset.jumpKind = state.kind
+ else delete this.player.dataset.jumpKind
+ this.previousMode = mode
+ }
+
+ reset() {
+ Object.assign(this.position, { x: 84, y: FLOOR - SIZE, vy: 0 })
+ this.held.clear()
+ this.grounded = true
+ this.previousMode = "Standing"
+ this.lastJumpAt = -1
+ this.wallPushUntil = 0
+ this.reportedWall = undefined
+ }
+
+ destroy() {
+ window.removeEventListener("keydown", this.onKeyDown)
+ window.removeEventListener("keyup", this.onKeyUp)
+ }
+
+ private get axis(): Axis {
+ const left = this.held.has("KeyA") || this.held.has("ArrowLeft")
+ const right = this.held.has("KeyD") || this.held.has("ArrowRight")
+ return left === right ? 0 : left ? -1 : 1
+ }
+
+ private get wall(): Axis {
+ return this.position.x <= 1 ? -1 : this.position.x >= 640 - SIZE - 1 ? 1 : 0
+ }
+
+ private get jumpWall(): Axis {
+ return performance.now() < this.wallPushUntil ? 0 : this.wall
+ }
+
+ private readonly onKeyDown = (event: KeyboardEvent) => {
+ if (!this.isGameKey(event.code)) return
+ event.preventDefault()
+ const previousAxis = this.axis
+ this.held.add(event.code)
+ if (previousAxis !== this.axis) this.send({ _tag: "Move", axis: this.axis, at: performance.now() })
+ if (event.repeat) return
+
+ if (event.code === "Space" || event.code === "KeyW" || event.code === "ArrowUp") {
+ this.send({ _tag: "JumpPressed", at: performance.now(), y: this.position.y, wall: this.jumpWall })
+ } else if (event.code === "KeyS" || event.code === "ArrowDown") {
+ this.send({ _tag: "DownPressed", at: performance.now() })
+ } else if (event.code === "KeyR") {
+ this.reset()
+ this.send({ _tag: "Reset" })
+ }
+ }
+
+ private readonly onKeyUp = (event: KeyboardEvent) => {
+ const previousAxis = this.axis
+ this.held.delete(event.code)
+ if (previousAxis !== this.axis) this.send({ _tag: "Move", axis: this.axis, at: performance.now() })
+ if (event.code === "KeyS" || event.code === "ArrowDown") {
+ this.send({ _tag: "DownReleased", axis: this.axis, at: performance.now() })
+ }
+ }
+
+ private isGameKey(code: string) {
+ return [
+ "KeyA",
+ "KeyD",
+ "KeyW",
+ "KeyS",
+ "KeyR",
+ "Space",
+ "ArrowLeft",
+ "ArrowRight",
+ "ArrowUp",
+ "ArrowDown"
+ ].includes(code)
+ }
+}
diff --git a/examples/platformer/src/machine.ts b/examples/platformer/src/machine.ts
new file mode 100644
index 0000000..1ef6358
--- /dev/null
+++ b/examples/platformer/src/machine.ts
@@ -0,0 +1,386 @@
+import { Effect, Schema } from "effect"
+import { Machine } from "@typeonce/effect-machine"
+
+// Domain schemas are shared by state payloads and the public physics protocol.
+export const Axis = Schema.Literals([-1, 0, 1])
+export type Axis = typeof Axis.Type
+const JumpKind = Schema.Literals(["Ground", "Double", "Wall"])
+
+const State = Schema.TaggedUnion({
+ Character: {},
+ Locomotion: {},
+ Grounded: {},
+ Standing: {},
+ Running: { startedAt: Schema.Number },
+ Ducking: { startedAt: Schema.Number },
+ Landing: { impact: Schema.Number, resumeAxis: Axis, landedAt: Schema.Number },
+ Airborne: { originY: Schema.Number },
+ Motion: {},
+ Jumping: { startedAt: Schema.Number, push: Axis, kind: JumpKind },
+ Falling: { apexY: Schema.Number },
+ Diving: { startedAt: Schema.Number },
+ AirJump: {},
+ AirJumpGroundLock: {},
+ AirJumpWallLock: {},
+ AirJumpReady: {},
+ AirJumpSpent: {},
+ WallContact: {},
+ NoWall: {},
+ LeftWall: {},
+ RightWall: {},
+ Facing: {},
+ Left: {},
+ Right: {}
+})
+
+// Inputs and physics facts are one runtime-decoded, statically typed protocol.
+export const Event = Schema.TaggedUnion({
+ Move: { axis: Axis, at: Schema.Number },
+ JumpPressed: { at: Schema.Number, y: Schema.Number, wall: Axis },
+ WallContact: { wall: Axis },
+ DownPressed: { at: Schema.Number },
+ DownReleased: { axis: Axis, at: Schema.Number },
+ ApexReached: { y: Schema.Number },
+ Landed: { impact: Schema.Number, axis: Axis, at: Schema.Number },
+ Reset: {}
+})
+
+const InternalEvent = Schema.TaggedUnion({
+ LandingSettled: {},
+ AirJumpUnlocked: {},
+ TryAirJump: { at: Schema.Number },
+ DoubleJump: { at: Schema.Number },
+ WallJump: { at: Schema.Number, push: Axis }
+})
+
+const awayFrom = (wall: Axis): Axis => (wall === -1 ? 1 : wall === 1 ? -1 : 0)
+
+export const CharacterStates = Machine.defineStates({
+ Character: {
+ schema: State.cases.Character,
+ type: "parallel",
+ states: {
+ locomotion: {
+ schema: State.cases.Locomotion,
+ initial: "Grounded",
+ states: {
+ Grounded: {
+ schema: State.cases.Grounded,
+ initial: "Standing",
+ states: {
+ Standing: State.cases.Standing,
+ Running: State.cases.Running,
+ Ducking: State.cases.Ducking,
+ Landing: State.cases.Landing
+ }
+ },
+ Airborne: {
+ schema: State.cases.Airborne,
+ type: "parallel",
+ states: {
+ motion: {
+ schema: State.cases.Motion,
+ initial: "Jumping",
+ states: {
+ Jumping: State.cases.Jumping,
+ Falling: State.cases.Falling,
+ Diving: State.cases.Diving
+ }
+ },
+ airJump: {
+ schema: State.cases.AirJump,
+ initial: "AirJumpGroundLock",
+ states: {
+ AirJumpGroundLock: State.cases.AirJumpGroundLock,
+ AirJumpWallLock: State.cases.AirJumpWallLock,
+ AirJumpReady: State.cases.AirJumpReady,
+ AirJumpSpent: State.cases.AirJumpSpent
+ }
+ }
+ }
+ }
+ }
+ },
+ facing: {
+ schema: State.cases.Facing,
+ initial: "Right",
+ states: {
+ Left: State.cases.Left,
+ Right: State.cases.Right
+ }
+ },
+ contact: {
+ schema: State.cases.WallContact,
+ initial: "NoWall",
+ states: {
+ NoWall: State.cases.NoWall,
+ LeftWall: State.cases.LeftWall,
+ RightWall: State.cases.RightWall
+ }
+ }
+ }
+ }
+})
+
+const initialCharacter = () =>
+ CharacterStates.initial.Character(State.cases.Character.make({}), (character) =>
+ character
+ .locomotion(State.cases.Locomotion.make({}), (locomotion) =>
+ locomotion.Grounded(State.cases.Grounded.make({}), (grounded) =>
+ grounded.Standing(State.cases.Standing.make({}))
+ )
+ )
+ .facing(State.cases.Facing.make({}), (facing) => facing.Right(State.cases.Right.make({})))
+ .contact(State.cases.WallContact.make({}), (contact) => contact.NoWall(State.cases.NoWall.make({})))
+ )
+
+export const CharacterMachine = Machine.make({
+ id: "PlatformerCharacter",
+ states: CharacterStates.states,
+ events: Object.values(Event.cases),
+ internalEvents: Object.values(InternalEvent.cases),
+ initial: initialCharacter
+}).handle({
+ Character: {
+ on: {
+ Reset: initialCharacter
+ },
+ states: {
+ locomotion: {
+ states: {
+ Grounded: {
+ on: {
+ JumpPressed: ({ event, target }) =>
+ target.branch.Character.locomotion.Airborne(
+ State.cases.Airborne.make({ originY: event.y }),
+ (airborne) =>
+ airborne
+ .motion(State.cases.Motion.make({}), (motion) =>
+ motion.Jumping(
+ State.cases.Jumping.make({ startedAt: event.at, push: 0, kind: "Ground" })
+ )
+ )
+ .airJump(State.cases.AirJump.make({}), (airJump) =>
+ airJump.AirJumpGroundLock(State.cases.AirJumpGroundLock.make({}))
+ )
+ )
+ },
+ states: {
+ Standing: {
+ on: {
+ Move: ({ event, target }) =>
+ event.axis === 0
+ ? undefined
+ : target.local.Running(State.cases.Running.make({ startedAt: event.at })),
+ DownPressed: ({ event, target }) =>
+ target.local.Ducking(State.cases.Ducking.make({ startedAt: event.at }))
+ }
+ },
+ Running: {
+ on: {
+ Move: ({ event, target }) =>
+ event.axis === 0 ? target.local.Standing(State.cases.Standing.make({})) : undefined,
+ DownPressed: ({ event, target }) =>
+ target.local.Ducking(State.cases.Ducking.make({ startedAt: event.at }))
+ }
+ },
+ Ducking: {
+ on: {
+ DownReleased: ({ event, target }) =>
+ event.axis === 0
+ ? target.local.Standing(State.cases.Standing.make({}))
+ : target.local.Running(State.cases.Running.make({ startedAt: event.at }))
+ }
+ },
+ Landing: {
+ invoke: Machine.after("140 millis", InternalEvent.cases.LandingSettled.make({}), {
+ id: "landing-settle"
+ }),
+ on: {
+ Move: ({ event, state, target }) =>
+ target.local.Landing(Machine.retag(State.cases.Landing, state, { resumeAxis: event.axis })),
+ LandingSettled: ({ state, target }) =>
+ state.resumeAxis === 0
+ ? target.local.Standing(State.cases.Standing.make({}))
+ : target.local.Running(State.cases.Running.make({ startedAt: state.landedAt + 140 }))
+ }
+ }
+ }
+ },
+ Airborne: {
+ on: {
+ JumpPressed: Effect.fn(function* ({ event, runtime }) {
+ const machine = yield* runtime
+ const push = awayFrom(event.wall)
+ yield* machine.raise(
+ push === 0
+ ? InternalEvent.cases.TryAirJump.make({ at: event.at })
+ : InternalEvent.cases.WallJump.make({ at: event.at, push })
+ )
+ }),
+ Landed: ({ event, target }) =>
+ target.branch.Character.locomotion.Grounded(State.cases.Grounded.make({}), (grounded) =>
+ grounded.Landing(
+ State.cases.Landing.make({
+ impact: event.impact,
+ resumeAxis: event.axis,
+ landedAt: event.at
+ })
+ )
+ )
+ },
+ states: {
+ motion: {
+ on: {
+ DoubleJump: ({ event, target }) =>
+ target.local.Jumping(
+ State.cases.Jumping.make({ startedAt: event.at, push: 0, kind: "Double" })
+ ),
+ WallJump: ({ event, target }) =>
+ target.local.Jumping(
+ State.cases.Jumping.make({ startedAt: event.at, push: event.push, kind: "Wall" })
+ )
+ },
+ states: {
+ Jumping: {
+ on: {
+ ApexReached: ({ event, target }) =>
+ target.local.Falling(State.cases.Falling.make({ apexY: event.y })),
+ DownPressed: ({ event, target }) =>
+ target.local.Diving(State.cases.Diving.make({ startedAt: event.at }))
+ }
+ },
+ Falling: {
+ on: {
+ DownPressed: ({ event, target }) =>
+ target.local.Diving(State.cases.Diving.make({ startedAt: event.at }))
+ }
+ },
+ Diving: {}
+ }
+ },
+ airJump: {
+ on: {
+ WallJump: {
+ reenter: true,
+ transition: ({ target }) =>
+ target.local.AirJumpWallLock(State.cases.AirJumpWallLock.make({}))
+ }
+ },
+ states: {
+ AirJumpGroundLock: {
+ invoke: Machine.after("120 millis", InternalEvent.cases.AirJumpUnlocked.make({}), {
+ id: "ground-air-jump-unlock"
+ }),
+ on: {
+ AirJumpUnlocked: ({ target }) =>
+ target.local.AirJumpReady(State.cases.AirJumpReady.make({}))
+ }
+ },
+ AirJumpWallLock: {
+ invoke: Machine.after("240 millis", InternalEvent.cases.AirJumpUnlocked.make({}), {
+ id: "wall-air-jump-unlock"
+ }),
+ on: {
+ AirJumpUnlocked: ({ target }) =>
+ target.local.AirJumpReady(State.cases.AirJumpReady.make({}))
+ }
+ },
+ AirJumpReady: {
+ on: {
+ TryAirJump: Effect.fn(function* ({ event, runtime, target }) {
+ const machine = yield* runtime
+ yield* machine.raise(InternalEvent.cases.DoubleJump.make({ at: event.at }))
+ return target.local.AirJumpSpent(State.cases.AirJumpSpent.make({}))
+ })
+ }
+ },
+ AirJumpSpent: {}
+ }
+ }
+ }
+ }
+ }
+ },
+ facing: {
+ states: {
+ Left: {
+ on: {
+ Move: ({ event, target }) =>
+ event.axis === 1 ? target.local.Right(State.cases.Right.make({})) : undefined,
+ WallJump: ({ event, target }) =>
+ event.push === 1 ? target.local.Right(State.cases.Right.make({})) : undefined
+ }
+ },
+ Right: {
+ on: {
+ Move: ({ event, target }) =>
+ event.axis === -1 ? target.local.Left(State.cases.Left.make({})) : undefined,
+ WallJump: ({ event, target }) =>
+ event.push === -1 ? target.local.Left(State.cases.Left.make({})) : undefined
+ }
+ }
+ }
+ },
+ contact: {
+ on: {
+ WallContact: ({ event, target }) =>
+ event.wall === -1
+ ? target.local.LeftWall(State.cases.LeftWall.make({}))
+ : event.wall === 1
+ ? target.local.RightWall(State.cases.RightWall.make({}))
+ : target.local.NoWall(State.cases.NoWall.make({}))
+ },
+ states: {
+ NoWall: {},
+ LeftWall: {},
+ RightWall: {}
+ }
+ }
+ }
+ }
+})
+
+export type CharacterSnapshot = Machine.Machine.Snapshot
+export type CharacterEvent = Machine.Machine.InputEvent
+
+export const locomotionState = (snapshot: CharacterSnapshot) => {
+ const locomotion = snapshot.states.locomotion.state
+ return locomotion.path === "Character.locomotion.Grounded"
+ ? locomotion.state.value
+ : locomotion.states.motion.state.value
+}
+
+export type LocomotionMode = ReturnType["_tag"]
+
+export const locomotionMode = (snapshot: CharacterSnapshot): LocomotionMode => locomotionState(snapshot)._tag
+
+export const locomotionBranch = (snapshot: CharacterSnapshot) => snapshot.states.locomotion.state.value._tag
+
+export const airJumpMode = (snapshot: CharacterSnapshot) => {
+ const locomotion = snapshot.states.locomotion.state
+ return locomotion.path === "Character.locomotion.Airborne"
+ ? locomotion.states.airJump.state.value._tag
+ : undefined
+}
+
+export const wallContact = (snapshot: CharacterSnapshot) => {
+ return snapshot.states.contact.state.value._tag
+}
+
+export const facingDirection = (snapshot: CharacterSnapshot) => snapshot.states.facing.state.value._tag
+
+export const activeStateData = (snapshot: CharacterSnapshot) => {
+ const locomotion = snapshot.states.locomotion.state
+ const { _tag: _branch, ...branchData } = locomotion.value
+ if (locomotion.path === "Character.locomotion.Grounded") {
+ const { _tag: _leaf, ...leafData } = locomotion.state.value
+ return { ...branchData, ...leafData }
+ }
+ const { _tag: _motion, ...motionData } = locomotion.states.motion.state.value
+ return {
+ ...branchData,
+ ...motionData,
+ airJump: locomotion.states.airJump.state.value._tag
+ }
+}
diff --git a/examples/platformer/src/main.ts b/examples/platformer/src/main.ts
new file mode 100644
index 0000000..b9429a7
--- /dev/null
+++ b/examples/platformer/src/main.ts
@@ -0,0 +1,84 @@
+import "./styles.css"
+import { Effect, Fiber, Stream } from "effect"
+import { Machine } from "@typeonce/effect-machine"
+import { GameAdapter } from "./game.ts"
+import {
+ activeStateData,
+ airJumpMode,
+ CharacterMachine,
+ facingDirection,
+ locomotionBranch,
+ locomotionMode,
+ wallContact,
+ type CharacterEvent,
+ type CharacterSnapshot
+} from "./machine.ts"
+
+const requiredElement = (selector: string) => {
+ const element = document.querySelector(selector)
+ if (element === null) throw new Error(`Missing element: ${selector}`)
+ return element
+}
+
+const modeLabel = requiredElement("#active-mode")
+const stateData = requiredElement("#state-data")
+const lastEvent = requiredElement("#last-event")
+
+const showEvent = (event: CharacterEvent) => {
+ const { _tag, ...payload } = event
+ const detail = Object.keys(payload).length === 0 ? "" : ` ${JSON.stringify(payload)}`
+ lastEvent.textContent = `${_tag}${detail}`
+}
+
+let deliver: ((event: CharacterEvent) => void) | undefined
+const pending: Array = []
+const send = (event: CharacterEvent) => {
+ showEvent(event)
+ deliver === undefined ? pending.push(event) : deliver(event)
+}
+
+const game = new GameAdapter(
+ send,
+ requiredElement("#player"),
+ requiredElement("#player-pose")
+)
+
+const publish = (next: CharacterSnapshot) => {
+ game.setSnapshot(next)
+ const mode = locomotionMode(next)
+ const facing = facingDirection(next)
+ const contact = wallContact(next)
+ const airJump = airJumpMode(next)
+ modeLabel.textContent = [mode, airJump, contact, facing].filter(Boolean).join(" · ")
+ stateData.textContent = JSON.stringify(activeStateData(next))
+
+ const active = new Set([mode, facing, contact, locomotionBranch(next)])
+ if (airJump !== undefined) active.add(airJump)
+ document.querySelectorAll("[data-node]").forEach((node) => {
+ node.classList.toggle("is-active", active.has(node.dataset.node ?? ""))
+ })
+}
+
+const program = Effect.gen(function* () {
+ const actor = yield* Machine.start(CharacterMachine)
+ deliver = (event) => Effect.runFork(actor.send(event).pipe(Effect.catchTag("StoppedError", () => Effect.void)))
+ publish(yield* actor.state)
+ for (const event of pending.splice(0)) yield* actor.send(event)
+ yield* Stream.runForEach(actor.changes, ({ state }) => Effect.sync(() => publish(state)))
+})
+
+const fiber = Effect.runFork(program)
+
+let previous = performance.now()
+
+const frame = (now: number) => {
+ game.step((now - previous) / 1_000)
+ previous = now
+ requestAnimationFrame(frame)
+}
+
+requestAnimationFrame(frame)
+window.addEventListener("beforeunload", () => {
+ game.destroy()
+ Effect.runFork(Fiber.interrupt(fiber))
+})
diff --git a/examples/platformer/src/styles.css b/examples/platformer/src/styles.css
new file mode 100644
index 0000000..fd2e7f3
--- /dev/null
+++ b/examples/platformer/src/styles.css
@@ -0,0 +1,366 @@
+:root {
+ color: #e9fff9;
+ background: #0a1021;
+ font-family:
+ Inter,
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ sans-serif;
+ font-synthesis: none;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ min-height: 100vh;
+ background:
+ radial-gradient(circle at 12% 0%, rgb(46 196 182 / 16%), transparent 32rem),
+ radial-gradient(circle at 92% 18%, rgb(255 107 107 / 10%), transparent 28rem), #0a1021;
+}
+
+.app-shell {
+ width: min(1180px, calc(100% - 32px));
+ margin: 0 auto;
+ padding: 36px 0 50px;
+}
+.hero {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 24px;
+ margin-bottom: 22px;
+}
+.eyebrow {
+ margin: 0 0 8px;
+ color: #55d6be;
+ font:
+ 700 11px/1.2 ui-monospace,
+ monospace;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+h1 {
+ margin: 0;
+ font-size: clamp(42px, 7vw, 76px);
+ line-height: 0.9;
+ letter-spacing: -0.065em;
+}
+.lede {
+ margin: 14px 0 0;
+ color: #9aabc1;
+ font-size: 15px;
+}
+.status-light {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ color: #92a6bc;
+ font:
+ 650 11px ui-monospace,
+ monospace;
+ text-transform: uppercase;
+}
+.status-light span {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #55d6be;
+ box-shadow: 0 0 14px #55d6be;
+}
+
+.demo-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.65fr) minmax(310px, 0.85fr);
+ gap: 16px;
+}
+.game-card,
+.debug-card {
+ overflow: hidden;
+ border: 1px solid #243452;
+ border-radius: 18px;
+ background: rgb(16 24 46 / 88%);
+ box-shadow: 0 24px 70px rgb(0 0 0 / 28%);
+}
+#game {
+ display: block;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ background: #10182e;
+}
+.stars {
+ opacity: 0.75;
+}
+.skyline {
+ fill: #263d5d;
+}
+.ground {
+ fill: #273a54;
+}
+.ground-top {
+ fill: #55d6be;
+}
+.wall {
+ fill: #55d6be;
+ opacity: 0.75;
+}
+#player-pose {
+ transition: transform 80ms ease-out;
+}
+#player .body {
+ fill: #2ec4b6;
+ stroke: #102641;
+ stroke-width: 3;
+}
+#player .face {
+ fill: #fff1c1;
+}
+#player .eye {
+ fill: #14213d;
+}
+#player .scarf {
+ fill: #ff6b6b;
+}
+#player[data-mode="Jumping"] .body {
+ fill: #63e6d6;
+}
+#player[data-jump-kind="Double"] .body {
+ fill: #b388ff;
+}
+#player[data-mode="Falling"] .body {
+ fill: #70a9ff;
+}
+#player[data-mode="Diving"] .body {
+ fill: #ff8b7f;
+}
+#player[data-mode="Landing"] .body {
+ fill: #ffd166;
+}
+#player[data-air-jump="spent"] .scarf {
+ fill: #c77dff;
+ filter: drop-shadow(0 0 3px #c77dff);
+}
+.controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 14px 20px;
+ padding: 13px 16px;
+ border-top: 1px solid #243452;
+ color: #8fa2b9;
+ font-size: 11px;
+}
+.controls span {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+kbd {
+ min-width: 22px;
+ padding: 4px 6px;
+ border: 1px solid #3b506e;
+ border-bottom-width: 2px;
+ border-radius: 5px;
+ color: #d8fff6;
+ background: #1b2942;
+ font:
+ 700 10px ui-monospace,
+ monospace;
+ text-align: center;
+}
+
+.debug-card {
+ padding: 16px;
+}
+.panel-heading {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 14px;
+ color: #8196ad;
+ font:
+ 700 10px ui-monospace,
+ monospace;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+.chart {
+ padding: 12px;
+ border: 1px solid #273a58;
+ border-radius: 12px;
+ background: #0d162b;
+}
+.chart-root {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 10px;
+ border: 1px solid #3e5775;
+ border-radius: 7px;
+ color: #e9fff9;
+ background: #182742;
+ font:
+ 700 12px ui-monospace,
+ monospace;
+}
+.chart-root small {
+ color: #55d6be;
+ font-size: 9px;
+ text-transform: uppercase;
+}
+.regions {
+ display: grid;
+ gap: 9px;
+ margin-top: 9px;
+}
+.regions section {
+ padding: 9px;
+ border-left: 2px solid #334865;
+ background: #111d33;
+}
+.regions h2 {
+ margin: 0 0 8px;
+ color: #7389a1;
+ font:
+ 700 9px ui-monospace,
+ monospace;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+.branch + .branch {
+ margin-top: 7px;
+}
+.branch h3 {
+ display: inline-block;
+ margin: 0 0 5px;
+ padding: 3px 6px;
+ border-radius: 4px;
+ color: #7890a9;
+ font:
+ 700 9px ui-monospace,
+ monospace;
+}
+.parallel-label {
+ display: inline-block;
+ margin-left: 4px;
+ color: #55d6be;
+ font: 700 8px ui-monospace, monospace;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.nested-region {
+ padding: 6px;
+ border-left: 1px solid #304766;
+ background: #0e192d;
+}
+.nested-region + .nested-region {
+ margin-top: 4px;
+}
+.nested-region small {
+ display: block;
+ margin-bottom: 4px;
+ color: #637c98;
+ font: 700 8px ui-monospace, monospace;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+.state-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+.state-row span {
+ padding: 5px 6px;
+ border: 1px solid #2b3c59;
+ border-radius: 5px;
+ color: #70869e;
+ background: #131f35;
+ font:
+ 650 9px ui-monospace,
+ monospace;
+ transition: 120ms ease;
+}
+[data-node].is-active {
+ border-color: #55d6be !important;
+ color: #dffff8 !important;
+ background: rgb(46 196 182 / 16%) !important;
+ box-shadow: 0 0 0 2px rgb(46 196 182 / 8%);
+}
+.facing-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+}
+.facing-row span {
+ text-align: center;
+}
+
+.telemetry {
+ display: grid;
+ gap: 0;
+ margin: 14px 0 0;
+}
+.telemetry div {
+ display: grid;
+ grid-template-columns: 105px 1fr;
+ gap: 12px;
+ padding: 9px 2px;
+ border-top: 1px solid #23334f;
+}
+.telemetry dt {
+ color: #6f849c;
+ font:
+ 700 9px ui-monospace,
+ monospace;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+.telemetry dd {
+ min-width: 0;
+ margin: 0;
+ overflow-wrap: anywhere;
+ color: #c8d8e7;
+ font:
+ 600 10px/1.4 ui-monospace,
+ monospace;
+}
+code {
+ color: #ff9292;
+ font-family: ui-monospace, monospace;
+}
+.note {
+ max-width: 790px;
+ margin: 18px 4px 0;
+ color: #72879f;
+ font-size: 12px;
+ line-height: 1.7;
+}
+
+@media (max-width: 850px) {
+ .demo-grid {
+ grid-template-columns: 1fr;
+ }
+ .debug-card {
+ min-height: 0;
+ }
+}
+
+@media (max-width: 540px) {
+ .app-shell {
+ width: min(100% - 16px, 1180px);
+ padding-top: 24px;
+ }
+ .hero {
+ align-items: start;
+ flex-direction: column;
+ }
+ .controls {
+ gap: 10px;
+ }
+ .telemetry div {
+ grid-template-columns: 90px 1fr;
+ }
+}
diff --git a/examples/platformer/src/vite-env.d.ts b/examples/platformer/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/examples/platformer/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/platformer/tsconfig.json b/examples/platformer/tsconfig.json
new file mode 100644
index 0000000..ed48e42
--- /dev/null
+++ b/examples/platformer/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "moduleResolution": "Bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "strict": true,
+ "noUncheckedIndexedAccess": true
+ },
+ "include": ["src"]
+}
diff --git a/examples/platformer/vite.config.ts b/examples/platformer/vite.config.ts
new file mode 100644
index 0000000..20ed627
--- /dev/null
+++ b/examples/platformer/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "vite"
+
+export default defineConfig({
+ server: {
+ port: 5174
+ }
+})