├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── snaaake.png ├── src ├── engine │ ├── canvas.tsx │ ├── keyboard.ts │ └── timer.ts ├── index.tsx ├── main.css ├── react-app-env.d.ts ├── setupTests.ts ├── snaaake.tsx └── snake │ ├── draw-snake.ts │ ├── snake-machine.test.ts │ ├── snake-machine.ts │ ├── snake.ts │ └── util.ts ├── tsconfig.json ├── viz.png └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .vscode 26 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "arrowParens": "avoid" 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Christian Hamburger Grøngaard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🅂🄽🄰🄰🄰🄺🄴 2 | 3 | [![Netlify Status](https://api.netlify.com/api/v1/badges/7b7a8288-709b-4f69-8fc9-912a94094c97/deploy-status)](https://app.netlify.com/sites/snaaake/deploys) 4 | 5 | > A snake game driven by [XState](https://xstate.js.org/) 6 | 7 | Demo: [snaaake.netlify.app](https://snaaake.netlify.app) 8 | 9 | ![Snaaake](snaaake.png) 10 | 11 | ## Why? 12 | 13 | **The goal of Snaaake is to implement a game using XState where the driving statechart is agnostic to the implementation details of the game.** 14 | 15 | The statechart controls the direction of the snake, but is unaware of how to actually move or grow it (or know when it's supposed to die). Similarly, the statechart isn't concerned with how the bounds, the apples or even the snake itself are defined. To the statechart these are a generic shape. All of this has a couple of advantages: 16 | 17 | - It provides a clear separation of concerns. How the snake game looks - or what data structure is used to represent the snake - has little to do with how it works at the foundational level. 18 | - It makes it possible to implement different variations of snake (the game) on top of the same, generic statechart. 19 | - It makes unit testing of the statechart easier since the test data can be simplified. 20 | 21 | --- 22 | 23 | All of the above is of course just for show. 24 | 25 | The real reason for Snaaake is to give the author a fun example to practice [XState](https://xstate.js.org/) on. Creating a game is a great opportunity to implement usages of: 26 | 27 | - [Context](https://xstate.js.org/docs/guides/context.html) (for extended game state) 28 | - [History](https://xstate.js.org/docs/guides/history.html) (for the pause functionality) 29 | - [Null Events](https://xstate.js.org/docs/guides/events.html#null-events) (to evaluate the game state on "ticks") 30 | 31 | ## Visualization 32 | 33 | https://xstate.js.org/viz/?gist=15582ab43cd031fbb01e62accf5b32ce 34 | 35 | ![Visualization](viz.png) 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snaaake", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.12.0", 7 | "@testing-library/react": "^11.2.6", 8 | "@testing-library/user-event": "^13.1.9", 9 | "@types/jest": "^26.0.23", 10 | "@types/node": "^15.0.3", 11 | "@types/react": "^17.0.5", 12 | "@types/react-dom": "^17.0.5", 13 | "modern-normalize": "^1.1.0", 14 | "react": "^17.0.2", 15 | "react-dom": "^17.0.2", 16 | "react-scripts": "4.0.3", 17 | "typescript": "4.2.4", 18 | "xstate": "^4.19.1" 19 | }, 20 | "devDependencies": { 21 | "prettier": "^2.3.0" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "build": "npm run prettier-production && npm test && react-scripts build", 26 | "test": "react-scripts test --watchAll=false", 27 | "test-watch": "react-scripts test", 28 | "eject": "react-scripts eject", 29 | "prettier": "prettier --write '{*,**/*}.{css,html,js,ts,tsx}'", 30 | "prettier-production": "prettier --list-different '{*,**/*}.{css,html,js,ts,tsx}'" 31 | }, 32 | "eslintConfig": { 33 | "extends": "react-app" 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christianhg/snaaake/f0c610764ebf52308936cd0962d96e20f7a1e639/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 24 | 🅂🄽🄰🄰🄰🄺🄴 25 | 26 | 27 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christianhg/snaaake/f0c610764ebf52308936cd0962d96e20f7a1e639/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christianhg/snaaake/f0c610764ebf52308936cd0962d96e20f7a1e639/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "snaaake", 3 | "name": "Snaaake", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /snaaake.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christianhg/snaaake/f0c610764ebf52308936cd0962d96e20f7a1e639/snaaake.png -------------------------------------------------------------------------------- /src/engine/canvas.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from 'react'; 2 | 3 | export type CanvasSettings = { width: number; height: number; scale: number }; 4 | 5 | export function Canvas({ 6 | settings: { width, height, scale }, 7 | state, 8 | draw, 9 | }: { 10 | settings: CanvasSettings; 11 | state: State; 12 | draw: ( 13 | state: State, 14 | scale: number, 15 | context: CanvasRenderingContext2D 16 | ) => void; 17 | }) { 18 | const canvasRef = useRef(null); 19 | 20 | useEffect(() => { 21 | const context = canvasRef.current 22 | ? canvasRef.current.getContext('2d') 23 | : undefined; 24 | 25 | if (context) { 26 | draw(state, scale, context); 27 | } 28 | }); 29 | 30 | return ( 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/engine/keyboard.ts: -------------------------------------------------------------------------------- 1 | export type Key = string; 2 | 3 | export type KeyEventHandler = { 4 | down?: () => void; 5 | up?: () => void; 6 | }; 7 | 8 | export type KeyEventHandlers = Map; 9 | 10 | type KeyState = 'keyup' | 'keydown'; 11 | 12 | export type UnbindKeys = () => void; 13 | 14 | export function bindKeys({ 15 | element, 16 | handlers, 17 | }: { 18 | element: Window; 19 | handlers: KeyEventHandlers; 20 | }): UnbindKeys { 21 | const keyStates = new Map(); 22 | const isDown = (key: Key) => keyStates.get(key) === 'keydown'; 23 | const siblingsPressed = (keys: Key[], pressedKey: Key) => 24 | keys 25 | .filter(key => key !== pressedKey) 26 | .filter(key => keyStates.get(key) === 'keydown').length > 0; 27 | 28 | const onKeydown = (event: KeyboardEvent) => { 29 | handlers.forEach((handler, keys) => { 30 | const keyPressed = keys.find(key => key === event.key); 31 | 32 | if ( 33 | keyPressed && 34 | !siblingsPressed(keys, keyPressed) && 35 | !isDown(keyPressed) 36 | ) { 37 | handler.down?.(); 38 | keyStates.set(keyPressed, 'keydown'); 39 | } 40 | }); 41 | }; 42 | 43 | const onKeyup = (event: KeyboardEvent) => { 44 | handlers.forEach((handler, keys) => { 45 | const keyPressed = keys.find(key => key === event.key); 46 | 47 | if ( 48 | keyPressed && 49 | !siblingsPressed(keys, keyPressed) && 50 | isDown(keyPressed) 51 | ) { 52 | handler.up?.(); 53 | keyStates.set(keyPressed, 'keyup'); 54 | } 55 | }); 56 | }; 57 | 58 | element.addEventListener('keydown', onKeydown); 59 | element.addEventListener('keyup', onKeyup); 60 | 61 | return () => { 62 | element.removeEventListener('keydown', onKeydown); 63 | element.removeEventListener('keyup', onKeyup); 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /src/engine/timer.ts: -------------------------------------------------------------------------------- 1 | export function createTimer({ 2 | step, 3 | onTick, 4 | }: { 5 | step: number; 6 | onTick: () => void; 7 | }): void { 8 | let accumulatedTime = 0; 9 | let lastTime = 0; 10 | 11 | const animate = (time: number = 0) => { 12 | accumulatedTime += (time - lastTime) / 1000; 13 | 14 | while (accumulatedTime > step) { 15 | onTick(); 16 | 17 | accumulatedTime = accumulatedTime - step; 18 | } 19 | 20 | lastTime = time; 21 | 22 | requestAnimationFrame(animate); 23 | }; 24 | 25 | requestAnimationFrame(animate); 26 | } 27 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Snaaake } from './snaaake'; 4 | import './main.css'; 5 | 6 | ReactDOM.render( 7 | , 8 | document.getElementById('root') 9 | ); 10 | -------------------------------------------------------------------------------- /src/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #1e2127; 3 | color: #abb2bf; 4 | padding: 1em; 5 | text-align: center; 6 | } 7 | 8 | h1 { 9 | color: rgb(198, 120, 221); 10 | margin-bottom: 0; 11 | } 12 | 13 | .score, 14 | kbd { 15 | display: inline-block; 16 | text-transform: uppercase; 17 | font-weight: bold; 18 | border-radius: 5px; 19 | padding: 0.3em 0.5em; 20 | } 21 | 22 | .score { 23 | background-color: rgba(209, 154, 102, 0.2); 24 | border: 1px solid rgba(209, 154, 102, 0.4); 25 | color: rgb(209, 154, 102); 26 | } 27 | 28 | kbd { 29 | margin-left: 0.5em; 30 | margin-right: 0.5em; 31 | background-color: rgba(86, 182, 194, 0.2); 32 | color: rgb(86, 182, 194); 33 | border: 1px solid rgba(86, 182, 194, 0.4); 34 | } 35 | 36 | kbd span { 37 | font-size: 0.8em; 38 | vertical-align: middle; 39 | } 40 | 41 | canvas { 42 | background: #282c34; 43 | display: block; 44 | image-rendering: pixelated; 45 | padding: 0.5em; 46 | margin: 0 auto 1.5em; 47 | } 48 | 49 | a { 50 | color: #5c6370; 51 | text-decoration: none; 52 | } 53 | a:hover { 54 | text-decoration: underline; 55 | } 56 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/snaaake.tsx: -------------------------------------------------------------------------------- 1 | import 'modern-normalize'; 2 | import React, { Component } from 'react'; 3 | import { Canvas, CanvasSettings } from './engine/canvas'; 4 | import { 5 | SnakeMachine, 6 | SnakeMachineState, 7 | createSnakeMachine, 8 | } from './snake/snake-machine'; 9 | import { 10 | Apples, 11 | Bounds, 12 | Snake, 13 | willExceedBounds, 14 | willEatApple, 15 | willHitItself, 16 | moveSnake, 17 | growSnake, 18 | getInitialSnakeData, 19 | addApple, 20 | } from './snake/snake'; 21 | import { drawScene } from './snake/draw-snake'; 22 | import { bindKeys } from './engine/keyboard'; 23 | import { createTimer } from './engine/timer'; 24 | 25 | export class Snaaake extends Component< 26 | CanvasSettings, 27 | { 28 | game: { 29 | apples: Apples; 30 | bounds: Bounds; 31 | snake: Snake; 32 | }; 33 | status: SnakeMachineState; 34 | } 35 | > { 36 | private snakeMachine: SnakeMachine; 37 | 38 | constructor(props: CanvasSettings) { 39 | super(props); 40 | 41 | const game = getInitialSnakeData(props); 42 | 43 | this.state = { 44 | game, 45 | status: 'idle', 46 | }; 47 | 48 | this.snakeMachine = createSnakeMachine({ 49 | initialData: game, 50 | resetData: () => getInitialSnakeData(props), 51 | updateApples: addApple, 52 | willExceedBounds, 53 | willEatApple, 54 | willHitItself, 55 | moveSnake, 56 | growSnake, 57 | onUpdate: ({ apples, snake, state }) => { 58 | this.setState({ 59 | game: { 60 | ...this.state.game, 61 | apples, 62 | snake, 63 | }, 64 | status: state, 65 | }); 66 | }, 67 | }); 68 | 69 | bindKeys({ 70 | element: window, 71 | handlers: new Map([ 72 | [ 73 | [' '], 74 | { 75 | down: () => { 76 | this.snakeMachine.send('SPACE'); 77 | }, 78 | }, 79 | ], 80 | [ 81 | ['Escape'], 82 | { 83 | down: () => { 84 | this.snakeMachine.send('ESCAPE'); 85 | }, 86 | }, 87 | ], 88 | [ 89 | ['w', 'ArrowUp'], 90 | { 91 | down: () => { 92 | this.snakeMachine.send('UP'); 93 | }, 94 | }, 95 | ], 96 | [ 97 | ['d', 'ArrowRight'], 98 | { 99 | down: () => { 100 | this.snakeMachine.send('RIGHT'); 101 | }, 102 | }, 103 | ], 104 | [ 105 | ['s', 'ArrowDown'], 106 | { 107 | down: () => { 108 | this.snakeMachine.send('DOWN'); 109 | }, 110 | }, 111 | ], 112 | [ 113 | ['a', 'ArrowLeft'], 114 | { 115 | down: () => { 116 | this.snakeMachine.send('LEFT'); 117 | }, 118 | }, 119 | ], 120 | ]), 121 | }); 122 | } 123 | 124 | componentDidMount() { 125 | createTimer({ 126 | step: 1 / 8, 127 | onTick: () => { 128 | this.snakeMachine.send('TICK'); 129 | }, 130 | }); 131 | } 132 | 133 | render() { 134 | return ( 135 |
136 |

🅂🄽🄰🄰🄰🄺🄴

137 |

{this.state.game.snake.length - 1}

138 | 143 |

144 | {this.state.status === 'idle' ? ( 145 | <> 146 | 147 | 148 | 149 | 150 | 151 | ) : this.state.status === 'moving' ? ( 152 | <> 153 | 154 | Space (pause) 155 | 156 | 157 | ) : this.state.status === 'paused' ? ( 158 | <> 159 | 160 | Space (resume) 161 | 162 | 163 | Escape (restart) 164 | 165 | 166 | ) : ( 167 | <> 168 | 169 | Space (restart) 170 | 171 | 172 | )} 173 |

174 |

175 | {'{src}'} 176 |

177 |
178 | ); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/snake/draw-snake.ts: -------------------------------------------------------------------------------- 1 | import { Snake, Apples, Bounds } from './snake'; 2 | 3 | // const black = '#1E2127'; 4 | // const darkGrey = '#282C34'; 5 | const grey = '#5C6370'; 6 | // const lightGrey = '#ABB2BF'; 7 | const green = '#98C379'; 8 | const red = '#E06C75'; 9 | // const yellow = '#D19A66'; 10 | // const blue = '#61AFEF'; 11 | // const magenta = '#C678DD'; 12 | // const cyan = '#56B6C2'; 13 | 14 | function drawSnake( 15 | snake: Snake, 16 | scale: number, 17 | context: CanvasRenderingContext2D 18 | ): void { 19 | snake.forEach(([x, y]) => { 20 | context.fillStyle = green; 21 | context.fillRect(x * scale + 3, y * scale + 3, scale - 6, scale - 6); 22 | }); 23 | } 24 | 25 | function drawApples( 26 | apples: Apples, 27 | scale: number, 28 | context: CanvasRenderingContext2D 29 | ): void { 30 | apples.forEach(([x, y]) => { 31 | context.fillStyle = red; 32 | context.fillRect(x * scale + 3, y * scale + 3, scale - 6, scale - 6); 33 | }); 34 | } 35 | 36 | export function drawScene( 37 | { bounds, apples, snake }: { bounds: Bounds; apples: Apples; snake: Snake }, 38 | scale: number, 39 | context: CanvasRenderingContext2D 40 | ): void { 41 | context.clearRect( 42 | bounds[0][0], 43 | bounds[0][1], 44 | bounds[bounds.length - 1][0] * scale + scale, 45 | bounds[bounds.length - 1][1] * scale + scale 46 | ); 47 | bounds.forEach(([x, y]) => { 48 | context.strokeStyle = grey; 49 | context.beginPath(); 50 | context.lineWidth = 1; 51 | context.rect(x * scale + 2, y * scale + 2, scale - 4, scale - 4); 52 | context.stroke(); 53 | }); 54 | 55 | drawSnake(snake, scale, context); 56 | drawApples(apples, scale, context); 57 | } 58 | -------------------------------------------------------------------------------- /src/snake/snake-machine.test.ts: -------------------------------------------------------------------------------- 1 | import { createSnakeMachine, SnakeMachine, SnakeData } from './snake-machine'; 2 | import { 3 | Apples, 4 | Bounds, 5 | moveSnake, 6 | willEatApple, 7 | growSnake, 8 | Snake, 9 | willExceedBounds, 10 | willHitItself, 11 | createBounds, 12 | } from './snake'; 13 | 14 | describe(createSnakeMachine.name, () => { 15 | function setUpTest( 16 | { apples, snake }: Omit, 'bounds'>, 17 | onUpdate: jest.Mock 18 | ): SnakeMachine { 19 | const initialData: SnakeData = { 20 | apples, 21 | bounds: createBounds({ width: 6, height: 6 }), 22 | snake, 23 | }; 24 | 25 | return createSnakeMachine({ 26 | initialData, 27 | resetData: () => initialData, 28 | updateApples: ({ apples }) => apples, 29 | willEatApple, 30 | willExceedBounds, 31 | willHitItself, 32 | moveSnake, 33 | growSnake, 34 | onUpdate, 35 | }); 36 | } 37 | 38 | it('does not update on ticks when no direction is set', () => { 39 | const onUpdate = jest.fn(); 40 | const snakeMachine = setUpTest( 41 | { 42 | apples: [ 43 | [5, 3], 44 | [2, 3], 45 | [3, 4], 46 | ], 47 | snake: [[3, 3]], 48 | }, 49 | onUpdate 50 | ); 51 | 52 | snakeMachine.send('TICK'); 53 | 54 | expect(onUpdate).not.toBeCalled(); 55 | }); 56 | 57 | it('can quickly reverse its direction', () => { 58 | const onUpdate = jest.fn(); 59 | const snakeMachine = setUpTest( 60 | { 61 | apples: [], 62 | snake: [[3, 3]], 63 | }, 64 | onUpdate 65 | ); 66 | 67 | snakeMachine.send('UP'); 68 | snakeMachine.send('TICK'); 69 | 70 | expect(onUpdate).toHaveBeenNthCalledWith(1, { 71 | apples: [], 72 | snake: [[3, 2]], 73 | state: 'moving', 74 | }); 75 | 76 | snakeMachine.send('RIGHT'); 77 | snakeMachine.send('DOWN'); 78 | snakeMachine.send('TICK'); 79 | 80 | expect(onUpdate).toHaveBeenNthCalledWith(2, { 81 | apples: [], 82 | snake: [[4, 2]], 83 | state: 'moving', 84 | }); 85 | 86 | snakeMachine.send('TICK'); 87 | 88 | expect(onUpdate).toHaveBeenNthCalledWith(3, { 89 | apples: [], 90 | snake: [[4, 3]], 91 | state: 'moving', 92 | }); 93 | 94 | snakeMachine.send('RIGHT'); 95 | snakeMachine.send('UP'); 96 | 97 | snakeMachine.send('TICK'); 98 | 99 | expect(onUpdate).toHaveBeenNthCalledWith(4, { 100 | apples: [], 101 | snake: [[5, 3]], 102 | state: 'moving', 103 | }); 104 | 105 | snakeMachine.send('TICK'); 106 | 107 | expect(onUpdate).toHaveBeenNthCalledWith(5, { 108 | apples: [], 109 | snake: [[5, 2]], 110 | state: 'moving', 111 | }); 112 | 113 | snakeMachine.send('LEFT'); 114 | snakeMachine.send('UP'); 115 | snakeMachine.send('TICK'); 116 | 117 | expect(onUpdate).toHaveBeenNthCalledWith(6, { 118 | apples: [], 119 | snake: [[4, 2]], 120 | state: 'moving', 121 | }); 122 | 123 | snakeMachine.send('TICK'); 124 | 125 | expect(onUpdate).toHaveBeenNthCalledWith(7, { 126 | apples: [], 127 | snake: [[4, 1]], 128 | state: 'moving', 129 | }); 130 | 131 | snakeMachine.send('LEFT'); 132 | snakeMachine.send('DOWN'); 133 | snakeMachine.send('TICK'); 134 | 135 | expect(onUpdate).toHaveBeenNthCalledWith(8, { 136 | apples: [], 137 | snake: [[3, 1]], 138 | state: 'moving', 139 | }); 140 | 141 | snakeMachine.send('TICK'); 142 | 143 | expect(onUpdate).toHaveBeenNthCalledWith(9, { 144 | apples: [], 145 | snake: [[3, 2]], 146 | state: 'moving', 147 | }); 148 | }); 149 | 150 | it('can run straight up into a wall', () => { 151 | const onUpdate = jest.fn(); 152 | const snakeMachine = setUpTest( 153 | { 154 | apples: [ 155 | [5, 3], 156 | [2, 3], 157 | [3, 4], 158 | ], 159 | snake: [[3, 3]], 160 | }, 161 | onUpdate 162 | ); 163 | 164 | snakeMachine.send('UP'); 165 | snakeMachine.send('TICK'); 166 | snakeMachine.send('TICK'); 167 | snakeMachine.send('TICK'); 168 | snakeMachine.send('TICK'); 169 | 170 | expect(onUpdate).toHaveBeenNthCalledWith(1, { 171 | apples: [ 172 | [5, 3], 173 | [2, 3], 174 | [3, 4], 175 | ], 176 | snake: [[3, 2]], 177 | state: 'moving', 178 | }); 179 | expect(onUpdate).toHaveBeenNthCalledWith(2, { 180 | apples: [ 181 | [5, 3], 182 | [2, 3], 183 | [3, 4], 184 | ], 185 | snake: [[3, 1]], 186 | state: 'moving', 187 | }); 188 | expect(onUpdate).toHaveBeenNthCalledWith(3, { 189 | apples: [ 190 | [5, 3], 191 | [2, 3], 192 | [3, 4], 193 | ], 194 | snake: [[3, 0]], 195 | state: 'moving', 196 | }); 197 | expect(onUpdate).toHaveBeenNthCalledWith(4, { 198 | apples: [ 199 | [5, 3], 200 | [2, 3], 201 | [3, 4], 202 | ], 203 | snake: [[3, 0]], 204 | state: 'dead', 205 | }); 206 | }); 207 | 208 | it('can grow', () => { 209 | const onUpdate = jest.fn(); 210 | const snakeMachine = setUpTest( 211 | { 212 | apples: [ 213 | [5, 3], 214 | [2, 3], 215 | [3, 4], 216 | ], 217 | snake: [[3, 3]], 218 | }, 219 | onUpdate 220 | ); 221 | 222 | snakeMachine.send('RIGHT'); 223 | snakeMachine.send('TICK'); 224 | snakeMachine.send('TICK'); 225 | 226 | expect(onUpdate).toHaveBeenNthCalledWith(1, { 227 | apples: [ 228 | [5, 3], 229 | [2, 3], 230 | [3, 4], 231 | ], 232 | snake: [[4, 3]], 233 | state: 'moving', 234 | }); 235 | expect(onUpdate).toHaveBeenNthCalledWith(2, { 236 | apples: [ 237 | [2, 3], 238 | [3, 4], 239 | ], 240 | snake: [ 241 | [5, 3], 242 | [4, 3], 243 | ], 244 | state: 'moving', 245 | }); 246 | }); 247 | 248 | it('can go in circles', () => { 249 | const onUpdate = jest.fn(); 250 | const snakeMachine = setUpTest( 251 | { 252 | apples: [ 253 | [5, 3], 254 | [2, 3], 255 | [3, 4], 256 | ], 257 | snake: [[3, 3]], 258 | }, 259 | onUpdate 260 | ); 261 | 262 | snakeMachine.send('LEFT'); 263 | snakeMachine.send('TICK'); 264 | 265 | expect(onUpdate).toHaveBeenNthCalledWith(1, { 266 | apples: [ 267 | [5, 3], 268 | [3, 4], 269 | ], 270 | snake: [ 271 | [2, 3], 272 | [3, 3], 273 | ], 274 | state: 'moving', 275 | }); 276 | 277 | snakeMachine.send('DOWN'); 278 | snakeMachine.send('TICK'); 279 | 280 | expect(onUpdate).toHaveBeenNthCalledWith(2, { 281 | apples: [ 282 | [5, 3], 283 | [3, 4], 284 | ], 285 | snake: [ 286 | [2, 4], 287 | [2, 3], 288 | ], 289 | state: 'moving', 290 | }); 291 | 292 | snakeMachine.send('RIGHT'); 293 | snakeMachine.send('TICK'); 294 | 295 | expect(onUpdate).toHaveBeenNthCalledWith(3, { 296 | apples: [[5, 3]], 297 | snake: [ 298 | [3, 4], 299 | [2, 4], 300 | [2, 3], 301 | ], 302 | state: 'moving', 303 | }); 304 | }); 305 | }); 306 | -------------------------------------------------------------------------------- /src/snake/snake-machine.ts: -------------------------------------------------------------------------------- 1 | import { 2 | StateSchema, 3 | Machine, 4 | interpret, 5 | Interpreter, 6 | assign, 7 | StateMachine, 8 | } from 'xstate'; 9 | 10 | interface SnakeStateSchema extends StateSchema { 11 | states: { 12 | idle: {}; 13 | moving: { 14 | states: { 15 | hist: {}; 16 | up: { 17 | states: { 18 | locked: {}; 19 | unlocked: {}; 20 | }; 21 | }; 22 | right: { 23 | states: { 24 | locked: {}; 25 | unlocked: {}; 26 | }; 27 | }; 28 | down: { 29 | states: { 30 | locked: {}; 31 | unlocked: {}; 32 | }; 33 | }; 34 | left: { 35 | states: { 36 | locked: {}; 37 | unlocked: {}; 38 | }; 39 | }; 40 | }; 41 | }; 42 | dead: {}; 43 | paused: {}; 44 | }; 45 | } 46 | 47 | export type SnakeData = { 48 | bounds: TBounds; 49 | apples: TApples; 50 | snake: TSnake; 51 | }; 52 | 53 | export enum Direction { 54 | up = 'up', 55 | right = 'right', 56 | down = 'down', 57 | left = 'left', 58 | } 59 | 60 | type SnakeContext = SnakeData< 61 | TApples, 62 | TBounds, 63 | TSnake 64 | > & { 65 | nextDirection?: Direction; 66 | }; 67 | 68 | type SnakeEvent = 69 | | { type: 'UP' } 70 | | { type: 'RIGHT' } 71 | | { type: 'DOWN' } 72 | | { type: 'LEFT' } 73 | | { type: 'SPACE' } 74 | | { type: 'ESCAPE' } 75 | | { type: 'TICK' }; 76 | 77 | export type SnakeMachineState = NonNullable< 78 | StateMachine< 79 | SnakeContext, 80 | SnakeStateSchema, 81 | SnakeEvent 82 | >['initial'] 83 | >; 84 | 85 | export type SnakeMachine = Interpreter< 86 | SnakeContext, 87 | SnakeStateSchema, 88 | SnakeEvent 89 | >; 90 | 91 | export type WillExceedBounds = ({ 92 | bounds, 93 | snake, 94 | direction, 95 | }: { 96 | bounds: TBounds; 97 | snake: TSnake; 98 | direction: Direction; 99 | }) => boolean; 100 | 101 | export type WillHitItself = ({ 102 | snake, 103 | direction, 104 | }: { 105 | snake: TSnake; 106 | direction: Direction; 107 | }) => boolean; 108 | 109 | export type WillEatApple = ({ 110 | apples, 111 | snake, 112 | direction, 113 | }: { 114 | apples: TApples; 115 | snake: TSnake; 116 | direction: Direction; 117 | }) => boolean; 118 | 119 | export type GrowSnake = ({ 120 | apples, 121 | snake, 122 | direction, 123 | }: { 124 | apples: TApples; 125 | snake: TSnake; 126 | direction: Direction; 127 | }) => { apples: TApples; snake: TSnake }; 128 | 129 | export type MoveSnake = ({ 130 | snake, 131 | direction, 132 | }: { 133 | snake: TSnake; 134 | direction: Direction; 135 | }) => TSnake; 136 | 137 | export function createSnakeMachine({ 138 | initialData, 139 | resetData, 140 | updateApples, 141 | willExceedBounds, 142 | willEatApple, 143 | willHitItself, 144 | moveSnake, 145 | growSnake, 146 | onUpdate, 147 | }: { 148 | initialData: SnakeData; 149 | resetData: () => SnakeData; 150 | updateApples: (data: SnakeData) => TApples; 151 | willExceedBounds: WillExceedBounds; 152 | willEatApple: WillEatApple; 153 | willHitItself: WillHitItself; 154 | moveSnake: MoveSnake; 155 | growSnake: GrowSnake; 156 | onUpdate: ({ 157 | apples, 158 | snake, 159 | state, 160 | }: { 161 | apples: TApples; 162 | snake: TSnake; 163 | state: SnakeMachineState; 164 | }) => void; 165 | }): SnakeMachine { 166 | const machine = Machine< 167 | SnakeContext, 168 | SnakeStateSchema, 169 | SnakeEvent 170 | >( 171 | { 172 | id: 'snake', 173 | context: initialData, 174 | initial: 'idle', 175 | states: { 176 | idle: { 177 | on: { 178 | UP: { target: 'moving.up' }, 179 | RIGHT: { target: 'moving.right' }, 180 | DOWN: { target: 'moving.down' }, 181 | LEFT: { target: 'moving.left' }, 182 | }, 183 | }, 184 | moving: { 185 | on: { 186 | SPACE: { target: '#snake.paused' }, 187 | UP: { actions: ['queueUp'] }, 188 | RIGHT: { actions: ['queueRight'] }, 189 | DOWN: { actions: ['queueDown'] }, 190 | LEFT: { actions: ['queueLeft'] }, 191 | }, 192 | states: { 193 | hist: { 194 | type: 'history', 195 | }, 196 | up: { 197 | entry: ['resetQueue'], 198 | on: { 199 | TICK: [ 200 | { target: '#snake.dead', cond: 'boundUp' }, 201 | { target: '#snake.dead', cond: 'snakeUp' }, 202 | { 203 | cond: 'appleUp', 204 | target: '.unlocked', 205 | actions: ['growUp', 'updateApples', 'notifyUpdate'], 206 | }, 207 | { target: '.unlocked', actions: ['moveUp', 'notifyUpdate'] }, 208 | ], 209 | }, 210 | initial: 'locked', 211 | states: { 212 | locked: {}, 213 | unlocked: { 214 | always: [ 215 | { cond: 'rightQueued', target: '#snake.moving.right' }, 216 | { cond: 'leftQueued', target: '#snake.moving.left' }, 217 | ], 218 | }, 219 | }, 220 | }, 221 | right: { 222 | entry: ['resetQueue'], 223 | on: { 224 | TICK: [ 225 | { target: '#snake.dead', cond: 'boundRight' }, 226 | { target: '#snake.dead', cond: 'snakeRight' }, 227 | { 228 | cond: 'appleRight', 229 | target: '.unlocked', 230 | actions: ['growRight', 'updateApples', 'notifyUpdate'], 231 | }, 232 | { 233 | target: '.unlocked', 234 | actions: ['moveRight', 'notifyUpdate'], 235 | }, 236 | ], 237 | }, 238 | initial: 'locked', 239 | states: { 240 | locked: {}, 241 | unlocked: { 242 | always: [ 243 | { cond: 'upQueued', target: '#snake.moving.up' }, 244 | { cond: 'downQueued', target: '#snake.moving.down' }, 245 | ], 246 | }, 247 | }, 248 | }, 249 | down: { 250 | entry: ['resetQueue'], 251 | on: { 252 | TICK: [ 253 | { target: '#snake.dead', cond: 'boundDown' }, 254 | { target: '#snake.dead', cond: 'snakeDown' }, 255 | { 256 | cond: 'appleDown', 257 | target: '.unlocked', 258 | actions: ['growDown', 'updateApples', 'notifyUpdate'], 259 | }, 260 | { 261 | target: '.unlocked', 262 | actions: ['moveDown', 'notifyUpdate'], 263 | }, 264 | ], 265 | }, 266 | initial: 'locked', 267 | states: { 268 | locked: {}, 269 | unlocked: { 270 | always: [ 271 | { cond: 'rightQueued', target: '#snake.moving.right' }, 272 | { cond: 'leftQueued', target: '#snake.moving.left' }, 273 | ], 274 | }, 275 | }, 276 | }, 277 | left: { 278 | entry: ['resetQueue'], 279 | on: { 280 | TICK: [ 281 | { target: '#snake.dead', cond: 'boundLeft' }, 282 | { target: '#snake.dead', cond: 'snakeLeft' }, 283 | { 284 | cond: 'appleLeft', 285 | target: '.unlocked', 286 | actions: ['growLeft', 'updateApples', 'notifyUpdate'], 287 | }, 288 | { 289 | target: '.unlocked', 290 | actions: ['moveLeft', 'notifyUpdate'], 291 | }, 292 | ], 293 | }, 294 | initial: 'locked', 295 | states: { 296 | locked: {}, 297 | unlocked: { 298 | always: [ 299 | { cond: 'upQueued', target: '#snake.moving.up' }, 300 | { cond: 'downQueued', target: '#snake.moving.down' }, 301 | ], 302 | }, 303 | }, 304 | }, 305 | }, 306 | }, 307 | dead: { 308 | entry: ['notifyUpdate'], 309 | on: { 310 | SPACE: { target: 'idle', actions: ['reset', 'notifyUpdate'] }, 311 | }, 312 | }, 313 | paused: { 314 | entry: ['notifyUpdate'], 315 | on: { 316 | SPACE: { target: 'moving.hist' }, 317 | ESCAPE: { target: 'idle', actions: ['reset', 'notifyUpdate'] }, 318 | }, 319 | }, 320 | }, 321 | }, 322 | { 323 | actions: { 324 | queueUp: assign({ 325 | nextDirection: ({ nextDirection }) => Direction.up, 326 | }), 327 | queueRight: assign({ 328 | nextDirection: ({ nextDirection }) => Direction.right, 329 | }), 330 | queueDown: assign({ 331 | nextDirection: ({ nextDirection }) => Direction.down, 332 | }), 333 | queueLeft: assign({ 334 | nextDirection: ({ nextDirection }) => Direction.left, 335 | }), 336 | resetQueue: assign({ 337 | nextDirection: ({ nextDirection }) => undefined, 338 | }), 339 | 340 | moveUp: assign({ 341 | snake: ({ snake }) => moveSnake({ snake, direction: Direction.up }), 342 | }), 343 | moveRight: assign({ 344 | snake: ({ snake }) => 345 | moveSnake({ snake, direction: Direction.right }), 346 | }), 347 | moveDown: assign({ 348 | snake: ({ snake }) => moveSnake({ snake, direction: Direction.down }), 349 | }), 350 | moveLeft: assign({ 351 | snake: ({ snake }) => moveSnake({ snake, direction: Direction.left }), 352 | }), 353 | 354 | growUp: assign({ 355 | apples: ({ apples, snake }) => 356 | growSnake({ apples, snake, direction: Direction.up }).apples, 357 | snake: ({ apples, snake }) => 358 | growSnake({ apples, snake, direction: Direction.up }).snake, 359 | }), 360 | growRight: assign({ 361 | apples: ({ apples, snake }) => 362 | growSnake({ apples, snake, direction: Direction.right }).apples, 363 | snake: ({ apples, snake }) => 364 | growSnake({ apples, snake, direction: Direction.right }).snake, 365 | }), 366 | growDown: assign({ 367 | apples: ({ apples, snake }) => 368 | growSnake({ apples, snake, direction: Direction.down }).apples, 369 | snake: ({ apples, snake }) => 370 | growSnake({ apples, snake, direction: Direction.down }).snake, 371 | }), 372 | growLeft: assign({ 373 | apples: ({ apples, snake }) => 374 | growSnake({ apples, snake, direction: Direction.left }).apples, 375 | snake: ({ apples, snake }) => 376 | growSnake({ apples, snake, direction: Direction.left }).snake, 377 | }), 378 | 379 | updateApples: assign({ 380 | apples: ({ apples, bounds, snake }) => 381 | updateApples({ apples, bounds, snake }), 382 | }), 383 | 384 | notifyUpdate: ({ apples, snake }, event, meta) => { 385 | onUpdate({ 386 | apples, 387 | snake, 388 | state: (typeof meta.state.value === 'string' 389 | ? meta.state.value 390 | : Object.keys(meta.state.value)[0]) as SnakeMachineState< 391 | TApples, 392 | TBounds, 393 | TSnake 394 | >, 395 | }); 396 | }, 397 | 398 | reset: assign(context => { 399 | const { apples, snake } = resetData(); 400 | 401 | return { apples, snake }; 402 | }), 403 | }, 404 | guards: { 405 | upQueued: ({ nextDirection }) => nextDirection === Direction.up, 406 | rightQueued: ({ nextDirection }) => nextDirection === Direction.right, 407 | downQueued: ({ nextDirection }) => nextDirection === Direction.down, 408 | leftQueued: ({ nextDirection }) => nextDirection === Direction.left, 409 | 410 | appleUp: ({ apples, snake }) => 411 | willEatApple({ apples, snake, direction: Direction.up }), 412 | appleRight: ({ apples, snake }) => 413 | willEatApple({ apples, snake, direction: Direction.right }), 414 | appleDown: ({ apples, snake }) => 415 | willEatApple({ apples, snake, direction: Direction.down }), 416 | appleLeft: ({ apples, snake }) => 417 | willEatApple({ apples, snake, direction: Direction.left }), 418 | 419 | boundUp: ({ bounds, snake }) => 420 | willExceedBounds({ bounds, snake, direction: Direction.up }), 421 | boundRight: ({ bounds, snake }) => 422 | willExceedBounds({ bounds, snake, direction: Direction.right }), 423 | boundDown: ({ bounds, snake }) => 424 | willExceedBounds({ bounds, snake, direction: Direction.down }), 425 | boundLeft: ({ bounds, snake }) => 426 | willExceedBounds({ bounds, snake, direction: Direction.left }), 427 | 428 | snakeUp: ({ snake }) => 429 | willHitItself({ snake, direction: Direction.up }), 430 | snakeRight: ({ snake }) => 431 | willHitItself({ snake, direction: Direction.right }), 432 | snakeDown: ({ snake }) => 433 | willHitItself({ snake, direction: Direction.down }), 434 | snakeLeft: ({ snake }) => 435 | willHitItself({ snake, direction: Direction.left }), 436 | }, 437 | } 438 | ); 439 | 440 | const interpreter = interpret(machine).start(); 441 | 442 | return interpreter; 443 | } 444 | -------------------------------------------------------------------------------- /src/snake/snake.ts: -------------------------------------------------------------------------------- 1 | import { Direction, SnakeData } from './snake-machine'; 2 | import { getRandomItem } from './util'; 3 | 4 | type Tuple = [A, B]; 5 | export type Coords = Tuple; 6 | export type Apple = Coords; 7 | export type Apples = ReadonlyArray; 8 | export type Bounds = ReadonlyArray; 9 | export type Snake = ReadonlyArray; 10 | 11 | function createCoords(a: number, b: number): Coords { 12 | return [a, b]; 13 | } 14 | 15 | export function createBounds({ 16 | width, 17 | height, 18 | }: { 19 | width: number; 20 | height: number; 21 | }): Bounds { 22 | let bounds: Coords[] = []; 23 | 24 | for (let x = 0; x <= width - 1; x++) { 25 | for (let y = 0; y <= height - 1; y++) { 26 | bounds.push([x, y]); 27 | } 28 | } 29 | 30 | return bounds as Bounds; 31 | } 32 | 33 | function getFreeSquares({ 34 | apples, 35 | bounds, 36 | snake, 37 | }: SnakeData): Coords[] { 38 | return bounds 39 | .filter( 40 | bound => !snake.some(part => part[0] === bound[0] && part[1] === bound[1]) 41 | ) 42 | .filter( 43 | bound => 44 | !apples.some(apple => apple[0] === bound[0] && apple[1] === bound[1]) 45 | ); 46 | } 47 | 48 | export function getApple({ 49 | apples, 50 | bounds, 51 | snake, 52 | }: SnakeData): Apple | undefined { 53 | return getRandomItem(getFreeSquares({ apples, bounds, snake })); 54 | } 55 | 56 | export function getInitialSnakeData({ 57 | width, 58 | height, 59 | }: { 60 | width: number; 61 | height: number; 62 | }): SnakeData { 63 | const bounds = createBounds({ width, height }); 64 | const snakePart = getRandomItem(bounds); 65 | 66 | if (!snakePart) { 67 | throw Error('Not enough room to create a snake'); 68 | } 69 | 70 | const snake = [snakePart]; 71 | 72 | return { 73 | apples: addApple({ apples: [], bounds, snake }), 74 | bounds, 75 | snake, 76 | }; 77 | } 78 | 79 | export function addApple({ 80 | bounds, 81 | apples, 82 | snake, 83 | }: SnakeData): Apples { 84 | const apple = getApple({ apples, bounds, snake }); 85 | 86 | return apple ? [...apples, apple] : apples; 87 | } 88 | 89 | export function moveSnake({ 90 | snake, 91 | direction, 92 | }: { 93 | snake: Snake; 94 | direction: Direction; 95 | }): Snake { 96 | const head = snake[0]; 97 | const newHead = 98 | direction === Direction.up 99 | ? createCoords(head[0], head[1] - 1) 100 | : direction === Direction.right 101 | ? createCoords(head[0] + 1, head[1]) 102 | : direction === Direction.down 103 | ? createCoords(head[0], head[1] + 1) 104 | : createCoords(head[0] - 1, head[1]); 105 | 106 | if (snake.length === 1) { 107 | return [newHead]; 108 | } 109 | 110 | return [newHead, ...snake.slice(0, snake.length - 1)]; 111 | } 112 | 113 | export function willEatApple({ 114 | apples, 115 | snake, 116 | direction, 117 | }: { 118 | apples: Apples; 119 | snake: Snake; 120 | direction: Direction; 121 | }): boolean { 122 | const head = snake[0]; 123 | 124 | return direction === Direction.up 125 | ? apples.some(apple => apple[0] === head[0] && apple[1] === head[1] - 1) 126 | : direction === Direction.right 127 | ? apples.some(apple => apple[0] === head[0] + 1 && apple[1] === head[1]) 128 | : direction === Direction.down 129 | ? apples.some(apple => apple[0] === head[0] && apple[1] === head[1] + 1) 130 | : apples.some(apple => apple[0] === head[0] - 1 && apple[1] === head[1]); 131 | } 132 | 133 | export function willExceedBounds({ 134 | bounds, 135 | snake, 136 | direction, 137 | }: { 138 | bounds: Bounds; 139 | snake: Snake; 140 | direction: Direction; 141 | }): boolean { 142 | const head = snake[0]; 143 | 144 | return direction === Direction.up 145 | ? !bounds.some(bound => bound[0] === head[0] && bound[1] === head[1] - 1) 146 | : direction === Direction.right 147 | ? !bounds.some(bound => bound[0] === head[0] + 1 && bound[1] === head[1]) 148 | : direction === Direction.down 149 | ? !bounds.some(bound => bound[0] === head[0] && bound[1] === head[1] + 1) 150 | : !bounds.some(bound => bound[0] === head[0] - 1 && bound[1] === head[1]); 151 | } 152 | 153 | export function willHitItself({ 154 | snake, 155 | direction, 156 | }: { 157 | snake: Snake; 158 | direction: Direction; 159 | }): boolean { 160 | const head = snake[0]; 161 | const tail = snake.slice(1, snake.length); 162 | 163 | return direction === Direction.up 164 | ? tail.some(part => part[0] === head[0] && part[1] === head[1] - 1) 165 | : direction === Direction.right 166 | ? tail.some(part => part[0] === head[0] + 1 && part[1] === head[1]) 167 | : direction === Direction.down 168 | ? tail.some(part => part[0] === head[0] && part[1] === head[1] + 1) 169 | : tail.some(part => part[0] === head[0] - 1 && part[1] === head[1]); 170 | } 171 | 172 | export function growSnake({ 173 | apples, 174 | snake, 175 | direction, 176 | }: { 177 | apples: Apples; 178 | snake: Snake; 179 | direction: Direction; 180 | }): { apples: Coords[]; snake: Snake } { 181 | const head = snake[0]; 182 | const newHead: Coords = 183 | direction === Direction.up 184 | ? [head[0], head[1] - 1] 185 | : direction === Direction.right 186 | ? [head[0] + 1, head[1]] 187 | : direction === Direction.down 188 | ? [head[0], head[1] + 1] 189 | : [head[0] - 1, head[1]]; 190 | 191 | const newSnake: Snake = [newHead, ...snake]; 192 | 193 | return { 194 | apples: apples.filter( 195 | apple => !(apple[0] === newHead[0] && apple[1] === newHead[1]) 196 | ), 197 | snake: newSnake, 198 | }; 199 | } 200 | -------------------------------------------------------------------------------- /src/snake/util.ts: -------------------------------------------------------------------------------- 1 | export function getRandomItem(xs: ReadonlyArray): A | undefined { 2 | return xs.length > 0 ? xs[Math.floor(Math.random() * xs.length)] : undefined; 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /viz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christianhg/snaaake/f0c610764ebf52308936cd0962d96e20f7a1e639/viz.png --------------------------------------------------------------------------------