├── .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 | [](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 | 
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 | 
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 | You need to enable JavaScript to run this app.
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
--------------------------------------------------------------------------------