Building the Game of Life part 1
29.04.2023 - 21:48During my early days as a TA at Le Wagon, a colleague and I discovered Conway's Game of Life and were intrigued by the concept. We spun around the idea of using it in art installations or at parties as a lighting effect and thought about how to translate it into code. At that time, my skills were much less developed and nothing more than theoretical ideas came out of it. Since then, this idea has been sitting in my head and a few weeks ago I finally sat down and implemented the whole thing with Typescript and React.
The current version can be tested here.
Divided into several blogposts, I would like to explain how I coded the individual components.
The actual page:
1import cn from 'classnames' 2import { NextSeo } from 'next-seo' 3 4import { useGameOfLife } from '@/components/gameoflife' 5 6import gameOfLifeSEO from '@/config/gameoflife-seo.config' 7 8import gameStyles from '@/styles/game.module.scss' 9 10export default function Gameoflife() { 11 const { 12 useInitializedGame, 13 gameState, 14 gameFieldRef, 15 handleTouchStart, 16 InitializedGameControls 17 } = useGameOfLife() 18 19 useInitializedGame() 20 21 return ( 22 <main 23 className={cn(gameStyles['layout-shift'], gameStyles['confirm-dialog'])} 24 > 25 <NextSeo {...gameOfLifeSEO} /> 26 <section 27 className={cn( 28 gameStyles.container, 29 { [gameStyles.loading]: gameState.isLoading }, 30 { [gameStyles.fullscreen]: gameState.isFullscreen } 31 )} 32 draggable="false" 33 > 34 {InitializedGameControls} 35 <div 36 className={gameStyles['game-field']} 37 ref={gameFieldRef} 38 onClick={(event) => handleTouchStart(event)} 39 onTouchMove={(event) => handleTouchStart(event)} 40 onMouseMove={(event) => 41 handleTouchStart(event, gameState.isMouseDown) 42 } 43 > 44 {gameState.grid.map((rows, rowindex) => ( 45 <div key={rowindex} className={gameStyles.row} id={`${rowindex}`}> 46 {rows.map((cell, cellIndex) => ( 47 <div 48 key={`${rowindex}-${cellIndex}`} 49 id={`${rowindex}-${cellIndex}`} 50 data-row={rowindex} 51 data-cell={cellIndex} 52 className={cn(gameStyles.cell, { 53 [gameStyles.active]: cell 54 })} 55 /> 56 ))} 57 </div> 58 ))} 59 </div> 60 </section> 61 </main> 62 ) 63}
Let's have a look at the individual parts:
1<main 2 className={cn(gameStyles['layout-shift'], gameStyles['confirm-dialog'])} 3>
The initial load causes a layout shift as the cells pop in. To prevent this I use the follwoing CSS:
1.layout-shift { 2 height: calc(100vh - 7rem); 3}
This way the height of the page is already set and the layout shift is prevented.
gameStyles['confirm-dialog']
overwrites some of the default styles coming from
prime-reacts confirm dialog. It was a bit challenging to select the css classes
and I came up with this:
1.confirm-dialog { 2 [class~='p-dialog-mask'] { 3 background-color: var(--black-transparent); 4 padding: 2rem; 5 text-align: center; 6 7 [class~='p-dialog'] { 8 border: 2px solid var(--grey); 9 border-radius: 0.2em; 10 background: var(--light-black); 11 padding: 2em; 12 text-align: center; 13 14 [class~='p-dialog-header'] { 15 display: none; 16 } 17 18 [class~='p-dialog-content'] { 19 margin-bottom: 2em; 20 color: var(--white); 21 } 22 23 [class~='p-dialog-footer'] { 24 align-self: center; 25 26 // duplicate code warning is unavoidable here 27 // -> no direct access to the button for now 28 //noinspection DuplicatedCode 29 [class~='p-button'] { 30 cursor: pointer; 31 box-shadow: 0 0 2px var(--white); 32 border: none; 33 border-radius: 0.2em; 34 background: var(--black); 35 padding: 1em; 36 min-width: 4rem; 37 color: var(--white); 38 font-weight: bold; 39 40 &:first-child { 41 margin-right: 3rem; 42 } 43 44 &:hover { 45 box-shadow: 0 0 2px var(--grey); 46 color: var(--grey); 47 } 48 49 // deactivate blue focus color 50 &:focus-visible { 51 outline: inherit; 52 } 53 } 54 } 55 } 56 } 57}
I know this is a lot of css for something that should work out of the box and I'm not really happy with the solution. Also prime-react adds quite a lot of css to the bundle and I'm thinking about replacing it with my own solution.
The next section is the wrapper of the gamefield and the controls:
1<section 2 className={cn( 3 gameStyles.container, 4 { [gameStyles.loading]: gameState.isLoading }, 5 { [gameStyles.fullscreen]: gameState.isFullscreen } 6 )} 7 draggable="false" 8>
I'm using classnames to conditionally add css classes. The loading
class
adds a short transition at the beginning while the gamefield gets initialized.
1.loading { 2 opacity: 0; 3 background-color: var(--light-black); 4} 5 6.container { 7 // ... 8 opacity: 1; 9 transition: opacity 0.75s ease; 10 // ... 11}
The fullscreen
class changes the position of the wrapper to fixed
:
1.fullscreen { 2 position: fixed; 3 top: 0; 4 left: 0; 5 z-index: 4; 6 width: 100vw; 7 height: 100vh; 8 9 .game-field { 10 height: 100vh; 11 } 12}
1{ 2 InitializedGameControls 3}
This loads the controls for the gamefield. I will explain this in more detail in another blogpost.
The next part is the actual gamefield:
1<div 2 className={gameStyles['game-field']} 3 ref={gameFieldRef} 4 onClick={(event) => handleTouchStart(event)} 5 onTouchMove={(event) => handleTouchStart(event)} 6 onMouseMove={(event) => 7 handleTouchStart(event, gameState.isMouseDown) 8 } 9>
Here is the css of the gamefield including the red background effect:
1.game-field { 2 --g1: rgb(170, 0, 0); 3 --g2: rgb(164, 147, 151); 4 display: flex; 5 flex-direction: column; 6 justify-content: space-evenly; 7 animation: background-pan 10s linear infinite; 8 margin: 0 auto; 9 background: linear-gradient(to right, var(--g1), var(--g2), var(--g1)); 10 background-size: 200%; 11 width: 100%; 12 height: 100%; 13 // ... 14} 15 16@keyframes background-pan { 17 from { 18 background-position: 0 center; 19 } 20 21 to { 22 background-position: -200% center; 23 } 24}
The animation in the background is a linear gradient at 200%
width that pans from left to right.
1<div 2 // ... 3 onClick={(event) => handleTouchStart(event)} 4 onTouchMove={(event) => handleTouchStart(event)} 5 onMouseMove={(event) => 6 handleTouchStart(event, gameState.isMouseDown) 7 } 8>
This part was quite tricky because I wanted the drawing feature to behave more or less the same on desktop and mobile.
Initially I was working with event listeners on every cell, but that had some performance drawbacks and also didn't work
with touch events as there is no touchEnter
event. So I decided to use a single event listener on the gamefield itself
and then calculate the cell that was touched. I will explain the logic in more detail in another blogpost.
The handleTouchStart
covers all events and should probably be renamed 😂.
The last part is the actual maping of the cells:
1{ 2 gameState.grid.map((rows, rowindex) => ( 3 <div key={rowindex} className={gameStyles.row} id={`${rowindex}`}> 4 {rows.map((cell, cellIndex) => ( 5 <div 6 key={`${rowindex}-${cellIndex}`} 7 id={`${rowindex}-${cellIndex}`} 8 data-row={rowindex} 9 data-cell={cellIndex} 10 className={cn(gameStyles.cell, { 11 [gameStyles.active]: cell 12 })} 13 /> 14 ))} 15 </div> 16 )) 17}
I'm still impressed that this part runs as smooth as it does right now. It's rerendering all the cells every 100ms and I did some testing: Right now the calculations roughly need 20-25ms on my machine so there is quite some headroom. In the event listener that I will show another time I use the id of each cell to determine where the user clicked. The rest is just a simple css class that gets toggled on and off.
1.cell { 2 flex-shrink: 0; 3 cursor: pointer; 4 margin: 1px; 5 background: var(--black); 6 width: 16px; 7 height: 16px; 8 9 &:hover { 10 background: var(--black-transparent); 11 } 12} 13 14.active, 15.active:hover { 16 background: transparent; 17}
And that's it for the first part. I will show all the different components in more detail in the upcoming blogposts. Btw. this was initially all done in one file with more than 400 lines... 🤯