I've got a habit of writing a lot which I'm going to eventually improve on as I start making more of these, if you just want to download it and give it a go then here's the links.
Npm link: Npm package - cellular-automata-react.
Git repo: Cellular-automata-react git.
To give some context to the problem space that we're working within, what we will be trying to solve is creating a React component which allows you to run Conway's game of life on it.
One thing I'm starting to focusing on is sharing more about how I approach certain problems, I feel documenting this monologue could be beneficial for others to follow along as well as act as a benchmark for how I approach problems in the future.
Plus.. its a fun problem and I've wanted to publish an npm package for ages, I'd also reccomend watching The Art of Code and then possibly looking at The Wolfram Physics Project, which I believe was what spurred me on to try this.
Note: I purposefully left mistakes I've made in this work-through and reasonings for refactoring/changing approaches to give some exposure to those that don't just want to see something perfect straight away, but appreciate the journey.
Conway's game of life is a single player game in which you set some initial coordinates for pixels in a grid and then allow them to run with a set of rules.
With the rules being:
But there are also variants of this that are also Turing Complete on this stanford article which inspired the idea of rules being customisable throughout.
I'm wanting to do this without looking at any other implementations to make it challenging for myself, and I want to show it within my application at the end.
The way I picture this looking is, a grid that I can plop on the screen as a component, from which I can pass in some props for initial pixel coordinates, and another prop for the rules they will follow, here's how I see it first:
// Draft component: // <ConwayGrid // initialCoords // rules // > // Where type initialCoords = Array<[number, number]>; type rules = Array<ConwayRules>; // And interface ConwayRules { description: string; rule: string; } // And ConwayRules.rule will consist of some enumerated values such as: // 'IF PIXEL NEAR PIXEL DIE' // I'll dive into this more later in this section.
Pretty self explanatory as it's a bunch of pixels within a grid, so I need some form of grid that can be represented inside a website, so I'm just going to use a CSS grid with some rows, columns and elements inside.
This needs:
I need a pixel that:
As I showed in the code snippet, as we're allowing the passing in off some rules as a string, these need to be interpreted and transformed into the methods that the Pixel object will have.
So this leads to this, we need to destructure the instruction string then with each of the values:
If valid then these values should consist of:
While I was thinking about this as a concept, I stopped myself part way through as it could get pretty crazy, I thought it could make more semantic sense to just use callbacks and then define the logic within it by passing the current stateful representation of the pixel and then using its own object, no more funky strings that require crazy validation rules, which is good.
The callback bit made sense as you could pass this down as a prop to the component, then automatically give it to each pixel, something like this.
interface Pixel { neighbors: number; isAlive: boolean; } (pixel: Pixel) => { if (pixel.neighbors < 3) { pixel.isAlive = false; } };
Yeah, that's better, I'm gonna stick with this and see how it goes.
Feels like it will be more adaptable rather than having to define how a string translates into some javascript functionality, as if I wanted to add more rules such as some of the variations within variants of CGOL it would be pretty exhausting, so just using the programming language properly.
Let's test this by putting in some callback rules for the generic CGOL (Conway's game of Life):
<ConwaysGrid rules={((pixel: Pixel) => { // Making an immutable copy for nice checking. const {isAlive, neighbors} = pixel; // Any dead cell with three live neighbours becomes a live cell. if (!isAlive && neighbors === 3) { pixel.isAlive = true; return; } // Any live cell with two or three live neighbours survives. if (isAlive && (neighbors === 2 || neighbors === 3) { pixel.isAlive = true; return; } // All other live cells die in the next generation. Similarly, all other dead cells stay dead. if (isAlive) { pixel.isAlive = false; return; } })} />
Just from writing this out I thought of another bit I'm missing, a concept of how fast iterations happen, this can be achieved through just using something provided by the WebAPI, setInterval
, which we can use for some iteration logic for inside the component, but we need to pass how long each iteration to keep it configurable.
<ConwaysGrid iterationTimeMs={1000} />
which will be used something like this...
let iterator; function init(iterationTimeMs: number): void { iterator = setInterval(() => { // Apply rules evolve(); }, iterationTimeMs); }
Feel like this bit is pretty well ideated now, this is the concept I will be working on moving into the next section.
I want this to be modular so I can use it again, and to eventually throw it in an NPM package because I haven't done that before, so why not.
I'll be following this guide for publishing to npm.
So, we have a conceptual idea of how the pixels will behave, but we need somewhere where they will live, and how to reference to them within this grid.
So I'll try it out here within some React code.
import React from 'react'; const renderNCopiesOfElement = ( element: ReactElement, size: number, key: string ): Array<ReactElement> => { // NOTE we're passing in a generic key // then making it unique, albeit it wont be unique // if we have many conway components. // Array(size) creates an element with N elements, clone element allows you to add props to an // element you're passing in (neat trick) - it returns a React Element. return [...Array(size)].map((e, i) => React.cloneElement(element, { key: `${key}-${size}-${i}` }) ); }; interface ConwayGridRowProps { size: number; rowIndex: number; } const ConwayGridRow = ({ size }: ConwayGridRowProps) => { // Render {size} copies of the pixel element. return ( <div className="grid-row"> {renderNCopiesOfElement( <div className="grid-element" />, size, 'grid-element' )} </div> ); }; interface ConwayGridProps { size: number; } const ConwayGrid = ({ size }: ConwayGridProps) => { return ( <div className="grid-container"> {renderNCopiesOfElement( <ConwayGridRow size={size} />, size, 'ConwayGridRow' )} </div> ); };
This gives us a "grid" with N rows and N elements within them, the className
are set up in a way that we can easily use nested SCSS to make the stylesheet nice.
From this we have a nice area for the pixels to populate, but now we need to think, what are we passing in.
As mentioned before, we have a grid now, but we need to make the positional elements meaningful, so for this section we need to:
So, lets define what it is in ts
:
// I chose a class because its a nice way of encapsulating // a "Thing", the values that live within it the methods it has as a "Thing". // think conceptually like a "Dog": // values: [breed, barksLoud], methods: [bark (loud/quiet/or if westie breed then yap non-stop)] export class Pixel { // Can think of a constructor as a function input // then we declare the object variables inside. constructor(xPosition: number, yPosition: number, isAlive: boolean) { this.xPosition = xPosition; this.yPosition = yPosition; this.isAlive = isAlive; } } // which we can call like: const pixel = new Pixel(0, 0, false); // gives: /* { xPosition: 0, yPosition: 0, isAlive: false, } */ // Which we can adapt as our understanding increases.
Now, relating this to how we use it within the grid:
// We need to map out the new Pixel Element with the index of the // column its added to to preserve the "X" coordinate it is on, so we need to amend the renderCopies // function to allow more props: // We don't need this anymore // const renderNCopiesOfElement = ( // element: ReactElement, // size: number, key: string, // extraProps: {[key: string]: string | number} = {} // ): Array<ReactElement> => { // // We allow for optional additional props to be passed in // // and then spread them inside the props area of cloneElement along with the key. // return [...Array(size)].map((e, index) => React.cloneElement( // element, {key: `${key}-${size}-${index}`, extraProps['yValue'] ? [yValue]: index, ...extraProps} // )); // } // We could possibly do this, but it could grow. // This is where single responsibility principle is important. // we can just keep this map inside the component so we don't // which leads to another principle, DRY. // // We initially created the utility because it was repeated code and had // purpose, it now isn't, so we can basically get rid of the utility and just use the maps. // and refactor the props to be more meaningful with our new Pixel context. interface ConwayGridRowProps { size: number yValue: number } const ConwayGridRow = ({size, yValue}: ConwayGridRowProps) => { // Render {size} copies of the pixel element. return ( <div className="grid-row"> { [...Array(size)].map((_, xValue) => ( <Pixel yValue={yValue} xValue={xValue} key={`${size}-${xValue}-${yValue}`} /> )) } </div> ) } interface ConwayGridProps { size: number } const ConwayGrid = ({size}: ConwayGridProps) => { return ( <div className="grid-container"> {[...Array(size)].map((_, yValue) => ( <ConwayGridRow size={size} yValue={yValue} key={`ConwayGridRow-${size}-${yValue}`} />, ))} </div> ) } // Now for this Pixel component const Pixel = ({xValue, yValue}: PixelProps) => { const pixel = new Pixel(0, 0, false); // Here we have hit a problem // Problem: How do we pass make the initial "alive" state configurable. return ( <div className="grid-element" />, ) }
That was a lot of code in one bit so will take a bit of a pause to talk around the alive state problem reached at the end.
The issue is, we now don't have a way to pass in the alive initial state for a pixel, we have some options here:
So to bring things in, this is what we have now:
// components/Pixel/index.tsx export const Pixel = ({xValue, yValue}: PixelProps) => { const pixel = new Pixel(0, 0, false); // Here we have hit a problem // Problem: How do we pass make the initial "alive" state configurable. return ( <div className="grid-element" />, ) }
// components/Pixel/Pixel.ts export class Pixel { // Can think of a constructor as a function input // then we declare the object variables inside. constructor(xPosition: number, yPosition: number, isAlive: boolean) { this.xPosition = xPosition; this.yPosition = yPosition; this.isAlive = isAlive; } }
// components/ConwayGrid.tsx interface ConwayGridProps { size: number } const ConwayGrid = ({size}: ConwayGridProps) => { return ( <div className="grid-container"> {[...Array(size)].map((_, yValue) => ( <ConwayGridRow size={size} yValue={yValue} key={`ConwayGridRow-${size}-${yValue}`} />, ))} </div> ) }
// components/ConwayGridRow.tsx interface ConwayGridRowProps { size: number; yValue: number; } const ConwayGridRow = ({ size, yValue }: ConwayGridRowProps) => { // Render {size} copies of the pixel element. return ( <div className="grid-row"> {[...Array(size)].map((_, xValue) => ( <Pixel yValue={yValue} xValue={xValue} key={`${size}-${xValue}-${yValue}`} /> ))} </div> ); };
And what we have planned is to have use ContextAPI from React to give the Grid some stateful representation, and to enable being able to set which pixels are "alive" initially somewhere.
If you think about this, the only thing that is really "stateful" is whether the pixel is alive, the position is static, we just want a store for which are alive, and which aren't, then get this from within the pixel component.
This opens up the opportunity of when we pass the "rules" callback, we just need to pass this from the ConwaysGrid
component, into a hook parameters which uses the contexted state, and can act on which are alive and which aren't there.
We will be using a package called Zustand, rather than using ContextAPI or a redux store, this package allows us to simply create React State stores without the need of wrapping a bunch of Providers around the Component(s)/Module needing the shared state, got this on reccomendation from a friend @Yache-li, it's really handy (I've not used this before and towards the end I will definitely use this again).
It's even handy enough to completely discount the need for us creating a "Pixel" class, which in hindsight was a poor choice given we can define methods within context and use the props as references (disadvantages of writing that part at 11pm 🤦).
As a design decision, I'll introduce a concept of "Modules" within React which allows keeping components relatively logicless, and provides a wrapper for where the logic should lie, this module will initialise the "Store" for the state, which will be used in the components that need it.
// modules/ConwaysGameOfLife.tsx import create from 'zustand' export type Pixel = [number, number]; type PixelStore = { pixelsActive: Array<Pixel> rules: () => void, setPixelsActive: (pixelsActive: Array<Pixel>) => void checkRulesForActive: (rules: ( pixel: Pixel, pixels: Array<Pixel>, size: number, setPixelsActive: (pixelsActive: Array<Pixel>) => void, removeActivePixel: (pixel: Pixel) => void ) => void, size: number) => void, setRules: (rules: () => void) => void } // Creating the zustand store that can be used anywhere. // Note is exported, you bring it in where it's needed. export const usePixelStore = create<PixelStore>((set, get) => ({ // Getters - with initial values we need to set and // set in the actual Module. pixelsActive: [], rules: () => {}, // https://github.com/pmndrs/zustand#read-from-state-in-actions pixelIsActive: (x: number, y: number) => { const pixels = get().pixelsActive; // This is where javascript is annoying, // you can't do [1,1] === [1,1], that is false. // So we need to stringify the callback item [a,b] to get // a proper comparison, I'm not using lodash/_ just for this. return pixels.some((pixel) => JSON.stringify(pixel) === `[${x},${y}]`); }, // Setters setRules: (rules) => set(() => rules)), setPixelsActive: (pixelsActive) => set(state => ({ pixelsActive:[...state.pixelsActive, ...pixelsActive] })), })) interface ConwaysGameOfLifeProps { rules: () => {} pixelsActive: Array<[number, number]> size: number } export const ConwaysGameOfLife = ({rules, pixelsActive, size}) => { const setPixelsActive = usePixelStore(state => state.setPixelsActive); // You get the state from the store, and the state also includes the functions too. const setRules = usePixelStore(state => state.setRules) if (size <= 0) { return <p>Provide a size greater than 0.</p> } // Only do this on initial render (setting initial state). useEffect(() => { setPixelsActive(pixelsActive); setRules(rules); }, []) return ( <ConwaysGrid size={size} /> ) }
Now all that changes is that we import the store into the Pixel
component and get whether it is active, and for us to show this visually it brings forth another package classnames, which allows us to react (not a react pun) to prop changes and change the classname on the element appropriately.
And we can delete the Pixel.ts
class file as it is no longer necessary as we have this representative store.
// components/Pixel/index.tsx import cn from 'classnames'; export const Pixel = ({xValue, yValue}: PixelProps) => { const isAlive = useStore(state => state.pixelIsActive(xValue, yValue)); // and this brings us a new import we can use to conditionally // set classNames for active, enter "classnames". const className = cn('grid-element', {'grid-element-alive': isAlive}); // This basically will add grid-element by default as it has no check // and will only add grid-element-alive if isAlive is true. // pixelIsActive was what we defined in the previous module, it checks // if the coordinates exist in the pixelIsAlive state. // Problem: How do we pass make the initial "alive" state configurable. return ( <div className={className} />, ) }
Now we have a way of toggling which pixels are active, and showing it visually in the grid, which is a big milestone, we now need to apply the rule callback prop that we defined a few sections ago appropriately to our pixels.
Considerations:
The core thing we need is the timer, we will add this first, make it run indefinitely, then find a way of applying the rules every run of this loop.
Reference from previous section:
let iterator; function init(iterationTimeMs: number): void { iterator = setInterval(() => { // Apply rules evolve(); }, iterationTimeMs); }
Our original module - with timer included:
// modules/ConwaysGameOfLife.tsx import create from 'zustand'; interface ConwaysGameOfLifeProps { rules: () => {}; pixelsActive: Array<[number, number]>; size: number; iterationTimeInMs: number; } export const usePixelStore = create((set, get) => ({ pixelsActive: [], // NOTE: removed rules & it's setter, as it isn't stateful in hindsight // we can get it from function input on checkRulesForActive pixelIsActive: (x: number, y: number) => { const pixels = get().pixelsActive; return pixels.some((pixel) => JSON.stringify(pixel) === `[${x},${y}]`); }, setPixelsActive: (pixelsActive) => set((state) => ({ pixelsActive: [...state.pixelsActive, ...pixelsActive], })), checkRulesForActive: (rules) => { const pixels = get().pixelsActive; // get active pixels pixels.forEach((pixel) => { rules(pixel); // Run the callback on each pixel }); }, })); export const ConwaysGameOfLife = ({ rules, pixelsActive, size, iterationTimeInMs, }) => { const setPixelsActive = usePixelStore((state) => state.setPixelsActive); const checkRulesForActive = usePixelStore( (state) => state.checkRulesForActive ); if (size <= 0) { return <p>Provide a size greater than 0.</p>; } if (iterationTimeInMs <= 0 || iterationTimeInMs < 200) { return ( <p> Do you want your browser to crash? - try set iteration time higher </p> ); } useEffect(() => { setPixelsActive(pixelsActive); }, []); // Using useLayoutEffect to ensure we only apply the // rules once the component has been mounted and can // actually effect the state properly. useLayoutEffect(() => { let timer: ReturnType<typeof setInterval>; setTimeout(() => { timer = setInterval(() => { checkRulesForActive(rules, size); // run the new method we defined in the store - passing in our rules callback. }, iterationTimeInMs); }, iterationTimeInMs); // cleanup method - when component unmounts remove the interval // this prevents memory leaks. return () => clearInterval(timer); }, []); return <ConwaysGrid size={size} />; };
Now we have a way to call the rules every set iterationTimeInMs
interval, we need to revisit how we defined our rules at the very beginning:
<ConwaysGrid rules={((pixel: Pixel) => { // Making an immutable copy for nice checking. const {isAlive, neighbors} = pixel; // This no longer holds as we // aren't assigning these properties to // a Pixel item. // How do we get neighbors? // Any dead cell with three live neighbours becomes a live cell. if (!isAlive && neighbors === 3) { pixel.isAlive = true; return; } // Any live cell with two or three live neighbours survives. if (isAlive && (neighbors === 2 || neighbors === 3) { return; } // All other live cells die in the next generation. Similarly, all other dead cells stay dead. if (isAlive) { pixel.isAlive = false; return; } })} />
There are three main bits that no longer hold:
Instead we must:
Testing that theory:
export const usePixelStore = create((set, get) => ({ pixelsActive: [], // NOTE: removed rules & it's setter, as it isn't stateful in hindsight // we can get it from function input on checkRulesForActive pixelIsActive: (x: number, y: number) => { const pixels = get().pixelsActive; return pixels.some((pixel) => JSON.stringify(pixel) === `[${x},${y}]`); }, setPixelsActive: (pixelsActive) => set((state) => ({ pixelsActive: [...state.pixelsActive, ...pixelsActive], })), checkRulesForActive: (rules, size) => { // Now passing in size as well to get bounds. const pixels = get().pixelsActive; // get active pixels const setPixelsActive = get().setPixelsActive; pixels.forEach((pixel) => { rules(pixel, pixels, size, setPixelsActive); // Run the callback on each pixel and pass in else what is necessary // to do mutations and check the current state and size. }); }, }));
Then applying this to update our rules:
type AlivePixel = [number, number]; <ConwaysGameOfLife // Using any for now, as I'm writing this without having set this up (discourage using any) rules={(pixel: AlivePixel, pixels: Array<AlivePixel>, size: number) => { // Making an immutable copy for nice checking. // All the neighbors, so we can generate nearby. const movementCombinations = [ [-1, 1], [0, 1], [1, 1], [-1, 0], /*X0Y0 */ [1, 0], [-1, -1], [0, -1], [1, -1], ]; // Map over these combinations and generate neighbors if in bounds. const pixelsNearby = movementCombinations.map((movements, index) => { // XXX | is our space, // XCX | we want to get the coordinates of all the X's // XXX | and not to include them if any values greater than {size}. const newPosition = [ pixel[0] + movements[0], pixel[1] + movements[1], ]; if ( !newPosition.some((coordinate) => { // Not outside max or min return coordinate > size - 1 || coordinate < 0; }) ) { return newPosition; } return undefined; }); // Return array of neighbors that are alive. const aliveNeighbors = pixelsNearby.filter((pixel) => { return pixels.includes(pixel); }); const neighbors = aliveNeighbors.length; // Any live cell with two or three live neighbours survives. if (neighbors === 3) { pixel.isAlive = true; // We just hit another problem!!!!! // How do we know what is around all the other "dead" pixels. // This is getting pretty algorithmy - but lets continue. return; } // Any dead cell with three live neighbours becomes a live cell. if (neighbors === 2 || neighbors === 3) { pixel.isAlive = true; return; } // All other live cells die in the next generation. Similarly, all other dead cells stay dead. else { // } }} />;
Within this code example we hit another problem, that for us to use a rule such as:
Any dead cell with three live neighbours becomes a live cell.
We're missing some data, but this is testing how robust this callback is, in my mind we still have enough state to traverse around this problem.
Let me try draw this out:
/** A = Alive D = Dead AAA | Won't be a problem AAD | AAA | AA[D] | Will be a problem AAD | look at the top right pixel. AAA | How do we know it doesnt have another alive pixel near it? We need this: ADD AADD | We need the neighbours AADD | around this dead pixel AAA | In fact: we need to check all non active pixels around the alive ones | and get if they have any alive pixels around them. **/
So algorithmically:
Not going to lie wasn't expecting an algorithm problem when starting this walkthrough, but felt nice to solve 😁, one of the drawbacks of not storing state of every pixel but it makes our algorithm faster.
So.. revisiting our callback:
type Pixel = [number, number]; <ConwaysGameOfLife // Using any for now, as I'm writing this without having set this up (discourage using any) rules={(pixel, pixels, size, setPixelsActive, removeActivePixel) => { // All the neighbors, so we can generate nearby. const movementCombinations: Array<[number, number]> = [ [-1, 1], [0, 1], [1, 1], [-1, 0], /*X0Y0 */ [1, 0], [-1, -1], [0, -1], [1, -1], ]; const isValidPixel = (pixel: undefined | Pixel) => typeof pixel !== 'undefined'; const pixelsNearby = (pixelToCheck: Pixel) => { // Map over these combinations and generate neighbors if in bounds. return movementCombinations .map((movements) => { // XXX | is our space, // XCX | we want to get the coordinates of all the X's // XXX | and not to include them if any values greater than {size}. const newPosition: Pixel = [ pixelToCheck[0] + movements[0], pixelToCheck[1] + movements[1], ]; if ( !newPosition.some((coordinate) => { // Not outside max or min return coordinate > size - 1 || coordinate < 0; }) ) { return newPosition; } }) .filter(isValidPixel) as Array<Pixel>; // To get rid of typescript bug not inferring properly. // We've guarenteed it isn't undefined. }; // Return array of neighbors that are alive. const nearbyAlivePixelsInState = (pixelToCheck: Pixel) => { const pixelsNearbyArray = pixelsNearby(pixelToCheck); return pixelsNearbyArray.filter((pixel) => { if (!pixel) return; return JSON.stringify(pixels).includes(JSON.stringify(pixel)); }); }; // Return alive neighbors for current pixel being checked in callback. const aliveNeighborPixels = nearbyAlivePixelsInState(pixel); const aliveNeighbors = aliveNeighborPixels.length; /** * DEAD CELL ACTION: */ // We need to only run if has any dead neighbors if (aliveNeighbors) { // Getting all of the pixels in the area around an alive pixel. const pixelsNearbyArray = pixelsNearby(pixel); // Get an array of the coordinates of the dead pixels around an alive pixel. const deadNeighbors = pixelsNearbyArray.filter((pixel) => { return !JSON.stringify(pixels).includes(JSON.stringify(pixel)); }); // From these dead pixels from neighborhood, return back the ones that have // 3 alive neighbors around them. const deadPixelsWith3AliveNeighbors = deadNeighbors.filter( (deadPixel: Pixel) => { return nearbyAlivePixelsInState(deadPixel).length === 3; } ); if (deadPixelsWith3AliveNeighbors.length) { setPixelsActive(deadPixelsWith3AliveNeighbors); } } /** * ALIVE CELL ACTIONS: */ // Any live cell with two or three live neighbours survives. if (aliveNeighbors === 3 || aliveNeighbors === 2) { return; } // All other live cells die in the next generation. Similarly, all other dead cells stay dead. else { // NOTE: We have hit a problem, how can we remove pixels statefully? } }} />;
After some refactoring and solving that algorithm (I've left as many comments as I felt useful to explain), we now check all the dead cells around an alive cell and their neighbors (this may not be as exhaustive as necessary - will return to this when I reach final section).
We've reached one last problem to solve this callback, we don't have a way of removing alive pixels, thankfully this is easy, we just need to add another method to the store that removed it from the state via filters.
Which is just this:
export const usePixelStore = create((set, get) => ({ pixelsActive: [], pixelIsActive: (x: number, y: number) => { const pixels = get().pixelsActive; return pixels.some((pixel) => JSON.stringify(pixel) === `[${x},${y}]`); }, setPixelsActive: (pixelsActive) => set((state) => ({ pixelsActive: [...state.pixelsActive, ...pixelsActive], })), // This is a pretty generic way to remove things from state // But again, as javascript is annoying, the arrays need to be stringified for comparison. removeActivePixel: (pixel) => set((state) => ({ pixelsActive: state.pixelsActive.filter((pixelFromActive) => { return ( JSON.stringify(pixelFromActive) !== JSON.stringify(pixel) ); }), })), checkRulesForActive: (rules, size) => { // Now passing in size as well to get bounds. const pixels = get().pixelsActive; // get active pixels const setPixelsActive = get().setPixelsActive; const removeActivePixel = get().removeActivePixel; pixels.forEach((pixel) => { // Added new method after getting then assigning it. rules(pixel, pixels, size, setPixelsActive, removeActivePixel); }); }, }));
Then we can just add it to where our problem was in the callback:
// Change callback params to: // rules={((pixel: Pixel, pixels: Array<Pixel>, size: number, setPixelsActive: any, removeActivePixel) => { // All other live cells die in the next generation. Similarly, all other dead cells stay dead. else { // Another problem! // Becomes: removeActivePixel(pixel); }
So... that was a bit of a trial of the structure that was set up, but it works and now its more robust, the next section will just be consolidating the components we have with a utility, we've now proven that rules can be defined and we can pass in initial state stuff, let it run in and components will be updated as a result.
But as usual as I made the mistake of not writing unit tests to cover the cases straight away in a hope to make this concise there's an issue that's arisen.
We're adding duplicates on setting the active pixels, which is bad as its making the algorithm exponentially slower.
So we have two ways of solving this, keeping in mind that we have nested arrays, and javascript is crap at comparing deep arrays normally without workarounds.
I propose using the second option so its less invasive to the original algorithm, the way I picture this solution uses:
To do the second bit we need to enable downlevelIteration
within tsconfig.json
to true.
The resulting setter is this:
setPixelsActive: (pixelsActive) => set((state) => { const stringifiedPixelArray = [...state.pixelsActive, ...pixelsActive].map(pixel => { return JSON.stringify(pixel); }); const noDuplicateActivePixels = [...new Set(stringifiedPixelArray)]; const newPixels = noDuplicateActivePixels.map((stringPixel) => { return JSON.parse(stringPixel) as Pixel; }) return {pixelsActive: newPixels}; }),
Then finally, there is another issue with the way we're setting state per pixel iteration, if you think about this scenario:
The second point is the most necessary thing we will need to change, which will make our algorithm work the way we expect.
Here's what we need to do, we need to instantiate an empty array before mapping, per tick that contains all of the pixels that should be added, and all that should be deleted.
Only then do we set the state, this should also reduce the number of rerenders the component is doing and should in theory make the component more performant so it's a necessary and worthwhile change.
So I picture us not needing to change anything functionally within the Conways algorithm, but renaming them to show their new function.
The method to add will do the same thing as our setter in essence, so to avoid duplicates and to keep the code DRY, we should create a utility for this and use it here.
// Generic <T> allows us to pass in type as a parameter and keep us nice with our custom pixel type. export const groupAndRemoveDuplicatesOfNestedPixelArray = <T>( initialArray: Array<T>, secondArray: Array<T> ): Array<T> => { const arrayOfStringifiedArrays = [...initialArray, ...secondArray].map( (array) => { return JSON.stringify(array); } ); const noDuplicateArrayOfArray = [...new Set(arrayOfStringifiedArrays)]; const newArrayOfArray = noDuplicateArrayOfArray.map((stringArray) => { return JSON.parse(stringArray) as T; }); return newArrayOfArray; };
checkRulesForActive: (rules, size) => { const pixelsToAdd = []; const pixelsToDelete = []; const pixels = get().pixelsActive; // get active pixels const setPixelsActive = get().setPixelsActive; const removeActivePixel = get().removeActivePixel; pixels.forEach((pixel) => { rules(pixel, pixels, size, (newPixels) => groupAndRemoveDuplicatesOfNestedPixelArray(pixelsToAdd, newPixels), removeActivePixel ); }) },
Now lets take a second to think about the delete method, we were initially running a setState on each pixel, which feels inefficient to me because every change in state forces a new render.
To reduce the number of renders and to make our operation within this callback simpler lets change removeActivePixel
to removeActivePixels
, and then we can re use the groupAndRemoveDuplicatesOfNestedPixelArray
method to add to our pixels to be deleted.
If we think again what this will need to do, we will again need an array of stringified arrays, we know this functionality is within our groupAndRemoveDuplicatesOfNestedPixelArray
method, so lets pull this out and make it a utility.
export const stringifiedArrayOfArrays = <T>( arrayOfArrays: Array<T> ): Array<string> => { return arrayOfArrays.map((array) => { return JSON.stringify(array); }); };
Which changes our checker to this:
export const groupAndRemoveDuplicatesOfNestedPixelArray = ( initialArray: Array<Pixel>, secondArray: Array<Pixel> ): Array<Pixel> => { const arrayOfStringifiedArrays = stringifiedArrayOfArrays<T>([ ...initialArray, ...secondArray, ]); // Spread a set which gets rid of duplicates from array // no duplicates is a property of sets. const noDuplicateArrayOfArray = [...new Set(arrayOfStringifiedArrays)]; const newArrayOfArray = noDuplicateArrayOfArray.map((stringArray) => { return JSON.parse(stringArray) as Pixel; }); return newArrayOfArray; };
And then allows our delete function to become this:
removeActivePixels: (pixelsToDelete) => set(state => ({pixelsActive: state.pixelsActive.filter((pixelFromActive) => { return !stringifiedArrayOfArrays(pixelsToDelete).includes(JSON.stringify(pixelFromActive)); })})),
Since we've started using a bit of set theory this is A\B
, those in A that aren't in B, within javascript we're filtering out each pixel as a string that is not inside our pixels to delete, leaving us with only the ones remaining.
So this gives us this for our final checkRulesForActive
.
checkRulesForActive: (rules, size) => { // Changed to let so we can reassign based // on current value. // Resets with every tick of interval. let pixelsToAdd: Pixel[] = []; let pixelsToDelete: Pixel[] = []; const pixels = get().pixelsActive; // get active pixels const setPixelsActive = get().setPixelsActive; const removeActivePixels = get().removeActivePixels; pixels.forEach((pixel) => { rules(pixel, pixels, size, (newPixels) => { pixelsToAdd = groupAndRemoveDuplicatesOfNestedPixelArray(pixelsToAdd, newPixels), } (pixelsToRemove) => { pixelsToDelete = groupAndRemoveDuplicatesOfNestedPixelArray(pixelsToDelete, pixelsToRemove); } ); }) setPixelsActive(pixelsToAdd); removeActivePixels(pixelsToDelete); },
Then the last thing we need to do to make this work together (albeit we need to change the types but these are available within the github repo), is passing in an array of pixels to remove in our original conways algorithm and the name of the function in the parameters.
// All other live cells die in the next generation. Similarly, all other dead cells stay dead. else { removeActivePixels([pixel]); }
I wrote this article without touching an IDE, and when I put some of the code into it there was a few typeerrors, I think I managed to catch them all but I've learnt for my next article to modularise the code that is being displayed in markdown with versions.
If you find any issues let me know and I'll rectify them, but all the code is available on git so you can walk through and ask any questions I don't mind clarifying stuff.
Some things I did post this article as well is:
Things I plan on doing that help dev experience.
Things I plan for my next article.
2021-12-12 - React |
---|
A walkthrough of creating a Cellular Automata React component using a Zustand store, that can run Conways game of life on it efficiently, reccomended that you have some understanding of React before reading. |
Think this would help others? |
|