Writing about writing about programming

Reading time: 19 minutes


When I’m being first-order productive, I’m programming: creating and interacting with a system.

This first-order productivity is great, but it isn’t discoverable for others: there’s generally a high bar to entry for comprehending another person’s work when it’s expressed solely as a structured program.

Second-order productivity is when I’m writing about programming, or about systems. This kind of productivity is generally more accessible and distributable, and forms most of the content of this blog!

Third-order productivity involves writing software to help me produce the writing about programming.

And that brings us, today, to fourth-order productivity: writing about a system I’ve made to help me write about systems I’ve made.

Creating unnecessary tooling

Last week, I was writing some as-yet-unpublished technical content, with the twist that the reader should be able to follow along and run the program with me as it’s being described.

The result I envisioned was going to include lots of interspersed prose and code! I needed some way to keep the content organized. And not just that: programming is so often about the feedback loop between making a small change, observing the result, and building a bit more. I wanted this to be reflected in the reader’s experience.

At many checkpoints throughout the post, the user should be able to compile the code they’ve seen so far without errors. They should be able to run the program, and see something that lines up with where we are in the explanation. All of this should be automatically enforced: I shouldn’t be able to introduce an error by forgetting to carry forward an edit to future code snippets.

What I’m describing above will more or less be an implementation of literate programming, though my understanding is that literate programming doesn’t have the ‘checkpoints’ concept I’m after. One user of literate programming gives their testimony:

I used Literate Programming for a project once. It was really hard, and the results were really good. Seemed like a reasonable tradeoff.

Obviously, we’ll need to implement a bespoke literate programming scheme from scratch.

penpal

penpal is the tool I’ve written to facilitate these tasks:

  1. Define a syntax for intermixing prose and code.
  2. Progressively expand the program, and add code to the source tree as it becomes relevant to the prose.
  3. Produce markup suitable for embedding in this blog.
  4. Produce source trees at each checkpoint, ensuring the code is compilable and the user can follow along.
  5. Hot reload when the content changes to facilitate a tight feedback loop for writing content.

Rather than take you through a tour of the implementation of my new literate programming scheme by using my literate programming scheme (which would be just a tad too meta for my sensibilities), I’ll instead use this post to demonstrate penpal’s usage by implementing something small and straightforward: a Rust-based 2048 clone.

A Rust-based 2048 clone?!

I wrote out that sentence, so now I’m committed. Let’s get started! I won’t spend too long on the specifics of the Rust implementation, since the main point here is the preprocessing tooling. I’ll stick to a stdin/out-based interface for now, rather than setting up all the plumbing that a pixel-driven interface would entail.

The above is a bog-standard code block, and we’ve no need for dynamicism yet. It’s represented as simple markdown:

Runloop with room to grow

Programs are generally structured as a series of types, operations, and a core runloop that drives work. When building a program I’ll normally start off by sketching a runloop that doesn’t do much, then continue by defining the types and data model.

Our runloop has a pretty straightforward design. We’ll wait for some input, apply it to the game state, and print out the new state. Let’s start by adding an entry point.

Now this is interesting! We’ve defined a file, src/main.rs, that contains some boilerplate. Unlike the shell command up above, though, this code isn’t going to remain static. It’s going to grow over time, and it’ll eventually need to include imports, game setup, and our runloop. In this blog’s source code for the snippet above, I’ve referenced other snippets that can be progressively filled in. We’ll add in each piece of code to the source tree as it becomes relevant to the implementation.

Here’s what the snippet above looks like in the markup backing this blog post, which is provided as input to penpal and later rendered to HTML.

We define a snippet called main_rs, with a given source language and file path. In it, we embed a few other snippets which are yet to be defined:

  • dependency_imports
  • module_imports
  • module_declarations
  • set_up_board
  • main_runloop

We then show the main_rs snippet so its current state renders in the blog. This works even though the other snippets haven’t been defined yet, because it’s convenient to do so. Otherwise, I get bogged down defining things that aren’t going to be used for a while!

Let’s generate an executable representing our (tiny!) source tree so far.


$ Starting up
Computer
        
Press the power button to start

Next, let’s define the game board and the cells it contains. We’ll introduce a new module, board.rs, so let’s fill in the module_declarations snippet we mentioned earlier.

First, let’s see the markup provided to penpal in the source code of this blog:

And now, the rendered result:

Hey, that’s cool! We defined module_declarations, and penpal was smart enough to remember that it was used earlier in the top-level main_rs snippet. penpal then showed the newly defined snippet in-context with the surrounding code, and highlighted it for clarity.

I’ll keep the meta-commentary to a minimum from here on out, and focus on soaking up the sights of gradually composing a program in markup.

Each Cell stores its own cartesian coordinates, and will either store a value or be Empty.

The Board is backed by a flat list of Cells.

Let’s set up an initial Board to mess around with.



$ Initial board
Computer
        
Press the power button to start

This Board is going to be a bit tough to visualize without some more work. Rendering it nicely will go a long way. Let’s add some utilities for pretty-printing a Board and its Cells.

We’ll need to use some String formatting machinery from the standard library, so let’s go ahead and import that now.

Even though the Board stores its Cells as a flat list, it can be quite convenient to interact with the cells as though they’re stored in a series of rows, or in a series of columns. For example, when ‘pressing’ the board to one direction, we’ll want to group the cells along the direction’s axis.

To facilitate this, we’ll introduce a couple utility methods that give us the cell indexes grouped by rows, or grouped by columns. The users of these functions, like the Display implementation we’re after, can then iterate over the indexes in whichever grouping is convenient.

We’re now ready to provide the impl Display for Board!

And now, we’ll check out our Display implementation by removing the :? format specifier.


$ Seeing the board
Computer
        
Press the power button to start

