Porting Zelda Classic to the web

Porting Zelda Classic to the web

I be wild about ingredients, because they are adorable!!

April 29, 2022

I ported Zelda Classic (a game engine based on the original Zelda) to the web. You can play it here–grab a gamepad if you have one!

It’s a PWA, so you can also install it.

I’ve written some background information on Zelda Classic, and chronicled the technical process of porting a large C++ codebase to the web using WebAssembly.

Follow Zelda Classic on Twitter!

Zelda ClassicOn the WebPorting Zelda Classic to the WebGetting it workingEmscriptenStarting offLearning CMake, Allegro, and EmscriptenAllegro LegacyStarting to build Zelda Classic with EmscriptenLet there be threadsOf mutexes and deadlocksGetting it fully functionalPlaying MIDI with TimidityMusic working, but no SFX?Build script hackingMaking it awesomeQuest ListMP3s, and OGGs and retro musicPersisting dataGamepadsMobile supportPWATakeaways

ZQuest, the Zelda Classic quest editor

Zelda Classic is a 20+ year old game engine originally made to recreate and modify the original Legend of Zelda. The engine grew to support far more features than what was necessary to create the original game, and today there are over 600 custom games – the community calls them quests.

Many are spiritual successors to the original, perhaps with improved graphics, but very recognizable as a Zelda game. They range in complexity, quality and length. Fair warning, some are just awful, so be discerning and use the rating to guide you.

If you are a fan of the original 2D Zelda games, I believe you’ll find many Zelda Classic quests to be well worth your time. Some are 20+ hour games with expansive overworlds and engaging, unique dungeons. The engine today supports scripting, and many have used that to push it to the limits: it’s almost impossible to believe that some quests implemented character classes, online networking, or achievements in an engine meant to create the original Zelda.

However, the most recent version of Zelda Classic only supports Windows… until now!

On the Web
I spent the last two months (roughly ~150 hours) porting Zelda Classic to run in a web browser.

There’s a lot of quests to choose from, but here’s just a small sampling! Click any of these to jump into the quest:

I hope my efforts result in Zelda Classic reaching a larger audience. It’s been challenging work, far outside my comfort zone of web development, and I’ve learned a lot about WebAssembly, CMake and multithreading. Along the way, I discovered bugs across multiple projects and did due diligence in fixing (or just reporting) them when I could, and even proposed a change to the HTML spec.

The rest of this article is an overview of the technical process of porting Zelda Classic to the web.

If you’re interested in the minutia, I’ve made my daily notes available. This was the first time I kept notes like this, and I found the process improved my working memory significantly… and it definitely helped me write this article.

Getting it working
Emscripten is a compiler toolchain for building C/C++ to WebAssembly. The very TL;DR of how it works is that it uses clang to transform the resultant LLVM bytecode to Wasm. It’s not enough to just compile code to Wasm–Emscripten also provides Unix runtime capabilities by implementing them with JavaScript/Web APIs (ex: implementations for most syscalls; an in-memory or IndexedDB-backed filesystem; pthreads support via Web Workers). Because many C/C++ projects are built with Make and CMake, Emscripten also provides tooling for interoping with those tools: emmake and emcmake. For the most part, if a C/C++ program is portable, it can be built with Emscripten and run in a browser, although you’ll like have to make changes to accommodate the browser main loop.

If you are developing a Wasm application, the Chrome DevTools DWARF extension is essential. See this article for how to use it. When it works, it’s excellent. You may need to drop any optimization for best results. Even with no optimization pass, I often ran into cases where some frames of the call stacktrace were obviously wrong, so I sometimes had to resort to printf-style debugging.

Starting off
Zelda Classic is written in C++ and uses Allegro, a low-level cross platform library for window management, drawing to the screen, playing sounds, etc. Well, it actually uses Allegro 4, released circa 2007. Allegro 4 does not readily compile with Emscripten, but Allegro 5 does. The two versions are vastly different but fortunately there is an adapter library called Allegro Legacy which allows an Allegro 4 application to be built using Allegro 5.

So that’s the first hurdle–Zelda Classic needs to be ported to Allegro 5, and its CMakeLists.txt needs to be modified to build allegro from source.

Allegro 5 is able to support building with Emscripten because it can use SDL as its backend, which Emscripten supports well.

Before working on any of that directly, I needed to address my lack of knowledge of CMake and Allegro.

