├── .babelrc ├── .gitignore ├── .travis.yml ├── Procfile ├── README.md ├── demo ├── demo.gif └── sensors.gif ├── package.json ├── saves └── 89.json ├── server.js ├── src ├── config.json ├── game │ ├── Curve.js │ ├── SaveManager.js │ ├── canvas-debug.js │ ├── canvas-setup.js │ └── charts.js ├── genetics │ ├── Genome.js │ └── Pool.js ├── global.scss ├── index.html.ejs ├── index.js └── lib │ └── helper.js ├── tests └── PoolTest.js ├── webpack.config.base.js ├── webpack.config.development.js ├── webpack.config.js └── webpack.config.production.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { "targets": { "node": 7, "browsers": ["last 2 versions"] }, "useBuiltIns": true }], 4 | "stage-0" 5 | ], 6 | "plugins": ["add-module-exports"], 7 | "env": { 8 | "production": { 9 | "plugins": ["babel-plugin-dev-expression"] 10 | }, 11 | "development": { 12 | "plugins": [ 13 | "transform-class-properties", 14 | "transform-es2015-classes", 15 | ] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | temp/ 2 | public/savedStates 3 | .DS_Store 4 | node_modules 5 | todo 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 7 4 | - 6 5 | before_install: npm install -g npm 6 | cache: 7 | directories: 8 | - node_modules 9 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node server.js 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # snakeneuralnetworkjs [![Build Status][travis-image]][travis-url] 2 | 3 | DEMO: https://snakeneuralnetwork.herokuapp.com/ 4 | ### Neuroevolution of Neural Network of snakes in the Browser. 5 | 6 | This is a demonstration of evolving a neural network thanks to genetics algorithms in the browser 7 | using a multilayer perceptron (150-15-15-1). 8 | 9 | The initial population contains 36 individuals, each assigned a different genome. 10 | They will fight following a round-robin tournament. 11 | At the end the top 7 are kept alive, and the remaining 29 are created by breeding from the 7. 12 | 13 | Each snake has 50 sensors, each reporting 3 inputs: 14 | 1) The distance the sensor has hit something normalized between 0 and 1 15 | 2) 1 if this sensor touched the enemy body 16 | 3) 1 if this sensor touched the enemy body 17 | 18 |
19 | 20 | 21 | ## Screenshot 22 | 23 | ### Snakes fighting: 24 | ![Snakes](/demo/demo.gif) 25 | 26 | ### Sensors: 27 | ![Snakes](/demo/sensors.gif) 28 | 29 | Green: The sensor touched the enemy body 30 | Yellow: The sensor did not report any activity 31 | Red: The sensor is hitting a wall or its own body 32 | Blue: The sensor is touching the enemy head 33 | 34 | ## Install 35 | 36 | * **Note: requires a node version >= 6 and an npm version >= 3.** 37 | 38 | First, clone the repo via git: 39 | 40 | ```bash 41 | git clone https://github.com/elyx0/snakeneuralnetworkjs.git your-project-name 42 | ``` 43 | 44 | And then install dependencies. 45 | 46 | ```bash 47 | $ cd your-project-name && npm install 48 | ``` 49 | 50 | :bulb: *you will need to run npm run build for publishing like for heroku* 51 | 52 | ## Run 53 | 54 | ```bash 55 | $ node server.js 56 | ``` 57 | Then head to `localhost:8080` in the browser. 58 | 59 | ## Testing 60 | ```bash 61 | $ npm run test 62 | ``` 63 | 64 | [travis-image]: https://travis-ci.org/Elyx0/snakeneuralnetworkjs.svg?branch=master 65 | [travis-url]: https://travis-ci.org/Elyx0/snakeneuralnetworkjs 66 | -------------------------------------------------------------------------------- /demo/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elyx0/snakeneuralnetworkjs/ba14e06556815d984f3706194d72f2cccc7a341f/demo/demo.gif -------------------------------------------------------------------------------- /demo/sensors.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elyx0/snakeneuralnetworkjs/ba14e06556815d984f3706194d72f2cccc7a341f/demo/sensors.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "neatjssnakes", 3 | "version": "1.0.0", 4 | "description": "N.E.A.T implementation in javascript allowing two snakes to learn to fight by neuroevolution of their neural network and genetics", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "babel-tape-runner tests/*.js | ./node_modules/.bin/tap-diff", 8 | "hot-server": "NODE_ENV=development node --trace-warnings -r babel-register ./node_modules/webpack-dev-server/bin/webpack-dev-server --config ./webpack.config.development.js", 9 | "build": "NODE_ENV=production node --trace-warnings -r babel-register ./node_modules/webpack/bin/webpack --config webpack.config.production.js --progress --profile --colors", 10 | "start": "node server.js" 11 | }, 12 | "keywords": [ 13 | "neural", 14 | "synaptic", 15 | "perceptron", 16 | "neurralnetwork" 17 | ], 18 | "author": "Elyx0", 19 | "license": "ISC", 20 | "dependencies": { 21 | "body-parser": "^1.16.1", 22 | "express": "^4.15.2", 23 | "glob": "^7.1.1", 24 | "lodash": "^4.17.4", 25 | "synaptic": "^1.0.10", 26 | "webpack-dev-middleware": "^1.10.1" 27 | }, 28 | "devDependencies": { 29 | "babel-loader": "^6.4.1", 30 | "babel-plugin-add-module-exports": "^0.2.1", 31 | "babel-plugin-dev-expression": "^0.2.1", 32 | "babel-preset-env": "^1.3.2", 33 | "babel-preset-stage-0": "^6.24.1", 34 | "babel-runtime": "^6.23.0", 35 | "babel-tape-runner": "^2.0.1", 36 | "css-loader": "^0.28.0", 37 | "extract-text-webpack-plugin": "^2.1.0", 38 | "html-webpack-plugin": "^2.28.0", 39 | "json-loader": "^0.5.4", 40 | "node-sass": "^4.5.2", 41 | "sass-loader": "^6.0.3", 42 | "style-loader": "^0.16.1", 43 | "tap-diff": "^0.1.1", 44 | "tape": "^4.6.3", 45 | "webpack": "^2.3.3", 46 | "webpack-merge": "^4.1.0" 47 | }, 48 | "engines": { 49 | "node": "7.4.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | const bodyParser = require('body-parser'); 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | const glob = require('glob'); 7 | const webpack = require('webpack'); 8 | const webpackMiddleware = require('webpack-dev-middleware'); 9 | const config = require('./webpack.config.js'); 10 | 11 | app.set('port', (process.env.PORT || 8080)); 12 | 13 | app.use(bodyParser.json({limit: '50mb'})); 14 | 15 | // Listing different saves 16 | app.get('/listsaves', (req, res) => { 17 | glob('./saves/*.json', (err, files) => { 18 | res.json(files.sort((x, y) => { 19 | return y.match(/[0-9]+/)[0] - x.match(/[0-9]+/); 20 | })); 21 | }) 22 | }); 23 | 24 | if (!process.env.NODE_ENV !== 'production') { 25 | 26 | // In dev use webpack dev middleware 27 | const compiler = webpack(config); 28 | const middleware = webpackMiddleware(compiler, { 29 | publicPath: config.output.publicPath, 30 | contentBase: path.join(__dirname, 'dist'), 31 | stats: { 32 | colors: true 33 | } 34 | }); 35 | app.use(middleware); 36 | app.get('/', function response(req, res) { 37 | res.write(middleware.fileSystem.readFileSync(path.join(__dirname, 'dist/index.html'))); 38 | res.end(); 39 | }); 40 | } else { 41 | 42 | // Run npm start build in prod and serve from static dist 43 | app.use(express.static(path.join(__dirname, '/dist'))); 44 | app.get('/', function response(req, res) { 45 | res.sendFile(path.join(__dirname, 'dist/index.html')); 46 | }); 47 | } 48 | 49 | // Serving the saves files from /saves 50 | app.use('/saves',express.static(path.join(__dirname, '/saves'))); 51 | 52 | // POST in local on /savestate stores in /saves 53 | app.post('/savestate', (req, res) => { 54 | 55 | if (process.env.NODE_ENV != 'production') { 56 | var data = req.body; 57 | if (data) { 58 | fs.writeFile(`./saves/${data.generation}.json`, JSON.stringify(data), e => { 59 | if (e) 60 | console.log(e); 61 | } 62 | ); 63 | } 64 | } 65 | res.send({status: true}); 66 | }); 67 | 68 | 69 | 70 | app.listen(app.get('port'), _ => { 71 | console.log(`App Running on ${app.get('port')}`); 72 | }); 73 | -------------------------------------------------------------------------------- /src/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "C_W": 420, 3 | "C_H": 420, 4 | "InputSize": 150, 5 | "Outputs": 1, 6 | "Population": 36, 7 | "HiddenLayerSize":15, 8 | "KeepAlivePercent": 0.2, 9 | "MutationChance": 0.2, 10 | "nbWhiskers": 50, 11 | "inputsPerWhisker": 3, 12 | "whiskerSize": 140 13 | } 14 | -------------------------------------------------------------------------------- /src/game/Curve.js: -------------------------------------------------------------------------------- 1 | import config from '../config.json'; 2 | import { Game } from 'game/canvas-setup'; 3 | //import { pool } from '../genetics/pool'; 4 | const {C_W,C_H} = config; 5 | const topLeft = { 6 | x: 0, 7 | y: 0 8 | }; 9 | const topRight = { 10 | x: C_W, 11 | y: 0 12 | }; 13 | const bottomLeft = { 14 | x: 0, 15 | y: C_H 16 | }; 17 | const bottomRight = { 18 | x: C_W, 19 | y: C_H 20 | }; 21 | const HIT_BORDERS = [ 22 | [ 23 | topLeft, topRight 24 | ], 25 | [ 26 | topLeft, bottomLeft 27 | ], 28 | [ 29 | topRight, bottomRight 30 | ], 31 | [bottomLeft, bottomRight] 32 | ]; 33 | 34 | const curveSize = 6; 35 | class Curve { 36 | constructor(curvesList, id, x, y) { 37 | this.curvesList = curvesList; 38 | x = x || 40 + Math.random() * (C_W - 80); 39 | y = y || 40 + Math.random() * (C_H - 80); 40 | this.diedAt = 0; 41 | this.x = x; 42 | this.id = id; 43 | this.y = y; 44 | this.vector; 45 | this.history = []; 46 | this.speed = 90 / 75; //maxspeed 47 | this.holesize = 16; 48 | this.size = curveSize; 49 | this.radius = 40; //Turning radius??? maxradius? 50 | this.angle = TWO_PI * Math.random(); // 51 | this.maxAngle = TWO_PI / 9; 52 | this.stepAngle = this.maxAngle / 20; 53 | this.noDrawing = this.size; 54 | //this.holeLeft = 2 * Math.PI * this.§_-MF§.radius; 55 | this.direction = 2; // LEFT RIGHT STILL 56 | this.whiskersize = config.whiskerSize; 57 | this.randomPos(); 58 | this.lastInputLayer = Array.from(Array(config.InputSize)).map(k => 0); // Keeping it for debugging 59 | this.lastEvaluation = null; // Same 60 | this.diedOn = 0; 61 | } 62 | 63 | setStart() { 64 | this.history.length = 0; 65 | this.angle = TWO_PI * Math.random(); 66 | this.noDrawing = this.size; 67 | let foundPos = false; 68 | while (!foundPos) { 69 | this.randomPos(); 70 | if (this.curvesList.filter(c => c.id != this.id).some(c => distNotSquared(c.x, c.y, this.x, this.y) >= 100 * 100)) { 71 | foundPos = true; 72 | } 73 | } 74 | //curvesList.filter(c => c.id != this.id).forEach(c => console.log('Distance:',distNotSquared(c.x,c.y,this.x,this.y),140*140)); 75 | this.dead = false; 76 | this.direction = 2; 77 | } 78 | 79 | randomPos() { 80 | this.x = 70 + Math.random() * (C_W - 140); 81 | this.y = 70 + Math.random() * (C_H - 140); 82 | this.pos = createVector(this.x, this.y); 83 | } 84 | // Only used by Human Player 85 | updateDir() { 86 | const left = keyIsDown(LEFT_ARROW); 87 | const right = keyIsDown(RIGHT_ARROW); 88 | if (left) { 89 | this.direction = 0; 90 | } 91 | if (right) { 92 | this.direction = 1; 93 | } 94 | if (!left && !right) { 95 | this.direction = 2; 96 | } 97 | } 98 | 99 | shouldDraw() { 100 | if ((Math.random() < 0.01) && (this.noDrawing == 0)) { 101 | //console.log('Hole!'); 102 | this.noDrawing = this.holesize; 103 | } 104 | } 105 | 106 | getDistanceToHitSensor(x, y, a) { 107 | //Debug; 108 | let minDistance = 3; 109 | x += minDistance * Math.cos(a); 110 | y += minDistance * Math.sin(a); 111 | 112 | let lineX = x + this.whiskersize * Math.cos(a); 113 | let lineY = y + this.whiskersize * Math.sin(a); 114 | let hit = false; // Is the whisker triggered ? 115 | let from = false; // Is it me&wall or enemy? 116 | let isHead = false; // Is it the enemy head? 117 | 118 | let shorttestDistance = this.whiskersize; 119 | //First Checking borders 120 | let hitBorders = HIT_BORDERS.map(b => { 121 | 122 | let hit2 = collideLineLine(b[0].x, b[0].y, b[1].x, b[1].y, x, y, lineX, lineY, true); 123 | return hit2.x == false && hit2.y == false 124 | ? false 125 | : [hit2.x, hit2.y]; 126 | }).find(Boolean) || false; 127 | 128 | if (hitBorders) { 129 | //console.log('Whisker touching border!!',collided); 130 | hit = dist(this.pos.x, this.pos.y, hitBorders[0], hitBorders[1]); 131 | shorttestDistance = hit; 132 | lineX = hitBorders[0]; 133 | lineY = hitBorders[1]; 134 | from = false; 135 | } 136 | let curvesList = this.curvesList; 137 | let potentialColliders = []; 138 | //Loop through circles and check if line intersects 139 | for (let i = 0; i < curvesList.length; i++) { 140 | let c = curvesList[i]; 141 | let history = c.history.slice(); 142 | if (i==this.id) { 143 | potentialColliders = potentialColliders.concat(c.history); 144 | } else 145 | { 146 | potentialColliders = potentialColliders.concat(c.history,[c.pos.x,c.pos.y]); 147 | } 148 | } 149 | 150 | for (let i = 0; i < potentialColliders.length; i++) { 151 | let p = potentialColliders[i]; 152 | //if further than this.whiskersizepx discard 153 | if (distNotSquared(x, y, p.x, p.y) > this.whiskersize*this.whiskersize) 154 | continue; 155 | let collided = collideLineCircle(x, y, lineX, lineY, p.x, p.y, this.size * 2) 156 | if (collided) { 157 | //console.log('Whisker touching!!',collided); 158 | let distance = dist(x, y, collided[0], collided[1]); 159 | if (distance < shorttestDistance) { 160 | shorttestDistance = distance; 161 | hit = distance; 162 | lineX = collided[0]; 163 | lineY = collided[1]; 164 | from = (p.id != this.id); 165 | isHead = p.head 166 | ? 1 167 | : 0; 168 | } 169 | 170 | } 171 | } 172 | 173 | if (this.debug) { 174 | 175 | fill(255, 0, 0); 176 | stroke(225, 204, 0); 177 | ellipse(lineX, lineY, 4) 178 | ellipse(x, y, 2); 179 | 180 | //let result = [this.pos.x+100*cos(angle),this.pos.y+100*sin(angle)]; 181 | if (hit) { 182 | stroke(255, 0, 0); 183 | if (from) { 184 | stroke(0, 255, 0); 185 | } 186 | if (isHead) { 187 | stroke(0, 0, 255); 188 | } 189 | } else { 190 | stroke(225, 204, 0); 191 | }; 192 | line(x, y, lineX, lineY); 193 | } 194 | //fill(255,0,0); 195 | let result = {x: lineX, y: lineY, hit: hit, from: from, isHead: isHead}; 196 | 197 | return result; 198 | } 199 | 200 | getInputLayer(){ 201 | //loadPixels(); // Nope too heavy 202 | 203 | let displayedWhiskers = config.nbWhiskers; 204 | //let inputLayer = Array.from(Array(displayedWhiskers * 4)).map(x => 0); 205 | let inputLayer = Array.from(Array(displayedWhiskers*config.inputsPerWhisker)).map(x => 0); 206 | 207 | let step = TWO_PI / (displayedWhiskers * 1.2); 208 | for (let i = 0; i < displayedWhiskers; i++) { 209 | let modifier = i > displayedWhiskers / 2 210 | ? -1 211 | : 1; 212 | let angle = this.angle + step * (i % (displayedWhiskers / 2)) * modifier; 213 | let x = this.pos.x; 214 | let y = this.pos.y; 215 | let result = this.getDistanceToHitSensor(x, y, angle); 216 | if (result.hit) { 217 | let index = i*3; 218 | // inputLayer[index] = 1; 219 | result.hit = Math.min(result.hit,this.whiskersize); 220 | inputLayer[index] = 1 - map(result.hit,0,this.whiskersize,0,1); 221 | inputLayer[index + 1] = result.from; 222 | inputLayer[index + 2] = result.isHead; 223 | } 224 | } 225 | return inputLayer; 226 | } 227 | 228 | 229 | 230 | update() { 231 | if (this.dead) { 232 | //this.getInputLayer(); 233 | if (Game.showDraw) this.showSkeleton(); 234 | return; 235 | } else { 236 | // this.history.slice(0,-1).map(c => { 237 | // fill(0,0,255); 238 | // ellipse(c.x,c.y,this.size); 239 | // }); 240 | 241 | if (this.humanControlled) { 242 | this.updateDir(); 243 | } 244 | 245 | this.shouldDraw(); 246 | this.move(); 247 | if (Game.showDraw) this.show(); 248 | if (this.noDrawing != 0) this.noDrawing--; 249 | if (this.checkCollisions()) { 250 | 251 | //console.warn('Collided!'); 252 | //setup(); 253 | } 254 | this.store(); 255 | } 256 | 257 | 258 | } 259 | 260 | getInputsAndAssignDir() { 261 | //return; // REMOVE ME!! 262 | let inputs = this.getInputLayer(); 263 | //Add sensorsData to Inputs? 264 | let controller = this.id == 0 ? pool.evaluateP1Genome(inputs) : pool.evaluateP2Genome(inputs); 265 | //console.log(inputs,controller); 266 | this.lastInputLayer = inputs; 267 | this.lastController = controller; 268 | this.setPressedKey(controller); 269 | } 270 | 271 | // Outputs is an array with 3 elements [a,b,c] 272 | // We arbitrarily decided which is going to do what 273 | // I could have decided a was stay-still, b was left 274 | setPressedKey(outputs) { 275 | var value = outputs[0]; 276 | //console.log(value); 277 | this.direction = 2; 278 | if (outputs > 0.55) this.direction = 1; 279 | if (outputs < .45) this.direction = 0; 280 | } 281 | 282 | // Adds the snake position to its history if far enough from last one 283 | store() { 284 | if (this.noDrawing > 0) 285 | return; 286 | var farEnough = false; 287 | var lastHistory = this.history.length && this.history[this.history.length - 1]; 288 | if (!!lastHistory) { 289 | farEnough = distNotSquared(lastHistory.x, lastHistory.y, this.pos.x, this.pos.y) > ((this.size*this.size) + 1); 290 | } else { 291 | farEnough = true; 292 | } 293 | if (farEnough) { 294 | var history = this.pos.copy(); 295 | if (this.history.length) { 296 | this.history[this.history.length - 1].head = false; 297 | } 298 | history.head = true; 299 | history.id = this.id; 300 | this.history.push(history); 301 | } 302 | } 303 | 304 | // Did we collide? 305 | checkCollisions() { 306 | let curvesList = this.curvesList; 307 | if (this.history.length < 1) 308 | return false; 309 | var potentialColliders = this.history.slice(0, -1); 310 | 311 | //Adding current pos and history 312 | potentialColliders.push([this.pos.x,this.pos.y]); 313 | var ownHistoryIndex = potentialColliders.length; 314 | var others = curvesList.filter(c => c.id != this.id); 315 | 316 | 317 | others.forEach(o => { 318 | potentialColliders = potentialColliders.concat(o.history); 319 | }); 320 | 321 | 322 | var target = this.history[this.history.length - 1]; 323 | var isColliding = potentialColliders.some((pos,i) => { 324 | var d = distNotSquared(pos.x, pos.y, target.x, target.y); 325 | var colliding = d < this.size*this.size; 326 | if (colliding) { 327 | if (i > ownHistoryIndex) { 328 | this.diedOn = 1; // He died on enemy 329 | 330 | } 331 | this.diedAt = pool.roundTicksElapsed; 332 | if (Game.showDraw) this.showSkeleton(pos, target); 333 | this.stop(); 334 | }; 335 | return colliding; 336 | }); 337 | 338 | var isOutOfBounds = (this.pos.x > C_W || this.pos.x < 0 || this.pos.y > C_W || this.pos.y < 0); 339 | if (isOutOfBounds) { 340 | if (Game.showDraw) this.showSkeleton(this.pos); 341 | this.diedAt = pool.roundTicksElapsed; 342 | this.stop(); 343 | } 344 | return isColliding || isOutOfBounds; 345 | } 346 | 347 | // Debug curve skeleton 348 | showSkeleton(pos, target) { 349 | pos = pos || this.pos; 350 | this.history.slice(0, -1).map(c => { 351 | if (this.id <= 0) { 352 | stroke(255,90,137); 353 | fill(251, 71, 107); 354 | } else { 355 | fill(102, 51, 153); 356 | stroke(110,80,187); 357 | } 358 | 359 | ellipse(c.x, c.y, this.size); 360 | }); 361 | if (target) { 362 | fill(255, 0, 0); 363 | ellipse(target.x, target.y, this.size); 364 | } 365 | fill(0, 255, 0); 366 | ellipse(pos.x, pos.y, this.size); 367 | } 368 | 369 | stop() { 370 | //console.log('RIP',this.id); 371 | this.dead = true; 372 | } 373 | 374 | show() { 375 | //frameCount % (this.size/2) == 0 376 | if (this.id <= 0) { 377 | stroke(255,90,137); 378 | // fill(251, 71, 107); 379 | } else { 380 | //fill(102, 51, 153); 381 | stroke(110,80,187); 382 | } 383 | 384 | if (this.noDrawing == 0) { 385 | 386 | fill('rgba(255,255,255,1)'); 387 | ellipse(this.pos.x, this.pos.y, this.size, this.size); 388 | } else { 389 | 390 | if (this.debug) { 391 | fill('rgba(255,255,255,0.2)'); 392 | ellipse(this.pos.x, this.pos.y, this.size, this.size); 393 | } else { 394 | fill('rgba(255,255,255,.2)'); 395 | stroke(51); 396 | ellipse(this.pos.x, this.pos.y, this.size/2, this.size/2); 397 | } 398 | } 399 | } 400 | 401 | setDebug() { 402 | this.debug = true; 403 | } 404 | toggleDebug() { 405 | this.debug = !this.debug; 406 | } 407 | 408 | move() { 409 | if (this.direction != 2) { 410 | this.angle += (this.direction == 1 411 | ? 1 412 | : -1) * this.stepAngle; 413 | } 414 | this.pos.x += this.speed * Math.cos(this.angle); 415 | this.pos.y += this.speed * Math.sin(this.angle); 416 | } 417 | 418 | 419 | } 420 | 421 | export default Curve; 422 | -------------------------------------------------------------------------------- /src/game/SaveManager.js: -------------------------------------------------------------------------------- 1 | import Genome from '../genetics/Genome'; 2 | class SaveManager { 3 | constructor() { 4 | this.lastSaveTime = 0; 5 | this.previous = []; 6 | } 7 | elapsed() { 8 | return ~~((+(new Date()) - this.lastSaveTime) / 1000); 9 | } 10 | 11 | getLoadState(callback) { 12 | var sessionPool = sessionStorage.pool; 13 | if (sessionPool) { 14 | this.hydrate(JSON.parse(sessionPool),callback); 15 | } else { 16 | this.getPreviousSaves(saves => { 17 | if (!saves.length) { 18 | pool.init(); 19 | setTimeout(callback,500); 20 | } else { 21 | this.loadFile(saves[0],callback); 22 | } 23 | }); 24 | } 25 | } 26 | 27 | loadFile(file,callback) { 28 | var req = new XMLHttpRequest(); 29 | req.open('GET',file,true); 30 | req.onreadystatechange = e => { 31 | if (req.readyState == 4) { 32 | this.hydrate(JSON.parse(req.responseText),callback); 33 | } 34 | } 35 | req.send(null); 36 | } 37 | 38 | hydrate(json,callback) { 39 | // json is a representation of pool 40 | // Copy all the keys 41 | Object.assign(pool,json); 42 | 43 | //Re Hydrate the genomes 44 | pool.genomes = pool.genomes.map(g => { 45 | const hGen = new Genome(); 46 | Object.assign(hGen,g); 47 | hGen.hydrateNetwork(); 48 | return hGen; 49 | }); 50 | setTimeout(pool.hydrateChart.bind(pool),1000); 51 | callback(); 52 | } 53 | 54 | getPreviousSaves(callback) { 55 | var req = new XMLHttpRequest(); 56 | req.open('GET','/listsaves',true); 57 | req.onreadystatechange = e => { 58 | if (req.readyState == 4) { 59 | callback(JSON.parse(req.responseText)); 60 | } 61 | } 62 | req.send(null); 63 | } 64 | 65 | saveState(pool,callback) { 66 | if (this.elapsed() < 60) return; 67 | this.lastSaveTime = +(new Date()); 68 | callback = callback || function(){}; 69 | var poolJSON = JSON.stringify(pool); 70 | 71 | if (location.hostname == 'localhost') { 72 | var req = new XMLHttpRequest(); 73 | req.open('POST','/savestate',true); 74 | req.setRequestHeader("Content-Type", "application/json"); 75 | req.onreadystatechange = e => { 76 | if (req.readyState == 4) callback(); 77 | } 78 | req.onerror = e => console.log('Error Saving:',e),callback(); 79 | req.send(poolJSON); 80 | 81 | } else { 82 | var poolToSession = pool; 83 | // Network are heavy in size 2mb -> 200ko 84 | // They will get regenerated in the loading 85 | sessionStorage.setItem('pool',JSON.stringify(poolToSession)); 86 | callback(); 87 | } 88 | 89 | } 90 | } 91 | 92 | export let sm = new SaveManager(); 93 | -------------------------------------------------------------------------------- /src/game/canvas-debug.js: -------------------------------------------------------------------------------- 1 | import config from '../config.json'; 2 | // Right side canvas for the debugging infos 3 | const sketch = function (p) { 4 | const bgColor = 247; 5 | const D_W = 400; 6 | const D_H = 360; 7 | let speedSlider; 8 | 9 | p.setup = function () { 10 | p.textFont("Helvetica"); 11 | var canvas = p.createCanvas(D_W, D_H); 12 | //canvas.parent('debug'); 13 | speedSlider = p.createSlider(0, 300, Game.simulationSpeed,2); 14 | speedSlider.position(115,320); 15 | const showSensorsCheckbox = p.createCheckbox('Show Sensors',false); 16 | showSensorsCheckbox.position(270,248); 17 | showSensorsCheckbox.changed(_=>{ 18 | Game.showCurvesSensors = !Game.showCurvesSensors; 19 | reset(); 20 | }); 21 | const showDrawCheckbox = p.createCheckbox('Show Curves',true); 22 | const showDebugCheckbox = p.createCheckbox('Show Debug',true); 23 | const showHumanControlledCheckbox = p.createCheckbox('Control P2 with arrows',false); 24 | showHumanControlledCheckbox.changed(_=>{Game.humanControlled = !Game.humanControlled; reset();}); 25 | showHumanControlledCheckbox.position(85,248); 26 | 27 | // buttonFightCurrentStrongest = createButton('Play against current strongest'); 28 | // buttonFightCurrentStrongest.position(80, C_H+10); 29 | // buttonFightCurrentStrongest.mousePressed(_=>{ 30 | // if (!pool.previousGenerationChampion) { 31 | // alert('No champion yet! Wait at least one generation'); 32 | // return; 33 | // }; 34 | // showHumanControlledCheckbox.elt.getElementsByTagName('input')[0].checked = true; 35 | // humanControlled = true; 36 | // pool.p1Specie = pool.previousGenerationChampion[0]; 37 | // pool.p1Genome = pool.previousGenerationChampion[1]; 38 | // pool.species[pool.p1Specie].genomes[pool.p1Genome].generateNetwork() 39 | // reset(); 40 | //}); 41 | showDebugCheckbox.changed(_=>{Game.showDebug = !Game.showDebug}); 42 | showDrawCheckbox.changed(_=>{Game.showDraw = !Game.showDraw}); 43 | showDrawCheckbox.position(90,350); 44 | showDebugCheckbox.position(200,350); 45 | }; 46 | 47 | // Shows who is fighting who 48 | function displayStats(color,y,pool,player) { 49 | p.fill(color); 50 | p.stroke('rgba(0,0,0,.2)'); 51 | p.textFont("Helvetica"); 52 | p.text(`[${player?'P1':'P2'}]` + " Genome: " + (player?pool.p1GenomeIndex:pool.p2GenomeIndex), 0,y); 53 | const genome = player? pool.getP1Genome():pool.getP2Genome(); 54 | const played = genome.matches.length; 55 | const wins = genome.matches.reduce((acc,m) => m.winner ? acc+1 : acc,0); 56 | const fitness = genome.fitness; 57 | p.text("Wins: " + wins , 160,y); 58 | p.text("Losses: " + (played-wins), 215,y); 59 | p.text("Fitness: " + ~~fitness, 285,y); 60 | 61 | if (player) { 62 | p.text("Inputs P1 ", 20,10); 63 | p.text("Output P1 ", 200,10); 64 | } 65 | } 66 | 67 | // Stores Inputs Neurons and Outputs positions and values 68 | function Cell(x,y,value) { 69 | this.x = x; 70 | this.y = y; 71 | this.value = value; 72 | } 73 | 74 | // Debug Canvas 75 | p.draw = function () { 76 | let curvesList = Game.curvesList; 77 | if (frameCount % 5 !== 0) return; 78 | if(!Game.curvesList[0] || !Game.curvesList[1]) return; 79 | //console.log('Update Canvas'); 80 | //return; 81 | Game.simulationSpeed = speedSlider.value(); 82 | p.background(bgColor); 83 | 84 | p.textSize(12); 85 | 86 | var p1Color = 'rgb(255,90,137)'; 87 | var p2Color = 'rgb(110,80,187)'; 88 | 89 | var p1 = curvesList[0]; 90 | var p1Inputs = p1.lastInputLayer; 91 | 92 | 93 | 94 | var p2 = curvesList[1]; 95 | 96 | var rows = Math.ceil(Math.sqrt(config.InputSize)); 97 | var columns = rows; 98 | var blocSize = 8; 99 | 100 | p.fill(0); 101 | p.stroke(0); 102 | var pushY = 100; 103 | // return; // REMOVE ME 104 | displayStats(p1Color,rows*blocSize+pushY,pool,1); 105 | displayStats(p2Color,rows*blocSize+pushY+20,pool,0); 106 | 107 | var bufferX = 5; 108 | var bufferY = 160; 109 | 110 | p.fill(0); 111 | p.noStroke() 112 | p.text("Generation: " + pool.generation + ' ( '+pool.getGenerationAdvancement()+'% )', 5,10+bufferY+rows*blocSize); 113 | p.text("Max Fitness: " + ~~(pool.maxFitness) + " || ~" + ' Current Gen Max: ' + ~~(Math.max.apply(Math,pool.genomes.map(g => g.fitness))), 5,25+bufferY+rows*blocSize); 114 | p.text("Simulation Speed: " + speedSlider.value(),129,310); 115 | 116 | 117 | if (!Game.showDebug) return; 118 | 119 | var genome = pool.getP1Genome(); 120 | var network = genome.network; 121 | 122 | 123 | //p.push() 124 | var topBuffer = p; //Hack because I used topBuffer as a buffer previously 125 | topBuffer.fill(bgColor) 126 | var view = p1Inputs; 127 | 128 | var inputsGridColor = 'rgba(0,0,0,.5)'; 129 | var activeSignalColor = 230; 130 | 131 | var boxStrokeActiveColor = 0; 132 | var boxStrokeInactiveColor = 'rgba(0,0,0,.2)'; 133 | 134 | var boxFillActiveColor = 255; 135 | var boxFillInactiveColor = 'rgba(255,255,255,.2)'; 136 | 137 | var TextStrokeActiveColor = p1Color; 138 | var TextStrokeInactiveColor = 'rgba(0,0,0,.2)'; 139 | //Initialize cells list to draw connections with coordinates 140 | var cells = {}; 141 | topBuffer.translate(0,20); 142 | 143 | // Taking care of the inputs 144 | if (!p1.lastInputLayer) return; 145 | for (let i=0;i 0 ? boxStrokeActiveColor : boxStrokeInactiveColor); 180 | var colorFill = (value > 0 ? boxFillActiveColor : boxFillInactiveColor); 181 | topBuffer.strokeWeight(1); 182 | topBuffer.stroke(colorStroke); 183 | topBuffer.fill(colorFill); 184 | topBuffer.rect(x,y,blocSize,blocSize); 185 | 186 | var direction = 'None'; 187 | 188 | 189 | 190 | // Creating fake boxes to visually see the key from value 191 | if (config.Outputs == 1) { 192 | var m = blocSize/2; 193 | var color = [0,255,0]; 194 | var alpha = .1; 195 | 196 | //Output1 197 | var boX= x+40; 198 | var boY= y-10; 199 | colorStroke = (value > .55 ? boxStrokeActiveColor : boxStrokeInactiveColor); 200 | colorFill = (value > .55 ? boxFillActiveColor : boxFillInactiveColor); 201 | topBuffer.strokeWeight(1); 202 | topBuffer.stroke(colorStroke); 203 | topBuffer.fill(colorFill); 204 | topBuffer.rect(boX,boY,blocSize,blocSize); 205 | //console.log(value); 206 | topBuffer.stroke(value > .55 ? TextStrokeActiveColor : TextStrokeInactiveColor); 207 | topBuffer.fill(value > .55 ? TextStrokeActiveColor : TextStrokeInactiveColor); 208 | topBuffer.text("Right",boX+20,boY+blocSize); 209 | if (value > .55) alpha = .9; 210 | 211 | topBuffer.stroke('rgba('+color.join(',')+ ',' +alpha + ')'); 212 | topBuffer.line(x+m,y+m,boX+m,boY+m); 213 | 214 | //Output2 215 | boY+=20; 216 | colorStroke = (value < .45 ? boxStrokeActiveColor : boxStrokeInactiveColor); 217 | colorFill = (value <.45 ? boxFillActiveColor : boxFillInactiveColor); 218 | topBuffer.strokeWeight(1); 219 | topBuffer.stroke(colorStroke); 220 | topBuffer.fill(colorFill); 221 | topBuffer.rect(boX,boY,blocSize,blocSize); 222 | //console.log(value); 223 | topBuffer.stroke(value < .45 ? TextStrokeActiveColor : TextStrokeInactiveColor); 224 | topBuffer.fill(value < .45 ? TextStrokeActiveColor : TextStrokeInactiveColor); 225 | topBuffer.text("Left",boX+20,boY+blocSize); 226 | if (value < .45) alpha = .9; 227 | 228 | topBuffer.stroke('rgba('+color.join(',')+ ',' +alpha + ')'); 229 | topBuffer.line(x+m,y+m,boX+m,boY+m); 230 | 231 | 232 | } 233 | } 234 | return; 235 | 236 | 237 | // Taking care of the middle now 238 | // Generating sample cells for each 239 | Object.keys(network.neurons).forEach(k => { 240 | if (k >= Inputs && k < MaxNodes) { 241 | var neuron = network.neurons[k]; 242 | cells[k] = new Cell(230,62,neuron.value); 243 | } 244 | }); 245 | 246 | // Find where to place middle cells 247 | var minX = rows*blocSize; 248 | var maxX = OutputDrawStart-blocSize; 249 | var minY = 0; 250 | var maxY = rows*blocSize; 251 | var c1Factor = 0.75; 252 | var c2Factor = 0.25; 253 | 254 | //Adjusting Wizardry. 255 | for (var i=0;i<3;i++) { 256 | 257 | genome.genes.forEach(g => { 258 | if (g.enabled) { 259 | var c1 = cells[g.into]; 260 | var c2 = cells[g.out]; 261 | if (g.into >= Inputs && g.into < MaxNodes) { 262 | c1.x = c1Factor*c1.x + c2Factor*c2.x; 263 | if (c1.x > c2.x) c1.x -= 40; 264 | if (c1.x < minX) c1.x = minX; 265 | if (c1.x > maxX) c1.x = maxX; 266 | c1.y = c1Factor*c1.y + c2Factor*c2.y 267 | } 268 | 269 | if (g.out >= Inputs && g.out < MaxNodes) { 270 | c2.x = c2Factor*c1.x + c1Factor*c2.x; 271 | if (c1.x > c2.x) c2.x += 40; 272 | if (c2.x < minX) c2.x = minX; 273 | if (c2.x > maxX) c2.x = maxX; 274 | c2.y = c2Factor*c1.y + c1Factor*c2.y; 275 | } 276 | } 277 | }); 278 | 279 | } 280 | 281 | // Draw the middle cells 282 | Object.keys(cells).forEach(k => { 283 | if (k >= Inputs && k < MaxNodes) { 284 | var cell = cells[k]; 285 | var value = cell.value; 286 | var colorStroke = (value > 0 ? boxStrokeActiveColor : boxStrokeInactiveColor); 287 | var colorFill = (value > 0 ? boxFillActiveColor : boxFillInactiveColor); 288 | topBuffer.stroke(colorStroke); 289 | topBuffer.fill(colorFill); 290 | topBuffer.rect(cell.x,cell.y,blocSize,blocSize); 291 | } 292 | 293 | }); 294 | 295 | genome.genes.forEach(g => { 296 | if (g.enabled) { 297 | var c1 = cells[g.into]; 298 | var c2 = cells[g.out]; 299 | var alpha = .1; 300 | var color = [255,0,0]; // Red 301 | if (c1.value > 0) alpha = .9; 302 | if (g.weight > 0) { 303 | color = [0,255,0]; //Green 304 | } 305 | topBuffer.stroke('rgba('+color.join(',')+ ',' +alpha + ')'); 306 | var m = blocSize/2; 307 | topBuffer.line(c1.x+m,c1.y+m,c2.x+m,c2.y+m); 308 | } 309 | }); 310 | //p.image(topBuffer,bufferX,bufferY); 311 | 312 | //p.stroke(255); 313 | //p.line(0,0,400,400); 314 | //p.line(8,0,400,0); 315 | }; 316 | }; 317 | 318 | const debugSketch = new p5(sketch, 'debug'); 319 | -------------------------------------------------------------------------------- /src/game/canvas-setup.js: -------------------------------------------------------------------------------- 1 | import config from '../config.json'; 2 | import { pool } from '../genetics/Pool'; 3 | import charts from './charts'; 4 | import Curve from './Curve'; 5 | 6 | class MainCanvas { 7 | constructor() { 8 | this.curvesList = []; 9 | this.simulationSpeed = 2; 10 | this.showDebug = 1; 11 | this.showDraw = 1; 12 | this.showCurvesSensors = 0; 13 | this.humanControlled = 0; 14 | this.waitForReset = 0; 15 | this.setupChart(); 16 | } 17 | setupChart() { 18 | document.addEventListener("DOMContentLoaded", e => { 19 | window.chart = charts.perfChart(); 20 | window.ageChart = charts.ageChart(); 21 | }); 22 | } 23 | setup() { 24 | this.curvesList.length = 0; 25 | const {C_W,C_H} = config; 26 | const canv = createCanvas(C_W,C_H); 27 | canv.parent('sketch-holder'); 28 | } 29 | reset() { 30 | background(51); 31 | this.curvesList.length = 0; 32 | this.waitForReset = 0; 33 | this.curvesList.push(new Curve(this.curvesList,0),new Curve(this.curvesList,1)); 34 | this.curvesList[1].setStart(); 35 | if (this.showCurvesSensors) { 36 | this.curvesList[0].setDebug(); 37 | this.curvesList[1].setDebug(); 38 | } 39 | 40 | if (this.humanControlled) { 41 | this.curvesList[1].humanControlled = true; 42 | return; 43 | } 44 | pool.roundTicksElapsed = 0; 45 | pool.pickPlayers(); 46 | } 47 | draw() { 48 | if (this.curvesList.length != 2) return; 49 | if (this.curvesList.some(c => c.debug)) background(51); 50 | 51 | // Speed up simulation 52 | for (var i=0;i c.dead); 54 | if (allDead) { 55 | if (this.humanControlled) { 56 | // Human VS Ai rounds don't count 57 | if (!this.waitForReset) this.waitForReset = setTimeout(reset,3000); 58 | this.handleNextTick(); 59 | } 60 | else { 61 | //2 A.I Fighting 62 | //Compute who died first 63 | let winner = this.curvesList[0]; 64 | let loser = this.curvesList[1]; 65 | if (winner.diedAt < loser.diedAt) { 66 | const tmp = loser; 67 | loser = winner; 68 | winner = tmp; 69 | } 70 | if (winner.id == 0) { 71 | // P1 Died. 72 | //console.log('P1, died'); 73 | pool.matchResult({winner,loser}); 74 | } else { 75 | //console.log('P2, died'); 76 | // P2 Died. 77 | pool.matchResult({winner,loser}); 78 | } 79 | 80 | reset(); 81 | return; 82 | } 83 | } else { 84 | // One curve is still alive, let it go 85 | this.handleNextTick(); 86 | } 87 | } 88 | } 89 | handleNextTick() { 90 | pool.roundTicksElapsed++; 91 | const [p1,p2] = this.curvesList; 92 | if (pool.roundTicksElapsed % 2 == 0) { 93 | !p1.dead && p1.getInputsAndAssignDir(); 94 | 95 | if (!this.humanControlled) { 96 | !p2.dead && p2.getInputsAndAssignDir(); 97 | } else { 98 | if (p2.debug == 1) { 99 | p2.getInputLayer(); 100 | } 101 | } 102 | } 103 | this.curvesList.forEach(c => c.update()); 104 | } 105 | 106 | } 107 | 108 | export let Game = new MainCanvas(); 109 | -------------------------------------------------------------------------------- /src/game/charts.js: -------------------------------------------------------------------------------- 1 | import config from '../config'; 2 | const perfChart = () => { 3 | return new Chart(document.getElementById("perfChart"), { 4 | type: 'line', 5 | data: { 6 | datasets: [ 7 | { 8 | fill: false, 9 | // xAxisID: 'Generations', 10 | // yAxisID: 'Fitness', 11 | pointBorderColor: 'rgb(255,255,255)', 12 | pointBackgroundColor: 'rgb(255,90,137)', 13 | label: 'Champion Fitness per Generation', 14 | data: [] 15 | } 16 | ] 17 | }, 18 | options: { 19 | scales: { 20 | xAxes: [ 21 | { 22 | type: 'linear', 23 | position: 'bottom' 24 | } 25 | ] 26 | } 27 | } 28 | }); 29 | }; 30 | 31 | const ageChart = () => { 32 | const baseArrayPop = Array.from(Array(~~(config.Population*config.KeepAlivePercent))); 33 | return new Chart(document.getElementById("ageChart"), { 34 | type: 'bar', 35 | data: { 36 | labels: baseArrayPop.map((x,i) => i+1), 37 | datasets: [ 38 | { 39 | label: 'Age of the top ' + baseArrayPop.length + ' genomes', 40 | backgroundColor: baseArrayPop.map(e => 'rgb(255,90,137)'), 41 | borderColor: baseArrayPop.map(e => 'rgb(230,230,230)'), 42 | data: baseArrayPop.map(e => 0), 43 | } 44 | ] 45 | }, 46 | options: { 47 | scales: { 48 | yAxes: [{ 49 | ticks: { 50 | beginAtZero:true 51 | } 52 | }] 53 | } 54 | } 55 | }); 56 | }; 57 | 58 | export default { 59 | perfChart, 60 | ageChart 61 | } 62 | -------------------------------------------------------------------------------- /src/genetics/Genome.js: -------------------------------------------------------------------------------- 1 | import config from '../config.json'; 2 | import { Architect, Network } from 'synaptic'; 3 | 4 | class Genome { 5 | constructor() { 6 | this.network = {}; 7 | this.matches = []; 8 | this.addNetwork(); 9 | this.fitness = 0; 10 | this.age = 0; 11 | } 12 | addNetwork() { 13 | // Remember to add bias when evaluating 14 | let network = new Architect.Perceptron(config.InputSize,config.HiddenLayerSize,config.HiddenLayerSize,config.Outputs); 15 | this.network = network; 16 | return network; 17 | } 18 | hydrateNetwork() { 19 | this.network = Network.fromJSON(this.network); 20 | } 21 | addMatch(result) { 22 | this.matches.push(result); 23 | this.fitness += result.score; 24 | } 25 | } 26 | 27 | export default Genome; 28 | -------------------------------------------------------------------------------- /src/genetics/Pool.js: -------------------------------------------------------------------------------- 1 | import { cloneDeep } from 'lodash'; 2 | import config from '../config.json'; 3 | import Genome from './Genome'; 4 | import { Network } from 'synaptic'; 5 | import { sm } from '../game/SaveManager'; 6 | class Pool { 7 | constructor() { 8 | this.roundTicksElapsed = 0; 9 | this.generation = 0; 10 | this.maxFitness = 0; 11 | this.currentGenerationMaxFitness = 0; 12 | this.previousMaxFitness = 0; 13 | this.championsPerfs = []; 14 | this.p1GenomeIndex = 0; 15 | this.p2GenomeIndex = 1; 16 | this.genomes = []; 17 | } 18 | 19 | newGeneration() { 20 | this.currentGenerationMaxFitness = 0; 21 | this.genomes.forEach(g => { 22 | if (g.fitness > this.maxFitness) this.maxFitness = g.fitness; 23 | }); 24 | // Kill worst genomes 25 | this.genomes = this.selectBestGenomes(this.genomes,config.KeepAlivePercent,config.Population); 26 | 27 | 28 | const bestGenomes = _.clone(this.genomes); 29 | 30 | 31 | // Crossover 32 | while (this.genomes.length < config.Population - 2) { 33 | const gen1 = this.getRandomGenome(bestGenomes); 34 | const gen2 = this.getRandomGenome(bestGenomes); 35 | const newGenome = this.mutate(this.crossOver(gen1,gen2)); 36 | this.genomes.push(newGenome); 37 | } 38 | // 2 random from the best will get mutations 39 | while (this.genomes.length < config.Population) { 40 | const gen = this.getRandomGenome(bestGenomes); 41 | const newGenome = this.mutate(gen); 42 | this.genomes.push(newGenome); 43 | } 44 | 45 | // Increment the age of a Genome for debug checking 46 | // If the top Genome keeps aging and aging it means no children was able to beat him 47 | // Which might indicate that we're stuck and the network converged 48 | this.genomes.forEach(g => { g.age++ }); 49 | 50 | const generationMax = Math.max.apply(Math,this.genomes.map(g => g.fitness)); 51 | const chartsData = { 52 | x:pool.generation,y:generationMax 53 | }; 54 | this.championsPerfs.push(chartsData); 55 | this.hydrateChart(); 56 | 57 | // Reset Matches & fitness 58 | this.genomes.forEach(g => {g.matches = []; g.fitness = 0}); 59 | 60 | //Save JSON 61 | this.saveState(this); 62 | console.log(`Completed Generation ${this.generation}`); 63 | this.generation++; 64 | } 65 | 66 | saveState(pool) { 67 | sm.saveState(pool); 68 | } 69 | 70 | mutate(gen) { 71 | let networkJSON = gen.network.toJSON(); 72 | const newGenome = new Genome(); 73 | networkJSON.neurons = this.mutateDataKeys(networkJSON.neurons, 'bias', config.MutationChance); 74 | networkJSON.connections = this.mutateDataKeys(networkJSON.connections, 'weight', config.MutationChance); 75 | newGenome.network = Network.fromJSON(networkJSON); 76 | return newGenome; 77 | } 78 | 79 | // Given an array of object with key and mutationChance 80 | // randomly mutate the value of each key 81 | mutateDataKeys(obj,key,mutationChance) { 82 | const finalObj = cloneDeep(obj); 83 | finalObj.forEach(o => { 84 | if (Math.random() < mutationChance) { 85 | o[key] += o[key] * (Math.random() - 0.5) * 3 + (Math.random() - 0.5); 86 | }; 87 | }); 88 | return finalObj; 89 | } 90 | 91 | hydrateChart() { 92 | chart.data.datasets[0].data = this.championsPerfs.slice(); 93 | chart.update(); 94 | const ageStats = this.genomes.map(g => g.age); 95 | ageStats.length = ~~(config.Population * config.KeepAlivePercent); 96 | ageChart.data.datasets[0].data = ageStats; 97 | ageChart.update(); 98 | } 99 | getRandomGenome(list) { 100 | return list[~~(Math.random()*list.length)]; 101 | } 102 | 103 | // Will only touch the neurons part of the network 104 | // Taking some part from gen1 network, and the rest from gen2 105 | crossOver(gen1,gen2,swapChance=0.5) { 106 | // Grab the json version of their networks 107 | // then compute changes 108 | if (Math.random() < swapChance) [gen1,gen2] = [gen2,gen1]; 109 | 110 | //Extract their networks 111 | const [ net1, net2 ] = [gen1,gen2].map(g => g.network.toJSON()); 112 | const child = new Genome(); 113 | 114 | // Get the result of crossover of the bias of the neurons 115 | const crossedNeurons = this.crossOverDataKey(net1.neurons,net2.neurons, 'bias'); 116 | net1.neurons = crossedNeurons; 117 | // Reconstruct the synaptic Network back 118 | child.network = Network.fromJSON(net1); 119 | return child; 120 | } 121 | 122 | // Given 2 arrays of objects, 123 | // select a crossOver point randomly, 124 | // swap values starting at cut 125 | crossOverDataKey(a,b, key, cutLocation) { 126 | const childNeurons = cloneDeep(a); 127 | cutLocation = cutLocation || ~~(Math.random()*a.length); 128 | for (let i=cutLocation;i g2.fitness - g1.fitness); 137 | genomes.length = ~~(keepRatio * populationCount); 138 | return genomes; 139 | } 140 | 141 | // Populate according to the config with random mutated Genomes 142 | buildInitGenomes() { 143 | let builded = []; 144 | for (let i=0;i g.matches.length).reduce((x,y)=> x+y,0)/config.Population); 179 | } 180 | 181 | getP1Genome() { return this.genomes[this.p1GenomeIndex] } 182 | 183 | getP2Genome() { return this.genomes[this.p2GenomeIndex] } 184 | 185 | getGenomeOfCurve(id) { 186 | return id ? this.getP2Genome() : this.getP1Genome(); 187 | } 188 | 189 | // P1 aka curvesList[0] is played by p1GenomeIndex Genome 190 | getIndexOfCurveGenome(id) { 191 | return id ? this.p2GenomeIndex : this.p1GenomeIndex; 192 | } 193 | 194 | // Receives both Curves and id of the 195 | matchResult({winner, loser}) { 196 | // Winner adds loser to its matches 197 | const winnerGenome = this.getGenomeOfCurve(winner.id); 198 | const loserGenome = this.getGenomeOfCurve(loser.id); 199 | winnerGenome.addMatch( 200 | { 201 | opponent: this.getIndexOfCurveGenome(loser.id), 202 | score: ~~(10 + Math.log10(winner.diedAt)), 203 | winner:true, 204 | }); 205 | loserGenome.addMatch( 206 | { 207 | opponent: this.getIndexOfCurveGenome(winner.id), 208 | score: ~~(Math.log10(loser.diedAt)), 209 | winner:false, 210 | }); 211 | } 212 | 213 | pickPlayers() { 214 | let foundP1 = false; 215 | let foundP2 = false; 216 | foundP1 = this.findOpponent(false,this.genomes); 217 | if (foundP1 === false) { 218 | this.p1GenomeIndex = 0; 219 | this.p2GenomeIndex = 1; 220 | this.newGeneration(); 221 | } else { 222 | foundP2 = this.findOpponent(foundP1, this.genomes); 223 | if (!foundP2) throw new Error('Could not find opponent'); 224 | this.p1GenomeIndex = foundP1; 225 | this.p2GenomeIndex = foundP2; 226 | } 227 | } 228 | 229 | findOpponent(specificOpponent=false, genomesList) { 230 | const { Population } = config; 231 | const matchesToplay = Population - 1; 232 | 233 | for (let i = 0,l = genomesList.length; i m.opponent == specificOpponent); 245 | if (!alreadyPlayed) return i; 246 | } 247 | 248 | } 249 | } 250 | } 251 | return false; 252 | } 253 | 254 | } 255 | export let pool = new Pool(); 256 | -------------------------------------------------------------------------------- /src/global.scss: -------------------------------------------------------------------------------- 1 | html { 2 | background: grey; 3 | } 4 | body { 5 | min-height: 100%; 6 | font-family: Helvetica; 7 | font-size: 13px; 8 | //overflow: hidden; 9 | position: relative; 10 | padding:0; 11 | padding-top:20px; 12 | margin:0; 13 | } 14 | 15 | #defaultCanvas0 { 16 | border-radius: 5px; 17 | //border: 2px solid rgba(0,0,0,.5); 18 | } 19 | #debug-holder { 20 | flex:1; 21 | position: relative; 22 | margin-bottom:20px; 23 | flex-basis: auto; 24 | min-width: 420px; 25 | width:420px; 26 | margin: 0 20px 20px; 27 | text-align: center; 28 | height:420px; 29 | overflow: hidden; 30 | border-radius: 5px; 31 | } 32 | 33 | #see-from-beginning { 34 | position: absolute; 35 | bottom: 8px; 36 | z-index: 42; 37 | transform: translateX(-50%); 38 | } 39 | #debug { 40 | width:420px; 41 | height: 420px; 42 | position: relative; 43 | margin: auto; 44 | padding-top: 10px; 45 | } 46 | .container { 47 | height: 100%; 48 | display: flex; 49 | flex-wrap: wrap; 50 | flex-direction: row; 51 | align-items: center; 52 | /*justify-content: flex-start; 53 | align-items: flex-start; 54 | align-content: flex-start;*/ 55 | flex-basis: auto; 56 | } 57 | 58 | #sketch-holder { 59 | align-items: center; 60 | justify-content: center; 61 | flex:1; 62 | text-align: center; 63 | margin-bottom:20px; 64 | flex-basis: auto; 65 | min-width: 400px; 66 | border-radius: 5px; 67 | overflow: hidden; 68 | margin: 0 20px 20px; 69 | } 70 | #explain-holder, 71 | #charts-holder, 72 | #genealogic-holder, 73 | #age-charts-holder { 74 | margin: 0 20px 20px; 75 | align-items: center; 76 | justify-content: center; 77 | flex:1; 78 | margin-bottom:20px; 79 | flex-basis: auto; 80 | min-width: 400px; 81 | height: 420px; 82 | border-radius: 5px; 83 | overflow: hidden; 84 | background-color: rgb(230,230,230); 85 | } 86 | 87 | #age-charts-holder, 88 | #charts-holder, 89 | #genealogic-holder, 90 | #defaultCanvas0, 91 | #debug-holder { 92 | border-radius: 5px; 93 | overflow: hidden; 94 | box-shadow: 0px 6px 11px 1px rgba(0, 0, 0, 0.2); 95 | background-color: rgb(247, 247, 247); 96 | } 97 | 98 | #explain-holder { 99 | overflow: scroll; 100 | padding:20px; 101 | line-height: 20px; 102 | } 103 | 104 | .charts { 105 | width: 400px; 106 | height:400px; 107 | margin: auto; 108 | text-align: center; 109 | } 110 | -------------------------------------------------------------------------------- /src/index.html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | manifest="<%= htmlWebpackPlugin.files.manifest %>"<% } %>> 7 | 8 | 9 | 10 | 11 | 122 | 123 | <%= htmlWebpackPlugin.options.title || 'Webpack App'%> 124 | 125 | <% if (htmlWebpackPlugin.files.favicon) { %> 126 | 127 | <% } %> 128 | <% if (htmlWebpackPlugin.options.mobile) { %> 129 | 130 | <% } %> 131 | 132 | 133 | 134 | 135 | <% if (htmlWebpackPlugin.options.unsupportedBrowser) { %> 136 | 137 |
138 | Sorry, your browser is not supported. Please upgrade to 139 | the latest version or switch your browser to use this site. 140 | See outdatedbrowser.com 141 | for options. 142 |
143 | <% } %> 144 | 145 | 146 | <% if (htmlWebpackPlugin.options.window) { %> 147 | 152 | <% } %> 153 |
154 |
155 |
156 |
157 | 158 |
159 | 160 |
161 |
162 |
163 | 164 |
165 |
166 | 167 |
168 |
169 | 170 |
171 |
172 | 173 |
174 |

Neuroevolution of Neural Network of snakes in the Browser.

175 | 176 | This is a demonstration of evolving a neural network thanks to genetics algorithms in the browser 177 | using a multilayer perceptron (150-15-15-1). 178 | 179 | The initial population contains 36 individuals, each assigned a different genome. 180 | They will fight following a round-robin tournament. 181 | At the end the top 7 are kept alive, and the remaining 29 are created by breeding from the 7. 182 | 183 | Each snake has 50 sensors, each reporting 3 inputs: 184 |
    185 |
  • 1) The distance the sensor has hit something normalized between 0 and 1
  • 186 |
  • 2) 1 if this sensor touched the enemy body
  • 187 |
  • 3) 1 if this sensor touched the enemy body
  • 188 |

