site/content/blog/life.md

15 KiB

title author date lastmod draft
Creating a GTK4 Game of Life Application Mossfet 2023-02-14T16:50:00+01:00 2023-02-14T16:50:00+01:00 true

In A-Level computer science, one of the tasks we're marked on is a "Non-Examined Assessment" (NEA). This is a small project, in which we think of and design a program, establish stakeholders and success criteria, implement the program and its tests, and finally judge our final program based on success criteria. For my NEA, I had decided to write a GNOME app for Conway's Game of Life.

For those unfamiliar with Game of Life, it's a "zero-player game" created by a Mathematician called John Conway in 1970. It takes place in an infinite 2D board in which each cell can be "alive" or "dead". The state of the board for one turn deterministically shapes the state for the next, in the following way:

  • A live cell with less than two live neighbours (orthogonal or diagonal) dies, as if by starvation
  • A live cell with more than three live neighbours dies, as if by overpopulation
  • A dead cell with exactly three live neighbours comes to life, as if by reproduction.

This can create fun patterns. The "game" is turing complete, so users have created entire computers with Life. They've even created computers that can run Game of Life!

The program I wrote was divided into two parts - a Rust library, which implemented the core logic and was cross-platform, and a GNOME app, which used GTK and was intended for Linux.

Most of this writeup is adopted from my original NEA writeup

Beginning

My initial problem was the issue of storing a grid of infinite size in a program. While the size is infinite, the game will only ever have a finite number of live cells, making a hashset an ideal data structure for representing this. A hashset is a hashmap that does not store any value associated with each key, only whether an entry for a given key exists. I can use a tuple of integers as the hashset, giving it a grid size with 2^128 cells. My user will not be reaching the end of this, I think. (Rust has a bigint library, but I wasn't convinced that coordinates of this size were necessary, as no user would realistically reach the edge).

The Game struct contains a few fields. primary_board contains a HashSet representing the current state of the board. initial_state is another HashSet representing the earliest tracked change to the board. This is done to implement reverting the board state. count counts the number of iterations taken since initial_state.

When advancing to the next turn, each eligible cell has its neighbours counted up and applied to the game's rules. It doesn't make sense to check every cell (which is impossible on an infinite board), but thankfully only live cells and their neighbours have the capacity to change state. A "check set" is created, containing each live cell on the board and its neighbours. The set is then collected into a Vector and is split into chunks, with one chunk per core on the machine. A thread is then spawned for each chunk, which is tasked with determining the next state of each cell in its chunk. Once all threads have been completed, the secondary board is cleared and is extended with the values of each thread's results. The primary and secondary boards are then swapped.

The board implements Rust's Iterator trait (interface), allowing for lazily evaluated iteration over the game at each turn. While this is not used in the UI code, it can be useful for anyone seeking to use the library in their own project.

Reverting the board

For reverting the board, the game keeps track of its initial_state and a count - the nuber of turns since the initial state. To revert a turn, the board reverts to the initial state and advances count - 1 times. This is not performant at high counts, and cannot track before the last manual change (like the player clicking on a tile to flip its state manually), but is relatively simple to implement.

GUI

The game logic is implemented as a Rust library to be included in other projects. For this reason, it makes sense to implement the GUI in Rust as well. Unfortunately, Rust's default GTK bindings are quite difficult for me to use, as Rust's OOP system is quite different from GObjects, the cross-langauge OOP system that GTK uses. For example, GObjects has both inheritence and polymorphism, whereas Rust does not, opting for extensive use of "traits" (which are analogous to Java interfaces). This makes the GTK bindings cumbersome and full of unintuitive macros. This had stumped me for a while before I discovered Relm4, a Rust library for creating UIs that used gtk-rs as a backend but provided a much more intuitive API.

Relm4

Relm4 is based on "models", representations of application state, their representations in GTK, and the messages passed between them. The main model for the Game of Life GUI has several messages that it can receive from UI elements - advance the board, revert the board, flip at a coordinate, etc.

Although GTK is not web-based, it uses CSS to style itself. The GNOME project provides the libadwaita library and stylesheets to enforce a consistent and clean look across their apps, which I opted to use. The program opens onto a single window, containing a headerbar with controls for the grid and a grid in the center of the screen.

The grid uses another useful concept that Relm4 has - factories. A factory is a collection of elements that each contain the data for a UI element. Adding an item to the collection (in my case, an item being a struct containing a coordinate and whether a cell is live) is sufficient to add it to the grid.

Two buttons on the left and right of the top bar send advance and revert messages to the app model, while the middle "play" button is a component with its own model, which, upon being clicked, toggles the "running" boolean through the toggle message. When the play button model receives a message, it checks if it is running, and if it is, spawns a thread that sends the advance message to the main model, waits 1 second, then sends a "pass" message to its own model (think of it as a form of recursion). The pass message contains the id of the thread it is called from. The model also contains the ID of the currently running thread, which is compared and does not spawn a thread if the ID does not match. This is to avoid a scenario where the user presses the play button twice in half a second, which ends up spawning two instances of the threads recursing at once. Now, only the most recently spawned will be allowed to continue.

To add cells to the factory, the app model requests a "view" from the game of life library - a list of all live cells between a top-left and bottom-right coordinate. The model then iterates through all coordinates between these two, adds it with the live value set to false if it is not in the view, and to true if it is. The model performs this synchronisation every time it receives a message.

File access

There are several file formats that board states in game of life can be encoded in. The most popular are "plaintext" and "RLE". RLE uses a form of run-length encoding, which I opted not to use to avoid unnecessary complexity. The other, plaintext, sees each line in the file represent a row on the board, with "." representing a dead cell and "O" representing a live cell. An older format, Life 1.05, uses "*" as a live cell. My game of life library supports both, and can generate a new game from them. This allows me to load pretty much whatever pattern I want from the internet and test it, which is helpful.

Relm4 provides a widget to allow file selection using the OS's native UI, giving me a path to feed into my reader function. This file selection also works on Flatpaks' sandboxing. Rather than giving the program universal file access, it has to open a dialog controlled by the OS for the user to select a file that they consent to the program accessing.

Performance and optimisation

Implementing file loading allowed me to conduct more thorough benchmarks. Particularly, I was interested in how the performance of my system would manage with patterns whose live cell counts dramatically increase over time. Space fillers are a type of pattern that expand to fill the grid as quickly as possible. I downloaded a plaintext file for a spacefiller and tried to benchmark how quickly it would slow down. Sadly, the slowdowns were quite quick, with performance dropping off after 60 turns or so. It didn't reach a speed lower than the UI advanced it either way, but I still found it unsatisfactory. Reaching 150 turns took about 5.8 seconds, and I wanted to lower this.

Looking at the advance_board function, there were a few places I could see room for improvement. Rather than use a double buffer, with secondary board being drained and filled with the new values, I simplified it by removing the secondary board and simply initialising a new board each update, which lowered memory requirements. I experimented with changing the form of each thread's results from a HashSet to a vector, but this had no major effect. The system finds out how many threads it should split itself into by calling the available_parallelism function, which queries the system for a core count. This was being done every advance in my current implementation, which the documentation for the function warns against. I moved the call into the board's new function and stored it in the game struct itself, which improved it by about 0.2 seconds. Removing unnecessary prints in my code vastly improved performance. Still, only around 5.2 seconds for 150 iterations on the spacefiller.

I looked for other, more efficient algorithms for life and one that stood out to me was HashLife. HashLife is notable firstly because it's much faster, but also because it does not calculate the board turn-by-turn. It is able to look ahead and calculate several turns at once, which allows for a variety of optimisations. Sadly, the ability to traverse multiple turns does not make sense for my program, and the algorithm is also notoriously complex to implement, and would require me to implement several data structures I'm unfamiliar with, as well as my own garbage collector.

I had also been reccomended using SIMD for further improvements, but I was very unfamiliar with SIMD concepts and wasn't confident in implementing it. I explored simplifying the boolean logic for the neighbour counts, but I found it to have no benefit for performance and decrease readability. I also tried a parallel iteration library called rayon, which allowed me to abstract away the process of chunking and mapping each hashset, which was faster, but I wanted to try to do the game logic with as few libraries as possible.

The Rust Performance Book's section of hashing details that the default hasher used in HashMaps and HashSets is relatively safe, but slow. However, it is possible to provide a custom hasher. The book details the rustc-hash crate (a crate is a Rust library), which provides a custom hasher that is considerably faster at the cost of a higher collision rate. Applying this to my board gave a moderate speedup (about 0.6s) with no apparent logic errors. It also mentions the nohash_hasher crate, which is able to use integers as keys verbatim, without hashing. I wanted to see if I could do something similar with my key, a tuple of isizes (an isize is a signed integer with size dependent on the architecture). Sadly, no implementation I could provide was faster than rustc-hash, so I didn't pursue it further, but it did give me some useful knowledge of Rust's Hash and Hasher traits (interfaces).

Testing

The library has a number of integration tests. These ensure that the rules of Life are followed in the program's simulation. These tests typically involve taking established patterns like the R-Pentomino, advancing them a couple turns, and checking them against a result I took from another simulator. The Linux UI automated testing scene is not well-established, and the application has no tests for ITs UI. Rust's extensive use of static analysis allows many things that would otherwise have been tested for to be caught as compile-time, such as type errors, memory unsafety, and race conditions (Rust is famously impossible to get memory unsafety without an explicit unsafe keyword).

Packaging

So far, if a user wanted to install the application, they would have to install the Rust toolchain (compiler, package manager, etc), install the necessary GTK development libraries, and compile my program from source, a long and performance-intensive process. This may be acceptable for some users, but most are not willing to install GTK libraries and the Rust toolchain.

Fortunately, Relm4 provides a template for building applications into Flatpaks. After integrating the necessary build files and installing runtimes, it becomes possible to build a full Flatpak for my application. Not only does this make my application very easy to install (Flatpak installation is well-documented on almost all Linux systems), but it also provides users a degree of security assurance - for example, Flatpaks do not have file access by default outside of a file picker that the user controls and the application cannot tamper with. This was not a design goal, but is nice to have.

Documentation

Rust has a built-in documentation system, in which code and appropriate comments are compiled into an HTML page containing documentation for any given type, module, or library. This allows me to document any given function by writing a comment directly above it. My documentation coverage mainly covers the Rust library.

hardware requirements

As this is a GNOME app, it is only runnable on a Linux system, or through WSL2, which needs some technical knowledge to set up. Installing from source requires Rust and several GTK development libraries, which are readily available through most Linux package managers, while Flatpaks make installation significantly easier. Flatpaks can also be installed through Crostini, making the application available on ChromeOS (with some elbow grease). The application is not available for macOS, Android, and iOS. It is untested for Linux phones but should run (though presently the user would not be able to scroll).

It is a lightweight app, with low hardware requirements. The speed at which the board advances is slow enough that the cost of computing the next term is negligable. It will struggle on very low-spec systems due to the requirements of GTK, but works well on my budget Chromebook.

What's next?

I have a few further goals with which I intend to extend the program after the NEA is complete:

  • I want to ensure that the program works on mobile Linux devices
  • I want to modify the logic library to allow for custom rulesets.
  • I'd quite like a presets menu to select preset patterns for the board.