Purpose
Conway's Game of Life is a great exercise to learn about basic techniques for rendering graphics such as double buffering. However, the goal of this version was to implement the Game of Life while exploring more advanced techniques. I originally chose to implement the game using finite state machines with XState but expanded the scope to include writing the game to a fragment shader.
Features
Three.js and GLSL used to write the game in a fragment shader. All of the state for the game is actually stored on textures. The GPU can parallelize shader calculations and provide a smooth animation even at high resolutions.
Rather than initialize the game board with randomized squares or classic shapes, user entered text can be rendered.
Knowledge Gained
Rules of the Game
For each colored pixel on the canvas, sum its eight neighbors.
- If the pixel is alive and does not have exactly 2 or 3 neighbors, then it dies.
- If the pixel is dead and has exactly 3 neighbors, then it comes to life.
This simple rule set allows for pixels or squares to animate on their own. There are a vast number of patterns that can appear.
Three.js and Shaders
Prior to this project, I had only known shaders in relation to video games and graphics cards marketing material. The main thing I've learned is that there is an endless amount to learn about graphics programming and it's amazing to see what other people come up with. There was a lot of time spent on Shadertoy checking out the crazy things programmers come up with.
As much as I love functional programming and a higher level of abstraction that languages like TypeScript deliver, it is clear that there huge performance gains to be made by dropping down to a lower level. Writing a shader in GLSL is not easy but it feels like a miracle to watch the shader come to life when it works.
State Machines and XState
The original idea for this project was to use XState for the actual game implementation. The rules are perfect for implementation with a state machine. As soon as I finished it, I was unhappy with the number of generations I was getting each second. I wanted something that was fast and smooth but I wasn't getting that with what I had implemented. I ended up going as far as I could for performance without changing the actual algorithm, but I still retained XState for managing the state in the rest of my application.
I really like how stable and predictable my application was with using XState and I'll continue using it when I can in the future. The ability to generate a state chart based on my application was really useful for understanding how everything fit together.
Challenges
In Over My Head
The features I had planned for the project initially were not really doable with a shader and I should have re-evaluated the scope of the project at that point. It was frustrating to feel like I was hitting a new wall each time I wanted to do something additional. Originally, I wanted more interactivity with the game. For example, manually adding gliders or other patterns manually.
I managed to make it so pixels could be turned on and off one-by-one with the mouse but I should have re-scoped the project to remove that kind of interactivity once I moved onto the shader idea.
What Could Have Been Different?
Canvas
Just using the canvas would have been the easiest to implement if I were looking for good performance without advanced game algorithms. It was a great experience learning something so far outside of what I had done before.
Next.js
I used Next.js as a way to bootstrap a React project because it's quick but none of its features were used on this project. It added little hiccups to development with no benefit. I love using Next.js but there wasn't any reason for it here. On that note...
React
This should have been done with vanilla TypeScript. All of the logic for the shader was dumped into a useEffect with a ref to a DOM element. Not a good use of React at all.