TypeScript that compiles in the browser (with real autocomplete)
Write a script on an object, hit Play, it runs. No npm install, no build step, no toolchain - you type TypeScript and the enemy starts moving about ten seconds later. Here's exactly how that's built, because it's less magic and more "two good libraries wired together carefully."
flowchart LR M["Monaco editor
(+ Awaken .d.ts)"] --> S["your TS source"] S --> E["esbuild-wasm
transform → JS"] E --> B["blob: URL
dynamic import()"] B --> F["ScriptFactory
() => new Behavior()"] F --> R["ScriptRegistry
attached per entity"]
The two libraries
The editor is Monaco - the same editor core as VS Code - so you get the good stuff for free: syntax highlighting, multi-cursor, the works. Compilation is esbuild, but the WebAssembly build of it, so a genuinely fast TS→JS compiler runs inside the page. Two libraries, one browser tab, no server.
Autocomplete for an API that only exists in memory
Monaco will happily colour your code, but for real IntelliSense - api. popping up moveAndSlide, input, setField with types - it needs to know the shape of the engine API. TypeScript types are normally files on disk; here the API is compiled into the running app. So Awaken feeds Monaco the engine's type definitions as an extra library:
monaco.languages.typescript.typescriptDefaults.addExtraLib(AWAKEN_DTS, "awaken.d.ts");AWAKEN_DTS is the hand-authored .d.ts describing ScriptBehavior and ScriptApi. Monaco's TS language service treats it as an ambient module, so autocomplete, hover docs and red squiggles all work against an API that lives only in memory. It's the difference between "a text box that happens to be monospace" and "an IDE for a language binding you invented."
Compiling with no compiler installed
One call turns your source into runnable JS:
const js = (await esbuild.transform(source, { loader: "ts", format: "esm", target: "es2022" })).code;It's transform, not bundle - pure type-stripping and downlevelling, no module resolution (scripts have no imports). Then you have to load that JS string as a module, which is the fun bit: wrap it in a blob: URL and dynamically import() it.
const url = URL.createObjectURL(new Blob([js], { type: "text/javascript" }));
const mod = await import(/* @vite-ignore */ url); // data: URL fallback for non-browser loaders
return () => new mod.default(); // a factory, not the classOne subtlety worth stealing: what's stored is a factory (() => new Cls()), not the class. Each entity that attaches the script gets its own instance, so two doors don't open in eerie unison.
Hooking the engine - reflection, not a thousand bindings
A behavior is onStart / onUpdate / onCollision, handed an api. The interesting part is getField/setField: a script can read or write any field of any component by string name, with no engine change when a new component appears.
setField(component, field, value) {
const type = world.registry.get(component); // ComponentType by name
if (!type) return; // unknown component → no-op
const comp = world.get(entity, type);
if (comp) comp[field] = value;
}That's the whole binding layer. The World auto-registers every component the first time it's used, so api.setField("RigidBody", "velocity", …) works with zero imports and keeps working for components that didn't exist when the scripting API was written. Physics and audio hang off the same api (moveAndSlide, raycast, playSound), wired as optional hooks so a script degrades gracefully when there's no live session.
And the params panel writes itself: Awaken discovers a script's public fields by both running the factory to read real default values and regex-scanning the source, so speed = 120 // 0..360 becomes a slider bounded 0–360 with the comment as its tooltip, and side: "left" | "right" becomes a dropdown. You author a plain class; you get a typed inspector.
📷 Screenshot needed: the Code panel (Monaco) mid-edit showing the
api.autocomplete dropdown, and ideally the auto-generated script params in the Inspector beside it.
The one place it gets weird: shipping
Scripts run live in the editor and inside the exported game - and there the clean blob: + dynamic import() breaks, because a game opened by double-clicking game.html runs from a file:// origin, which the browser treats as unique and opaque and forbids from dynamically importing blob:/data: URLs. So for export, each script is compiled a second way - esbuild format: "iife" - into a self-executing global stashed on window.__AWAKEN_BEHAVIORS__, run by a classic <script> on the page's own origin. Same source, two compiles: esm + blob for the editor, iife + global for a double-clickable file. It's a small shim, and it's the whole difference between "works on my machine" and "a stranger downloads it and it runs."