That’s looking a lot better! Let’s add a bit of temporary code to ensure the board is rendering its cells correctly.



$ Testing our cells
Computer
        
Press the power button to start

Cool! Things are looking good with our Board and Cell representations. Let’s start building out the game logic.

Implementing 2048

In 2048, the board starts out with two tiles spawned in random locations. These tiles, and any spawned later, contain a randomly chosen value of either 2 or 4. When the user swipes left, right, up, or down, the tiles snug up in that direction. If two adjacent tiles contain the same value (e.g. 16 and 16), the tiles will merge into a doubled value.

Since we’re now going to be injecting a bit of randomness, let’s add the rand crate to our Cargo.toml.

Next, let’s implement a utility to select an empty cell and spawn a new value.

We’ll also need a convenient way to ask whether a given cell is empty.

All done, let’s add it to the runloop!

Remove the use board::BOARD_WIDTH, etc; imports that we introduced earlier for testing. We just need the Board!

And spawn a couple tiles when the game starts up:


$ Spawning tiles
Computer
        
Press the power button to start

Responding to input

One of 2048’s core mechanics is sliding pieces around. This will serve as a core input mechanism for the user, and as a fun venue for us to watch our program grow.

We’ll keep things simple by reading input from stdin. The input from the user will control the Direction that we slide the board, so let’s also add a fallible conversion from a string of user input to a Direction.

Add an import for our new module:

And lastly, we’ll hook up input handling to our runloop. We’ll get the next line of input from the user, see if it’s something valid that we can interpret as a Direction, and print out what we’ve got.


$ Processing user input

➤ Tip: You can scroll the PC display. The web version supports arrow keys, and doesn’t require enter.

Computer
        
Press the power button to start

Squashing tiles

Combining tiles in 2048 brings together the carnal satisfaction of organizing a square with the unmatched thrill of counting out powers of two. First up, let’s import the Direction enum that we defined up above.

Next, we’ll implement the main logic for pushing tiles around.

First up, let’s define an operation for ‘moving’ a tile from one cell into another, emptying the source cell in the process. This way, we won’t accidentally forget to clear a Cell after moving its value elsewhere.

Since we want to support sliding tiles in two axes (horizontal / vertical), and in two directions (lower / upper), our code will need to be generic over the axis and direction that it works with. Otherwise, we run the risk of repeating ourselves for the sake of minor tweaks in our index access strategy, such as in this C implementation of 2048 that I wrote for axle.

We’re now holding a source row and dest row. The neat part is that based on the requested Direction, source and dest automatically refer to either a horizontal row (in the case of Left and Right) or a vertical column (for Up and Down), and iterate either from lower-to-upper (as with Right and Up) or upper-to-lower (Left and Down). It’s automatic! The rest of the method can just encode the business logic, and never needs to worry about the particulars of how to index and iterate correctly.

The last bit we have to do here is to actually move a tile if we see that the next space over is empty. We’ll do this process in a loop until we make it all the way through the tiles in the group without making any more changes.

Let’s define the operation that we used above to iterate the cells in the correct groupings based on the given Direction.

The return type mentions Either because the type of Iterator we need to use will be Reversed in certain directions. Let’s add itertools to our dependencies…

… and tidy everything up by importing the types we used above.

Now, for the ‘squash’ operation itself! For the moment, we’ll just use our logic above to slide all the tiles in the provided Direction, without merging any tiles together.

Lastly, let’s invoke this new press method from the runloop when we receive user input.


$ Sliding tiles around
Computer
        
Press the power button to start

Nice! One more thing, let’s add in the functionality to spawn a new tile on each turn.

This also marks the first point at which it’s possible for the player to lose. If the board runs out of space, we should join in howls of despair alongside our bereft user, and reset the board to a clean slate.

Checking if a board is full is straightforward: if any cell is Empty, it’s not full.

Emptying a board is similarly small.


$ Spawning new tiles
Computer
        
Press the power button to start

Merging tiles

For the final major piece of game functionality we’ll implement, let’s handle ‘merging’ two tiles with the same value together.

First, let’s take a page from Option<T>’s book and implement unwrap() to forcibly get the contained value out of an Occupied Cell.

Next up, the main kahuna of this section: merging contiguous tiles. The general structure here is pretty similar to the logic to squash cells, but the policy is a little different. Squashing is only possible when the source cell contains a value, and the destination cell is empty. By contrast, merging is only allowed when both cells contain a value, and these values match each other.

Everything so far is almost exactly the same as our logic in Board.press(direction: Direction)! Here’s where we really diverge.

All that’s left to do now is to invoke our merging logic when handling user input.

One small piece of cleanup we’ll need to do: the act of merging cells might’ve created another gap, so we’ll ask our squasher to sort everything out once again.


$ 2048!
Computer
            
Press the power button to start

The final snapshot of the game we’ve built up can be found here, and penpal itself is open source too.

Writing about writing about writing about programming, or: The making of this blog post

The code we’ve built up together reads input from stdin and sends output to stdout, which doesn’t exist in the web’s execution environment. Since we don’t set the TTY to raw mode, there’s no way for us to capture arrow keys, and yet the demos on this page can respond to arrow-keyed input. What gives?

Fortunately for me, the galaxy-brained metaprogram templating stopped here. I manually copied the source trees that were produced at each checkpoint, added some shared scaffolding to each so that they could be compiled to Wasm, and tweaked the code in the later demos slightly so that they could accept input asynchronously from JavaScript.

On the webpage side, I then set up some JavaScript that would launch each Wasm module, and invoke all the necessary input handling callbacks. The code looks like this:

That’s all folks!


Newsletter

Put your email in this funny little box, and I'll send you a message when I post new stuff.

09 Jan, 2024: There was a bug here. If you tried to sign up before please try again!