USM is a tiny, adapter-first state machine for games, 3D sites, and interactive apps. Keep your state logic pure; plug in adapters for Three.js, GSAP, Input (keyboard/pointer), Time/Loop, Audio, Loader, and Picking.
npm i @nappycat/usm
# optional peers: three, gsap (only if you use those adapters)import { USM } from '@nappycat/usm';
type Ctx = { score: number };
const machine = new USM<Ctx>({
id: 'demo',
initial: 'menu',
context: { score: 0 },
states: {
menu: { on: { START: 'play' } },
play: {
enter(ctx){ ctx.score = 0; },
tick(dt, ctx){ /* game loop here */ },
on: { GAME_OVER: 'over' }
},
over: { on: { RESTART: 'play' } }
}
});
machine.start();threeAdapter({ THREE, scene, camera, renderer, mode:'container'|'window' })
Handles DPR clamp + resize + camera.aspect updates.keyboardAdapter({ down, up, preventRepeat, combo })
TracksSet<string>of pressed keys (KeyboardEvent.code), dispatches events.pointerAdapter({ target, bind, onDrag, onTap, onWheel })
Unifies mouse/touch/pen, swipe detection, wheel.uiAdapter()
Syncs<body data-state="...">for CSS.creativeAudioAdapter({ autoUnlock, suspendOnStop, volume })
Web Audio helper: master/bus gains, load/play buffers, fades.
import { adapters } from '@nappycat/usm';
// machine = new USM({ ..., adapters: [ adapters.keyboardAdapter({...}), ... ] })import { USM, adapters } from '@nappycat/usm';
const m = new USM({
initial: 'menu',
states: {
menu: { on: { START: 'play' } },
play: {
async enter(ctx) {
// prepare audio
await ctx.audio.loadAll({ click: '/sfx/click.mp3', loop: '/music/loop.mp3' });
ctx.audio.play('loop', { loop: true, bus: 'music', volume: 0.6 });
},
on: { CLICK: 'menu' }
}
},
adapters: [
adapters.creativeAudioAdapter({ autoUnlock: true, suspendOnStop: false, volume: 0.9 })
]
});<script src="https://cdn.jsdelivr.net/npm/@nappycat/usm/dist/usm.global.js"></script>
<script>
const { USM, adapters } = window.USM;
const m = new USM({ initial:'menu', states:{ menu:{ on:{ START:'play' } }, play:{} } });
m.start();
</script>MIT © nappycat
This repo has two main use-cases:
- Developing USM itself (TypeScript library)
- Running the examples (plain HTML/JS using the built
distfiles)
usm/
├─ src/ # TypeScript source for the library
│ ├─ usm-core.ts # core machine
│ ├─ adapters/ # adapters (three, gsap, time, pointer, keyboard, audio, loader, picking)
│ └─ index.ts # exports
├─ dist/ # built outputs (generated by `npm run build`)
│ ├─ usm.esm.js # ESM build (for bundlers)
│ ├─ usm.cjs # CommonJS build (Node require)
│ ├─ usm.global.js # UMD build (single <script> in the browser)
│ └─ adapters/ # per-adapter builds (esm/cjs/d.ts)
├─ examples/ # demo apps that use the built files from dist
├─ README.md
├─ LICENSE
└─ package.json
- Make code changes in
src/**. - Run
npm run buildto regeneratedist/**. - Examples load from
dist/**, so always rebuild after changes.
Create a folder under examples/ with this shape:
examples/my-demo/
├─ index.html
├─ main.js
└─ assets/
├─ pixel.png
└─ beep.wav
index.html (UMD mode; no bundler required):
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>USM Demo</title>
<style> html,body,#app{height:100%;margin:0} canvas{display:block} .hint{position:fixed;left:8px;top:8px;color:#fff;font:12px monospace}</style>
</head>
<body>
<div id="app"><div class="hint">Loading…</div></div>
<!-- libs (optional) -->
<script src="https://cdn.jsdelivr.net/npm/three/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap/dist/gsap.min.js"></script>
<!-- USM UMD build from your local dist -->
<script src="../../dist/usm.global.js"></script>
<!-- your app code -->
<script src="./main.js"></script>
</body>
</html>main.js (minimal working skeleton):
const { USM: Machine, adapters } = window.USM;
const THREE = window.THREE; // if you use three
const app = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({ antialias: true });
app.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b0f1a);
const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100);
camera.position.set(0,1.2,3);
function resize(){
const w = app.clientWidth || window.innerWidth;
const h = app.clientHeight || window.innerHeight;
renderer.setSize(w, h); camera.aspect = w/h; camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize); resize();
const cube = new THREE.Mesh(new THREE.BoxGeometry(1,1,1), new THREE.MeshStandardMaterial({color:0x44aaff}));
scene.add(new THREE.AmbientLight(0xffffff,0.6));
const dir = new THREE.DirectionalLight(0xffffff,0.8); dir.position.set(1,2,2); scene.add(dir);
const setHint = (t)=>{ const el=document.querySelector('.hint'); if(el) el.textContent=t; };
const m = new Machine({
initial: 'menu',
states: {
menu: { on: { START: 'play' }, enter(){ setHint('Click or Space to start'); } },
play: {
enter(){ scene.add(cube); },
tick(dt){ cube.rotation.y += dt; renderer.render(scene, camera); },
on: { QUIT: 'menu' },
exit(){ scene.remove(cube); }
}
},
adapters: [
adapters.timeAdapter({ fixedStep: 1/60 }),
adapters.pointerAdapter({ target: renderer.domElement, capture: renderer.domElement, map: { click: 'START' } }),
adapters.keyboardAdapter({ bindings: { Space:'START', KeyQ:'QUIT' } }),
adapters.threeAdapter({ THREE, scene, camera, renderer, mode:'window' })
]
});
m.start();Assets: put images/audio under
examples/my-demo/assets/and reference with./assets/…frommain.js.
- UMD script in examples:
../../dist/usm.global.js - Local asset in an example:
./assets/pixel.png - Adapter import (ESM in another project):
import { timeAdapter } from '@nappycat/usm/adapters/time'
If you’re not inside this repo, install the package and import only what you need:
import { USM } from '@nappycat/usm';
import { timeAdapter } from '@nappycat/usm/adapters/time';
import { pointerAdapter } from '@nappycat/usm/adapters/pointer';
// import { threeAdapter } from '@nappycat/usm/adapters/three';
// import { pickingAdapter } from '@nappycat/usm/adapters/picking';Use Vite/webpack/Parcel to serve your app; these will tree-shake unused adapters.