Darklaga Cannonball Symphony
2024 remake of a 2004 PDA game

This article is a technical discussion of how the 2024 remake was built. You can play the game online, or read an older article about the 2004 version.

Animated screen capture of the game

In 2022, I found an archive DVD with the C++ source code, sprites, sounds and music of my 2004 video game Darklaga Cannonball Symphony. It could no longer be built (due to missing proprietary dependencies), so I decided to re-implement it as a late evening hobby project. As an additional challenge to porting a game I wrote when I was 20 years younger, I decided on two additional constraints:

  1. Only use technologies that can be expected to still be present (and backwards-compatible) 20 years from now.
  2. The game should be quick to download and easy to install on as many open platforms as possible.

The complete source code of the remake can be found in this GitHub repository, and can be played online here.

Easy to install

The remake is a web page that runs directly in the browser. It consists of a single HTML page darklaga.html which downloads a single binary blob darklaga.bin containing the JavaScript code, the sprites and the sounds.

Only two permissions are requested:

Both are non-essential (the game can be played without full-screen mode and without audio), and on most platforms the two follow the same logic of being denied until the user first interacts with the page, after which they are automatically granted. For this reason, the initial loading screen remains visible until the first interaction, at which point the game begins with both permissions granted.

The only essential dependency that might not be 100% portable is WebGL, which is used for performance reasons. To ensure the widest browser support, I avoided using anything outside of WebGL 1.0 (from 2011), and steered clear of the recent but unsupported WebGPU.

Quick to download

When publishing anything online, it is a question of respect to have as few HTTP requests as possible, and to keep the downloaded data as small as possible.

Like the rest of my website, the game page does not make any third-party requests, show any ads, or run any tracking code. This should be the standard.

The entire remake weighs 1433 KiB (1042 KiB compressed), divided as follows (one dot is 4 KiB):

2.04 KiB — the HTML and CSS of darklaga.html.

1.79 KiB — the JavaScript bootstrap code inside darklaga.html. This code initiates the retrieval of darklaga.bin, then extracts the JavaScript code contained inside and evaluates it.

36.30 KiB — the "loading" image data, a 240×320 PNG image embedded as a base64 data-URL inside darklaga.html. The base64 encoding significantly increases the file size (from 26.50 KiB), but most of it is clawed back by the compression.

327.15 KiB — the complete JavaScript code of the video game, found at the beginning of darklaga.bin. This code is not minified ; while using a modern minification library would bring the size of this section down to 139 KiB (a reduction of over 50%), the entire darklaga.bin blob is compressed, and JavaScript compresses very well: 54 KiB compressed, 37 KiB minified and compressed. Increasing the complexity of the build system for 17 KiB was not worth it.

Even worse, several minifiers I have tested actually broke my JavaScript code! Bugs in minifiers happen, and the industry standard for dealing with those is to include the minifier in CI, to detect if a change to the code, or a migration to a newer minifier version, breaks the project. I am fine with this approach in my day job, but it does not satisfy my constraint for the remake to still be usable 20 years from now.

415.36 KiB — all the sprites, textures and fonts in the game, combined into one large 1024×1024 texture atlas, encoded as a lossless PNG image file, at the end of darklaga.bin. What the atlas looks like Ironically, the above 512×415 image, which is a shrunk down version of the atlas, weighs 443 KiB! This is because the choice of palette, as well as the absence of partially-transparent pixels, greatly help the PNG compression, but did not survive the downsizing.

18.09 KiB — mapping information for the texture atlas. There are a total of 772 items in the texture atlas, and each of them has six associated float32 values: top, left, right and bottom normalized coordinates within the atlas, and width and height (in pixels) for rendering.

13.70 KiB — all the levels in the game, taken as-is from the 2004 game, and included in darklaga.bin. These use a very simple binary format with 6 bytes for every enemy that spawns in that level (meaning, Darklaga Cannonball Symphony contains 2353 enemies across 16 levels).

584.99 KiB — all the sounds and music in the game, compressed as MP3 (from 1.29 MiB of original WAV files) and concatenated together in darklaga.bin. Surprisingly, the largest asset category in the game is actually the audio!

Anecdote! The entire darklaga.bin is mapped as an ArrayBuffer, and views over subsets of that buffer are then passed to the various APIs (TextDecoder for extracting the JavaScript, URL.createObjectUrl() for the WebGL texture atlas, etc.) but the APIs used for sound, AudioContext.decodeAudioData(), detaches the buffer passed as argument! Which, being a view over the entire darklaga.bin, caused all of it to be detached as well, making it unusable for any operation. This is a tiny detail that is not documented outside of the specification. The behavior is in fact sensible—if you don't need the buffer anymore, it allows decodeAudioData() to avoid having to copy its contents, and if you do need the buffer, you can always clone the buffer view (with .slice(0)) and let the clone be detached instead—but it will confuse people, and it takes much prodding to get ChatGPT to acknowledge that it is even happening. I may one day be replaced by LLMs, but today is not that day. Thank you, obscure JavaScript APIs!

Like the rest of nicollet.net, the game is served as two static files from nginx. To save on CPU usage, I use option gzip_static on, so that if the browser indicates support for compressed transfers (by including header accept-encoding: gzip in the request), nginx will respond with the already-compressed darklaga.html.gz and darklaga.bin.gz files, instead of compressing darklaga.html and darklaga.bin on the fly. This also means that I can upload compressed versions with a stronger but more time-consuming compression level.

I do not plan to include a CDN in front of my server, despite the obvious immediate benefits for myself. To keep the free web alive, I have a duty to make your browser connect to my server without a huge organization in the middle having the power to treat either of us as a robot, a spammer, or a criminal.

