Skip to content

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.

jswebassemblyastrodeployment

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.