Self-hosting WebAssembly game with Astro
How to embed multiple WASM demos in Astro with modal launchers, flexible file paths and build-time demo discovery.
Embedding WASM games in Astro like itch.io does
I wanted to embed multiple playable WASM demos directly on my Astro site - click play, game loads in a modal, no page navigation. Basically what itch.io does, but self-hosted. Sounds simple, but WASM has some specific requirements that made this more complex than expected.
The problem
I had compiled my game engine (ColumbaEngine) to WebAssembly and wanted to showcase multiple demos on my site. Just dropping the files into Astro and calling it a day? Not happening. WASM needs specific headers, correct MIME types, and SharedArrayBuffer support for threading. Plus, I wanted a smooth experience where users could switch between demos without page reloads.
First attempt - just drop files in src/
Started by putting WASM files directly in the src directory. Astro’s build process immediately broke this - files got hashed, moved around, and the WASM loader couldn’t find anything.
// This doesn't work - Astro will process these files
Module.locateFile = (filename) => {
return '/src/demos/' + filename; // Files won't be there after build
}
Moving to public/ - better but not enough
Moved everything to public/demos/
. Files stay untouched during build, paths are predictable. Each demo gets its own directory:
public/demos/
├── demo1/
│ ├── demo1.wasm
│ ├── demo1.js
│ ├── demo1.data
│ └── demo1.worker.js
└── demo2/
├── game.wasm # Some demos use different naming
├── game.js
└── game.data
Created a flexible path resolver to handle naming variations
Different build tools and Emscripten versions produce different file naming patterns. Some demos had demo1.wasm
, others had generic game.wasm
. Instead of renaming everything manually (and breaking future builds), I built a fallback system that checks multiple naming patterns. First it looks for files matching the demo slug name, then falls back to generic names. This way I can drop in any WASM build and it just works.
function getGameConfig(slug) {
return {
wasmFile: `/demos/${slug}/${slug}.wasm`,
jsFile: `/demos/${slug}/${slug}.js`,
dataFile: `/demos/${slug}/${slug}.data`,
workerFile: `/demos/${slug}/${slug}.worker.js`,
fallbacks: {
wasmFile: `/demos/${slug}/game.wasm`,
jsFile: `/demos/${slug}/game.js`,
dataFile: `/demos/${slug}/game.data`,
workerFile: `/demos/${slug}/game.worker.js`
}
};
}
async function getWorkingPath(primary, fallback) {
if (await checkFileExists(primary)) return primary;
if (fallback && await checkFileExists(fallback)) return fallback;
return primary;
}
With the files in place and the paths sorted, I thought I was done. Then I tried enabling threading and hit the next wall.
The CORS nightmare - SharedArrayBuffer requirements
SharedArrayBuffer enables true multi-threading in WASM, but browsers disabled it after Spectre/Meltdown unless you promise you know what you’re doing via specific headers. Without Cross-Origin-Opener-Policy
and Cross-Origin-Embedder-Policy
, your multi-threaded WASM silently falls back to single-threaded mode or just crashes with “SharedArrayBuffer is not defined”. The worst part? These headers must be set everywhere - on the HTML page, on the WASM files, on the worker scripts. Miss one place and everything breaks.
Development setup via Vite plugin in astro.config.mjs
:
function addCrossOriginHeaders() {
return {
name: 'add-cross-origin-headers',
configureServer(server) {
server.middlewares.use((req, res, next) => {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
// Correct MIME type for WASM
if (req.url?.endsWith('.wasm')) {
res.setHeader('Content-Type', 'application/wasm');
}
next();
});
}
};
}
Production headers via public/_headers
file (this is Cloudflare Pages’ way of setting headers - other hosts use different methods like .htaccess
or vercel.json
):
/demos/*
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: cross-origin
The _headers
file tells Cloudflare to apply these headers to anything under /demos/
. But that’s not enough - the HTML page loading the game also needs these headers, otherwise the browser blocks SharedArrayBuffer at the page level. So in the Astro page component itself:
// In src/pages/demos/index.astro
if (Astro.response) {
Astro.response.headers.set('Cross-Origin-Embedder-Policy', 'require-corp');
Astro.response.headers.set('Cross-Origin-Opener-Policy', 'same-origin');
}
Headers fixed, threading working. Now I needed a way to manage all these demos without editing code every time I added a new one.
Automating demo discovery
Instead of manually maintaining a list of demos, built a system that scans the demos directory at build time. Every time I add a new demo folder, it automatically appears on the site after rebuild. No config files to update, no hardcoded lists to maintain. Just drop a new WASM build in a folder and it’s live. If a demo has a metadata.json
file, it uses that for the title and description. Otherwise, it generates them from the folder name.
async function getDemos() {
const fs = await import('fs/promises');
const path = await import('path');
const demosDir = 'public/demos';
const demosList = [];
const entries = await fs.readdir(demosDir);
for (const entry of entries) {
const demoPath = path.join(demosDir, entry);
const stats = await fs.stat(demoPath);
if (stats.isDirectory()) {
const files = await fs.readdir(demoPath);
// Optional metadata.json for custom info
let metadata = {};
try {
const metadataPath = path.join(demoPath, 'metadata.json');
const metadataContent = await fs.readFile(metadataPath, 'utf-8');
metadata = JSON.parse(metadataContent);
} catch {
// Use defaults if no metadata
}
demosList.push({
slug: entry,
title: metadata.title || entry.charAt(0).toUpperCase() + entry.slice(1).replace(/-/g, ' '),
description: metadata.description || `WebAssembly demo: ${entry}`,
coverImage: files.find(f => f.match(/^cover\.(png|jpg|webp)$/)) ?
`/demos/${entry}/${coverImage}` : null,
tags: metadata.tags || ['Demo']
});
}
}
return demosList;
}
So now I had a nice grid of demos that auto-populated from the file system. Clicking on them though? That’s where things got interesting with Emscripten’s expectations about DOM structure.
The modal loader
Built a modal system that creates a fresh canvas for each game. The key insight was that Emscripten expects a specific canvas element to exist before initialization, and it gets confused if you try to reuse the same canvas for different games. So instead of complex canvas recycling logic, I just create a new canvas element each time a user clicks play. The modal handles the overlay, escape key binding, and click-outside-to-close behavior, while the canvas container gets completely replaced for each game:
async function openGameModal(slug, title) {
currentDemoSlug = slug;
modal.classList.add('active');
document.body.style.overflow = 'hidden';
// Fresh canvas every time
const canvas = document.createElement('canvas');
canvas.id = 'canvas';
canvas.width = 820;
canvas.height = 640;
canvasContainer.innerHTML = '';
canvasContainer.appendChild(canvas);
await loadGame(slug);
}
The actual WASM loading with Emscripten module setup:
async function loadGame(slug) {
const canvas = document.getElementById('canvas');
const config = getGameConfig(slug);
// Get actual file paths with fallbacks
const GAME_CONFIG = {
wasmFile: await getWorkingPath(config.wasmFile, config.fallbacks.wasmFile),
jsFile: await getWorkingPath(config.jsFile, config.fallbacks.jsFile),
dataFile: await getWorkingPath(config.dataFile, config.fallbacks.dataFile),
workerFile: await getWorkingPath(config.workerFile, config.fallbacks.workerFile)
};
// Module configuration
window.Module = {
canvas,
locateFile(filename) {
if (filename.endsWith('.wasm')) return GAME_CONFIG.wasmFile;
if (filename.endsWith('.data')) return GAME_CONFIG.dataFile;
if (filename.endsWith('.worker.js')) return GAME_CONFIG.workerFile;
return `/demos/${slug}/` + filename;
},
monitorRunDependencies(left) {
updateLoadingText(left ? `Loading... (${left} files)` : 'Starting...');
},
onRuntimeInitialized() {
console.log('[WASM] Runtime initialized');
updateLoadingText('Ready!');
hideLoadingSoon();
canvas.focus();
},
print: (t) => console.log('[GAME]', t),
printErr: (t) => console.error('[GAME ERROR]', t)
};
// Enable threading if available
const hasWorker = await checkFileExists(GAME_CONFIG.workerFile);
const hasSAB = typeof SharedArrayBuffer !== 'undefined';
if (hasWorker && hasSAB) {
gameModule.PTHREAD_POOL_SIZE = 2;
// Worker wrapper setup for SharedArrayBuffer support...
}
// Load WASM binary first
const wasmResponse = await fetch(GAME_CONFIG.wasmFile);
const wasmBuffer = await wasmResponse.arrayBuffer();
gameModule.wasmBinary = wasmBuffer;
// Then load the JS glue code
const script = document.createElement('script');
script.src = GAME_CONFIG.jsFile + '?t=' + Date.now();
document.head.appendChild(script);
}
Everything was working great - games loaded, ran smoothly, modal closed properly. But then I noticed the browser getting slower after loading multiple demos. Time to deal with cleanup.
Memory management - the nuclear option
Tried implementing proper cleanup for switching between demos. Module cleanup, GL context disposal, worker termination - worked sometimes, crashed randomly. The games would leak memory, and after 4-5 demos the browser tab would die.
The solution? Page refresh:
function exitGame() {
console.log('[WASM] Refreshing page to clean up game...');
window.location.reload();
}
Sounds crude, but it’s actually brilliant. The browser’s built-in cleanup on page navigation is bulletproof. Since everything is static and cached (thanks to Astro’s build process), the refresh is nearly instant. Users get a clean slate every time, no memory leaks, no accumulated state.
Production build considerations
Astro build configuration that keeps everything working:
export default defineConfig({
output: 'static',
adapter: cloudflare({
mode: 'directory'
}),
vite: {
plugins: [addCrossOriginHeaders()],
assetsInclude: ['**/*.wasm', '**/*.data'],
optimizeDeps: {
exclude: ['*.wasm', '*.data', '*.worker.js']
},
build: {
format: 'file' // Generates .html files for all routes
}
}
});
The result
A seamless WASM game embedding system that:
- Loads games in modals without page navigation
- Handles multiple file naming conventions
- Supports SharedArrayBuffer and threading
- Cleans up perfectly via page refresh
- Works in development and production (Cloudflare Pages)
- Auto-discovers demos from the file system
Live demos at: columbaengine.org/demos
The key lesson: stop fighting the platform. Browsers are really good at certain things - use those strengths instead of trying to reinvent them. WASM in the browser is complex enough without adding unnecessary complexity on top.