To illustrate how React-Sim works, let's recreate John Conway's game of life.
Our finished demo:
Setup
This assumes you have a running React project. If not, check out create-react-app.
In your project, install react-sim:
npm install react-sim
And start a new file.
import React from 'react';import {Model} from 'react-sim';const GameOfLife = () => <Model />;export default GameOfLife;
You'll have a very simple model up and running, but that is not game of life.
To get our simulation started, we need:
- a way to initialize the data,
- a way to update data after each tick,
- a way to render the data, and
- controls.
Let's build all this!
Initializing the data
Now, we're going to add a function to initialize our data. The data will look like a 2d grid with random 0s or 1s, corresponding to whether a cell is full or not. So, to initiate our grid, we need to know its dimensions, and we can also control how full our grid is going to be. So, we'll give three arguments to this function: height, width and density. Height and width will be integers, the number of cells in each dimension, and density will be a value between 0 and 1. The lower density is, the emptier the grid will be.
Add a function:
function initGrid({ height, width, density }) {return Array(height).fill(0).map(row =>Array(width).fill(0).map(() => Number(Math.random()) < density));}
The arguments to initGrid should come from the model's params.
So let's change const GameOfLife = () => <Model />;
into:
const GameOfLife = () => (<ModelinitialParams={{height: 28,width: 28,density: 0.15}}initData={initGrid}/>)
Now, when the simulation will start, data will be updated to a random grid.
Rendering the data
Let's create a Frame component to render our data.
const Frame = ({ data = [[]], size = 12, initData }) => (<div>{data.map((row, index) => (<divkey={`r-${index}`}styles={{ display: 'flex', flexDirection: 'row', height: size }}>{row.map((cell, index) => (<divkey={`c-${index}`}style={{width: size,background: cell ? '#000' : 'none',}}/>))}</div>))}</div>);
Now, let's update our model:
const GameOfLife = () => (<ModelinitialParams={{height: 28,width: 28,density: 0.15}}initData={initGrid}><Frame /></Model>)
Nothing super unusual, we literally display a black square if a cell is full and nothing if it isn't.
This frame is actually a common pattern, so there's a frame helper for it: Grid.. We can simply substitute our Frame by Grid, like so:
import React from 'react';import {Grid, Model} from 'react-sim';// ...const GameOfLife = () => (<ModelinitialParams={{height: 28,width: 28,density: 0.15}}initData={initGrid}><Grid /></Model>)
That's what we have at this point. Now we can display our random grid, but it doesn't change as the simulation plays, because we're not updating the data. Let's fix that.
Updating data
Next, we're going to create a function that updates our data.
The rules of the game of life is that after each turn:
- Any live cell with 2 or 3 live neighbors survive.
- Any dead cell with three live neighbors becomes a live cell.
- All other live cells die in the next generation. Similarly, all other dead cells stay dead.
So, first we need a way to count neighbors of a cell. Let's add this helper function:
function countNeighbors(x, y, grid) {const height = grid.length;if (!height) {return 0;}const width = grid[0].length;let n = 0;for (let xOffset = -1; xOffset <= 1; xOffset++) {for (let yOffset = -1; yOffset <= 1; yOffset++) {const x1 = x + xOffset;const y1 = y + yOffset;if (x1 < width &&x1 > 0 &&y1 < height &&y1 > 0 &&(x1 !== x || y1 !== y)) {n += grid[y1][x1];}}}return n;}
And now, let's create our update function:
function updateGrid({ data }) {return data.map((row, y) =>row.map((cell, x) => {const neighbors = countNeighbors(x, y, data);if (cell && (neighbors < 2 || neighbors > 3)) {// living cell has too few or too many neighbors, and dies.return 0;}if (!cell && neighbors === 3) {// dead cell has the right amount of neighbors, and lives!return 1;}// no changereturn cell;}));}
Finally, let's plug our update function in our model:
const GameOfLife = () => (<ModelinitialParams={{height: 24,width: 48,density: 0.15}}initData={initGrid}updateData={updateGrid}><Frame /></Model>)
Now, our game of life updates as the simulation plays!
There is a problem though - if the data reaches a stable state, ie no new cell live or die, the simulation continues. It would be nice if it stopped in this case.
Let's change our update function -
function updateGrid({ data, complete }) {let changes = 0;const updatedGrid = data.map((row, y) =>row.map((cell, x) => {const neighbors = countNeighbors(x, y, data);if (cell && (neighbors < 2 || neighbors > 3)) {// living cell has too few or too many neighbors, and dies.changes++;return 0;}if (!cell && neighbors === 3) {// dead cell has the right amount of neighbors, and lives!changes++;return 1;}// no changereturn cell;}));if (changes === 0) {complete();}return updatedGrid;}
In your update function, you can access the method complete()
, and so define conditions in this function that will call this method.
When complete()
is called, the simulation stops, until it is reset.
Now our simulation will stop if it can't go any further.
Controls
Right now, the simulation has default controls, which is the Timer
slider.
We can see time, we can move the slider back and forth. This will take us back to previous states of the simulation, or take us many steps in the future.
But let's get rid of the slider for now. On the other hand, it would be nice if we could adjust the density of the grid.
So let's address these two things.
const GameOfLife = () => (<ModelshowTimeSlider={false}controls={{param: 'density',maxValue: 1,step: 0.01,label: 'Grid density',resetOnChange: true,}}initData={initGrid}updateData={updateGameOfLifeGrid}initialParams={{height: 24,width: 48,density: 0.15,}}><Frame /></Model>);
We can hide the time slider by setting showTimeSlider
to false.
We're adding a control via the controls
prop. Here we have only one control, so we can use this simple syntax of just passing one object describing the control, with:
- the param it's going to affect ("density"),
- its maxValue, if different from the default of 100,
- its step, if different from the default of 1,
- a label, which is the name of the param, by default.
- resetOnChange: we want the simulation to reinitialize if we touch that control.
That's it! we now can control the density of the grid.
Wrapping up
The simulation is still super fast, as it refreshes 60 times per second. We can't see patterns so well. We can add a delay between each animation tick - through the delay property.
const GameOfLife = () => (<Modeldelay={100}showTimeSlider={false}controls={{param: 'density',maxValue: 1,step: 0.01,label: 'Grid density',resetOnChange: true,}}initData={initGrid}updateData={updateGameOfLifeGrid}initialParams={{height: 24,width: 48,density: 0.15,}}><Frame /></Model>);
And that concludes our tutorial!