Techniques for writing really compact JavaScript
Tuesday, November 19, 2024
I recently wrote a tiny web game in 512 bytes, the same size as a hard drive boot sector. I did this both as a challenge and to see if I could learn anything new about JavaScript’s edge behaviors.
The source code can be viewed here on GitHub.
At first, I thought UglifyJS would be enough to minimize my JavaScript code, without having to wring out any more bytes by hand. But in practice, it was insufficient for a few reasons:
It can’t make many assumptions, due in large part to the fact that JavaScript is weakly and dynamically typed. UglifyJS has less information to work with than, e.g. a Haskell compiler, whose language’s type system is static and very strong.
In JavaScript, it is not always safe to rename variables and object fields, as you cannot statically determine every point of reference, in general. (Consider the fact that you can subscript any object by a dynamically generated string.)
Another example would be JavaScript’s variable scoping rules. They are not as straightforward as they are in other languages, and this limits what a minifier like UglifyJS can safely do.
It’s designed for robust, modern JavaScript code. For real-world applications, this is a good thing, but for my purposes, it means it won’t rely on as many hacks as I would if I wrote the minified code manually.
I’m sure there are some relevant JavaScript tools used in the demoscene. Lots of demos larger than a few KiB will contain a decompressor, with the remaining code being stored in a compressed form to save space. But for the tiny scale I am working on, I doubt such tools would be suitable.
I ended up doing most of the minification manually and then using UglifyJS to
remove whitespace and apply simple transformations to some expressions. For
example, UglifyJS will remove redundant pairs of parentheses and can rewrite the
literal 1000
as 1e3
, saving a byte.
Here are the most interesting minification tricks I used.
1. Use “old school” JavaScript
A lot of newer features are more verbose than their older (yet still
supported) counterparts. There are many newer language features that can make
code simpler or terser—promises and async
, for
example—but I couldn’t find ways to use any of them.
Declare variables in the global scope. Every
let
statement costs four bytes, including the space character between the keyword and the identifier. JavaScript does not require variables to be explicitly scoped, and as long as you don’t have naming conflicts, it shouldn’t cause any issues to declare variables as plainx = y
expressions.Use
String.prototype.substr
instead ofString.prototype.substring
. The function name is shorter, and its interface is better (for my purposes, at least). Thesubstr
function is actually deprecated, but I tested it in a few browsers, and it’s still supported. (There are often unfortunate tradeoffs with compatibility when it comes to writing compact code.)Set CSS styles all at once using
element.style = string
. It’s more concise than setting each attribute individually.
2. Create aliases for long names
If you call a built-in JavaScript function more than once, you can save space by giving the object a shorter name.
For example, my program uses setTimeout
twice and
document.body
four times, so I wrote this near the beginning of my
program:
h = setTimeout; b = document.body;
This way, I can call h
instead of setTimeout
and
use the name b
instead of writing out
document.body
.
UglifyJS is apparently not smart enough to do this (and I would guess there are lots of edge cases that are hard or impossible to handle correctly), but this trick can save a substantial number of bytes!
However, this is not always a size optimization! In my code, I have
b.style
spelled out twice, but the name is short enough that what I
have is more efficient than any attempt I made to use the name only once.
As an aside, if you program in JavaScript, you should know that the variable
window
is the “global object” which contains the global scope:
window.foo
references the same object as the mere expression
foo
(if referencing the global scope). So, for example, you can
write this to handle keydown events, instead of
window.onkeydown
:
onkeydown = e => { // ... }
This also means that window.window.window.window.location.href
is referentially transparent with window.location.href
(as well as
location.href
if referencing the global scope).
3. Use assignments as expressions
This is a holdover from C. You can assign variables in any part of an expression.
I use this feature on line 37 to reference o.stop
twice: I
already have to create a callback for it for the purposes of
setTimeout
, and I assign it to the variable l
so that
I can call the method on line 32 without writing it out.
This language feature also lets you declare or assign multiple variables at once, as long as you want them all set to the same value.
4. Get more out of function arguments
When using the shorthand () => expr
function syntax in
JavaScript, you get one argument for free: you can leave out the parentheses for
the parameter list as long as there is exactly one named argument. In one of my
functions, shown below, I use a dummy argument called x
, which
isn’t actually used but saves a byte when compared to an equivalent function
that takes no arguments. This is another optimization that UglifyJS won’t do (I
think because it can interfere with reflection).
// Game tick function // n = Random number in range 1..6 (or 1..7 if no current color) t = x => { n = (Math.random() * (6 + !c) + 1) | 0; // Set new color according to the random number. If there is already a // color being displayed, avoid repeating it by adding 1 to values >= // the old color. c = n + (c && n >= c); // Update with pitch 3 u(3); // Set a timeout to rerun this function. Use a shorter delay as s gets bigger. h(t, 1500 * (0.96 ** s)); };
On lines 76 and 32, I use a related trick: if I have an expression followed by function call with no arguments, I can sometimes move the expression into the empty parentheses. Thus, two statements can be reduced into one, conserving a byte that would otherwise be used for a semicolon.
Before:
s=c=l=0;t()
After:
t(s=c=l=0)
5. Put JavaScript in the body
Assuming you want your JavaScript to run in a standalone HTML document (thus,
in essence creating a compact HTML document, rather than just compact JavaScript
code), you can avoid the overhead of setting an onload
callback by
simply putting the JavaScript code in the body of the page, where it will only
run after the page has loaded.
Modern browsers are robust enough that all you really need is a
<b>
tag before your <script>
tag. The
browser will implicitly create the body element when it sees the
<b>
tag.
The WHATWG HTML standard prescribes how malformed HTML must be parsed, a precaution which ensures that all browsers will behave the same way when displaying broken webpages. This is important because only 0.5% of the top 200 websites use valid HTML as of 2024.
Because invalid HTML will be handled in a consistent, predictable manner
across different browsers and configurations, I can, for example, safely leave
out the closing </b>
tag from my document.
6. Use overlapping substrings
My game features eight different colors, defined on line 42, the significant part of which minifies down to just 28 bytes:
"#" + "fff04f8080".substr(c, 3)
If I wanted to specify each color individually, their three-digit hex strings alone would cost 32 bytes.
To conserve space, I overlap the RGB components of each color. This way, I can define all the colors using just ten hex digits.
The game’s first color, white, uses the first three
digits—#fff
—then the next color uses #ff0
,
overlapping its RG components with white’s GB components, and then we have
#f04
, and so on.
It took some work to find suitable colors whose RGB components overlap in that way: I wanted to have a good variety of hues, and I also wanted to ensure that no two colors looked too alike.
The color sequence I ultimately worked out looks great. I don’t think there is another hex string out there that would have given better results.
Conclusion
I’m pleased with the amount of game material JavaScript makes it possible to fit into 512 bytes. I even managed to include sound effects.
I would bet that there are still a few more bytes that could be shaved off without sacrificing functionality. As I got close to my size limit, I repeatedly found new ways of trimming down the code, which allowed me to pack in extra features, such as adding more colors and improving the randomization algorithm.
As I wrapped my code up, I had some leeway as to how to allocate the last few
characters. If I had to pare my game down to 511 bytes, I would change my CSS
font family from Arial
to sans
.
A 512-byte HTML document doesn’t seem very big, especially considering that the average webpage has a total download size of over 2 MiB. But let’s not forget that even the number of unique arrangements of a standard deck of 52 playing cards is so great that it would take longer than the expected lifespan of the universe to reorder the deck into every possible configuration.