Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ npm run fetch-wasm # latest release
npm run fetch-wasm v1.9.0 # specific version
```

This downloads `hemlock.js` and `hemlock.wasm` into `public/wasm/`. The app runs without the binary (block editing and code preview work), but game execution requires it.
This downloads `hemlock.js` and `hemlock.wasm` into `public/wasm/`. Set `GITHUB_TOKEN` to authenticate the download (higher rate limits). The app is fully functional without the binary — when it is absent, game execution falls back to the built-in TypeScript interpreter.

## Architecture

Expand All @@ -32,9 +32,9 @@ Visual Blocks → Block AST → Hemlock Source → hemlock_context_eval() → Ou
(Code Preview panel)
```

The game runtime lives entirely in TypeScript. Hemlock only runs user-authored event handler logic. Per frame, the JS engine serializes game state, passes it to the WASM interpreter, and reads back the result.
The game runtime lives entirely in TypeScript. Hemlock only runs user-authored event handler logic. At game start the engine compiles each event handler's blocks to a Hemlock script (`hemlock_compile_script`); per frame it serializes instance state into the interpreter context, runs the handler (`hemlock_run_script`), and reads the mutated state back.

For development without the WASM binary, a built-in TypeScript interpreter executes compiled block actions directly.
When the WASM binary is absent (or any script fails to compile), the engine automatically falls back to a built-in TypeScript interpreter that executes compiled block actions directly — same blocks, same behavior.

### Project Structure

Expand Down
10 changes: 7 additions & 3 deletions scripts/fetch-wasm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
* Files are written to public/wasm/hemlock.{js,wasm}.
*/

import { execSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
Expand All @@ -22,13 +21,18 @@ const REPO = "hemlang/hemlock";

const version = process.argv[2] || "latest";

// Set GITHUB_TOKEN to authenticate (higher rate limits, private repos).
const authHeaders = process.env.GITHUB_TOKEN
? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
: {};

async function fetchRelease() {
const apiUrl =
version === "latest"
? `https://api.github.com/repos/${REPO}/releases/latest`
: `https://api.github.com/repos/${REPO}/releases/tags/${version}`;

const res = await fetch(apiUrl);
const res = await fetch(apiUrl, { headers: authHeaders });
if (!res.ok) {
throw new Error(`GitHub API returned ${res.status}: ${await res.text()}`);
}
Expand All @@ -37,7 +41,7 @@ async function fetchRelease() {

async function downloadAsset(url, dest) {
const res = await fetch(url, {
headers: { Accept: "application/octet-stream" },
headers: { Accept: "application/octet-stream", ...authHeaders },
});
if (!res.ok) throw new Error(`Failed to download ${url}: ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
Expand Down
28 changes: 26 additions & 2 deletions src/bridge/hemlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,36 @@ export async function loadHemlock(): Promise<HemlockExports> {

// The prebuilt hemlock.js module (from Hemlock GitHub releases) exposes an
// init function that loads the .wasm file and returns the exports.
// @ts-expect-error — dynamic WASM module loaded from public/wasm at runtime
const mod = await import("/wasm/hemlock.js");
// The path is built at runtime so Vite/Rollup leave the import dynamic;
// the file lives in public/wasm and is not part of the bundle.
const modulePath = "/wasm/hemlock.js";
const mod = await import(/* @vite-ignore */ modulePath);
instance = await mod.default();
return instance!;
}

/**
* Load the Hemlock WASM interpreter if the binary is present, or return
* null without throwing. Used by the engine to decide between WASM
* execution and the built-in TypeScript interpreter.
*/
export async function tryLoadHemlock(): Promise<HemlockExports | null> {
if (instance) return instance;
try {
// Probe before importing: the Vite dev server answers missing paths
// with the SPA index.html (status 200), so check the content type too.
const probe = await fetch("/wasm/hemlock.js", { method: "HEAD" });
if (!probe.ok) return null;
const type = probe.headers.get("content-type") ?? "";
if (type.includes("text/html")) return null;

return await loadHemlock();
} catch (err) {
console.warn("Hemlock WASM unavailable, using built-in interpreter:", err);
return null;
}
}

export function getHemlock(): HemlockExports {
if (!instance) throw new Error("Hemlock WASM not loaded — call loadHemlock() first");
return instance;
Expand Down
69 changes: 63 additions & 6 deletions src/runtime/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ import {
GameInstance,
GameState,
CompiledSprite,
CompiledHandler,
KeyboardState,
} from "./types.js";
import { InputManager } from "./input.js";
import { Renderer } from "./renderer.js";
import { executeActions } from "./interpreter.js";
import { HemlockRunner } from "./hemlock-runner.js";
import { tryLoadHemlock } from "../bridge/hemlock.js";
import { compileWorkspace } from "../editor/compiler.js";
import { BlockWorkspace } from "../editor/workspace.js";

Expand All @@ -39,6 +42,7 @@ export class GameEngine {
private animFrameId: number = 0;
private running: boolean = false;
private callbacks: EngineCallbacks;
private hemlockRunner: HemlockRunner | null = null;

constructor(canvas: HTMLCanvasElement, callbacks: EngineCallbacks) {
this.canvas = canvas;
Expand Down Expand Up @@ -90,6 +94,26 @@ export class GameEngine {
}
}

// Prefer the Hemlock WASM interpreter when the binary is present;
// otherwise event handlers run through the built-in TS interpreter.
this.hemlockRunner = null;
const hemlock = await tryLoadHemlock();
if (hemlock) {
this.hemlockRunner = HemlockRunner.create(hemlock, [
...this.compiledSprites.values(),
]);
if (!this.hemlockRunner) {
console.warn(
"Hemlock script compilation failed; falling back to the built-in interpreter."
);
}
}
console.info(
`Acorn engine: executing via ${
this.hemlockRunner ? "Hemlock WASM interpreter" : "built-in TypeScript interpreter"
}`
);

// Set up current room (first room)
this.currentRoom = project.rooms[0] || null;
if (!this.currentRoom) {
Expand Down Expand Up @@ -128,6 +152,8 @@ export class GameEngine {
this.animFrameId = 0;
}
this.input.detach();
this.hemlockRunner?.dispose();
this.hemlockRunner = null;
}

isRunning(): boolean {
Expand Down Expand Up @@ -183,10 +209,10 @@ export class GameEngine {
const compiled = this.compiledSprites.get(instance.spriteId);
if (!compiled) continue;

for (const handler of compiled.handlers) {
for (let i = 0; i < compiled.handlers.length; i++) {
const handler = compiled.handlers[i];
if (handler.event !== event) continue;
const output = executeActions(handler.actions, instance, this.state);
this.handleOutput(output);
this.runHandler(compiled.spriteId, i, handler, instance);
}
}
}
Expand All @@ -198,7 +224,8 @@ export class GameEngine {
const compiled = this.compiledSprites.get(instance.spriteId);
if (!compiled) continue;

for (const handler of compiled.handlers) {
for (let i = 0; i < compiled.handlers.length; i++) {
const handler = compiled.handlers[i];
if (!handler.key) continue;

let shouldFire = false;
Expand All @@ -215,13 +242,43 @@ export class GameEngine {
}

if (shouldFire) {
const output = executeActions(handler.actions, instance, this.state);
this.handleOutput(output);
this.runHandler(compiled.spriteId, i, handler, instance);
}
}
}
}

/**
* Execute one event handler for one instance, routing through the
* Hemlock WASM runner when active, otherwise the TS interpreter.
*/
private runHandler(
spriteId: string,
handlerIndex: number,
handler: CompiledHandler,
instance: GameInstance
): void {
if (this.hemlockRunner) {
const result = this.hemlockRunner.runHandler(
spriteId,
handlerIndex,
instance
);
if (result) {
if (result.error) {
this.callbacks.onError(`Error: ${result.error}`);
}
this.state.pendingCreations.push(...result.creations);
this.handleOutput(result.output);
return;
}
// No compiled script for this handler — fall through to the TS path.
}

const output = executeActions(handler.actions, instance, this.state);
this.handleOutput(output);
}

private createInstance(
spriteId: string,
x: number,
Expand Down
Loading