React Snake 2.5D

Gravatar Profile nate-wilkins@code-null.com
Nate-Wilkins
6 min read
Mar 4, 2024

I made 2.5D Snake in

react-three-fiber
!

I've made Snake at least 10 times on various hardware and different environments.

One of my first programs was Snake on a TI-83 Graphing Calculator.

3D Printed Maze 1

You can find the source here.

Game

Game Arena

1<> 2 <Camera fov={45} near={0.005} far={10000} /> 3 <ambientLight intensity={0.5} distance={50} color="white" /> 4 {showAxisHelper && ( 5 <axesHelper 6 size={6} 7 position={[pointLightPosX, pointLightPosY, pointLightPosZ]} 8 /> 9 )} 10 <pointLight 11 intensity={0.5} 12 position={[pointLightPosX, pointLightPosY, pointLightPosZ]} 13 /> 14 <Map 15 width={width} 16 height={height} 17 wireframe={wireframe} 18 effects={mapEffects} 19 onEffectsApplied={remainingMapEffects => 20 setMapEffects(remainingMapEffects) 21 } 22 /> 23 <Snake pieces={snakePieces} wireframe={wireframe} /> 24 {apple && ( 25 <Apple 26 position={[ 27 getPaddedPosition(apple.x), 28 getPaddedPosition(apple.y), 29 getPaddedPosition(apple.z), 30 ]} 31 isGolden={apple.isGolden} 32 wireframe={wireframe} 33 /> 34 )} 35</>

Game Loop

The basic game loop - however components can tie into

useFrame
when mounted so the game loop is really "all over the place" which makes timing fun to work with since mount order will determine game loop order most everything is here though.

This is

Arena.jsx
.

1useFrame((_, delta) => { 2 if (gamePaused || gameOver) { 3 return; 4 } 5 updateSnakePiecesFromMovement({ delta }); 6 updateSnakePiecesAnimation({ delta }); 7 updateApple(); 8 updateCollision(); 9});

Here are the controls, there's also gesture code in here for mobile.

This is

GameHud.jsx
.

1// Snake controls. 2const handleKeydown = event => { 3 const { keyCode } = event; 4 5 // Up, down, right, left. 6 let newSnakeDirection = snakeDirection; 7 if (keyCode === KEYCODE_UP || keyCode === KEYCODE_W) { 8 newSnakeDirection = 'U'; 9 } else if (keyCode === KEYCODE_DOWN || keyCode === KEYCODE_S) { 10 newSnakeDirection = 'D'; 11 } else if (keyCode === KEYCODE_RIGHT || keyCode === KEYCODE_D) { 12 newSnakeDirection = 'R'; 13 } else if (keyCode === KEYCODE_LEFT || keyCode === KEYCODE_A) { 14 newSnakeDirection = 'L'; 15 } 16 setSnakeDirection(newSnakeDirection); 17 18 // Paused game? 19 if (keyCode === KEYCODE_ESC) { 20 setGamePaused(gamePaused => !gamePaused); 21 } 22 23 // Keyboard shortcuts for game over. 24 if (gameOver) { 25 if (keyCode === KEYCODE_ENTER) { 26 onReplay && onReplay(); 27 } 28 } 29};

Canvas Textures

Textures in this game are generated based on the theme colors so we can modify them in-game.

This is

useCanvasTexture.jsx
.

