Star Traveler Postmortem: Deconstructing a #1 Ranked 1K JavaScript Demo

, Demo, JS1204, Post-Mortem

It’s time for a proper postmortem of Star Traveler, a 1K JavaScript demo I developed back in July 2020. This project achieved the top rank in the canvas category, a result I’m thrilled about! You can experience Star Traveler firsthand and play it in your browser right now.

Development Journey

One of the appealing aspects of 1K demos is their relatively short development cycle. You can realistically create one in about a week, even while maintaining a regular work schedule. My work on this particular demo spanned from July 4th to July 11th.

My initial step was to establish a streamlined development environment. This setup allowed for instant demo reloading in the browser whenever I made code modifications and provided a real-time byte count display. The build script continuously works in the background, rebuilding the minified demo version and using WebSocket to trigger browser reloads. For minification, I used Terser, and RegPack for further size compression. There were also custom AST transformation steps integrated into the build process. These were primarily implemented to ensure the code passed ESLint checks; for example, one step automatically removes any top-level variable declarations.

With this hot reloading environment in place, I dedicated significant time to fine-tuning parameters like colors, speeds, and object sizes, observing the immediate visual feedback in the browser. My aim was to craft a narrative through color and animation, striving for a polished aesthetic even if the underlying technology wasn’t groundbreaking. Inspiration for the color palette came from mountain photography found on Flickr.

On a slightly concerning note, my aging 2013 MacBook Pro began to show its limitations – the Firefox debugging window started to leave a persistent burn-in on my LCD screen.

By July 14th, I had successfully achieved a seamless looping demo, reduced its size to just under the 1024-byte limit, and uploaded the final version. While further tweaking was possible, the limited byte budget and my overall satisfaction with the result led me to conclude the development at that point.

Unveiling the Demo’s Mechanics

The core rendering logic operates through a loop that iterates over a list of callback functions each frame. Each of these functions is responsible for drawing a single element within the scene. The functions are ordered to ensure objects are rendered from back to front, creating depth. All data required for drawing a specific object is encapsulated within a function closure.

Mountains and Clouds: These elements are generated using midpoint displacement fractals. For clouds, the mountain fractal is flipped vertically and stretched horizontally. I experimented with several approaches to midpoint displacement fractal generation. The most compact version turned out to be a functional style using recursive functions, as any imperative method seemed to require more bytes.

Here’s the fractal generation function:

let fractal = (x, y, i, z) => i-- ? ((z = (x + y) / 2 + ((Math.random() - 0.5) * (i

Admittedly, it’s not pretty! The parameter z is actually used as a local variable within the function.

Stars: Stars are rendered as simple canvas paths. To create a twinkling effect, their sizes are randomly adjusted each frame.

The Planet: The planet is drawn separately at the end of the main render loop, ensuring it appears in the foreground. In the initial submitted version, the planet was drawn on top of everything, which, on larger screens, made it visible over the clouds at the beginning. This layering issue has been corrected in the version available here.

Technical Insights and Techniques

Path2D Efficiency: Path2D proved to be a highly efficient method for defining canvas paths. It allows path specifications using a string that contains single-letter commands, such as “m” for “moveTo,” significantly reducing code size.

Here’s a path example from the demo:

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

As you can see, in JavaScript, converting an array to a string using implicit conversion (`+ array) results in comma-separated elements, which is more byte-efficient than using[].join(‘,’)`.

The “color” Function: This function is a versatile workhorse. It handles color interpolation, decodes 3-digit numbers as color codes, sets the canvas fill color, and returns the final color as an array. It’s crucial for creating the smoothly transitioning color palettes throughout the demo.

To maximize space efficiency, the function interprets 3-digit numbers as color values. Each digit represents red, green, and blue respectively, so 111 is black, 999 is white, and 346 represents a bluish-gray. Here’s how the cloud color 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: Smooth transitions are achieved using a bit of mathematical ingenuity. The most straightforward smooth functions involve exponentials. The smooth step function implemented here is used to control the camera movement and the fading of the clouds.

// 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 Consideration: Initially, I explored method hashing as a potential size-saving technique. Method hashing involves renaming object methods to shorter aliases. However, it ultimately turned out to increase, rather than decrease, the byte count in this specific project. Furthermore, method hashing carries the risk of conflicts with future browser updates if new methods are introduced that happen to hash to the same short values.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *