Star Traveler Postmortem

, Demo, JS1204, Post-Mortem

It’s About time I posted a proper postmortem for Star Traveler, a 1K JavaScript demo I made in July 2020. The demo ranked #1 in the canvas category, so that’s exciting! You can play Star Traveler in your browser right now.

Development

What I like about 1K demos is that you can make them in a relatively short amount of time—something like a week or so, without taking time off from work. I started working on the demo on July 4 and finished on July 11.

The first thing I did was set up a development environment which would reload the demo in the browser every time I made changes and display how many bytes I had remaining. The build script constantly rebuilds the final minified version of the demo and sends a mesasge over WebSocket to make the browser reload. Terser minifies the demo and RegPack compresses it further, and there are a couple custom transformation steps that modify the AST during the build process. These custom transformation steps are mostly there so the code can pass ESLint checks—for example, a transformation step strips out any top-level variable declarations.

With hot reloading set up, I spent a lot of time tweaking parameters like colors, speeds, and sizes, and immediately saw the results. I wanted to tell a story with the colors and animations, and I wanted the demo to have a polished look to it even if the technology wasn’t innovative. Pictures of mountains on Flickr helped me figure out the colors.

As a side note… I could tell that my 2013 MacBook Pro was showing its age, because the Firefox debugging window got burned into my LCD screen.

By the end of July 14, I had gotten the demo to loop seamlessy, trimmed it down to just under 1024 bytes, and uploaded the result. I could have spent more time tweaking on it, but there wasn’t much room left and I was feeling good!

How the Demo Works

The main render loop iterates over a list of callback functions each frame. Each callback function draws a single object, and the functions are listed so the objects get drawn back to front. Any data needed to draw an object is stored in a function closure.

Mountains and clouds are made from midpoint displacement fractals, with the mountains flipped upside-down and stretched out to make clouds. I tried out a few different ways to generate a midpoint displacement fractals, and the smallest version was a functionale approach with recursive functions. Any imperative approach seemed to take more bytes.

The fractal generation function:

let fractal = (x, y, i, z) =>
  i--
    ? ((z =
        (x + y) / 2 +
        ((Math.random() - 0.5) * (i < 5) * 2 ** i) / 2),
      [fractal(x, z, i), fractal(z, y, i)].flat())
    : [x];

Ugly, isn’t it? The z parameter is actually a local variable.

Stars are simple canvas paths that are randomly resized each frame to make them twinkle.

The planet is drawn separately, at the end of the main render loop. The submitted version draws the planet on top of everything else, which means you can see it on top of the clouds near the beginning if you look carefully on a big screen. This issue was fixed in the version here.

Notes on Techniques

Path2D turns out to be a very efficient way to specify a path for a canvas, since you can just pass it a string containing your path. The string uses single-letter commands like “m” for “moveTo”.

A path from the demo:

p = new Path2D(
  `M0,99L${fractal(0, 0, 10)
    .map((y, i) => [i, y])
    .join('L')}L1e3,99`
);

You can see that in JavaScript, converting an array to a string will just result in the array elements separated by commas, which is shorter than writing [].join(',').

The “color” function is a beast. It interpolates between different colors, decodes 3-digit numbers as colors, sets the canvas fill color, and returns the final color as an array. It’s used to create all the smoothly-changing colors in the demo.

To save space, the function interprets 3-digit numbers as colors. The three digits correspond to red, green, and blue, so 111 is black, 999 is white, and 346 is a bluish-gray. Here is how the color for the clouds is calculated:

// Mountain color
color(
  time * 0.7 - 1,
  color(z, 889, 346), // cloudy day
  color(z * 2, 222, 815, 933), // sunset
  color(z, 112, 334) // night
);

Smooth interpolation is done with a little bit of math. The simplest smooth functions involve exponentials. The smooth step function here is used to move the camera and make the clouds fade away.

// Return a value that changes from 0 to 1 at time x. The speed
// is controlled by y. If y is negative, the value changes from 1
// to 0 instead of 0 to 1.
let smooth = (x, y) => 1 / (1 + 3 ** (y * (x - time)));

Method hashing turned out to cost more bytes than it saved, so I didn’t use it. Method hashing is the technique where you rename the methods on an object to shorter names, and it also has the disadvantage that it can cause problems when future versions of the browser introduce new methods that hash to the same value.