1import { useEffect } from 'react'; 2import { CanvasTexture } from 'three'; 3 4let canvasTextures = {}; 5const getCanvasTexture = (color, strokeColor, strokeWidth) => { 6 return canvasTextures[`${color}-${strokeColor}-${strokeWidth}`]; 7}; 8const setCanvasTexture = (color, strokeColor, strokeWidth, texture) => { 9 canvasTextures[`${color}-${strokeColor}-${strokeWidth}`] = texture; 10 return texture; 11}; 12 13export const useCanvasTexture = (color, strokeColor, strokeWidth) => { 14 // Create canvas texture on mount. 15 useEffect(() => { 16 if (getCanvasTexture(color, strokeColor, strokeWidth)) { 17 return; 18 } 19 const canvas = document.createElement('canvas'); 20 canvas.width = 16; 21 canvas.height = 16; 22 23 const ctx = canvas.getContext('2d'); 24 ctx.fillStyle = color; 25 ctx.fillRect(0, 0, canvas.width, canvas.height); 26 ctx.strokeStyle = strokeColor; 27 ctx.lineWidth = strokeWidth; 28 ctx.strokeRect(0, 0, canvas.width, canvas.height); 29 30 setCanvasTexture( 31 color, 32 strokeColor, 33 strokeWidth, 34 new CanvasTexture(canvas), 35 ); 36 }, [color, strokeColor, strokeWidth]); 37 38 return getCanvasTexture(color, strokeColor, strokeWidth); 39};

Map Effects

The map effects of eating an apple.

This is

Map.jsx
.

1// Splash effects. 2const effectSplashes = useRef([]); 3useEffect(() => { 4 if (effects.length <= 0) return; 5 6 for (let effect of effects) { 7 if (effect.type === 'SPLASH') { 8 effectSplashes.current.push({ 9 x_inc: 1, 10 y_inc: 0, 11 z_inc: 0, 12 x: effect.x + 1, 13 y: effect.y, 14 z: effect.z, 15 length: 1, 16 color: themeMapEffectSplashColor, 17 original_x: effect.x + 1, 18 original_y: effect.y, 19 original_z: effect.z, 20 }); 21 effectSplashes.current.push({ 22 x_inc: -1, 23 y_inc: 0, 24 z_inc: 0, 25 x: effect.x - 1, 26 y: effect.y, 27 z: effect.z, 28 length: 1, 29 color: themeMapEffectSplashColor, 30 original_x: effect.x - 1, 31 original_y: effect.y, 32 original_z: effect.z, 33 }); 34 effectSplashes.current.push({ 35 x_inc: 0, 36 y_inc: 0, 37 z_inc: -1, 38 x: effect.x, 39 y: effect.y, 40 z: effect.z - 1, 41 length: 1, 42 color: themeMapEffectSplashColor, 43 original_x: effect.x, 44 original_y: effect.y, 45 original_z: effect.z - 1, 46 }); 47 effectSplashes.current.push({ 48 x_inc: 0, 49 y_inc: 0, 50 z_inc: 1, 51 x: effect.x, 52 y: effect.y, 53 z: effect.z + 1, 54 length: 1, 55 color: themeMapEffectSplashColor, 56 original_x: effect.x, 57 original_y: effect.y, 58 original_z: effect.z + 1, 59 }); 60 } 61 } 62 63 onEffectsApplied([]); 64}, [effects]); 65 66const effectSplashElapsedTime = useRef(0); 67useFrame((_state, delta) => { 68 // Update pieces forward based on game speed. 69 effectSplashElapsedTime.current += delta; 70 if (effectSplashElapsedTime.current <= effectSplashTimeInterval) { 71 return; 72 } 73 effectSplashElapsedTime.current = 0; 74 75 // Iterate through effects. 76 // Find map piece corresponding to effect 77 // NOTE: Should be a clone. 78 let newBlocks = [...blocks]; 79 for (let effectSplash of effectSplashes.current) { 80 let newBlockIndex = 0; 81 for (let block of blocks) { 82 // Apply to blocks vertically. 83 if (effectSplash.x === block.x && effectSplash.z === block.z) { 84 // Apply to all blocks at effect position. 85 newBlocks[newBlockIndex].color = effectSplash.color; 86 } else if ( 87 effectSplash.x + effectSplash.z_inc * effectSplash.length >= 88 block.x && 89 effectSplash.x + effectSplash.z_inc * -1 * effectSplash.length <= 90 block.x && 91 effectSplash.z === block.z 92 ) { 93 // Apply to all blocks in "right" direction given length of effect. 94 newBlocks[newBlockIndex].color = effectSplash.color; 95 } else if ( 96 effectSplash.x - effectSplash.z_inc * effectSplash.length >= 97 block.x && 98 effectSplash.x - effectSplash.z_inc * -1 * effectSplash.length <= 99 block.x && 100 effectSplash.z === block.z 101 ) { 102 // Apply to all blocks in "left" direction given length of effect. 103 newBlocks[newBlockIndex].color = effectSplash.color; 104 } else if ( 105 effectSplash.z - effectSplash.x_inc * effectSplash.length >= 106 block.z && 107 effectSplash.z - effectSplash.x_inc * -1 * effectSplash.length <= 108 block.z && 109 effectSplash.x === block.x 110 ) { 111 // Apply to all blocks in "down" direction given length of effect. 112 newBlocks[newBlockIndex].color = effectSplash.color; 113 } else if ( 114 effectSplash.z + effectSplash.x_inc * effectSplash.length >= 115 block.z && 116 effectSplash.z + effectSplash.x_inc * -1 * effectSplash.length <= 117 block.z && 118 effectSplash.x === block.x 119 ) { 120 // Apply to all blocks in "up" direction given length of effect. 121 newBlocks[newBlockIndex].color = effectSplash.color; 122 } 123 newBlockIndex++; 124 } 125 126 // Clear splash effect color. 127 if ( 128 effectSplash.color === themeMapEffectSplashColor && 129 effectSplash.length === 3 130 ) { 131 effectSplashes.current.push({ 132 x_inc: effectSplash.x_inc, 133 y_inc: effectSplash.y_inc, 134 z_inc: effectSplash.z_inc, 135 x: effectSplash.original_x, 136 y: effectSplash.original_y, 137 z: effectSplash.original_z, 138 length: 1, 139 color: themeMapColor, 140 original_x: effectSplash.original_x, 141 original_y: effectSplash.original_y, 142 original_z: effectSplash.original_z, 143 }); 144 } 145 146 if ( 147 effectSplash.x <= 0 || 148 effectSplash.z <= 0 || 149 effectSplash.x >= width || 150 effectSplash.z >= height 151 ) { 152 // Remove splash effect when it reaches the edges of the map. 153 effectSplash.deleted = true; 154 // Check to see if the effect created a cleanup effect. 155 if ( 156 effectSplash.color === themeMapEffectSplashColor && 157 !effectSplashes.current.find( 158 s => 159 s.color === themeMapColor && 160 s.original_x === effectSplash.original_x && 161 s.original_y === effectSplash.original_y && 162 s.original_z === effectSplash.original_z, 163 ) 164 ) { 165 effectSplashes.current.push({ 166 x_inc: effectSplash.x_inc, 167 y_inc: effectSplash.y_inc, 168 z_inc: effectSplash.z_inc, 169 x: effectSplash.original_x, 170 y: effectSplash.original_y, 171 z: effectSplash.original_z, 172 length: 1, 173 color: themeMapColor, 174 original_x: effectSplash.original_x, 175 original_y: effectSplash.original_y, 176 original_z: effectSplash.original_z, 177 }); 178 } 179 } else { 180 // Apply effect. 181 effectSplash.x += effectSplash.x_inc; 182 effectSplash.y += effectSplash.y_inc; 183 effectSplash.z += effectSplash.z_inc; 184 effectSplash.length += 1; 185 } 186 } 187 setBlocks(newBlocks); 188 effectSplashes.current = effectSplashes.current.filter(e => !e.deleted); 189});

Takeaways

  • Not the best way to write a game but

    react-three-fiber
    follows the
    react
    mindset and if you know that writing small
    canvas
    graphics like this is a fun exercise.

  • You will fight with state with these abstractions if you're used to mutable state in other rendering engines.


External Resources