← All posts

Compiling AssemblyScript in the tab, and a library that cannot reach anything

Post #5 put a TypeScript compiler in the browser tab so gameplay could be written without a toolchain. That covers gameplay. It does not cover the other kind of code a game needs: generating a terrain chunk, meshing a voxel column, running a pathfinder. Work that takes 40 milliseconds and must not happen on the frame thread.

So Awaken has a second kind of script: a module. You write AssemblyScript or C, it compiles to WebAssembly, and your gameplay calls its entry points as jobs on a worker pool.

Why AssemblyScript is the default

asc is a plain JavaScript package. No native toolchain behind it, no SDK, no install. That is the entire reason it is the default module language, and it is the same reason post #5 picked esbuild-wasm: a reader needs a browser, not a build environment.

The compiler plus binaryen is several megabytes, and most sessions never author a module, so it loads on first compile rather than being bundled into the app chunk. The promise is cached, so the second compile does not re-import it.

It compiles with runtime: "stub", which is the interesting choice. No managed runtime, no GC. The module exports its own alloc and free over a raw heap and the host owns every pointer. That keeps the module freestanding, which is what lets one ABI serve both AssemblyScript and a future clang path: the C module and the AssemblyScript module produce the same shape.

Isolation you get for free

Here is the property I care about most. A module declares no imports at all.

Not "we sandbox it". Not "we audit what it calls". There is nothing to call. A WebAssembly module can only reach the outside world through imports the host supplies, and this ABI supplies none. It cannot touch the DOM, open a socket, read the scene, or see another module. It takes bytes and returns bytes.

That is structural isolation, and it is a much stronger claim than a policed one. Compare it with the plugin sandbox, where the code is JavaScript and the isolation has to be built out of an opaque origin and a content security policy, layer on layer, because JavaScript can reach for things. Here the reach does not exist.

The whole ABI is four things:

memory                                  linear memory the host reads and writes
alloc(size: i32) -> i32                 host-owned lifetime
free(ptr: i32)
<entry>(inPtr: i32, inLen: i32) -> i32  ptr to a 4-byte-aligned { ptr: u32, len: u32 }

You do not write that marshalling; the starter template has it. But it is worth seeing, because the shape of it is the reason for the isolation.

Jobs, and the pool that runs them

const bytes = await api.jobs.run("generate", input);

The pool owns N workers, and each holds its own instance of every loaded module, with its own linear memory. Nothing is shared between workers. That means no SharedArrayBuffer, and therefore no COOP/COEP headers on the host page, which matters a great deal for a thing you are meant to be able to double-click from disk.

Two details that turned out to be load-bearing:

Cancellation is not an optimisation. A chunk streamer continuously starts work for chunks that leave range before they finish. Without cancel, a player walking in a straight line accumulates a queue that never drains, and the pool falls further behind forever.

A missing pool rejects rather than resolving empty. With no job host wired (headless, or the editor before Play), run returns a handle that rejects. An empty buffer would surface as an invisible mesh, and an invisible mesh is an order of magnitude harder to diagnose than an error naming the cause.

And because setup often needs a job before anything can run, onStart may be async: the host holds onUpdate back until it resolves, so a script that awaits its first chunk reads as a sequence rather than a chain of callbacks.

Addressing an entry without naming a module

A script declares which modules it may call in a uses list, set in the editor. Then it calls an entry by name:

api.jobs.run("generate", input)     // resolved against this script's declared modules

No module id appears in the calling code. The entry name is the whole address. That is deliberate: renaming or re-importing a module cannot silently rebind the call, because the binding lives in project structure rather than in a string somebody typed. When two declared modules expose the same entry, the first declared one answers.

The editor generates declare module typings from each module's compiled binary, so autocomplete offers only entries the module really exports, and importing something it does not export squiggles while you type instead of failing at run time.

One module, several files

A thousand-line generator wants to be a noise file, a terrain file and a mesher file. The obvious move is three modules. It does not work, and the reason is the isolation above: a module declares no imports, so two modules can never call each other. Splitting that way would duplicate every helper and shuttle data between them through the job queue.

So the split is at the source level. Several sources link into one module and one binary, with imports resolving by module name through the same machinery behaviour scripts already use, cycles included. Mutable module-level state stays a single variable across the files, which is what makes the split usable rather than forcing accessor functions around everything.

The contract is unchanged, and there is a test asserting it: a linked module still declares zero imports. If linking ever leaked one, a module could suddenly reach the host, and the structural claim above would quietly stop being true.

Two shipping bugs worth the price of admission

A module is not a script. The exporter used to compile everything in the project as TypeScript. Compiling AssemblyScript source as TypeScript produces nonsense, and it registered that nonsense as a library namespace under the very id the wasm dispatch answers to. An importing script could bind to garbage. Modules now ship as base64 binaries and are never handed to the TypeScript path.

Two stores for one thing. A TypeScript library is linked in-process, so the scripts importing it bind its namespace at module scope, as their own script tag parses. The exporter was writing library namespaces onto a window bag that the player copied into the runtime registry at boot. Every import landed in the gap between the two and threw:

Script "voxel-atlas" was imported before it compiled - an import cycle, or a compile-order bug

The compile order was correct. The emission order was correct. The error named the one thing that was fine, which is the signature of a bug at a seam rather than in a step. Libraries now register straight into the runtime's own registry, and there is one store.

What it is for

The voxel demo is the honest test of this. Terrain generation and meshing are wasm modules; placement, streaming and the atlas are ordinary scripts; the engine contains no voxel code at all. If the module seam were not good enough, that demo would have forced engine changes, and it did not.

← All posts