Skip to content

That’s cool, but can your build system run Snake?

Once upon a time a young and innocent Antonio spent healthy chunks of his youth playing Snake on a Nokia 3310; chasing apples and dodging his own tail. Surprisingly, much later, he found himself chasing the world of Developer Experience (DevEx) and build engineering, with no alchemy to be found.

It was as that same Antonio that I entered the world of DevEx and first encountered Bazel. Bazel was beautifully complex and elegant, although at times moody and hard to communicate with. Nevertheless, our relationship persisted, and so I had to face the harsh reality of not being able to run Snake on Bazel.

Sure, I could build a version of snake using Bazel, but that's not the same as running it using the Bazel engine. So what was stopping me from running Snake on Bazel? Well, nothing really, and besides, it might even be fun!

Playing snake on Bazel

Looks fun to me!

Well, it turns out that Bazel is a bit strict about what you can run, and how you can run it. For starters, Starlark, the configuration language used by Bazel (as well as Copybara and Buck2) is very limited and prohibits unbound loops and recursions. This makes it very hard to get something running for as long as needed.

To be able to run any game, we need a way to get around that loop duration restriction. If unbound loops are not possible then how about making bound loops that last a really long time. I doubt a lack of Turing completeness could stand in front of the heat-death of the universe anyway...

This is surprisingly easy to implement since we can use the range function to generate arbitrarily long sequences to iterate on and we can nest for-loops. Something like this should suffice:

Starlark
1
2
3
4
for _ in range(1000000):
    for _ in range(1000000):
        for _ in range(1000000):
            # and so on :)

With this out of the way, we basically have 3 problems left to tackle:

  • Displaying output
  • Getting input
  • Handling mutable state

Displaying output is relatively easy to tackle, since both repository-rules and rules make it possible to print text on a terminal.

However, grabbing input is anything but trivial. Bazel does not provide an API to do so, rightfully so since it is supposed to be a build system. Linux though has no such limitation, and as long as we’re able to find and read a pseudo-terminal device we can definitely handle inputs (matter of fact, we could also use this to handle the output).

Getting input from the pseudo-terminal shouldn’t be too hard, but we do need a way to run arbitrary commands on the system eagerly. Rules cannot do that as the only way to inject commands is through actions and actions are evaluated lazily and as needed. On the other hand, repository-rules faces no such limitations, and through the use of repo_ctx.execute you can execute arbitrary commands immediately! This way we just need a command to find Bazel’s tty and one to read from it.

Let's start by creating some primitives to grab Bazel's pid and subsequently its tty:

Starlark
1
2
3
4
5
6
7
8
def get_pid(repo_ctx):
    # A bit wonky since it assumes only a single running Bazel instance
    return repo_ctx.execute(["/bin/bash", "-c", "pidof -s bazel"]).stdout

def get_tty(repo_ctx, pid):
    return "/dev/%s" % repo_ctx.execute([
        "/bin/bash", "-c", "ps -o tty= -q %s" % (pid),
    ]).stdout

This in turn allows us to implement some very rudimentary yet working keyboard-input handling:

Starlark
def game_logic(repo_ctx, ...):
    pid = get_pid(repo_ctx)
    tty = get_tty(repo_ctx, pid)
    repo_ctx.file(
        "input_reader.sh",
        executable = True,
        content = """
#!/bin/bash

read -sn 1 -t .1 in < {tty}
printf $in
        """.strip().format(
            tty = tty,
        )
    )

    def read():
        exec_res = repo_ctx.execute(["/bin/bash", "-c", repo_ctx.path("input_reader.sh")])
        input = exec_res.stdout.strip()
        if input != None and len(input) > 0:
            return input
        return None

    ### more game logic

With that out of the way we have a mostly working Snake game.

The last trick is handling mutable state. This can easily be done by creating a dictionary to contain the game’s state.

Starlark
1
2
3
4
5
6
7
8
def game_logic(repo_ctx, ...):
    state = {
        "snake_position": [(0, 0)],
        "direction": "s", # one of "w", "a", "s", "d"
        "food_position": (3, 7),
        # etc...
    }
    ### more game logic

This was quite the fun challenge! You can find the code in the snazel repository.

Whilst this was mostly a fun exercise for me to make, I think it does provide some useful insights into Starlark’s language design, Bazel’s execution model and the design restrictions imposed to improve hermeticity and usability of Bazel at scale.

Furthermore, this paves the way for the most important question in software engineering:

Can it run Doom?