Lisp Game Jam - "Wireworld" - Hoot's low level Wasm tooling in action

Christine Lemmer-Webber —

Wireworld + splash screen

This blog post focuses on our second Spring Lisp Game Jam 2023 entry, wasm4-wireworld, an implementation of Wireworld on top of Hoot's lower-level assembly tools which are a part of our Guile → Wasm project to bring Spritely Goblins-powered distributed applications to the common web browser.

For our entry, we targeted WASM-4, a "fantasy console" which uses low-level WebAssembly constructs.

In fact, if you see the animated "wireworld" splash screen at the top of this article, that's running wasm-wireworld itself... you can even run it live in your browser!

A brief intro to Wireworld

The real point of this blog post isn't Wireworld itself, but how Hoot enabled making this Wireworld demo. So we're going to walk into how Hoot turned out to be an incredible toolkit to build this game jam entry, but first let's talk about Wireworld to give some more context!

Wireworld is a powerful cellular automata, categorically similar to Conway's Game of Life. Unlike Life, Wireworld's paths are fixed: "electrons" flow upon copper wires. The rules of Wireworld are very simple:

  • Blank tiles remain blank
  • Electron heads always become electron tails
  • Electron tails always become copper
  • Copper stays copper, unless there are one or two electron heads in any neighboring cells, in which case it becomes an electron head

That's it! Despite that simplicity, this leads to powerful emergent behavior. Electron head and tail pairs appear to "flow" fluidly upon copper wires and can branch easily. But we can also make "generators" and diodes that only permit electrons to flow in one direction and not another:

Wireworld + intro

(Try it live!)

These rules result in behavior which permits visually stunning examples, many of which even look like computational circuitry.

Below is a "binary adder" circuit running our implementation of Wireworld.

Wireworld + binary adder

Encoded in little endian, 3 (011) + 6 (110) = 9 (1001)! (And here's the live version... press the "x" button to start the simulation when you're ready!)

If you find this kind of thing delightful, see The Wireworld Computer for a tutorial that uses Wireworld to build from the simplest elements all the way up to a computer that can calculate prime numbers.

Try Wireworld yourself!

While building a cool version of Wireworld wasn't the real goal of our participation in the game jam, it was a lot of fun to build... and we hope it's fun to use and play with, too! And since you're reading this in a web browser and we're using Webassembly anyway, why not try playing with Wireworld right here in the browser?

Programmatically generating WebAssembly

Unfortunately, WebAssembly is very verbose. Consider how messy our "update" procedure was already looking:

  `(func (export "update")
     (call $blit ,$smiley$
           (i32.const 76) (i32.const 76)  ; draw at 76, 76
           (i32.const 8) (i32.const 8)    ; 8x8 sprite
           (i32.const 0)))                ; 1bpp sprite

This is very noisy! All of those i32.const operations are getting in the way of us placing our smiley and defining its width and height. Not to mention that the final argument defines various flags specified by individual bits which are very hard to remember. If we were writing many of these, this would get very hard to read indeed. Time for an abstraction!

(define (maybe-i32.const x)
  (if (number? x)
      `(i32.const ,x)
      x))