Learning CMake, Allegro, and Emscripten
Allegro claims to support Emscripten, but I wanted to confirm it for myself. Luckily they provided some instructions on how to build with Emscripten. My first PRs were to Allegro to improve this documentation.

I wasted a few hours here because of an unfortunate difference between bash and zsh.

Next I found an interesting example program showcasing palette swapping–encoding a bitmap as indices into an arbitrary set of colors, which can be swapped out at runtime. But, it didn’t work when built with Emscripten. To get a little practice with Allegro, I worked on improving this example.

The fragment shader:

uniform sampler2D al_tex;
uniform vec3 pal[256];
varying vec4 varying_color;
varying vec2 varying_texcoord;
void main()
vec4 c=texture2D(al_tex, varying_texcoord);
int index=int(c.r * 255.0);
if (index !=0) {
gl_FragColor=vec4(pal[index], 1);
else {
gl_FragColor=vec4(0, 0, 0, 0);
Allegro passes a bitmap’s texture to the shader as al_tex, and in this program that bitmap is just a bunch of numbers 0-255. Attached to the shader as an input is a palette of colors pal, and at runtime the program swaps out the palette, changing the colors rendered by the shader. There were two things wrong here that results in this shader not working in WebGL:

It lacks a precision declaration. In WebGL, this is not optional. Very simple fix–just add precision mediump float;
It uses a non-constant expression to index an array. WebGL does not support that, so the entire shader needed to be redesigned. This was more involved, so I’ll just link to the PR

The resulting program is hosted here.

It turned out that none of this knowledge of how to do palette swapping in Allegro 5 would be necessary for upgrading Zelda Classic’s Allegro, although
initially I thought it might. Still, it was a nice introduction to the library.

Next I wanted to write a simple CMakeLists.txt that I could wrap my head around, one that builds Allegro from source and also supports building with Emscripten.

Emscripten supports building projects configured with CMake via emcmake, which is a small program that configures an Emscripten CMake toolchain. Essentially, running emcmake cmake configures the build to use emcc as the compiler.

I spent some time reading many tutorials on CMake, going through real-world CMakeLists.txt and trying to understand it all line-by-line. The CMake documentation was excellent during this process. Eventually, I ended up with this:


cmake_minimum_required(VERSION 3.5)
project (AllegroProject)
GIT_REPOSITORY https://github.com/liballeg/allegro5.git
if(NOT allegro5_POPULATED)
if (MSVC)
add_subdirectory(${allegro5_SOURCE_DIR} ${allegro5_BINARY_DIR} EXCLUDE_FROM_ALL)

add_executable(al_example src/main.c)
target_include_directories(al_example PUBLIC ${allegro5_SOURCE_DIR}/include)
target_include_directories(al_example PUBLIC ${allegro5_BINARY_DIR}/include)
target_link_libraries(al_example LINK_PUBLIC allegro allegro_main allegro_font allegro_primitives)

file(COPY ${allegro5_SOURCE_DIR}/addons/font/allegro5/allegro_font.h
DESTINATION ${allegro5_SOURCE_DIR}/include/allegro5
file(COPY ${allegro5_SOURCE_DIR}/addons/primitives/allegro5/allegro_primitives.h
DESTINATION ${allegro5_SOURCE_DIR}/include/allegro5

This could have been simpler, but Allegro’s CMakeLists.txt requires a few modifications for it to be easily consumed as a dependency.

Initally I tried using CMake’s ExternalProject instead of FetchContent, but the former was problematic with Emscripten because it runs cmake under the hood, and it seemed like it was not aware of the toolchain that emcmake provides. I don’t know why I couldn’t get it to work, but I know FetchContent is the newer of the two and I had better luck with it.

Allegro Legacy
Allegro 4 and 5 can be considered entirely different libraries:

literally every API was rewritten, and not in a 1:1 way
A4 uses polling for events while A5 uses event queues / loops
A4 only supports software rendering, and directly supports palettes (which ZC makes heavy use of); while A5 supports shaders / GPU-accelerated rendering (but dropped palette manipulation)
And most importantly for my concerns, only A5 can be compiled with Emscripten (trivially, because of its SDL support)

Replacing calls to A4’s API with A5 essentially means a rewrite, and given the size of Zelda Classic that was not an option. Fortunately, this is where Allegro Legacy steps in.

To support multiple platforms, Allegro abstracts anything OS-specific to a “system driver”. There is one for each supported platform that implements low-level operations like filesystem access, window management, etc. Allegro Legacy bridges the gap between A4 and A5 by creating a system driver that uses A5 to implement A4’s system interfaces. In other words, Allegro Legacy is just A4 with A5 as its driver. All the files in src are just A4 (with a few modifications), except for the a5 folder which provides the A5 implementation.

This is the entire architecture of running Zelda Classic in a browser:

🐢 I’ve fixed/worked-around bugs in every layer of this.

I used my newly wrangled working knowledge of CMake to configure Zelda Classic’s CMakeLists.txt to build Allegro 5 & Allegro Legacy from source. Allegro Legacy was very nearly a drop-in replacement. I struggled initially with an “unresolved symbol” linker error, for a function I was certain was being included in the compilation, but this turned out to be a simple oversight in a header file. Not really being a C/C++ guy this took me way too long to debug!

Once things actually linked and compilation was successful, Allegro Legacy just worked, although I fixed some minor bugs related to sticky mouse input and file paths.

I sent a PR for upgrading to Allegro 5 to the Zelda Classic repro, but I expect it will remain unmerged until a future major release.

Starting to build Zelda Classic with Emscripten
Even though Zelda Classic was now on A5 and building it from source, there were still a few pre-built libraries being used for music. I didn’t want to deal with this yet, so to start I stubbed out the music layer with dummy functions so everything would still link with Emscripten.


#include “zcmusic.h”int32_t zcmusic_bufsz=64;

bool zcmusic_init(int32_t flags) { return false; }
bool zcmusic_poll(int32_t flags) { return false; }
void zcmusic_exit() {}

ZCMUSIC const *zcmusic_load_file(char *filename) { return NULL; }
ZCMUSIC const *zcmusic_load_file_ex(char *filename) { return NULL; }
bool zcmusic_play(ZCMUSIC *zcm, int32_t vol) { return false; }
bool zcmusic_pause(ZCMUSIC *zcm, int32_t pause) { return false; }
bool zcmusic_stop(ZCMUSIC *zcm) { return false; }
void zcmusic_unload_file(ZCMUSIC *&zcm) {}
int32_t zcmusic_get_tracks(ZCMUSIC *zcm) { return 0; }
int32_t zcmusic_change_track(ZCMUSIC *zcm, int32_t tracknum) { return 0; }
int32_t zcmusic_get_curpos(ZCMUSIC *zcm) { return 0; }
void zcmusic_set_curpos(ZCMUSIC *zcm, int32_t value) {}
void zcmusic_set_speed(ZCMUSIC *zcm, int32_t value) {}

Zelda Classic reads various configuration files from disk, including data files containing large things like MIDIs. Emscripten can package such data alongside Wasm deployments via the –preload-data flag. These files can be pretty large (zc.data is ~9 MB), so a long-term caching strategy is best: –use-preload-cache is a nice Emscripten feature that will cache this file in IndexedDB. However, the key it uses is unique to every build, so any deployment invalidates the cache of all users. That’s no good, but there’s a quick hack to make the hash content-based instead:

HASH=$(shasum -a 256 module.data | awk ‘{print $1}’)
sed -i -e “s/
Read More
Share this on knowasiak.com to discuss with people on this topicSign up on Knowasiak.com now if you’re not registered yet.

Related Articles

Stripe Crypto

The crypto ecosystem and its regulatory outlook continue to evolve rapidly, and our feature availability varies by region and use case. Please see our crypto supportability page for more details on our current product availability. Fill out the form to tell us more about what you’re building so we can better understand how to support…

Windows 11 Guide

A guide on setting up your Windows 11 Desktop with all the essential Applications, Tools, and Games to make your experience with Windows 11 great! Note: You can easily convert this markdown file to a PDF in VSCode using this handy extension Markdown PDF. Getting Started Windows 11 Desktop Bypass Windows 11’s TPM, CPU and…

Create your crypto business with Stripe

The crypto ecosystem and its regulatory outlook continue to evolve rapidly, and our feature availability varies by region and use case. Please see our crypto supportability page for more details on our current product availability. Fill out the form to tell us more about what you’re building so we can better understand how to support…

What’s recent in Emacs 28.1?

By Mickey Petersen It’s that time again: there’s a new major version of Emacs and, with it, a treasure trove of new features and changes.Notable features include the formal inclusion of native compilation, a technique that will greatly speed up your Emacs experience.A critical issue surrounding the use of ligatures also fixed; without it, you…


Your email address will not be published. Required fields are marked *