The shared packages
The editor, the runtime and your own code are built from the same packages, and several of them are available to you. Which ones depends on where your code runs.
| Package | Gives you | In a script | In a plugin | In a component extension |
|---|---|---|---|---|
@was/ecs | entities, components, the world | ✓ | ✓ | ✓ |
@was/engine | the component classes | ✓ | ✓ | ✓ |
@was/signals | the reactivity system | — | ✓ | ✓ |
@was/svdt | schemas and validation | ✓ | ✓ | — |
@was/utils | small helpers | ✓ | ✓ | — |
@was/ui | the editor's own component library | — | ✓ | ✓ |
@was/icons | the editor's icon set | — | ✓ | ✓ |
react | React 19 | — | ✓ | ✓ |
ctx.effectA script cannot import the signals package directly — ctx.effect is the same mechanism with
the lifetime managed for you, so an effect dies with its object instead of leaking.
@was/signals — the reactivity system
This is the thing the whole platform is built on: values that know who is reading them, so a change updates exactly what depended on it and nothing else.
import { signal, computed, effect, batch, untracked } from '@was/signals';
const score = signal(0);
const doubled = computed(() => score.value * 2);
const stop = effect(() => {
render(score.value); // re-runs only when score changes
});
batch(() => {
score.value += 1;
score.value += 1; // effects run once, not twice
});
stop();
| Function | Does |
|---|---|
signal(v) | a value that can be watched; read and write .value |
computed(fn) | a derived value, recalculated only when what it reads changes |
effect(fn) | runs now, and again whenever what it read changes; returns a stop |
batch(fn) | group writes so watchers run once at the end |
untracked(fn) | read without becoming a dependency |
ref(obj) | make a whole object reactive, down to its leaves |
snapshot(obj) | a plain, non-reactive copy |
raw(obj) | the underlying object, without tracking |
readonly(obj) | a view that cannot be written |
For React panels there is a companion with useSignal, useComputed, useSignalEffect and
useLiveSignal, so a component re-renders from a signal without any wiring.
This is what makes the editor and the runtime behave the way they do: nothing polls, nothing re-renders speculatively, and a panel updates because the data changed rather than because something told it to. → Why this engine
@was/ui — the editor's components
Plugin panels and component extensions can be built from the same library the editor uses, so they look native instead of like an embedded web page. Thirty-odd components:
Accordion · Avatar · Button · Card · Checkbox · Chip · CloseButton
DimensionInput · Draggable · Dropdown · Flag · Icon · IconButton · Input
Menu · Modal · Outside · Panel · Popover · Portal · Render · Scroll
Search · Section · Segmented · Select · Skeleton · Switcher · Tabs
Toast · Toolbar · Tooltip
Plus @was/icons for the icon set.
Panels use a fixed set of utility classes shipped with the library. Arbitrary values like
text-[13px] are not in it — use an inline style for sizes outside the scale.
@was/ecs — the world model
Entity, Component, Space. A plugin reads and changes a scene with exactly the same API the
engine uses internally; there is no separate, weaker "plugin API".
Space — the world
| Call | Returns |
|---|---|
getEntity(id) | one entity, or nothing |
hasEntity(id) | whether it exists |
getEntities() | all of them |
queryEntities(A, B, …) | every entity carrying all of those components |
makeQueryEntities(A, B, …) | the same query, pre-built, for repeated use |
createEntity(id?, components?) | a new entity, added to the world |
addEntity(…e) · removeEntity(…e) | put one in or take it out |
getSystem(S) · hasSystem(S) | reach a system |
execute() | run one frame |
queryEntities is the one to reach forIt is backed by an index, so asking for "everything with a light and a transform" is cheap.
getEntities() is not the same thing — it hands you the whole world and makes you filter.
Entity — a thing in the world
| Call | Does |
|---|---|
getComponent(Type) | one component, or null |
getComponents(A, B) | several at once, in that order |
hasComponent(A, B) | whether it carries all of them |
addComponent(…c) | add; adding a type it already has is ignored |
removeComponent(…c) | remove, by class or instance |
component(fn) | run fn for every component, now and in future |
clone() · clean() | copy it, or strip it bare |
.id · .components | its id, and its components keyed by type |
Component — the data
| Call | Does |
|---|---|
.x or get('x') | read a field; inside an effect this also subscribes to it |
$data | the whole thing as a plain object |
$rawData | the stored object, without subscribing — read-only |
update({ … }) | the only way to write |
updateAt(path, value) | write deep inside a large component without re-validating all of it |
version(field?) | a counter that ticks when something changes, so you can depend on a field without reading it |
reset(data?) | back to defaults |
clone() | a copy |
version() is the trick behind big-list performanceReading a large value subscribes you to every leaf inside it. Depending on its version counter instead means you hear that it changed without watching all of it — which is how a panel survives a list of twenty thousand items.
@was/engine — the component classes
Every component type as a class: TransformComponent, MaterialComponent, RigidBodyComponent
and the rest. You import the class and hand it to getComponent, hasComponent or query.
The full list with every field: component reference.
@was/svdt — schemas
The validation layer components are described with. Compiled rather than interpreted, which is why parsing is not a cost on the animation path.
import { s, compile } from '@was/svdt';
const schema = s.object({ speed: s.f64(1), name: s.string('') });
const codec = compile(schema); // compile once, at declaration — never per call
const value = codec.parse(input);
Describing a shape
Numbers: s.f32 s.f64 s.i8 s.u8 s.i16 s.u16 s.i32 s.u32, each taking a default.
Scalars: s.bool, s.string, s.color, s.literal, s.enum, s.unknown, s.ref.
Vectors: s.vec2, s.vec3, s.mat4.
Composites: s.object, s.variant, s.array, s.record, s.union, s.preprocess, s.lazy.
Chainable on any of them: .default(v), .optional(), .nullable(), .min(n), .max(n),
.int().
What a compiled codec gives you
| Call | Does |
|---|---|
parse(input) | validate and normalise, throwing on bad input |
safeParse(input) | the same, returning success or an error instead of throwing |
parseAt(path, v) | validate one field without touching the rest |
equals(a, b) | deep comparison, generated for this shape |
diff(a, b) | what changed |
apply(target, p) | apply a diff |
invert(p) | reverse one — the basis of undo |
pack · unpack | to and from a compact binary form |
And for inspecting a schema rather than data: introspect, keys, requiredKeys.
@was/utils
Small things everything uses: pick, omit, assign, keys, values, entries,
capitalize, basename, extname, dispose (glue cleanup functions together), and the MIME
helpers behind upload validation.
Next: Glossary