(define* (call-blit sprite-ptr x y width height
                    #:key 2bpp? rotate? flip-x? flip-y?)
  (define flags
    (logior #b0                            ; start with empty byte
            (if 2bpp?   #b00000001 #b0)    ; rightmost bit for 2-bits-per-pixel
            (if flip-x? #b00000010 #b0)    ; second bit for flipping x axis
            (if flip-y? #b00000100 #b0)    ; third bit for flipping y axis
            (if rotate? #b00001000 #b0)))  ; fourth bit for rotating 90 degrees
  `(call $blit ,sprite-ptr
         ,(maybe-i32.const x) ,(maybe-i32.const y)
         ,(maybe-i32.const width) ,(maybe-i32.const height)
         ,(maybe-i32.const flags)))

Now we can update our update procedure to rotate and place the smiley multiple times:

(func (export "update")
  ,(call-blit $smiley$ 76 76 8 8)
  ,(call-blit $smiley$ 42 42 8 8
              #:flip-y? #t #:rotate? #t))

Despite now blitting the smiley twice, and rotating and flipping it on the second version, this is now dramatically easier to read than the first version.

Scheme procedures to generate data and whole programs

While we're not translating Scheme to WebAssembly directly in this particular usage of Hoot, we are using Scheme to generate WebAssembly... and that means we have the full power of Scheme at our fingertips!

This turned out to be hugely useful during the game jam. For instance, while the "smiley" sprite we showed earlier was defined in 1 bit per pixel ("1BPP") and thus could be "seen while squinting" in its binary representation:

(define smiley-data
  #vu8(#b11000011      ; 1bpp (1 bit per pixel) sprite!
       #b10000001      ; We have two colors represented by 0 and 1
       #b00100100      ; which means if you squint
       #b00100100      ; you can kinda see the smiley!
       #b00000000
       #b00100100
       #b10011001
       #b11000011))

However, for Wireworld we wanted to take advantage of WASM-4's support for a 4-color palette to draw prettier sprites. These are not readable as text in pure binary data in the same way. However, for the sake of fast iteration and easy ability to "play with" sprite appearance, we wanted to get back the defined-in-ascii-art approach.

So we did just that. Here are the head and tail sprite definitions:

(define head-text
  "\
X##X
#~.#
#~~#
X##X")

(define tail-text
  "\
XXXX
X#~X
X##X
XXXX")

Here X represents a dark blue pixel, # is dark purple, ~ is light purple, and . is off-white. These sprites are compiled into their binary representation from within Scheme itself:

scheme@(guile-user)> (text->2bpp-sprite-bv head-text)
$7 = #vu8(235 146 150 235 0)

We can then directly insert the binary representation into the assembly of the program! (Understanding how text->2bpp-sprite-bv works is left as an exercise for the reader.)

When constructing this blog post, we also wanted a way to generate multiple Wireworld WASM-4 carts which each started with an initial world state. Once again, we wanted to define these in plaintext for fast iteration and experimentation while preserving readability:

  *#                                #@
 @  ################################  *
  #                                  #
 #                                    #
  #                                  #
 #                                    #
  ######     ####### ####   ####     #
 #     #     #   #   #   # #          #
  #    #  #  #   #   ####   ###      #
 #     #  #  #   #   #  #  #          #
  #    #  #  #   #   #   # #         #
 #      ## ##  #######   ## ####      #
  #                             ######
 #                                    #
  #                                  #
 #                                    #
  #  #     #   ##   ####  #    ###   #
 #   #     #  #  #  #   # #    #  #   #
  #  #  #  # *    @ ####  #    #  #  #
 #   #  #  # @    * #  #  #    #  #   #
  #  #  #  #  #  #  #   # #    #  #  #
 #    ## ##    ##   #   # #### ###    #
  #                                  #
 #                                    #
  #                                  #
 #                                    #
  #                                  #
 *  ################################  @
  @#                                #*

Writing a text parser interpreted by a Wasm program for reading textual wireworld descriptions would have been too much work (both for us and for the spirit of an old-school fantasy console). We took the same technique as with the textual representation of sprites: our Scheme program loaded and translated worlds from the textual representation to the very in-memory representation our WASM-4 game would use, then thanks to the power of quasiquote, we simply inserted the game into the generated program.

The really cool thing here is that the generation of carts is itself a procedure. Generating a custom cart is as simple as:

(wat->wasm (make-wireworld-game
            #:world (load-world-file world-filename)))

Thus our make file could simply spit out custom files with custom initial world states, "baking" the initial level descriptions into memory:

$ make
Built build/wireworld-adder.wasm
Built build/wireworld-splash.wasm
Built build/wireworld-intro.wasm
Built build/wireworld-blank.wasm

If you've ever heard lisp programmers talk about "programs that write programs", consider this a nice example!

Lessons learned and meta-observations

Our biggest successes were when we began embracing the abstraction powers provided by Hoot. Initially we simply hand-coded using WebAssembly's textual format and compiled such files. Once we moved to the scheme-as-code-generator abstractions shown in this article there was a marked uptick in productivity and correctness. Code became easier to write and understand and iteration became faster. Tools such as the textual representations of sprites and levels became easy to directly integrate. And significantly, by embracing the "programs that write programs" philosophy, generating the custom carts shown off in this article became as simple as passing in the relevant arguments to the procedure which "baked" the carts with the relevant levels. This latter part was particularly satisfying but was simply a natural outgrowth of the style of programming we took.

Hoot's primary goal is to get Spritely's tooling available in the browser by directly compiling Scheme to WebAssembly. However it turns out that Hoot's lower level layers of abstractions are powerful tooling in their own right! Game jams are a great opportunity to put your tooling to the test, and we're delighted with the outcome.