Editor-Only Scripts
An editor-only script is a tool that runs inside the editor to automate scene work - and is stripped from every export, so it never ends up in your shipped game.
Runtime scripts are your game. Editor scripts are how you build it faster: bulk-rename a hundred imported objects, flood-fill and combine a modular building into one mesh, tidy up a messy import. They are the Awaken equivalent of a Unity editor script, a Godot @tool, or a Blender operator.
The distinguishing rule is simple and absolute: an editor script must never run in a player. A tool that reparents and deletes objects would wreck a live game. Awaken enforces this by marking the script and filtering it out of every shipping path.
The kind flag
Every script asset carries an optional kind:
ts
interface ScriptAsset {
name: string;
source: string;
kind?: "runtime" | "editor"; // absent = "runtime"
}kindabsent or"runtime"- a gameplayScriptBehavior. Attachable to objects, compiled into exports, runs in Play and in the shipped game. This is every script covered elsewhere in this section.kind: "editor"- a tool. It is a first-class script asset (edited in the same Code panel, persisted in the project), but it is excluded from the Script component picker and from every export.
The flag is preserved across save and load, so an editor script stays an editor script over the project's lifetime.
Where they are filtered out
The kind: "editor" guard is applied at three places - worth knowing so you can trust the boundary:
- The Script component picker - the Inspector's script dropdown lists only
kind !== "editor"scripts, so you cannot accidentally attach a tool to a game object. game.htmlexport - the payload builder skipskind === "editor"scripts when compiling behaviours, so a tool's code is never bundled into the single-file game.- Folder export - the scene sidecar written for a hosted export applies the same filter, mirroring the
game.htmlpath.
The net effect: an editor script exists only inside your .awaken project and the editor session. It cannot be attached to an object and cannot leak into anything a player downloads.
Marking a script as editor kind
The kind field is set on the script asset. A newly created script is a runtime script by default (it starts from the gameplay ScriptBehavior template). An editor script is one whose asset has kind: "editor" - set when you create it as a tool or import a tool asset.
NOTE
Editor scripts run entirely inside the editor and are never compiled into a game. The tool run flow is wired today: a Run button in the Code panel, a live progress/Cancel overlay, and the tool-facing EditorApi - including api.yield() - execute a tool's run() against the scene as a single undoable group.
What a tool script looks like
The tool model differs from a ScriptBehavior: instead of per-frame hooks, a tool is a module with an async run function that the editor executes once, on demand.
ts
export const meta = { name: "Combine Buildings", description: "Merge building parts per building" };
export const params = [
{ name: "family", type: "string", default: "SM_Bld_House_" },
{ name: "gap", type: "number", default: 0.3 },
];
export default async function run(api: EditorApi, p: { family: string; gap: number }) {
const familyRe = new RegExp("^" + p.family);
const done = new Set<number>();
for (const seed of api.find(familyRe)) {
if (done.has(seed.id)) continue;
const parts = api.connected(seed, { match: familyRe, gap: p.gap });
for (const part of parts) done.add(part.id);
const doors = parts.filter((x) => /Door/i.test(x.name)); // keep dynamic
const statics = parts.filter((x) => !/Door/i.test(x.name));
if (statics.length < 2) continue;
const building = api.combine(statics, { name: seed.name + "_combined" });
for (const d of doors) d.setParent(building, { keepWorld: true });
api.log(`Combined ${statics.length} parts + ${doors.length} door(s)`);
}
}The tool-facing EditorApi (query, object handles, spatial connectivity, create/combine, selection, logging, and cooperative yielding) is a different surface from the runtime ScriptApi - it manipulates the scene graph rather than driving a live entity. Three guarantees shape it:
- One run = one undo. A tool that edits hundreds of objects collapses into a single undo entry, so a run you dislike is one Ctrl-Z away.
- All mutations go through commands, exactly like manual edits, so tool edits and hand edits interleave cleanly and are equally undoable.
- Long runs stay interactive.
await api.yield()hands a frame back to the browser inside heavy loops so the editor keeps painting and stays cancellable - covered next.
Keeping the editor responsive: api.yield()
A tool runs to completion the moment you press Run, and nothing preempts it. JavaScript is single-threaded, so while your run() is looping over ten thousand objects the browser can do nothing else - it can't repaint the viewport, update the progress log, spin the spinner, or even notice you clicking Cancel. A heavy tool that never pauses freezes the whole editor until it finishes.
api.yield() gives that time back. Awaiting it hands one turn of the event loop to the browser so it can paint a frame and drain input, then resumes your tool exactly where it left off:
ts
yield(): Promise<void>Call it periodically inside long loops. Everything between yields runs in one uninterrupted burst; each await api.yield() is a breather where the UI catches up.
It doubles as a cancel checkpoint
The progress overlay shown during a run has a Cancel button. It doesn't kill your tool mid-statement - that could leave the scene half-edited. Instead, api.yield() rejects at the next checkpoint, so a bare await api.yield() also means "stop here if the user asked to." The rejection unwinds out of run() and ends the tool cleanly.
Because the whole run is a single undo group, a cancelled - or crashed - run is never a mess to untangle: the partial edits collapse into one undo, and the run rolls back to exactly how the scene was before you pressed Run.
Yield in batches, not every iteration
Yielding isn't free - each await costs a frame - so yield every N iterations, not every one:
- Every iteration → one frame per object → a 5,000-object run takes ~5,000 frames (well over a minute). Too slow.
- Never → the editor is frozen for the whole run. Too janky.
- Every ~100–500 iterations → smooth progress, negligible overhead. Just right.
ts
export const meta = { name: "Suffix names", description: "Append a suffix to matching objects" };
export const params = [
{ name: "match", type: "string", default: "SM_" },
{ name: "suffix", type: "string", default: "_v2" },
];
export default async function run(api: EditorApi, p: { match: string; suffix: string }) {
const objs = api.find(p.match);
api.log(`Renaming ${objs.length} objects…`);
for (let i = 0; i < objs.length; i++) {
objs[i].setName(objs[i].name + p.suffix);
if (i % 200 === 0) {
api.log(` ${i}/${objs.length}`);
await api.yield(); // repaint + let Cancel through, ~every 200 objects
}
}
api.log("Done.");
}TIP
Under the hood, api.yield() prefers the browser's Scheduler API (scheduler.yield()), which resumes your tool at its original priority, and falls back to a MessageChannel ping - which dodges the 4 ms clamp a setTimeout(0) would impose. A yield is cheap; the only cost is the frame you're deliberately handing to the UI.
Why runtime game scripts have no yield
api.yield() lives only on the editor EditorApi, never on the runtime ScriptApi - and a game script doesn't need it. The game loop already calls onUpdate(api, dt) once per frame and returns, so the frame boundary is the yield point: you do a little work each tick, not one giant blocking loop. For time-based sequencing in a game, use that frame loop plus timers and events (api.after, api.every) rather than a blocking wait.
Use cases
- Bulk cleanup after an import - rename by pattern, strip empty nodes, retag.
- Combine modular kits - flood-fill the connected parts of a building and merge the static pieces into one mesh so it culls as a unit and collapses draw calls, while keeping doors dynamic. See Static Batching.
- Procedural placement - scatter props, snap objects to a grid, align to a surface.
- LOD / optimization prep - group and merge geometry ahead of shipping.
Because tools are trusted local automation (no sandbox - they get full editor access), they can do anything you could do by hand, just faster and repeatably. Errors during a run are caught and shown in the Console; a failed run still collapses its partial edits into one undoable group.
See also
- Writing & Compiling - the shared Code panel and compiler
- Plugins - the persistent cousin that adds editor panels, tools, and commands
- Scripting Overview - runtime vs editor script kinds
- Static Batching - the merge tools automate
- Export game.html - where editor scripts are excluded
- Console - where tool output and errors appear