Feature flags are decision points in an application that change it's behavior based on context and a set of rules. This is a fun little project that implements Conway's Game of Life using feature flag rules, a raspberry pi, and a neopixel matrix.
Surprisingly complex and fascinating behavior emerges from having the cells follow just 3 simple rules
The hardware
This being a quick weekend project, we'll start by hastily wiring a raspberry pi to a level shifter and finally to our LED matrix.
The flags
Our first step is to create a couple feature flags for controlling the grid. Our application will run in a loop, evaluating feature flags for each cell to determine it's next state. We will pass a couple of custom attributes to power the targeting rules:
- column and row: indicating the x and y coordinates of the cell on the grid
- alive: boolean indicating whether or not the cell is "alive"
- neighbors: the number of neighboring cells that are alive
- iteration: total number of iterations (loops) have run so far
- since: the iteration when the cells state last changed
- age: how long the cell has been in this state (iteration - since)
Config: Cell Alive
Determine if the cell is alive
Config: Cell Color
Determine the color of the LED representing the cell
Config: Steps Per Second
Determine how many iterations should be run per second
Rules to Live By
We now have an excellent base to implement our automaton. During each loop, we will evaluate the config-cell-alive
flag for every cell to determine its next state:
const nextState = await variation('config-cell-alive', this.cellUser({
column, row,
alive,
since,
neighbors: liveNeighbors,
age: Math.min(0, this.iteration - since),
}), defaultState)
return {
alive: nextState,
since: nextState == alive ? since : this.iteration
}
In Conway's Game of Life, cells follow a set of simple rules:
- Any live cell with fewer than two live neighbours dies, as if by underpopulation.
- Any live cell with two or three live neighbours lives on to the next generation.
- Any live cell with more than three live neighbours dies, as if by overpopulation.
- Any dead cell with exactly three live neighbours becomes a live cell, as if by reproduction.
These rules, which compare the behavior of the automaton to real life, can be condensed into the following:
- Any live cell with two or three live neighbours survives.
- Any dead cell with three live neighbours becomes a live cell.
- All other live cells die in the next generation. Similarly, all other dead cells stay dead.
source: Wikipedia
We can implement the simplified rules using the targeting rules in Config: Cell Alive:
Since we start with an empty world, let's add one more rule that randomly spawns live cells on the first iteration:
Neopixels are represented by a flat array with their indices determined by how they are wired together. In my smaller 8x8 matrix, converting x and y coordinates to a pixel index is very simple:
cellCoordinatesToIndex(x, y) {
return (y * this.width) + x
}
With my larger 16x16 matrix, it turned out that every other row gets flipped! I visualized this by setting all cells in the first column (`x = 0`) to green:
In order to support many types of panels without changing the code-base, I opted to create a feature flag to enable remapping the column of even rows in the renderPixels
function:
async function renderPixels() {
for(let i = 0; i < game.state.length; i++) {
const cell = game.state[i];
const [x,y] = game.cellIndexToCoordinates(i)
const color = await variation(`config-cell-color`,
game.cellUser({
row: y,
column: x,
age: game.iteration - cell.since,
...cell
}), cell.alive ? 0x00ff00 : 0x000000);
// the feature flag returns a hex string
const colorNumber = typeof color == 'string' ? parseInt(color, 16) : color
// Seperentine matrixes require remapping on even rows
if (await variation('enable-serpentine-matrix-remap', createContext(), false)
&& y % 2 == 0) {
const remapX = (game.width - x - 1) % game.width
pixels[game.cellCoordinatesToIndex(remapX, y)] = colorNumber
} else {
pixels[i] = colorNumber
}
}
neopixel.render()
}
Once we toggle the flag on, everything works as expected:
Other tricks
Using segments, we can easily save common patterns such as:
- Still lifes: patterns that live forever
- Oscillators: a cycle of patterns that return to their original state after a finite number of generations
- Spaceships: patterns that travel across the grid
- Generators: patterns that spawn other patterns/cells
Finishing up
To liven up the visuals a bit, I am going to add some additional rules to the cell color flag:
And finally, make the game automatically reset to iteration 0 when no cells are alive on the grid:
async function runGame() {
while(running) {
game.step()
await renderPixels()
const population = game.state.filter(x => x.alive).length
if(population == 0) game.reset()
await sleep(1000 / await conf('steps-per-second', 1))
}
console.log('done running')
}
Now we have a nice loop that will keep running without human intervention.
Future ideas
I plan to combine four 16x16 grids for a total of 1024 cells and a pretty frame to hang on my wall as a digital art piece. Eventually, I'd like to create a Web UI that allows users to see the grid and paint cells to life by clicking on them.