Skip to content

Modules & Jobs

A module is compiled code that runs off the main thread. You write it in AssemblyScript or C, Awaken compiles it to WebAssembly, and your scripts call its entry points as jobs on a worker pool. Use it for the work that would otherwise stall a frame: terrain generation, meshing, pathfinding, image processing.

This page also covers TypeScript libraries, which are the opposite trade: ordinary code, linked in-process, no worker involved.

The two kinds of shared code

TypeScript libraryModule (AssemblyScript / C)
Compiled toJavaScript, linked in-processWebAssembly binary
Called asAn ordinary function returning a valueA job returning a Promise<Uint8Array>
Runs onThe main threadA worker
Can touch the sceneYes, through whatever you pass itNo. It takes bytes and returns bytes
Ships asCompiled script sourceA binary, base64'd into the export

Reach for a library to share helpers. Reach for a module when the work is heavy enough that doing it on the main thread would drop frames.

Why a module cannot reach the scene

A module declares no imports at all. It cannot call the DOM, the network, or the editor, because there is nothing for it to call: the isolation is structural rather than a sandbox someone has to keep airtight.

That is also why the interface is bytes in, bytes out. Every module exports:

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

You do not write that marshalling by hand - the AssemblyScript starter the Code panel gives you already has it.

Creating a module

In the Code panel, create a script and set its Language to AssemblyScript or C. Changing the language of an existing asset replaces its source with that language's starter, so the editor asks first.

A module asset carries language: "assemblyscript" | "c" in its .awakenscript file. Absent means TypeScript.

Declaring which modules a script uses

A script names the modules it may call in its uses list, set in the Code panel. Awaken generates declare module "<name>" typings from the module's compiled binary, so autocomplete offers only entries the module really exports, and an import of something it does not export squiggles in the editor rather than failing at run time.

Import a module by its name, which is the only thing you are ever shown - ids are UUIDs and appear nowhere in the UI:

ts
import terrain from "Voxel Terrain";          // whole module
import { generate } from "Voxel Terrain";     // individual entries

Renaming a module rewrites the imports that name it, which buys back the stability an id would have given.

Running a job

ts
const bytes = await api.jobs.run("generate", input);
SignatureUse
api.jobs.run(entry, input)Run an entry on one of the modules this script declares. The entry name is the whole address, so no module id appears in your code and re-importing a module cannot rebind the call. When two declared modules expose the same entry, the first declared one answers.
api.jobs.run(moduleId, entry, input)Run an entry on one named module, when you genuinely mean that one regardless of what the script declares.

A Job is { result: Promise<Uint8Array>, cancel(): void }. Cancelling rejects result with JobCancelled; a module trap rejects with the trap message. Callers that cancel deliberately should swallow JobCancelled and only that.

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

Async onStart

onStart may return a promise, and the host holds onUpdate back until it resolves. Setup that awaits a job therefore reads as a sequence:

ts
export default class implements ScriptBehavior {
  async onStart(api: ScriptApi) {
    const mesh = await api.jobs.run("buildChunk", seedBytes);
    api.mesh.set(decode(mesh));
  }
}

Splitting a module across several sources

A thousand-line generator wants to be a noise file, a terrain file and a mesher file. It cannot be several modules: a module declares no imports, so two of them can never call each other, and splitting that way would duplicate every helper and copy data between them through the job queue.

So the split is at the source level. Several sources link into one module and one binary: imports resolve by module name through the same machinery behaviour scripts 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 does not change. A linked module still declares zero imports.

TypeScript libraries

A TypeScript script with no default-exported class is a library. Import it by name from any other script:

ts
import { buildAtlas, FAMILIES } from "Voxel Atlas";

It is linked in-process, so buildAtlas(...) returns a value rather than a job.

Because the importing script's module body runs the moment it loads, its dependencies must already be compiled: compile order is a real constraint, not a preference. Awaken compiles dependencies first, deepest first. An import cycle is rejected with the loop named rather than tolerated, because a partially-initialised namespace fails somewhere far from the import that caused it.

The same order applies in an exported game, where each library publishes its namespace as its own script tag runs.

What ships

  • A module ships as its compiled binary, base64'd into game.html. It is never compiled as TypeScript at export time; doing so produced a library namespace registered under the very id the wasm dispatch answers to, so an importing script could bind to nonsense.
  • A library ships as compiled script source, emitted before anything that imports it.

See Export to game.html.

See also

Awaken — browser-native WebGPU game engine.