How this skill is triggered — by the user, by Claude, or both
Slash command
/ecspresso:ecspressoThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
ECSpresso is a type-safe Entity-Component-System library for TypeScript. This skill provides the essential patterns and signatures needed to write correct ECSpresso code.
ECSpresso is a type-safe Entity-Component-System library for TypeScript. This skill provides the essential patterns and signatures needed to write correct ECSpresso code.
For full API details, see api-reference.md.
For plugin definition and built-in plugin catalog, see plugins.md.
For deeper reference on any topic, see the docs/ directory in the ECSpresso package.
Before assuming current behavior, check CHANGELOG.md at the repo root (also shipped in the npm tarball) for recent breaking changes and additions. Read it when a user mentions upgrading versions, when API behavior seems inconsistent with this skill, or when working against an unfamiliar version.
preUpdate -> fixedUpdate (0..N times) -> update -> postUpdate -> render
Command buffers are flushed between each phase. Entities spawned in preUpdate are visible to fixedUpdate, etc.
The builder accumulates types automatically. Never pass explicit type params when the builder can infer them.
import ECSpresso from 'ecspresso';
const ecs = ECSpresso.create()
.withPlugin(somePlugin) // merge plugin types
.withComponentTypes<{ // type-level only, no runtime cost
position: { x: number; y: number };
velocity: { x: number; y: number };
health: number;
}>()
.withEventTypes<{
playerDied: { playerId: number };
}>()
.withResourceTypes<{ // declare types for resources added later
score: { value: number };
}>()
.withResource('score', { value: 0 }) // add resource with value
.withResource('config', () => loadConfig()) // or with factory
.withFixedTimestep(1 / 60) // optional, default is 1/60
.build();
// Derive the world type for use elsewhere
type ECS = typeof ecs;
| Method | Purpose |
|---|---|
.withPlugin(plugin) | Install a plugin, merge its types |
.withComponentTypes<T>() | Declare component types (type-level only) |
.withEventTypes<T>() | Declare event types (type-level only) |
.withResourceTypes<T>() | Declare resource types (type-level only) |
.withResource(key, value | factory) | Add a resource with value or factory |
.withRequired(trigger, required, factory) | Auto-add component when trigger is present |
.withDispose(componentName, callback) | Register cleanup on component removal |
.withAssets(configurator) | Configure asset loading |
.withScreens(configurator) | Configure screen/state management |
.withFixedTimestep(dt) | Set fixed timestep interval |
.build() | Create the ECSpresso instance |
The single inline withComponentTypes<{...}>() block above is fine for small projects and examples. For a real game where features keep introducing new components, decide now whether feature plugins will contribute their own types — that choice has cascading consequences and is hard to reverse later. See plugins.md — Choosing between the two before committing.
When keeping a central registry (i.e., not using canonical definePlugin per feature), prefer per-feature interface files aggregated at the builder, rather than declaring everything inline:
// src/turrets/types.ts
export interface TurretComponents {
turret: TurretComponent;
beamTurret: BeamTurretComponent;
}
export interface TurretEvents { 'turret:fired': { id: number } }
// src/types.ts
import type { TurretComponents, TurretEvents } from './turrets/types';
import type { HangarComponents } from './hangar/types';
export const builder = ECSpresso.create()
.withComponentTypes<TurretComponents & HangarComponents & /* ... */>()
.withEventTypes<TurretEvents & /* ... */>();
This keeps types.ts a thin aggregator and lets features own their interfaces.
Systems use a fluent builder API. They are automatically registered — no explicit termination call needed.
The callback receives a single destructured context object, not positional arguments:
ecs.addSystem('movement')
.addQuery('moving', { with: ['position', 'velocity'] })
.setProcess(({ queries, dt, ecs }) => {
for (const entity of queries.moving) {
entity.components.position.x += entity.components.velocity.x * dt;
entity.components.position.y += entity.components.velocity.y * dt;
}
});
Context fields: { queries, dt, ecs }. When .withResources() is used, resources is also available:
ecs.addSystem('scoring')
.withResources(['score', 'config'])
.setProcess(({ resources: { score, config } }) => {
// resources are resolved once on first call, then cached
});
setProcessEachFor single-query, per-entity iteration — the most common case — use setProcessEach to inline the query and the callback in one step. Callback context is { entity, dt, ecs } plus resources when declared:
ecs.addSystem('movement')
.setProcessEach({ with: ['position', 'velocity'] }, ({ entity, dt }) => {
entity.components.position.x += entity.components.velocity.x * dt;
entity.components.position.y += entity.components.velocity.y * dt;
});
ecs.addSystem('bounce')
.withResources(['bounds'])
.setProcessEach(
{ with: ['position', 'velocity', 'radius'] },
({ entity, dt, resources: { bounds } }) => { /* ... */ },
);
setProcessEach accepts the full query shape (with, without, optional, changed, parentHas, mutates). It's valid only on a builder with no prior addQuery / setProcess / setProcessEach call — TypeScript blocks the misuse and a runtime guard backs it up. For multi-query systems, keep using addQuery + setProcess.
When the query declares mutates, the callback may return false to skip the auto-mark for a specific entity (useful when the iteration body decides mid-flight that nothing changed). Returning true, undefined, or any other value stamps all components listed in mutates. Example:
ecs.addSystem('propagate-transforms')
.setProcessEach(
{ with: ['localTransform', 'worldTransform'], mutates: ['worldTransform'] },
({ entity }) => {
// copyTransform returns true iff the destination actually changed
return copyTransform(entity.components.localTransform, entity.components.worldTransform);
},
);
.addQuery('name', {
with: ['comp1', 'comp2'], // required components (guaranteed on entity)
without: ['comp3'], // exclude entities with these
changed: ['comp1'], // only entities where comp1 changed this tick
optional: ['comp4'], // included if present, not guaranteed
parentHas: ['parentComp'], // filter by parent's components
mutates: ['comp1'], // auto-markChanged these on every iterated entity
})
Entities in query results have their with components guaranteed on entity.components. Other components on the entity are Partial.
mutates — auto-mark + readonly narrowingmutates declares which components the system writes to. It does two things:
process() returns, every iterated entity gets markChanged(id, comp) called automatically for each listed component. Eliminates repeated ecs.markChanged(entity.id, 'localTransform') boilerplate.with but absent from mutates are narrowed to Readonly<T> on the iteration entity. Accidentally mutating an undeclared component is a compile error.ecs.addSystem('movement')
.addQuery('movers', {
with: ['position', 'velocity'],
mutates: ['position'], // declares: this system writes position
})
.setProcess(({ queries, dt }) => {
for (const entity of queries.movers) {
entity.components.position.x += entity.components.velocity.x * dt;
entity.components.velocity.x *= 0.99; // Type error — velocity is Readonly
// No ecs.markChanged needed — position gets auto-stamped.
}
});
Over-marking semantics: all iterated entities get stamped regardless of whether the body actually mutated them. For most producers (e.g., physics integration feeding transform propagation) this is fine — downstream value-diff checks absorb the false positives. For producers feeding a bare changed: consumer where per-entity precision matters, use setProcessEach with a boolean return (see above) or skip mutates and keep manual ecs.markChanged calls.
addSingleton(name, definition) is a named query that yields a single FilteredEntity | undefined instead of an array. Definition shape is identical to addQuery; the result surfaces on queries[name] alongside regular queries.
ecs.addSystem('hud')
.addSingleton('flagship', { with: ['commandVessel', 'kinematic'] })
.addQuery('ships', { with: ['ship'] })
.setProcess(({ queries }) => {
if (!queries.flagship) return; // FilteredEntity | undefined
const { kinematic } = queries.flagship.components;
for (const ship of queries.ships) { /* ... */ }
});
When multiple entities match, the first is returned (no error). Use the instance-level ecs.getSingleton(...) / ecs.tryGetSingleton(...) if you need strict enforcement. A singleton-only system is skipped when the singleton is absent unless .runWhenEmpty() is set, matching regular query gating.
ecs.addSystem('label')
.addQuery('name', { with: [...] }) // add named query (array result)
.addSingleton('name', { with: [...] }) // add singleton query (entity | undefined)
.withResources(['key1', 'key2']) // declare resource dependencies
.inPhase('fixedUpdate') // default: 'update'
.setPriority(100) // higher runs first within phase
.inGroup('groupName') // can call multiple times
.inScreens(['gameplay']) // only run in these screens; spawns inside process auto-scope to current screen
.excludeScreens(['pause']) // skip in these screens
.requiresAssets(['texture1']) // skip until assets loaded
.runWhenEmpty() // run even with 0 matching entities
.setOnEntityEnter('queryName', ({ entity, ecs }) => { ... })
.setOnInitialize(async (ecs) => { ... }) // runs during ecs.initialize(); for systems added after init, runs fire-and-forget on next update()
.setOnDetach((ecs) => { ... }) // runs on system removal
.setEventHandlers({
playerDied: ({ data, ecs }) => { ... }, // auto-subscribed event handlers
})
.setProcess(({ queries, dt, ecs }) => { ... })
// --- OR, for single-query systems, replace addQuery + setProcess with: ---
.setProcessEach({ with: [...] }, ({ entity, dt, ecs }) => { ... });
1 parameter = positional. 2+ parameters = single destructured object.
All multi-param callbacks use ({ param1, param2 }) style, not (param1, param2).
const ecs = ECSpresso.create()
.withPlugin(...)
.withComponentTypes<...>()
.build();
// Add systems (can be before or after initialize)
ecs.addSystem('movement').addQuery(...).setProcess(...);
// Initialize resources, plugins, system hooks
await ecs.initialize();
// Now safe to spawn entities and run the loop
ecs.spawn({ ... });
// Game loop
function loop(time: number) {
ecs.update(dt);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
ecs.onScreenEnter('playing', ({ config, ecs }) => { ... }); // multi-handler; fires on setScreen + pushScreen
ecs.onScreenExit('playing', ({ ecs }) => { ... }); // fires on setScreen-away + popScreen
const off = ecs.onScreenEnter('title', () => { ... });
off(); // returned disposer unregisters the handler
Prefer these over eventBus.subscribe('screenEnter', ...) + a manual if (screen !== 'x') return filter.
ecs.spawn({ enemy: { hp: 10 } }, { scope: 'playing' });
// ↑ removed automatically when 'playing' exits
Also available on spawnChild, commands.spawn, commands.spawnChild. Replaces hand-maintained teardown lists.
Auto-scoping inside gated systems. When a system declared with .inScreens([X]) (or [X, Y, ...]) calls ecs.spawn / ecs.spawnChild / ecs.commands.spawn / ecs.commands.spawnChild from inside its process tick without an explicit scope, the spawned entity is auto-scoped to the currently-active screen. This makes the right thing the default at every spawn site inside a screen-gated system, and you no longer need to repeat { scope: 'playing' } at every call.
world.addSystem('wave-spawner')
.inScreens(['playing'])
.setProcess(({ ecs }) => {
ecs.spawn({ enemy: { hp: 10 } }); // auto-scoped to 'playing'
ecs.commands.spawn({ projectile: {...} }); // also auto-scoped (captured at queue time)
});
Explicit values still win over the hint:
{ scope: 'title' } — scoped to a different screen.{ scope: null } — opt out of auto-scoping (entity outlives the screen).Auto-scoping does not apply to: spawns issued from onInitialize / onDetach / event handlers fired outside a system tick / direct calls from main code, or from systems that use only excludeScreens (no positive screen intent).
install receives (world, onCleanup). Register disposers; they run when the plugin is uninstalled.
definePlugin('legend').install((world, onCleanup) => {
onCleanup(world.onScreenEnter('title', () => { ... }));
const onKey = (e: KeyboardEvent) => { ... };
window.addEventListener('keydown', onKey);
onCleanup(() => window.removeEventListener('keydown', onKey));
});
ecs.uninstallPlugin('legend'); // reverse-order cleanup
ecs.dispose(); // uninstalls all plugins
Old positional callback style. Always use ({ queries, dt, ecs }), not (queries, dt, ecs).
Mutating entities during iteration without command buffer. Use ecs.commands.spawn() / ecs.commands.removeEntity() inside setProcess, not ecs.spawn() / ecs.removeEntity() directly.
Forgetting markChanged after in-place mutation. If you mutate a component's properties directly, call ecs.markChanged(entityId, 'componentName') so downstream changed queries detect it — or declare mutates: [...] on the query to auto-stamp every iterated entity.
Adding explicit type parameters when the builder infers them. The builder chain accumulates types automatically. Derive the world type with type ECS = typeof ecs.
Using ecs.getResource in resource-heavy systems instead of .withResources(). Declare resource deps on the system builder — they're resolved once and cached.
Spawning entities before initialize(). Call await ecs.initialize() first to set up plugin resources and run system onInitialize hooks.
docs/getting-started.md — Quick start and installationdocs/core-concepts.md — Entities, components, systems, resourcesdocs/systems.md — Phases, priorities, groups, lifecycle hooksdocs/queries.md — Query type utilities, reactive queriesdocs/plugins.md — Plugin definition, factory pattern, required componentsdocs/built-in-plugins.md — Input, timers, physics, collision, rendering, etc.docs/events.md — Event system and built-in eventsdocs/command-buffer.md — Deferred structural changesdocs/change-detection.md — Change tracking and sequence systemdocs/hierarchy.md — Parent-child relationships and traversaldocs/assets.md — Asset loading, groups, progress trackingdocs/screens.md — Screen/state management with transitionsdocs/type-safety.md — Type system details and error messagesdocs/performance.md — Performance tipsexamples/ — Working examples from simple movement to full gamesProvides behavioral guidelines to reduce common LLM coding mistakes, focusing on simplicity, surgical changes, assumption surfacing, and verifiable success criteria.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub deegeegames/ecspresso --plugin ecspresso