Skip to main content

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.

PackageGives youIn a scriptIn a pluginIn a component extension
@was/ecsentities, components, the world
@was/enginethe component classes
@was/signalsthe reactivity system
@was/svdtschemas and validation
@was/utilssmall helpers
@was/uithe editor's own component library
@was/iconsthe editor's icon set
reactReact 19
Scripts get reactivity through ctx.effect

A 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();
FunctionDoes
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.

Why this matters even if you never import it

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.

The stylesheet is prebuilt

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

CallReturns
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 for

It 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

CallDoes
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 · .componentsits id, and its components keyed by type

Component — the data

CallDoes
.x or get('x')read a field; inside an effect this also subscribes to it
$datathe whole thing as a plain object
$rawDatathe 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 performance

Reading 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

CallDoes
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 · unpackto 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