Still running 20 years from now

I'll bet that current web technology—HTML, CSS, JavaScript—will still be available in 2044. It's possible that some JavaScript features will be deprecated, and so I decided to minimize the number of features I use. Aside from standard JavaScript (functions, arrays, strings, numbers and classes), the unusual features are:

Third party JavaScript packages are a different story.

I chose not to depend on any packages for the remake. Not only do I need control over the contents of those packages (to reduce the runtime feature set required, as well as keep the game download small), but also because JavaScript packages tend to have absurd amounts of churn, and I would not trust any of them to still be compatible with my code event 5 years from now, let alone 20.

TypeScript

I wrote the remake in TypeScript because a strict, static type system makes me more productive. Will TypeScript still be around, in a usable form, 20 years from now ? I used to think that backwards compatibility was a fundamental aspect of programming languages, but... rather than guarantee that old programs will run unchanged, many programming languages focus on providing a simple migration path.

For example, C# used to allow class names that start with a lowercase letter, now it emits a warning with the stated intent that future versions may turn some lowercase identifiers into keywords (and if you had a public struct record { in your code base in C# 8, it would break in C# 10).

Another example, the Python 2 to Python 3 migration was made even more painful by splitting the package ecosystem into two incompatible halves, so that motivated package maintainers would be prevented from migrating their packages until all their dependencies did.

In short, to keep the ability to build and run code, one should fix the minor breakage every time a new language or compiler version is released, or make sure that a compatible version of the tooling is available 20 years later.

Some languages aim at keeping code compatible on very long timelines. The latest Fortran compiler from Intel defaults to Fortran 2018 semantics, but has a -f66 command-line option to use Fortran 1966 semantics instead. But TypeScript does not strike me as such a language.

In fact, TypeScript has the strict family of options, which you obviously want to enable to get the most out of the type system, but that feature is explicitly allowed to break backwards compatibility when future versions add new strict checks that can be triggered by old code.

On the other hand, a key objective of 2012 TypeScript as gradual typing tool is to make it possible to transpile to JavaScript just by stripping the type annotations: a purely syntactical rule to detect the annotations and delete them, leaving valid JavaScript code. This is still true in 2024 TypeScript, with two major exceptions:

By avoiding those edge cases, it's possible to write TypeScript code such that, even if a newer compiler version is no longer able to compile it, simple syntax-based tools such as this babel plugin or the ts-blank-space package exist to strip the type annotations without checking them. And if even those disappear, my future self from 2044 can certainly write one from scratch.

Modules

The Darklaga remake is about 8000 lines of code, and keeping them in a single file is possible, but not quite enjoyable, so I cut it into 27 TypeScript modules. The big question is, what kind of JavaScript will be produced from those modules ? My two criteria were that the transformation should be within reach of a hand-written tool (if the bundler du jour is no longer supported), and should support local development with a text editor and a browser (no need to have an HTTP server running on localhost, let alone HTTPS).

Most modern browsers support ES6 modules so it would be possible to just strip the TypeScript annotations from every file to turn it into a JavaScript module. On the other hand, every module script needs to be loaded separately ! Even if this worked with file:// URLs (it doesn't, you need an HTTP server on localhost), this would mean that every module is its own web request unless you mess around with URL.createObjectUrl() and a module import map.

So, let's revert to a bundling solution from before browsers supported modules. The two main ones are ServerJS (renamed to CommonJS) and Asynchronous Module Definition (AMD). They are mostly equivalent, and both are supported by TypeScript, so I'll be using AMD:

tsc src/index.ts --outFile dist/darklaga.js --module amd --target ES6

This transpiles src/index.ts, and all the other files it includes, into a single JavaScript file dist/darklaga.js.

To illustrate, remember that TypeScript module files tend to be shaped like this:

// b.ts
import * as A from "./a";
export const b = A.a();

The import and export are instructions that assume the entire file is a module, and so need to be transformed if multiple files are concatenated into one. AMD turns it into:

define("b", ["exports","a"], function(exports, A) {
const b = A.a();
exports.b = b;
});

The transformation is straightforward, and should 2044 TypeScript no longer support it, I could re-implement it manually.

The darklaga.html file includes a tiny implementation of define as well as a require that performs the linking of the modules.

Developing vs. Porting

The C++ source was very straightforward. There are no traps that my 19 year old self could have laid that could cause me any issues today. All the clever tricks I used at the time, such as fixed-point numbers or arena-allocated singly linked lists, are still part of my standard toolbox. The proprietary library used to load assets, draw sprites and play sounds, could be re-implemented by hand—the hardest part to replace was the fact that said library provided its own sin() and cos() methods (for performance and portability reasons). It used fixed-point for the results and 0-255 for the input angle, so I had to rework all the calls to use floating-point and radians instead.

There is something liberating about porting game code, when compared to writing it for the first time. It is similar to implementing a graphic design provided by someone else, instead of tweaking your CSS until things look good. All the important constants, layouts, animations and behaviors are already specified in exact detail. There is no guesswork involved. So long as the code follows the specification, it will look good, because someone else already did that part of the work.

And it feels good to be able to play the game again after twenty years. I could still remember some play strategies I used back then, and even what kind of music I was listening to when developing it (Muse's Absolution had just come out when I started work on Darklaga), and what other games I was playing then (the original PC Every Extend, Touhou's Perfect Cherry Blossom and Imperishable Night, and unhealthy amounts of Ragnarok Online).

20 years after its first release, Darklaga Cannonball Symphony is back. The Extreme mode is too hard now for my poor old man's reflexes, but I can still beat Normal mode with a reasonable score.

Can you beat it ? Play the game.