@nosafesky/moku-game
v0.3.0
Published
An ECS game framework for Moku (Spark-style public API & memory layout) with PixiJS rendering, built on @moku-labs/core and composable with @moku-labs/web
Downloads
1,190
Readme
game
An ECS game framework for Moku — Spark-style API and memory layout, PixiJS v8 rendering, with the live runtime exposed to agents over MCP.
game is a typed Entity-Component-System runtime you compose into a Moku app: archetype object-SoA component storage, a fixed-timestep loop that bypasses the kernel for the hot path, typed world resources for first-class systems access, and sixteen plugins that wire ECS, scheduling, rendering, input, assets, scenes, framework context, an MCP server, native WebAudio audio, versioned save persistence, per-portal publishing, particle/juice VFX, a PixiJS-native UI layer, a shared object-tweening layer, and a 2D follow/shake/parallax camera together. It is not a game engine UI or a level editor — there is no scene graph GUI, no asset pipeline, no runtime of its own beyond @moku-labs/core. You define components and systems in TypeScript and drive frames; the framework owns the data layout, the stage order, and the GPU lifecycle.
Why game · Quick start · How it works · Core concepts · Plugins · Events · Scripts · Requirements · Docs · License
Why game
- Archetype, object-SoA storage. Entities that share a component signature live in one archetype with parallel columns, so queries iterate cache-coherently — not a
Map<id, object>scan per frame. A per-component storage seam lets high-churn tags opt into"sparse"storage instead. - A runtime, not an engine. No editor, no scene-graph GUI, no built-in asset pipeline — you compose plugins into your own Moku app via
createAppand write systems in TypeScript. Define by negation: it owns the data model and the frame, you own the game. - The hot path bypasses the kernel. The fixed-timestep loop drives
scheduler.tick(dt)→world.tick(dt)→renderer.render()directly, with no event-bus round-trip per frame. Per-frame work is deliberately not emitted as kernel events. - Spark-style public API.
defineComponent, callable component tokens, typed variadic queries (arities 1–8), a deferred command buffer, andworld.tick(dt)— the API and memory layout are modeled on AlexTiTanium/spark. - First-class systems access. A typed world resource registry (
defineResource/setResource/getResource/resource/hasResource/removeResource) lets systems reach shared singletons through theworldthey already receive — no closure capture. Thecontextplugin binds the well-knownAssetsandGameContextresources, andlooppublishes aTimeclock — all read the same way as any consumer resource. The system signature stays(world, dt). - First-class MCP. A Model Context Protocol server exposes the live runtime to agent clients — query state, step the loop, spawn entities, load scenes, screenshot the frame — without touching game code. The transport is environment-aware:
["stdio"]under Node/Bun, an in-page["inMemory"]pair in the browser (no socket), plus optional Streamable HTTP. In-page agents reach the server viaapp.mcp.clientTransport(). - Runs headless. The renderer has a first-class
headlessmode (auto-detected when there is no DOM) that skips Pixi/GPU entirely while still definingTransformand running thesyncsystem, so the same framework drives a real game in the browser and boots cleanly under Bun/Node for tests, simulation, or agent-only hosts. - Composable with the Moku family. Built on
@moku-labs/core, logs and reads env via@moku-labs/common, and mounts its Pixi canvas into a DOM surface from@moku-labs/web.
Quick start
bun add game @moku-labs/core @moku-labs/common pixi.js[!NOTE] Status:
0.x— early. Pre-1.0; the public surface may still shift.gameis unpublished (version0.0.0) — install from the repository. Consumers usecreateAppfrom the framework and never import@moku-labs/coredirectly.
import { createApp } from "game";
import { Container } from "pixi.js";
// 1. Create the app (synchronous); start() is async and boots the Pixi Application.
const app = createApp({
pluginConfigs: {
renderer: { width: 1280, height: 720, background: 0x1099bb, mount: "#game" },
loop: { fixedDt: 1 / 60 }
}
});
await app.start();
// 2. Define components on the ECS world (app.ecs IS the World facade).
const Velocity = app.ecs.defineComponent(() => ({ dx: 0, dy: 0 }));
// 3. Define a scene — entities spawned here are owned by the scene.
app.scene.define("level1", {
setup: (world) => {
const player = world.spawn(
app.renderer.Transform({ x: 100, y: 100, rotation: 0, scaleX: 1, scaleY: 1 }),
Velocity({ dx: 60, dy: 0 })
);
app.renderer.attach(player, new Container());
}
});
// 4. A movement system runs every "update" stage; input is polled, not subscribed.
app.scheduler.addSystem("update", (world, dt) => {
const input = app.input.snapshot();
world.query(app.renderer.Transform, Velocity).updateEach(([t, v]) => {
if (input.isDown("ArrowRight")) t.x += v.dx * dt;
});
});
await app.scene.load("level1"); // the loop is already driving frames (autoStart: true)How it works
The loop plugin owns the frame. Each fixed step it drives the scheduler, which runs every system in canonical stage order against the single ECS world; the renderer draws once per frame. mcp reaches into the same runtime so agents can observe and control it.
flowchart LR
IN["Input / agent<br/>(keys · pointer · MCP)"] --> LOOP["loop<br/>fixed-timestep rAF"]
LOOP -->|"scheduler.tick(dt)"| SCH["scheduler<br/>input→update→physics→sync→render"]
SCH -->|"world.tick(dt)"| ECS["ecs<br/>archetype data core"]
ECS --> SYNC["renderer sync<br/>Transform → Pixi"]
LOOP -->|"render()"| REND["renderer<br/>PixiJS v8 draw"]
REND --> OUT["Frame on canvas"]
MCP["mcp server<br/>stdio · http · inMemory"] -.->|observe + control| ECS
MCP -.-> LOOP
classDef u fill:#0b7285,stroke:#08525f,color:#fff;
classDef m fill:#1864ab,stroke:#0d3d6e,color:#fff;
class IN,OUT u
class LOOP,SCH,ECS,SYNC,REND,MCP mCore concepts
- The ECS is the data core.
app.ecsreturns theWorldfacade directly — no wrapper.defineComponent(create, opts?)registers a component (callable token:Position({ x, y })produces a spawn payload);spawn,despawn,add/remove/get/set/has, and typedquery(...)over arities 1–8 are the surface.Entityis a generational handle, so stale references are detectably dead viaisAlive. - Stages are the contract. Systems register into one of five fixed, ordered stages —
input → update → physics → sync → render. The order is canonical; theschedulervalidates stage names and forwards to the world. - The command buffer is the only mutation path during iteration. Inside
updateEach(or any system), structural ops (spawn/despawn/add/remove) are deferred and flushed at each stage boundary insidetick. This is the path everymcpmutating tool uses. - World resources are first-class systems access. The world owns a typed singleton registry —
defineResource(create?)mints aResource<T>token;setResource/getResource/resource/hasResource/removeResourceread and write it.resource(token)asserts presence (throws an actionable error if unset with no factory);getResourcereturnsT | undefined. Resource ops are immediate — they bypass the command buffer even mid-iteration, and aren't counted bymaxStructuralOpsWarn. Systems reach shared services through theworldargument, with no closure overapp. The framework's well-known resources —Assets+GameContext(fromcontext) andTime(fromloop) — are wired atapp.start(). - The loop is fixed-timestep. Real time is accumulated and consumed in
fixedDtslices (clamped bymaxFrameDelta, capped atmaxStepsPerFrame) so simulation is frame-rate independent;step()advances exactly one deterministic tick + render. Each fixed step the loop updates theTimeworld resource in place ({ dt, elapsed, frame }, seconds), readable asapp.loop.timeorworld.resource(Time). - Three-layer Moku model.
createCoreConfig(config + events) →createCore(framework + the sixteen plugins) →createApp({ pluginConfigs })(your app). Consumers usecreateApp/createPluginfromgameand never import@moku-labs/coredirectly. For advanced / headless assembly,gamealso re-exportscreateCore(compose a custom core from a plugin subset) andcreateCoreConfig(build a bespoke Layer-1 config) — escape hatches for tooling, tests, and agent-only hosts;createAppstays the default.
Plugins
The framework is sixteen plugins, built and resolved in dependency order: ecs → scheduler → renderer + input → loop + assets → context → scene → audio + storage → platform → vfx → ui → tween → camera → mcp. (audio and storage are dependency-free; platform depends on audio + loop + storage; vfx depends on ecs + scheduler + renderer; ui depends on renderer + scheduler + input; tween depends on scheduler only; camera depends on renderer + scheduler + tween; mcp stays last.)
| Plugin | Tier | Responsibility | Key API |
|---|---|---|---|
| ecs | Complex | Generational entities, archetype object-SoA storage, typed queries, deferred command buffer, world resource registry, read-only introspection facet, world.tick. | app.ecs.defineComponent · spawn · query(...).updateEach · addSystem · defineResource · resource · componentByName · tick |
| scheduler | Standard | The ordered stage contract; thin facade forwarding to the ECS world. | app.scheduler.addSystem(stage, fn) · tick(dt) · stages |
| renderer | Complex | PixiJS v8 backend — owns the GPU Application, defines Transform, syncs ECS → display objects, attaches plain-data primitives. First-class headless mode (no Pixi/GPU). | app.renderer.Transform · attach · attachPrimitive · render · screenshot · tree · getView · getStage |
| input | Standard | Polled keyboard/pointer captured from DOM, frozen into a per-frame snapshot; programmatic key injection with alias normalization. | app.input.snapshot() → isDown · justPressed · pointer; keyDown · keyUp · keyPress |
| loop | Standard | Fixed-timestep rAF loop driving scheduler.tick then renderer.render each frame; publishes the Time world resource. | app.loop.start · stop · step (→ TimeStepResult) · isRunning · time |
| assets | Standard | Thin wrapper over Pixi v8 Assets — load/cache textures + bundles by alias, build sprites. | app.assets.load · loadBundle · sprite · get · isLoaded |
| context | Standard | Binds the well-known Assets + GameContext world resources so systems reach them via world.resource(token). | app.context → assets · game |
| scene | Standard | Named scene lifecycle with entity-ownership tracking, clean transitions, bundle pre-load. | app.scene.define · load · unload · currentScene · sceneNames · ownedEntities |
| mcp | Complex | First-class MCP server exposing the runtime to agents over stdio / Streamable HTTP / in-page inMemory (env-aware default); 15 registered tools (4 read-only, the rest mutation/play gated by enableMutations). | app.mcp.isRunning · httpEndpoint · toolNames · clientTransport |
| audio | Standard | Native WebAudio SFX + music — master mute bus (single-call duck for ad breaks), per-channel + master volume, user-gesture unlock() (no autoplay), decoded-buffer cache. Zero deps, headless-safe. | app.audio.unlock · load · play · playMusic · stopMusic · mute/unmute · setVolume · getVolume |
| storage | Standard | Namespaced, versioned key/value save persistence with a migration chain behind a pluggable StorageBackend seam; safe localStorage-or-memory default that never throws (in-memory fallback when storage is partitioned/blocked/absent). Zero deps. | app.storage.get · set · has · remove · clear · keys · isPersistent · getVersion · setBackend |
| platform | Complex | Portal-SDK adapter layer (CrazyGames / Poki / Newgrounds / no-op) selected per build via ctx.env; promise-based ads that capture-then-restore-pause loop + mute audio (frequency-capped, re-entrancy-guarded); injects a portal-native StorageBackend into storage; persists + rehydrates audio prefs. Zero new deps (SDKs runtime-injected). | app.platform.getPortal · gameplayStart/gameplayStop · loadingStart/loadingFinished · commercialBreak · rewardedAd · isAdPlaying |
| vfx | Complex | Game-juice layer — ECS-native particle emitters + one-shot bursts (renderer-owned Graphics views, fade-by-shrink), trauma-based screen shake, Transform scale-pop, floating damage/score text, and pure easing curves. Every effect is a scheduler system over ordinary ECS entities. Emits no events, headless-safe, zero new deps (Pixi via renderer). | app.vfx.createEmitter/configureEmitter/removeEmitter · burst · shake/stopShake · pop · floatText · easing/lerp |
| ui | Complex | Game-UI layer — a screen stack (title / pause / game-over / modal cards), a declarative widget set (label / button / panel / bar), a persistent HUD, and pointer/touch hit-testing — all rendered natively into the renderer's Pixi stage (retained-mode plain-data specs, opaque handles). Emits no events, headless-safe, zero new deps (Pixi via renderer). | app.ui.pushScreen/popScreen/replaceScreen/clearScreens · addHud/removeHud · getWidget/setText/setValue/setVisible · getRoot |
| tween | Standard | Shared, ECS-agnostic tweening — animate the numeric props of any plain object (to/from) or a scalar (value) over time with easing / delay / repeat / yoyo; opaque TweenHandle (stop/pause/resume/active + a done Promise). One scheduler "update" system advances all tweens, so a paused loop freezes them for free (pause-safe). Re-exposes the canonical easing table + lerp. Emits no events, headless-safe, zero new deps. | app.tween.to/from/value · killAll · count · easing/lerp |
| camera | Standard | A 2D game camera — follow (per-frame smoothing), instant + animated pan / zoom / rotate (moveTo/zoomTo/rotateTo delegate to app.tween), decaying screen shake, and parallax across world-space layer containers it owns (the HUD stays screen-fixed); maps screenToWorld/worldToScreen. One scheduler "sync" system applies the transform each frame. Pause-safe (rides scheduler.tick) and headless-safe (numeric state still tracks). Emits no events, zero new deps (Pixi via renderer). | app.camera.follow · setPosition/moveTo · setZoom/zoomTo · setRotation/rotateTo · shake · world/addLayer/layer · screenToWorld/worldToScreen |
Events
The event catalog is intentionally tiny — hot-path frame work (ticks, sync, render, input) is not emitted as kernel events. Only coarse milestones cross the bus:
| Event | Payload | When |
|---|---|---|
| assets:loaded | { alias: string; kind: "asset" \| "bundle" } | After app.assets.load() or loadBundle() succeeds (once per call, not per texture). |
| scene:loaded | { name: string } | After a scene's setup completes during app.scene.load(). |
| game:reset | { reason: "mcp" } | After the mcp game:reset tool despawns all MCP-tracked entities and unloads the current scene — listen to re-initialize consumer state. |
| audio:muteChanged | { muted: boolean } | After app.audio.mute() / unmute() / setMuted() changes the mute state — a storage/platform plugin persists it. |
| audio:volumeChanged | { channel: "master" \| "sfx" \| "music"; value: number } | After app.audio.setVolume() changes a channel volume. |
| platform:ready | { portal: Portal } | Once the active portal adapter is initialised and loading is signalled finished. |
| platform:adStart | { type: "interstitial" \| "rewarded" } | When an ad begins (after loop is paused + audio muted) — a HUD can show an "ad playing" overlay. |
| platform:adEnd | { type: "interstitial" \| "rewarded"; rewarded?: boolean } | When an ad ends (after loop + audio are restored). rewarded is set for rewarded ads. |
assets:loaded and scene:loaded are declared on the framework Events (src/config.ts); game:reset (from mcp), audio:muteChanged / audio:volumeChanged (from audio), and platform:ready / platform:adStart / platform:adEnd (from platform) are plugin-level events declared and emitted by their plugins. Subscribe from a consumer plugin via the hooks map (depends: [assetsPlugin], then hooks: _ctx => ({ "assets:loaded": ({ alias, kind }) => { … } })).
Scripts
bun run build # Build with tsdown
bun run lint # Biome check + ESLint
bun run lint:fix # Auto-fix lint issues (Biome --write + ESLint --fix)
bun run format # Format with Biome
bun run test # Run all tests (vitest run)
bun run test:unit # Unit tests only
bun run test:integration # Integration tests only
bun run test:coverage # Tests with coverageRequirements
- Node
>= 24and Bun>= 1.3.14— usebunexclusively (never npm/yarn/pnpm). - TypeScript in strict mode, with
exactOptionalPropertyTypesandnoUncheckedIndexedAccess. @moku-labs/core— the micro-kernel the three-layer factory chain is built on (consumers go throughcreateApp, never import it directly).@moku-labs/common— providesctx.log(logPlugin) andctx.env(envPlugin) on every plugin context.pixi.js^8— the rendering backend therendererandassetsplugins wrap.- Composable with
@moku-labs/web— mount the Pixi canvas into a DOM surface whenrenderer.mountis a selector. mcpadds@modelcontextprotocol/sdk,hono, andzod— the MCP server, HTTP transport, and tool input schemas.
Docs
- Per-plugin references: ecs · scheduler · renderer · input · loop · assets · context · scene · mcp · audio · storage · platform · vfx · ui
- LLM context:
llms.txt(concise) ·llms-full.txt(comprehensive reference)
