Recently, I entered a clone of the classic game Asteroids in the 10k Apart web development contest. The premise of the contest is to design the best mini front-end web application possible in 10 kilobytes or less. A daunting task to be sure, but I resolved to do something that I had never done before and I learned a lot in the process. Below is a basic rundown on how it's constructed.
I should say at the outset that I'm terrible at Asteroids. I always have been. My motivation for building an Asteroids clone was to try to get in the heads of the early game programmers. They worked very hard to make the most out of extremely limited resources and, in a way, 10k Apart is like climbing into a time machine to the late 70s. You have a big screen, a slow electron gun, no memory, and a slow processor: now make a game.
The game code is written with compressibility in mind. I use the YUI Compressor to minify and rewrite the code, and I tried to tune my code as much as possible to take advantage of the strengths of the compressor. I drew heavily from this YUIBlog entry by Nicholas Zakas. I also did a lot of comparison between my inputs and outputs: if I saw that there were a lot of return statements in the output, I tried to rewrite the code to minimize those statements, since they can't be minified.
One thing that took up a lot of space in the early builds was the pointmaps. These are sets of XY coordinates for drawing the elements in the game. The ships, text, asteroids, and effects are all arrays of XY pairs. For example, here's the code for the spaceship:
[ [[0,-10], [-8,5], [-7,8], [7,8], [8,5]], // the hull [[-2,2],[2,2],[3,0],[0,-5],[-3,0]] // the cockpit bubble ]
Even without spaces, this takes up a ton of room. And the ship isn't even all that complex. The most expensive glyph I ended up including is the "8" character, which is both rounded and has two parts to it. So I devised a way to encode the XY coordinates in something more compact.
I figured that I could eliminate the square brackets and commas and just have a string of
X1Y1X2Y2X3Y3...XNYN. In cases where there are multiple components to an element (such as the ship's hull and cockpit), I separated the two segments with a colon. It would only work if each coordinate was a fixed width, so I encoded the points in something similar to Base64, but with an easier-to-write algorithm. The result of compressing the above looks like this:
When the script interpreter passes over the element point map when it begins execution, it calls the
F_DECODE function, which returns them to their original nested array format. This adds up-front computation time, but reduced my overall size by 3-5%.
During the build process the whole program is encoded into a PNG8 using a PHP script I found in the comments on this entry. The script required some modification to get working the way I wanted it to, but it reduced the overall script size by between 30 and 40 percent.
The encoder works by looping through each character in the (pre-minified) file and reading its ASCII value. There are 255 characters in the ASCII character set--each ASCII character is defined with 8 bits--which is perfect because a 32-bit image stores 8 bits per channel. So the encoder reads the ASCII value and then writes a pixel to a new image file where the RGB value is equal to the ASCII value (repeated 3 times, once for each color). The end result is an image that looks like this:
The code is compressed because when PHP writes out as a PNG file, it has built in mechanisms for compressing the pixel data. The interface for writing images lets you place pixels on a point-by-point basis, but the file format stores those pixels in an extremely efficient manner. Basically, whatever compression PNG offers to a large image, it also offers to a small code file.
The data is then decoded by a function in the index.html that instantiates a canvas, writes the code/image to it, and then reads the pixel data out of the image and converts it back into ASCII characters. The resultant string is then eval()'d and executed. A nifty trick but really, really bad practice in a production environment.
Sound was the biggest thing I wanted to include, and also the biggest pain to get working. I know very little about how sound is stored and what a big or little sound file would be. I also had no idea what kind of support I'd find across browsers, and early research proved discouraging.
Audio compatibility cross-browser is atrocious and wildly inconsistent. Firefox doesn't support MP3, Chrome doesn't support WAV, the other browsers are all over the map. Of the big two standards-compliant browser engines, neither Firefox/Gecko nor Webkit/Chrome are likely to change their positions on those file formats anytime soon. Tickets entered into the Chromium project are turned down or ignored and Firefox won't support MP3 for similar reasons as why it won't support H.264 (MP3 is supposedly license-encumbered).
After a great deal of deliberation, I decided to drop WAV. The file format is totally uncompressed and was consuming twice as much space as the LAME-encoded MP3 was. My friend Jeffrey Pierce hooked me up with some awesome simulacra of the original Asteroids sound effects in super-compressed MP3 format. He knows the dark arts of sound compression, and got the sound down under 3kb.
But wait! There was a huge bug that only manifested itself when I uploaded it to a remote server. Moving from alpha to beta, I started testing it over a slower wire to see what would happen. All the sounds were delayed by a small--but noticeable--duration. Checking the activity log, Chrome was hitting the server any time it wanted to play a sound. It wouldn't re-download the sound, but it would still hit the server and wouldn't play until it got a response.
I suppose this is a feature rather than a bug: if you have a long audio file, you aren't going to notice an 80ms delay after you hit the play button. And the browser is just trying to make sure it has the latest version of the file, so it waits for a response, even if it's a 304 NOT MODIFIED. But my sound files are 150ms long, and I have this aching chasm between when the sound should play, and when it actually does.