https://nicollet.net/blog/darklaga/remake.html
[Me9hsRADs] 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:
* To run in full-screen mode with requestFullscreen().
* To play audio with an AudioContext.
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 240x320 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 1024x1024 texture atlas, encoded as a
lossless PNG image file, at the end of darklaga.bin. What the atlas
looks like Ironically, the above 512x415 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:
* The ArrayBuffer and the typed arrays (Int32Array, Float32Array,
etc). Given that these types are now used to interoperate with
web requests and WebAssembly, they should be safe to use.
* WebGL, specifically the WebGL 1.0 features only. Whether WebGL as
a whole will be replaced by WebGPU remains to be seen, but the
1.0 feature set is simple enough that I can imagine web browsers
implementing those in terms of WebGPU, whereas the 2.0 features
are more likely to be dropped, because they were never expected
to be universally supported.
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:
* Some rather deep code transformations are needed to target an
older version of JavaScript. For instance, for .. of loops or
async methods were introduced in ES6, so they will need to be
lowered to more complex code when targeting a version earlier
than ES6.
* Some features such as enums actually require code generation.
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 (DoDonPachi, the original PC
Every Extend, Warning Forever, 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.
[wOwaGt2PmV]
Can you beat it ? Play the game.