189 | If you reset the simulation, unselect "Show Curves" & "Show Debug" and put the simulation speed to something your CPU supports. 190 |
Code on Github 191 |
192 |
193 | Fork me on GitHub 194 | 195 | 196 | 197 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | //import 'game/utils'; 2 | import 'global.scss'; 3 | import { pool } from 'genetics/Pool'; 4 | import { sm } from 'game/SaveManager'; 5 | pool.init(); 6 | 7 | sm.getLoadState(() => { 8 | chart.data.datasets[0].data = pool.championsPerfs.slice().map(c => { return {x:c.generation,y:c.fitness}}); 9 | chart.update(); 10 | reset(); 11 | }); 12 | import { Game } from 'game/canvas-setup'; 13 | import 'game/canvas-debug'; 14 | window.pool = pool; 15 | window.Game = Game; 16 | // Called one time at load 17 | window.setup = () => { 18 | Game.setup(); 19 | console.log(Game); 20 | } 21 | 22 | // Reset the Canvas 23 | window.reset = () => { 24 | Game.reset(); 25 | } 26 | 27 | // Called on every frame 28 | window.draw = () => { 29 | Game.draw(); 30 | } 31 | 32 | //setTimeout(reset,500); 33 | -------------------------------------------------------------------------------- /src/lib/helper.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elyx0/snakeneuralnetworkjs/ba14e06556815d984f3706194d72f2cccc7a341f/src/lib/helper.js -------------------------------------------------------------------------------- /tests/PoolTest.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import config from '../src/config.json'; 3 | import { pool } from '../src/genetics/Pool'; 4 | import Genome from '../src/genetics/Genome'; 5 | test('Init should populate the pool correctly',t => { 6 | t.plan(1); 7 | t.equal(config.Population,pool.buildInitGenomes().length,'Populated correctly'); 8 | }); 9 | 10 | test('selectBestGenomes must return a trimmed down version', t => { 11 | t.plan(1); 12 | 13 | const actual = pool.selectBestGenomes( 14 | [{fitness:2},{fitness:3},{fitness:0}], .4, 3); 15 | const expected = [{fitness:3}]; 16 | t.deepEqual(actual,expected,'Correctly Trims down to 1'); 17 | }); 18 | 19 | test('crossover 2 genomes', t=> { 20 | t.plan(3); 21 | const gen1 = new Genome(); 22 | const gen2 = new Genome(); 23 | const [neurons1,neurons2] = [gen1,gen2].map(g => g.network.toJSON().neurons) 24 | const cutLocation = ~~(neurons1.length/2); 25 | const crossedKeys = pool.crossOverDataKey(neurons1,neurons2,'bias',cutLocation); 26 | const neuronsFrom = (arr1,arr2,key) => { 27 | return arr1.reduce((acc,neuron,i)=> { return (arr2[i][key] === neuron[key] ? acc+1 : acc)},0); 28 | }; 29 | 30 | t.equal(neuronsFrom(crossedKeys,neurons1,'bias'),cutLocation,'Neurons from a after crossover'); 31 | t.equal(neuronsFrom(crossedKeys,neurons2,'bias'),neurons1.length-cutLocation,'Neurons from b after crossover') 32 | const child = pool.crossOver(gen1,gen2,0); 33 | t.equal(child instanceof Genome,true,'Child is an instance of Genome'); 34 | }); 35 | 36 | test('Mutate',t => { 37 | t.plan(4); 38 | const gen = new Genome(); 39 | const networkJSON = gen.network.toJSON(); 40 | const newGen = pool.mutate(gen); 41 | const newGenNetworkJSON = newGen.network.toJSON(); 42 | const hasAtLeastOneDifferentKeyIn = (obj1,obj2,param,key) => { 43 | return obj1[param].some((el,i) => obj2[param][i][key] !== el[key]); 44 | } 45 | t.equal(hasAtLeastOneDifferentKeyIn(newGenNetworkJSON,newGenNetworkJSON,'neurons','bias'),false,'Same network check'); 46 | t.equal(hasAtLeastOneDifferentKeyIn(newGenNetworkJSON,networkJSON,'neurons','bias'),true,'Mutate changed some network neurons bias'); 47 | t.equal(hasAtLeastOneDifferentKeyIn(newGenNetworkJSON,networkJSON,'connections','weight'),true,'Mutate changed some network neurons weights'); 48 | t.equal(newGen instanceof Genome,true,'Mutated Genome is still a Genome proto'); 49 | }); 50 | 51 | test('Matches & Opponents', t => { 52 | t.plan(8); 53 | pool.init(); 54 | const winner = {id:0,diedAt:150}; 55 | const loser = {id:1,diedAt:10}; 56 | const otherLoser = {id:2,diedAt:20}; 57 | pool.matchResult({winner,loser}); 58 | const match1 = pool.getGenomeOfCurve(winner.id).matches[0]; 59 | const match2 = pool.getGenomeOfCurve(loser.id).matches[0]; 60 | t.deepEqual(match1,{opponent:1,score:12,winner:true},'Winner should get its score'); 61 | t.deepEqual(match2,{opponent:0,score:1,winner:false},'Loser should get its score'); 62 | 63 | let p1Index = pool.findOpponent(false,pool.genomes); 64 | let p2Index = pool.findOpponent(0,pool.genomes); 65 | 66 | t.equal(p1Index,0,'Assigns correctly matches to P1'); 67 | t.equal(p2Index,2,'Assign correctly matches to P2'); 68 | 69 | pool.p1GenomeIndex = p1Index; 70 | pool.p2GenomeIndex = p2Index; 71 | 72 | pool.matchResult({winner,loser:otherLoser}); 73 | 74 | const match3 = pool.getGenomeOfCurve(winner.id).matches[1]; 75 | t.deepEqual(match3,{opponent:2,score:12,winner:true},'P1 winning subsequent matches'); 76 | t.equal(pool.findOpponent(false,pool.genomes),0,'P1 is still the same Genome after 2 matches'); 77 | t.equal(pool.findOpponent(0,pool.genomes),3,'P2 is set to the next Genome'); 78 | t.equal(pool.getGenomeOfCurve(winner.id).fitness,24,'Winner Fitness Grew'); 79 | }); 80 | -------------------------------------------------------------------------------- /webpack.config.base.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import webpack from 'webpack'; 3 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 4 | import ExtractTextPlugin from 'extract-text-webpack-plugin'; 5 | 6 | export default { 7 | module : { 8 | rules: [ 9 | { 10 | test: /\.js$/, 11 | use: 'babel-loader', 12 | exclude: /node_modules/ 13 | }, { 14 | test: /\.json$/, 15 | use: 'json-loader' 16 | }, 17 | { 18 | test: /\.scss$/, 19 | use: ExtractTextPlugin.extract({ 20 | use: [{ 21 | loader: 'css-loader' 22 | },{ 23 | loader: 'sass-loader' 24 | }], 25 | fallback: 'style-loader' 26 | }) 27 | } 28 | ] 29 | }, 30 | output : { 31 | path: path.join(__dirname, 'dist'), 32 | filename: 'bundle.js', 33 | //libraryTarget: 'commonjs2' 34 | }, 35 | resolve : { 36 | extensions: [ 37 | '.js', '.json' 38 | ], 39 | modules: [ 40 | path.join(__dirname, 'src'), 41 | 'node_modules' 42 | ] 43 | }, 44 | plugins : [ 45 | new ExtractTextPlugin({ 46 | filename: "[name].[contenthash].css" 47 | }), 48 | new webpack.NamedModulesPlugin(), 49 | new HtmlWebpackPlugin({title: 'Neuroevolution of snakes', template: 'src/index.html.ejs'}) 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /webpack.config.development.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import webpack from 'webpack'; 3 | import merge from 'webpack-merge'; 4 | import ExtractTextPlugin from 'extract-text-webpack-plugin'; 5 | 6 | import baseConfig from './webpack.config.base'; 7 | 8 | const port = process.env.PORT || 3000; 9 | 10 | //const publicPath = `http://localhost:${port}/dist`; 11 | const publicPath = `/dist`; 12 | 13 | 14 | export default merge(baseConfig, { 15 | devtool: '#source-map', 16 | entry: [ 17 | path.join(__dirname, 'src/index.js') 18 | ], 19 | output: { 20 | publicPath 21 | }, 22 | plugins: [ 23 | new webpack.DefinePlugin({ 24 | 'process.env.NODE_ENV': JSON.stringify('development') 25 | }), 26 | new webpack.LoaderOptionsPlugin({ 27 | debug: true 28 | }) 29 | ] 30 | }); 31 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | require('babel-register'); 2 | module.exports = process.env.NODE_ENV !== 'production' ? require('./webpack.config.development') : require('./webpack.config.production'); 3 | -------------------------------------------------------------------------------- /webpack.config.production.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import webpack from 'webpack'; 3 | import merge from 'webpack-merge'; 4 | import ExtractTextPlugin from 'extract-text-webpack-plugin'; 5 | 6 | import baseConfig from './webpack.config.base'; 7 | 8 | const port = process.env.PORT || 3000; 9 | 10 | //const publicPath = `http://localhost:${port}/dist`; 11 | const publicPath = `/dist`; 12 | 13 | 14 | export default merge(baseConfig, { 15 | devtool: '#cheap-module-source-map', 16 | entry: [ 17 | path.join(__dirname, 'src/index.js') 18 | ], 19 | output: { 20 | publicPath 21 | }, 22 | plugins: [ 23 | new webpack.DefinePlugin({ 24 | 'process.env.NODE_ENV': JSON.stringify('production') 25 | }), 26 | new webpack.LoaderOptionsPlugin({ 27 | debug: true 28 | }) 29 | ] 30 | }); 31 | --------------------------------------------------------------------------------