├── .gitignore ├── LICENSE ├── README.md ├── bot-manager.js ├── broker.js ├── cell.js ├── coin-manager.js ├── config.js ├── package.json ├── public ├── SAT.min.js ├── channel-grid.js ├── favicon.ico ├── img │ ├── background-texture.png │ ├── bot-back.gif │ ├── bot-front.gif │ ├── bot-side-left.gif │ ├── bot-side-right.gif │ ├── food-eaten-logo.gif │ ├── forager-logo.gif │ ├── grass-1.gif │ ├── grass-2.gif │ ├── grass-3.gif │ ├── grass-4.gif │ ├── iogrid.gif │ ├── logo.png │ ├── others-back.gif │ ├── others-front.gif │ ├── others-side-left.gif │ ├── others-side-right.gif │ ├── sc-phaser-sample.png │ ├── time-left-label.gif │ ├── you-back.gif │ ├── you-front.gif │ ├── you-side-left.gif │ └── you-side-right.gif ├── index.html ├── phaser.min.js ├── rbush.min.js ├── sc-codec-min-bin.js └── socketcluster.js ├── server.js ├── state-manager.js ├── util.js └── worker.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2016-2017 SocketCluster.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | IOGrid 2 | ====== 3 | 4 | [![Join the chat at https://gitter.im/SocketCluster/iogrid](https://badges.gitter.im/SocketCluster/iogrid.svg)](https://gitter.im/SocketCluster/iogrid?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 5 | 6 | IOGrid is an IO game engine/framework built using SocketCluster and Phaser. 7 | It lets you build multi-player games like Agar.io and Slither.io and also multi-player simulations/experiments for research purposes. 8 | It is designed to scale across multiple processes to make use of all CPU cores on a machine. 9 | 10 | The game world is divided into cells which will be distributed across available SC worker processes. 11 | Basic initial tests indicate that this engine can scale linearly across available CPU cores - I've found that doubling 12 | the number of worker processes allowed the engine to handle approximately double the number of bots whilst maintaining the average CPU usage 13 | per worker process at 50%. 14 | 15 | Each cell in the world has its own instance of a cell controller (`cell.js`) - Ideally, this is where you should put all your back end game logic. 16 | If you follow some simple structural guidelines, your code should automatically scale. 17 | With this approach, you should be able to build very large worlds which can host thousands of concurrent players. 18 | 19 | If you've built a game using this engine, feel free to contribute back to this repo. 20 | Also, feel free to get in touch with me directly by email (see my GitHub profile http://github.com/jondubois) if you'd like to chat, have feedback, 21 | need advice or need help with a project. 22 | 23 | IOGrid demo 24 | 25 | Special thanks to the Percepts and Concepts Laboratory at Indiana University (http://cognitrn.psych.indiana.edu/) for sponsoring this project. 26 | 27 | ### Developing 28 | 29 | The front-end code is in `public/index.html`, the back-end code is in `worker.js` and `cell.js`. 30 | Read the comments in the code for more details about how it all works. 31 | 32 | ### Running 33 | 34 | To run on your machine, you need to have Node.js `v6.0.0` or higher installed. 35 | Then you can either clone this repo with Git using the command: 36 | 37 | ``` 38 | git clone git@github.com:SocketCluster/iogrid.git 39 | ``` 40 | 41 | ... Or you can download the zip: https://github.com/SocketCluster/iogrid/archive/master.zip and extract it to a directory of your choice. 42 | 43 | Once you have this repo setup in a `iogrid` directory on your machine, you need to navigate to it using the terminal and then run: 44 | 45 | ``` 46 | npm install 47 | ``` 48 | 49 | Then (while still inside the `iogrid` directory) you can launch the SocketCluster server using: 50 | 51 | ``` 52 | node server 53 | ``` 54 | 55 | To run the demo, navigate to `http://localhost:8000` in a browser - You should see a rabbit which you can move around using the arrow keys. 56 | 57 | To test the multi-player functionality from your localhost: 58 | 59 | Open up another browser window/tab to `http://localhost:8000` and put it side-by-side with the first window/tab - You should now have two rabbits - Each one can be controlled from a different tab. 60 | 61 | Note that while this demo demonstrates a few important optimizations, it can still be optimized further. 62 | For production usage, among other things, you may want to improve the current codec to make the packets that are sent to the client even smaller. 63 | You may want to build your own codec on top of https://github.com/SocketCluster/sc-codec-min-bin. 64 | 65 | If you want to run the server on port 80, you'll need to run the SocketCluster server with `sudo node server -p 80`. 66 | 67 | For more info about SocketCluster, visit http://socketcluster.io/. 68 | 69 | If you want to find out more about authentication and authorization, you may want to look into SC middleware: http://socketcluster.io/#!/docs/middleware-and- 70 | 71 | To run the engine on multiple CPU cores, you just need to add more worker and broker processes. 72 | You can do this by adding extra parameters to the node server command (`-w` is the number of worker processes and `-b` is the number of broker processes): 73 | 74 | ``` 75 | node server -w 3 -b 1 76 | ``` 77 | 78 | Unless your CPU/OS is particularly efficient with multitasking, you generally want to have one process per CPU core (to avoid sharing cores/context switching penalties). Note that in the example above, we are launching 4 processes in total; 3 workers and 1 broker. 79 | 80 | Deciding on the correct ratio of workers to brokers is a bit of a balancing act and will vary based on your specific workload - You will have to try it out and watch your processes. When you launch the engine, SocketCluster will tell you the PIDs of your worker and broker processes. 81 | 82 | Based on the rudimentary tests that I've carried out so far, I've found that you generally need more workers than brokers. The ratio of workers to brokers that seems 83 | to work best for most use cases is approximately 2:1. 84 | 85 | Also note that cell controllers (`cell.js`) will be evenly sharded across available workers. For this reason, it is highly recommended that you divide your world grid 86 | in such a way that your number of worker processes and total number of cells share a common factor. So for example, if you have 3 workers, you can have a world grid with dimensions of 3000 * 3000 pixels made up of 3 cells of dimensions 1000 * 3000 (rectangular cells are fine; in fact, I highly encourage them since they are more efficient). 87 | 88 | ### More Info 89 | 90 | It's still very early for this project, here are some things that still need improving: 91 | 92 | - The front end needs some sort of motion smoothing since we don't want to set the WORLD_UPDATE_INTERVAL too high (for bandwidth reasons) and so the animation should be smoothed out on the front end. 93 | - The front end needs an overall cleanup; maybe we need to move the core logic outside of index.html into its own .js file... And maybe we can start using the import statement (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) to load dependencies? 94 | - We need to make a custom SocketCluster codec specifically for this game engine to compress all outgoing messages to be as small as possible. Right now it's just using a general-purpose binary compression codec for SC - We should add another codec layer on top of this. 95 | 96 | ## License 97 | 98 | [MIT](LICENSE) 99 | -------------------------------------------------------------------------------- /bot-manager.js: -------------------------------------------------------------------------------- 1 | var uuid = require('uuid'); 2 | var SAT = require('sat'); 3 | 4 | var BOT_DEFAULT_DIAMETER = 80; 5 | var BOT_DEFAULT_SPEED = 1; 6 | var BOT_DEFAULT_MASS = 10; 7 | var BOT_DEFAULT_CHANGE_DIRECTION_PROBABILITY = 0.01; 8 | 9 | var BotManager = function (options) { 10 | this.worldWidth = options.worldWidth; 11 | this.worldHeight = options.worldHeight; 12 | if (options.botMoveSpeed == null) { 13 | this.botMoveSpeed = BOT_DEFAULT_SPEED; 14 | } else { 15 | this.botMoveSpeed = options.botMoveSpeed; 16 | } 17 | this.botMass = options.botMass || BOT_DEFAULT_MASS; 18 | this.botChangeDirectionProbability = options.botChangeDirectionProbability || BOT_DEFAULT_CHANGE_DIRECTION_PROBABILITY; 19 | this.botDefaultDiameter = options.botDefaultDiameter || BOT_DEFAULT_DIAMETER; 20 | 21 | this.botMoves = [ 22 | {u: 1}, 23 | {d: 1}, 24 | {r: 1}, 25 | {l: 1} 26 | ]; 27 | }; 28 | 29 | BotManager.prototype.generateRandomPosition = function (botRadius) { 30 | var botDiameter = botRadius * 2; 31 | var position = { 32 | x: Math.round(Math.random() * (this.worldWidth - botDiameter) + botRadius), 33 | y: Math.round(Math.random() * (this.worldHeight - botDiameter) + botRadius) 34 | }; 35 | return position; 36 | }; 37 | 38 | BotManager.prototype.addBot = function (options) { 39 | if (!options) { 40 | options = {}; 41 | } 42 | var diameter = options.diam || this.botDefaultDiameter; 43 | var radius = Math.round(diameter / 2); 44 | var botId = uuid.v4(); 45 | 46 | var bot = { 47 | id: botId, 48 | type: 'player', 49 | subtype: 'bot', 50 | name: options.name || 'bot-' + Math.round(Math.random() * 10000), 51 | score: options.score || 0, 52 | speed: options.speed == null ? this.botMoveSpeed : options.speed, 53 | mass: options.mass || this.botMass, 54 | diam: diameter, 55 | changeDirProb: this.botChangeDirectionProbability, 56 | op: {} 57 | }; 58 | if (options.x && options.y) { 59 | bot.x = options.x; 60 | bot.y = options.y; 61 | } else { 62 | var position = this.generateRandomPosition(radius); 63 | if (options.x) { 64 | bot.x = options.x; 65 | } else { 66 | bot.x = position.x; 67 | } 68 | if (options.y) { 69 | bot.y = options.y; 70 | } else { 71 | bot.y = position.y; 72 | } 73 | } 74 | 75 | return bot; 76 | }; 77 | 78 | BotManager.prototype.removeBot = function (bot) { 79 | bot.delete = 1; 80 | }; 81 | 82 | module.exports.BotManager = BotManager; 83 | -------------------------------------------------------------------------------- /broker.js: -------------------------------------------------------------------------------- 1 | var scClusterBrokerClient = require('sc-cluster-broker-client'); 2 | 3 | module.exports.run = function (broker) { 4 | console.log(' >> Broker PID:', process.pid); 5 | 6 | // This is defined in server.js (taken from environment variable SC_CLUSTER_STATE_SERVER_HOST). 7 | // If this property is defined, the broker will try to attach itself to the SC cluster for 8 | // automatic horizontal scalability. 9 | // This is mostly intended for the Kubernetes deployment of SocketCluster - In this case, 10 | // The clustering/sharding all happens automatically. 11 | 12 | if (broker.options.clusterStateServerHost) { 13 | scClusterBrokerClient.attach(broker, { 14 | stateServerHost: broker.options.clusterStateServerHost, 15 | stateServerPort: broker.options.clusterStateServerPort, 16 | authKey: broker.options.clusterAuthKey, 17 | stateServerConnectTimeout: broker.options.clusterStateServerConnectTimeout, 18 | stateServerAckTimeout: broker.options.clusterStateServerAckTimeout, 19 | stateServerReconnectRandomness: broker.options.clusterStateServerReconnectRandomness 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /cell.js: -------------------------------------------------------------------------------- 1 | /* 2 | Note that the main run() loop will be executed once per frame as specified by WORLD_UPDATE_INTERVAL in worker.js. 3 | Behind the scenes, the engine just keeps on building up a cellData tree of all different 4 | state objects that are present within our current grid cell. 5 | The tree is a simple JSON object and needs to be in the format: 6 | 7 | { 8 | // player is a state type. 9 | player: { 10 | // ... 11 | }, 12 | // You can add other custom state types. 13 | someType: { 14 | // Use the id as the key (in the place of someId). 15 | // It is recommended that you use a random uuid as the state id. See https://www.npmjs.com/package/uuid 16 | someId: { 17 | // All properties listed here are required. 18 | // You can add additional ones. 19 | id: someId, 20 | type: someType, 21 | x: someXCoordinate, 22 | y: someYCoordinate, 23 | }, 24 | anotherId: { 25 | // ... 26 | } 27 | } 28 | } 29 | 30 | You can add new type subtrees, new states and new properties to the cellData 31 | as you like. So long as you follow the structure above, the items will show 32 | up on the front end in the relevant cell in our world (see the handleCellData function in index.html). 33 | 34 | Adding new items to the cell is easy. 35 | For example, to add a new coin to the cell, you just need to add a state object to the coin subtree (E.g): 36 | cellData.coin[coin.id] = coin; 37 | 38 | See how coinManager.addCoin is used below (and how it's implemented) for more details. 39 | 40 | Note that states which are close to our current cell (based on WORLD_CELL_OVERLAP_DISTANCE) 41 | but not exactly inside it will still be visible within this cell (they will have an additional 42 | 'external' property set to true). 43 | 44 | External states should not be modified because they belong to a different cell and the change will be ignored. 45 | */ 46 | 47 | var _ = require('lodash'); 48 | var rbush = require('rbush'); 49 | var SAT = require('sat'); 50 | var config = require('./config'); 51 | var BotManager = require('./bot-manager').BotManager; 52 | var CoinManager = require('./coin-manager').CoinManager; 53 | 54 | // This controller will be instantiated once for each 55 | // cell in our world grid. 56 | 57 | var CellController = function (options, util) { 58 | var self = this; 59 | 60 | this.options = options; 61 | this.cellIndex = options.cellIndex; 62 | this.util = util; 63 | 64 | // You can use the exchange object to publish data to global channels which you 65 | // can watch on the front end (in index.html). 66 | // The API for the exchange object is here: http://socketcluster.io/#!/docs/api-exchange 67 | // To receive channel data on the front end, you can read about the subscribe and watch 68 | // functions here: http://socketcluster.io/#!/docs/basic-usage 69 | this.exchange = options.worker.exchange; 70 | 71 | this.worldColCount = Math.ceil(config.WORLD_WIDTH / config.WORLD_CELL_WIDTH); 72 | this.worldRowCount = Math.ceil(config.WORLD_HEIGHT / config.WORLD_CELL_HEIGHT); 73 | this.worldCellCount = this.worldColCount * this.worldRowCount; 74 | this.workerCount = options.worker.options.workers; 75 | 76 | this.coinMaxCount = Math.round(config.COIN_MAX_COUNT / this.worldCellCount); 77 | this.coinDropInterval = config.COIN_DROP_INTERVAL * this.worldCellCount; 78 | this.botCount = Math.round(config.BOT_COUNT / this.worldCellCount); 79 | 80 | var cellData = options.cellData; 81 | 82 | this.botManager = new BotManager({ 83 | worldWidth: config.WORLD_WIDTH, 84 | worldHeight: config.WORLD_HEIGHT, 85 | botDefaultDiameter: config.BOT_DEFAULT_DIAMETER, 86 | botMoveSpeed: config.BOT_MOVE_SPEED, 87 | botMass: config.BOT_MASS, 88 | botChangeDirectionProbability: config.BOT_CHANGE_DIRECTION_PROBABILITY 89 | }); 90 | 91 | if (!cellData.player) { 92 | cellData.player = {}; 93 | } 94 | 95 | for (var b = 0; b < this.botCount; b++) { 96 | var bot = this.botManager.addBot(); 97 | cellData.player[bot.id] = bot; 98 | } 99 | 100 | this.botMoves = [ 101 | {u: 1}, 102 | {d: 1}, 103 | {r: 1}, 104 | {l: 1} 105 | ]; 106 | 107 | this.coinManager = new CoinManager({ 108 | cellData: options.cellData, 109 | cellBounds: options.cellBounds, 110 | playerNoDropRadius: config.COIN_PLAYER_NO_DROP_RADIUS, 111 | coinMaxCount: this.coinMaxCount, 112 | coinDropInterval: this.coinDropInterval 113 | }); 114 | 115 | this.lastCoinDrop = 0; 116 | 117 | config.COIN_TYPES.sort(function (a, b) { 118 | if (a.probability < b.probability) { 119 | return -1; 120 | } 121 | if (a.probability > b.probability) { 122 | return 1; 123 | } 124 | return 0; 125 | }); 126 | 127 | this.coinTypes = []; 128 | var probRangeStart = 0; 129 | config.COIN_TYPES.forEach(function (coinType) { 130 | var coinTypeClone = _.cloneDeep(coinType); 131 | coinTypeClone.prob = probRangeStart; 132 | self.coinTypes.push(coinTypeClone); 133 | probRangeStart += coinType.probability; 134 | }); 135 | 136 | this.playerCompareFn = function (a, b) { 137 | if (a.id < b.id) { 138 | return -1; 139 | } 140 | if (a.id > b.id) { 141 | return 1; 142 | } 143 | return 0; 144 | }; 145 | 146 | this.diagonalSpeedFactor = Math.sqrt(1 / 2); 147 | }; 148 | 149 | /* 150 | The main run loop for our cell controller. 151 | */ 152 | CellController.prototype.run = function (cellData) { 153 | if (!cellData.player) { 154 | cellData.player = {}; 155 | } 156 | if (!cellData.coin) { 157 | cellData.coin = {}; 158 | } 159 | var players = cellData.player; 160 | var coins = cellData.coin; 161 | 162 | // Sorting is important to achieve consistency across cells. 163 | var playerIds = Object.keys(players).sort(this.playerCompareFn); 164 | 165 | this.findPlayerOverlaps(playerIds, players, coins); 166 | this.dropCoins(coins); 167 | this.generateBotOps(playerIds, players); 168 | this.applyPlayerOps(playerIds, players, coins); 169 | }; 170 | 171 | CellController.prototype.dropCoins = function (coins) { 172 | var now = Date.now(); 173 | 174 | if (now - this.lastCoinDrop >= this.coinManager.coinDropInterval && 175 | this.coinManager.coinCount < this.coinManager.coinMaxCount) { 176 | 177 | this.lastCoinDrop = now; 178 | 179 | var rand = Math.random(); 180 | var chosenCoinType; 181 | 182 | var numTypes = this.coinTypes.length; 183 | for (var i = numTypes - 1; i >= 0; i--) { 184 | var curCoinType = this.coinTypes[i]; 185 | if (rand >= curCoinType.prob) { 186 | chosenCoinType = curCoinType; 187 | break; 188 | } 189 | } 190 | 191 | if (!chosenCoinType) { 192 | throw new Error('There is something wrong with the coin probability distribution. ' + 193 | 'Check that probabilities add up to 1 in COIN_TYPES config option.'); 194 | } 195 | 196 | var coin = this.coinManager.addCoin(chosenCoinType.value, chosenCoinType.type, chosenCoinType.radius); 197 | if (coin) { 198 | coins[coin.id] = coin; 199 | } 200 | } 201 | }; 202 | 203 | CellController.prototype.generateBotOps = function (playerIds, players, coins) { 204 | var self = this; 205 | 206 | playerIds.forEach(function (playerId) { 207 | var player = players[playerId]; 208 | // States which are external are managed by a different cell, therefore changes made to these 209 | // states are not saved unless they are grouped with one or more internal states from the current cell. 210 | // See util.groupStates() method near the bottom of this file for details. 211 | if (player.subtype == 'bot' && !player.external) { 212 | var radius = Math.round(player.diam / 2); 213 | var isBotOnEdge = player.x <= radius || player.x >= config.WORLD_WIDTH - radius || 214 | player.y <= radius || player.y >= config.WORLD_HEIGHT - radius; 215 | 216 | if (Math.random() <= player.changeDirProb || isBotOnEdge) { 217 | var randIndex = Math.floor(Math.random() * self.botMoves.length); 218 | player.repeatOp = self.botMoves[randIndex]; 219 | } 220 | if (player.repeatOp) { 221 | player.op = player.repeatOp; 222 | } 223 | } 224 | }); 225 | }; 226 | 227 | CellController.prototype.keepPlayerOnGrid = function (player) { 228 | var radius = Math.round(player.diam / 2); 229 | 230 | var leftX = player.x - radius; 231 | var rightX = player.x + radius; 232 | var topY = player.y - radius; 233 | var bottomY = player.y + radius; 234 | 235 | if (leftX < 0) { 236 | player.x = radius; 237 | } else if (rightX > config.WORLD_WIDTH) { 238 | player.x = config.WORLD_WIDTH - radius; 239 | } 240 | if (topY < 0) { 241 | player.y = radius; 242 | } else if (bottomY > config.WORLD_HEIGHT) { 243 | player.y = config.WORLD_HEIGHT - radius; 244 | } 245 | }; 246 | 247 | CellController.prototype.applyPlayerOps = function (playerIds, players, coins) { 248 | var self = this; 249 | 250 | playerIds.forEach(function (playerId) { 251 | var player = players[playerId]; 252 | 253 | var playerOp = player.op; 254 | var moveSpeed; 255 | if (player.subtype == 'bot') { 256 | moveSpeed = player.speed; 257 | } else { 258 | moveSpeed = config.PLAYER_DEFAULT_MOVE_SPEED; 259 | } 260 | 261 | if (playerOp) { 262 | var movementVector = {x: 0, y: 0}; 263 | var movedHorizontally = false; 264 | var movedVertically = false; 265 | 266 | if (playerOp.u) { 267 | movementVector.y = -moveSpeed; 268 | player.direction = 'up'; 269 | movedVertically = true; 270 | } 271 | if (playerOp.d) { 272 | movementVector.y = moveSpeed; 273 | player.direction = 'down'; 274 | movedVertically = true; 275 | } 276 | if (playerOp.r) { 277 | movementVector.x = moveSpeed; 278 | player.direction = 'right'; 279 | movedHorizontally = true; 280 | } 281 | if (playerOp.l) { 282 | movementVector.x = -moveSpeed; 283 | player.direction = 'left'; 284 | movedHorizontally = true; 285 | } 286 | 287 | if (movedHorizontally && movedVertically) { 288 | movementVector.x *= self.diagonalSpeedFactor; 289 | movementVector.y *= self.diagonalSpeedFactor; 290 | } 291 | 292 | player.x += movementVector.x; 293 | player.y += movementVector.y; 294 | } 295 | 296 | if (player.playerOverlaps) { 297 | player.playerOverlaps.forEach(function (otherPlayer) { 298 | self.resolvePlayerCollision(player, otherPlayer); 299 | self.keepPlayerOnGrid(otherPlayer); 300 | }); 301 | delete player.playerOverlaps; 302 | } 303 | 304 | if (player.coinOverlaps) { 305 | player.coinOverlaps.forEach(function (coin) { 306 | if (self.testCircleCollision(player, coin).collided) { 307 | player.score += coin.v; 308 | self.coinManager.removeCoin(coin.id); 309 | } 310 | }); 311 | delete player.coinOverlaps; 312 | } 313 | 314 | self.keepPlayerOnGrid(player); 315 | }); 316 | }; 317 | 318 | CellController.prototype.findPlayerOverlaps = function (playerIds, players, coins) { 319 | var self = this; 320 | 321 | var playerTree = new rbush(); 322 | var hitAreaList = []; 323 | 324 | playerIds.forEach(function (playerId) { 325 | var player = players[playerId]; 326 | player.hitArea = self.generateHitArea(player); 327 | hitAreaList.push(player.hitArea); 328 | }); 329 | 330 | playerTree.load(hitAreaList); 331 | 332 | playerIds.forEach(function (playerId) { 333 | var player = players[playerId]; 334 | playerTree.remove(player.hitArea); 335 | var hitList = playerTree.search(player.hitArea); 336 | playerTree.insert(player.hitArea); 337 | 338 | hitList.forEach(function (hit) { 339 | if (!player.playerOverlaps) { 340 | player.playerOverlaps = []; 341 | } 342 | player.playerOverlaps.push(hit.target); 343 | }); 344 | }); 345 | 346 | var coinIds = Object.keys(coins); 347 | coinIds.forEach(function (coinId) { 348 | var coin = coins[coinId]; 349 | var coinHitArea = self.generateHitArea(coin); 350 | var hitList = playerTree.search(coinHitArea); 351 | 352 | if (hitList.length) { 353 | // If multiple players hit the coin, give it to a random one. 354 | var randomIndex = Math.floor(Math.random() * hitList.length); 355 | var coinWinner = hitList[randomIndex].target; 356 | 357 | if (!coinWinner.coinOverlaps) { 358 | coinWinner.coinOverlaps = []; 359 | } 360 | coinWinner.coinOverlaps.push(coin); 361 | } 362 | }); 363 | 364 | playerIds.forEach(function (playerId) { 365 | delete players[playerId].hitArea; 366 | }); 367 | }; 368 | 369 | CellController.prototype.generateHitArea = function (target) { 370 | var targetRadius = target.r || Math.round(target.diam / 2); 371 | return { 372 | target: target, 373 | minX: target.x - targetRadius, 374 | minY: target.y - targetRadius, 375 | maxX: target.x + targetRadius, 376 | maxY: target.y + targetRadius 377 | }; 378 | }; 379 | 380 | CellController.prototype.testCircleCollision = function (a, b) { 381 | var radiusA = a.r || Math.round(a.diam / 2); 382 | var radiusB = b.r || Math.round(b.diam / 2); 383 | 384 | var circleA = new SAT.Circle(new SAT.Vector(a.x, a.y), radiusA); 385 | var circleB = new SAT.Circle(new SAT.Vector(b.x, b.y), radiusB); 386 | 387 | var response = new SAT.Response(); 388 | var collided = SAT.testCircleCircle(circleA, circleB, response); 389 | 390 | return { 391 | collided: collided, 392 | overlapV: response.overlapV 393 | }; 394 | }; 395 | 396 | CellController.prototype.resolvePlayerCollision = function (player, otherPlayer) { 397 | var result = this.testCircleCollision(player, otherPlayer); 398 | 399 | if (result.collided) { 400 | var olv = result.overlapV; 401 | 402 | var totalMass = player.mass + otherPlayer.mass; 403 | var playerBuff = player.mass / totalMass; 404 | var otherPlayerBuff = otherPlayer.mass / totalMass; 405 | 406 | player.x -= olv.x * otherPlayerBuff; 407 | player.y -= olv.y * otherPlayerBuff; 408 | otherPlayer.x += olv.x * playerBuff; 409 | otherPlayer.y += olv.y * playerBuff; 410 | 411 | /* 412 | Whenever we have one state affecting the (x, y) coordinates of 413 | another state, we should group them together using the util.groupStates() function. 414 | Otherwise we will may get flicker when the two states interact across 415 | a cell boundary. 416 | In this case, if we don't use groupStates(), there will be flickering when you 417 | try to push another player across to a different cell. 418 | */ 419 | this.util.groupStates([player, otherPlayer]); 420 | } 421 | }; 422 | 423 | module.exports = CellController; 424 | -------------------------------------------------------------------------------- /coin-manager.js: -------------------------------------------------------------------------------- 1 | var uuid = require('uuid'); 2 | var SAT = require('sat'); 3 | 4 | var MAX_TRIALS = 10; 5 | 6 | var COIN_DEFAULT_RADIUS = 10; 7 | var COIN_DEFAULT_VALUE = 1; 8 | 9 | var CoinManager = function (options) { 10 | this.cellData = options.cellData; 11 | 12 | var cellBounds = options.cellBounds; 13 | this.cellBounds = cellBounds; 14 | this.cellX = cellBounds.minX; 15 | this.cellY = cellBounds.minY; 16 | this.cellWidth = cellBounds.maxX - cellBounds.minX; 17 | this.cellHeight = cellBounds.maxY - cellBounds.minY; 18 | 19 | this.playerNoDropRadius = options.playerNoDropRadius; 20 | this.coinMaxCount = options.coinMaxCount; 21 | this.coinDropInterval = options.coinDropInterval; 22 | 23 | this.coins = {}; 24 | this.coinCount = 0; 25 | }; 26 | 27 | CoinManager.prototype.generateRandomAvailablePosition = function (coinRadius) { 28 | var coinDiameter = coinRadius * 2; 29 | var circles = []; 30 | 31 | var players = this.cellData.player; 32 | 33 | for (var i in players) { 34 | var curPlayer = players[i]; 35 | circles.push(new SAT.Circle(new SAT.Vector(curPlayer.x, curPlayer.y), this.playerNoDropRadius)); 36 | } 37 | 38 | var position = null; 39 | 40 | for (var j = 0; j < MAX_TRIALS; j++) { 41 | var tempPosition = { 42 | x: this.cellX + Math.round(Math.random() * (this.cellWidth - coinDiameter) + coinRadius), 43 | y: this.cellY + Math.round(Math.random() * (this.cellHeight - coinDiameter) + coinRadius) 44 | } 45 | 46 | var tempPoint = new SAT.Vector(tempPosition.x, tempPosition.y); 47 | 48 | var validPosition = true; 49 | for (var k = 0; k < circles.length; k++) { 50 | if (SAT.pointInCircle(tempPoint, circles[k])) { 51 | validPosition = false; 52 | break; 53 | } 54 | } 55 | if (validPosition) { 56 | position = tempPosition; 57 | break; 58 | } 59 | } 60 | return position; 61 | }; 62 | 63 | CoinManager.prototype.addCoin = function (value, subtype, radius) { 64 | radius = radius || COIN_DEFAULT_RADIUS; 65 | var coinId = uuid.v4(); 66 | var validPosition = this.generateRandomAvailablePosition(radius); 67 | if (validPosition) { 68 | var coin = { 69 | id: coinId, 70 | type: 'coin', 71 | t: subtype || 1, 72 | v: value || COIN_DEFAULT_VALUE, 73 | r: radius, 74 | x: validPosition.x, 75 | y: validPosition.y 76 | }; 77 | this.coins[coinId] = coin; 78 | this.coinCount++; 79 | return coin; 80 | } 81 | return null; 82 | }; 83 | 84 | CoinManager.prototype.removeCoin = function (coinId) { 85 | var coin = this.coins[coinId]; 86 | if (coin) { 87 | coin.delete = 1; 88 | delete this.coins[coinId]; 89 | this.coinCount--; 90 | } 91 | }; 92 | 93 | CoinManager.prototype.doesPlayerTouchCoin = function (coinId, player) { 94 | var coin = this.coins[coinId]; 95 | if (!coin) { 96 | return false; 97 | } 98 | var playerCircle = new SAT.Circle(new SAT.Vector(player.x, player.y), Math.ceil(player.width / 2)); 99 | var coinCircle = new SAT.Circle(new SAT.Vector(coin.x, coin.y), coin.r); 100 | return SAT.testCircleCircle(playerCircle, coinCircle); 101 | }; 102 | 103 | module.exports.CoinManager = CoinManager; 104 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | // Global configuration options for IOGrid back end 2 | 3 | module.exports = { 4 | // Having a large world (lower player density) is more efficient. 5 | // You can divide it up into cells to split up the workload between 6 | // multiple CPU cores. 7 | WORLD_WIDTH: 4000, 8 | WORLD_HEIGHT: 4000, 9 | // Dividing the world into tall vertical strips (instead of square cells) 10 | // tends to be more efficient (but this may vary depending on your use case 11 | // and world size). 12 | WORLD_CELL_WIDTH: 1000, 13 | WORLD_CELL_HEIGHT: 4000, 14 | /* 15 | The WORLD_CELL_OVERLAP_DISTANCE allows players/states from two different 16 | cells on the grid to interact with one another. 17 | States from different cells will show up in your cell controller but will have a 18 | special 'external' property set to true. 19 | This represents the maximum distance that two states can be from one another if they 20 | are in different cells and need to interact with one another. 21 | A smaller value is more efficient. Since this overlap area requires coordination 22 | between multiple cells. 23 | */ 24 | WORLD_CELL_OVERLAP_DISTANCE: 150, 25 | /* 26 | This is the interval (in milliseconds) within which the world updates itself. 27 | It also determines the frequency at which data is broadcast to users. 28 | Making this value higher will boost performance and reduce bandwidth consumption 29 | but will increase lag. 20ms is actually really fast - If you add some sort of 30 | motion smoothing on the front end, 50ms or higher should be more than adequate. 31 | */ 32 | WORLD_UPDATE_INTERVAL: 20, 33 | // Delete states which have gone stale (not being updated anymore). 34 | WORLD_STALE_TIMEOUT: 1000, 35 | // Coins don't move, so we will only refresh them 36 | // once per second. 37 | SPECIAL_UPDATE_INTERVALS: { 38 | 1000: ['coin'] 39 | }, 40 | 41 | PLAYER_DEFAULT_MOVE_SPEED: 10, 42 | PLAYER_DIAMETER: 45, 43 | PLAYER_MASS: 20, 44 | 45 | // Note that the number of bots needs to be either 0 or a multiple of the number of 46 | // worker processes or else it will get rounded up/down. 47 | BOT_COUNT: 10, 48 | BOT_MOVE_SPEED: 5, 49 | BOT_MASS: 10, 50 | BOT_DEFAULT_DIAMETER: 45, 51 | BOT_CHANGE_DIRECTION_PROBABILITY: 0.01, 52 | 53 | COIN_UPDATE_INTERVAL: 1000, 54 | COIN_DROP_INTERVAL: 400, 55 | COIN_MAX_COUNT: 200, 56 | COIN_PLAYER_NO_DROP_RADIUS: 80, 57 | // The probabilities need to add up to 1. 58 | COIN_TYPES: [ 59 | { 60 | type: 4, 61 | value: 1, 62 | radius: 10, 63 | probability: 0.25 64 | }, 65 | { 66 | type: 3, 67 | value: 2, 68 | radius: 10, 69 | probability: 0.6 70 | }, 71 | { 72 | type: 2, 73 | value: 6, 74 | radius: 10, 75 | probability: 0.1 76 | }, 77 | { 78 | type: 1, 79 | value: 12, 80 | radius: 10, 81 | probability: 0.05 82 | } 83 | ], 84 | 85 | // We can use this to filter out properties which don't need to be sent 86 | // to the front end. 87 | OUTBOUND_STATE_TRANSFORMERS: { 88 | coin: genericStateTransformer, 89 | player: genericStateTransformer 90 | } 91 | }; 92 | 93 | var privateProps = { 94 | ccid: true, 95 | tcid: true, 96 | mass: true, 97 | speed: true, 98 | changeDirProb: true, 99 | repeatOp: true, 100 | swid: true, 101 | processed: true, 102 | pendingGroup: true, 103 | group: true, 104 | version: true, 105 | external: true 106 | }; 107 | 108 | function genericStateTransformer(state) { 109 | var clone = {}; 110 | Object.keys(state).forEach(function (key) { 111 | if (!privateProps[key]) { 112 | clone[key] = state[key]; 113 | } 114 | }); 115 | return clone; 116 | } 117 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "iogrid-game", 3 | "description": "A simple demo built using SocketCluster and Phaser", 4 | "version": "2.0.0", 5 | "dependencies": { 6 | "connect": "3.0.1", 7 | "express": "4.13.1", 8 | "lodash": "4.17.3", 9 | "minimist": "1.1.0", 10 | "morgan": "1.7.0", 11 | "rbush": "2.0.1", 12 | "sat": "0.6.0", 13 | "sc-cluster-broker-client": "1.x.x", 14 | "sc-codec-min-bin": "2.0.0", 15 | "sc-framework-health-check": "1.x.x", 16 | "sc-hot-reboot": "1.x.x", 17 | "serve-static": "1.8.0", 18 | "socketcluster": "5.x.x", 19 | "socketcluster-client": "5.x.x", 20 | "uuid": "3.0.1" 21 | }, 22 | "keywords": [ 23 | "socketcluster", 24 | "phaser", 25 | "realtime", 26 | "multiplayer", 27 | "game", 28 | "iogrid", 29 | "iogame" 30 | ], 31 | "readmeFilename": "README.md" 32 | } 33 | -------------------------------------------------------------------------------- /public/SAT.min.js: -------------------------------------------------------------------------------- 1 | /* SAT.js - Version 0.6.0 - Copyright 2012 - 2016 - Jim Riecken - released under the MIT License. https://github.com/jriecken/sat-js */ 2 | function x(){function c(a,e){this.x=a||0;this.y=e||0}function B(a,e){this.pos=a||new c;this.r=e||0}function n(a,e){this.pos=a||new c;this.angle=0;this.offset=new c;this.u(e||[])}function q(a,e,b){this.pos=a||new c;this.w=e||0;this.h=b||0}function w(){this.b=this.a=null;this.overlapN=new c;this.overlapV=new c;this.clear()}function C(a,e,b){for(var h=Number.MAX_VALUE,c=-Number.MAX_VALUE,k=a.length,g=0;gc&&(c=d)}b[0]=h;b[1]=c}function y(a,e,b,h,c,k){var g=r.pop(), 3 | d=r.pop();a=m.pop().c(e).sub(a);e=a.f(c);C(b,c,g);C(h,c,d);d[0]+=e;d[1]+=e;if(g[0]>d[1]||d[0]>g[1])return m.push(a),r.push(g),r.push(d),!0;k&&(g[0]d[1]?(b=g[0]-d[1],k.aInB=!1):(b=g[1]-d[0],h=d[1]-g[0],b=bb&&k.overlapN.reverse()));m.push(a);r.push(g);r.push(d);return!1}function z(a,e){var b=a.g(),c=e.f(a);return 0>c?-1:c>b?1:0} 4 | function D(a,e,b){for(var c=m.pop().c(e.pos).sub(a.pos),l=e.r,k=l*l,g=a.calcPoints,d=g.length,u=m.pop(),f=m.pop(),n=0;nk&&(b.aInB=!1);var p=z(u,f);if(-1===p){u.c(a.edges[r]);v=m.pop().c(c).sub(g[r]);p=z(u,v);if(1===p){p=f.j();if(p>l)return m.push(c),m.push(u),m.push(f),m.push(v),!1;b&&(b.bInA=!1,t=f.normalize(),q=l-p)}m.push(v)}else if(1===p){if(u.c(a.edges[v]),f.c(c).sub(g[v]),p=z(u,f),-1===p){p=f.j(); 5 | if(p>l)return m.push(c),m.push(u),m.push(f),!1;b&&(b.bInA=!1,t=f.normalize(),q=l-p)}}else{v=u.m().normalize();p=f.f(v);r=Math.abs(p);if(0l)return m.push(c),m.push(v),m.push(f),!1;b&&(t=v,q=l-p,0<=p||q<2*l)&&(b.bInA=!1)}t&&b&&Math.abs(q)f&&(f=d.x);d.yk&&(k=d.y)}return(new q(this.pos.clone().add(new c(b,h)),f-b,k-h)).l()};f.Box=q;q.prototype.toPolygon=q.prototype.l= 12 | function(){var a=this.pos,e=this.w,b=this.h;return new n(new c(a.x,a.y),[new c,new c(e,0),new c(e,b),new c(0,b)])};f.Response=w;w.prototype.clear=w.prototype.clear=function(){this.bInA=this.aInB=!0;this.overlap=Number.MAX_VALUE;return this};for(var m=[],t=0;10>t;t++)m.push(new c);for(var r=[],t=0;5>t;t++)r.push([]);var A=new w,F=(new q(new c,1E-6,1E-6)).l();f.isSeparatingAxis=y;f.pointInCircle=function(a,c){var b=m.pop().c(a).sub(c.pos),h=c.r*c.r,f=b.g();m.push(b);return f<=h};f.pointInPolygon=function(a, 13 | c){F.pos.c(a);A.clear();var b=E(F,c,A);b&&(b=A.aInB);return b};f.testCircleCircle=function(a,c,b){var f=m.pop().c(c.pos).sub(a.pos),l=a.r+c.r,k=f.g();if(k>l*l)return m.push(f),!1;b&&(k=Math.sqrt(k),b.a=a,b.b=c,b.overlap=l-k,b.overlapN.c(f.normalize()),b.overlapV.c(f).scale(b.overlap),b.aInB=a.r<=c.r&&k<=c.r-a.r,b.bInA=c.r<=a.r&&k<=a.r-c.r);m.push(f);return!0};f.testPolygonCircle=D;f.testCirclePolygon=function(a,c,b){if((a=D(c,a,b))&&b){c=b.a;var f=b.aInB;b.overlapN.reverse();b.overlapV.reverse(); 14 | b.a=b.b;b.b=c;b.aInB=b.bInA;b.bInA=f}return a};f.testPolygonPolygon=E;return f}"function"===typeof define&&define.amd?define(x):"object"===typeof exports?module.exports=x():this.SAT=x(); -------------------------------------------------------------------------------- /public/channel-grid.js: -------------------------------------------------------------------------------- 1 | if (typeof module == 'undefined') { 2 | module = { 3 | exports: window 4 | }; 5 | } 6 | 7 | var DEFAULT_LINE_OF_SIGHT = 1000; 8 | 9 | var ChannelGrid = function (options) { 10 | this.worldWidth = options.worldWidth; 11 | this.worldHeight = options.worldHeight; 12 | this.cellOverlapDistance = options.cellOverlapDistance; 13 | this.rows = options.rows; 14 | this.cols = options.cols; 15 | 16 | this.cellWidth = this.worldWidth / this.cols; 17 | this.cellHeight = this.worldHeight / this.rows; 18 | 19 | this.exchange = options.exchange; 20 | this.watchingCells = {}; 21 | }; 22 | 23 | ChannelGrid.prototype._generateEmptyGrid = function (rows, cols) { 24 | var grid = []; 25 | for (var r = 0; r < rows; r++) { 26 | grid[r] = []; 27 | for (var c = 0; c < cols; c++) { 28 | grid[r][c] = []; 29 | } 30 | } 31 | return grid; 32 | }; 33 | 34 | ChannelGrid.prototype.convertCellIndexToCoordinates = function (index) { 35 | return { 36 | r: Math.floor(index / this.cols), 37 | c: index % this.cols 38 | } 39 | }; 40 | 41 | ChannelGrid.prototype.convertCoordinatesToCellIndex = function (coords) { 42 | return coords.r * this.cols + coords.c; 43 | }; 44 | 45 | ChannelGrid.prototype.getCellIndex = function (object) { 46 | var coords = this.getCellCoordinates(object); 47 | return this.convertCoordinatesToCellIndex(coords); 48 | }; 49 | 50 | ChannelGrid.prototype.getCellCoordinates = function (object) { 51 | return { 52 | r: Math.floor(object.y / this.cellHeight), 53 | c: Math.floor(object.x / this.cellWidth) 54 | } 55 | }; 56 | 57 | ChannelGrid.prototype.getCellBounds = function (cellIndex) { 58 | var gridCoords = this.convertCellIndexToCoordinates(cellIndex); 59 | var x = gridCoords.c * this.cellWidth; 60 | var y = gridCoords.r * this.cellHeight; 61 | return { 62 | minX: x, 63 | minY: y, 64 | maxX: x + this.cellWidth, 65 | maxY: y + this.cellHeight 66 | }; 67 | }; 68 | 69 | ChannelGrid.prototype.getAllCellCoordinates = function (object, options) { 70 | if (!options) { 71 | options = {}; 72 | } 73 | var overlapDist = this.cellOverlapDistance; 74 | var exclusions = {}; 75 | if (options.excludeCellIndexes) { 76 | options.excludeCellIndexes.forEach(function (cellIndex) { 77 | exclusions[cellIndex] = true; 78 | }); 79 | } 80 | 81 | var objectArea = { 82 | minX: object.x - overlapDist, 83 | minY: object.y - overlapDist, 84 | maxX: object.x + overlapDist, 85 | maxY: object.y + overlapDist 86 | }; 87 | var minCell = this.getCellCoordinates({ 88 | x: objectArea.minX, 89 | y: objectArea.minY 90 | }); 91 | var maxCell = this.getCellCoordinates({ 92 | x: objectArea.maxX, 93 | y: objectArea.maxY 94 | }); 95 | var gridArea = { 96 | minC: Math.max(minCell.c, 0), 97 | minR: Math.max(minCell.r, 0), 98 | maxC: Math.min(maxCell.c, this.cols - 1), 99 | maxR: Math.min(maxCell.r, this.rows - 1) 100 | }; 101 | 102 | var affectedCells = []; 103 | 104 | for (var r = gridArea.minR; r <= gridArea.maxR; r++) { 105 | for (var c = gridArea.minC; c <= gridArea.maxC; c++) { 106 | var coords = { 107 | r: r, 108 | c: c 109 | }; 110 | var cellIndex = this.convertCoordinatesToCellIndex(coords); 111 | if (!exclusions[cellIndex]) { 112 | affectedCells.push(coords); 113 | } 114 | } 115 | } 116 | return affectedCells; 117 | }; 118 | 119 | ChannelGrid.prototype.getAllCellIndexes = function (object) { 120 | var self = this; 121 | var cellIndexes = []; 122 | var coordsList = this.getAllCellCoordinates(object); 123 | 124 | coordsList.forEach(function (coords) { 125 | cellIndexes.push(coords.r * self.cols + coords.c); 126 | }); 127 | return cellIndexes; 128 | }; 129 | 130 | ChannelGrid.prototype._getGridChannelName = function (channelName, col, row) { 131 | return '(' + col + ',' + row + ')' + channelName; 132 | }; 133 | 134 | 135 | ChannelGrid.prototype._flushPublishGrid = function (channelName, grid) { 136 | for (var r = 0; r < this.rows; r++) { 137 | for (var c = 0; c < this.cols; c++) { 138 | if (grid[r] && grid[r][c]) { 139 | var states = grid[r][c]; 140 | if (states.length) { 141 | this.exchange.publish(this._getGridChannelName(channelName, c, r), states); 142 | } 143 | } 144 | } 145 | } 146 | }; 147 | 148 | 149 | ChannelGrid.prototype.publish = function (channelName, objects, options) { 150 | var self = this; 151 | if (!options) { 152 | options = {}; 153 | } 154 | 155 | var grid = this._generateEmptyGrid(this.rows, this.cols); 156 | 157 | objects.forEach(function (obj) { 158 | var affectedCells; 159 | if (options.cellIndexesFactory) { 160 | var affectedCells = []; 161 | var cellIndexes = options.cellIndexesFactory(obj); 162 | cellIndexes.forEach(function (index) { 163 | affectedCells.push(self.convertCellIndexToCoordinates(index)); 164 | }); 165 | } else if (options.includeNearbyCells) { 166 | affectedCells = self.getAllCellCoordinates(obj); 167 | } else { 168 | affectedCells = [self.getCellCoordinates(obj)]; 169 | } 170 | affectedCells.forEach(function (cell) { 171 | if (grid[cell.r] && grid[cell.r][cell.c]) { 172 | grid[cell.r][cell.c].push(obj); 173 | } 174 | }); 175 | }); 176 | 177 | this._flushPublishGrid(channelName, grid); 178 | }; 179 | 180 | ChannelGrid.prototype.publishToCells = function (channelName, objects, cellIndexes) { 181 | var self = this; 182 | 183 | var grid = this._generateEmptyGrid(this.rows, this.cols); 184 | 185 | var targetCells = []; 186 | cellIndexes.forEach(function (index) { 187 | targetCells.push(self.convertCellIndexToCoordinates(index)); 188 | }); 189 | 190 | objects.forEach(function (obj) { 191 | targetCells.forEach(function (cell) { 192 | if (grid[cell.r] && grid[cell.r][cell.c]) { 193 | grid[cell.r][cell.c].push(obj); 194 | } 195 | }); 196 | }); 197 | 198 | this._flushPublishGrid(channelName, grid); 199 | }; 200 | 201 | ChannelGrid.prototype.watchCell = function (channelName, col, row, watcher) { 202 | var gridChannelName = this._getGridChannelName(channelName, col, row); 203 | this.exchange.subscribe(gridChannelName).watch(watcher); 204 | }; 205 | 206 | ChannelGrid.prototype.watchCellAtIndex = function (channelName, cellIndex, watcher) { 207 | var coords = this.convertCellIndexToCoordinates(cellIndex); 208 | this.watchCell(channelName, coords.c, coords.r, watcher); 209 | }; 210 | 211 | ChannelGrid.prototype.unwatchCell = function (channelName, col, row, watcher) { 212 | var gridChannelName = this._getGridChannelName(channelName, col, row); 213 | var channel = this.exchange.channel(gridChannelName); 214 | channel.unwatch(watcher); 215 | channel.unsubscribe(); 216 | channel.destroy(); 217 | }; 218 | 219 | ChannelGrid.prototype.updateCellWatchers = function (state, channelName, options, handler) { 220 | if (!this.watchingCells[channelName]) { 221 | this.watchingCells[channelName] = {}; 222 | } 223 | var lineOfSight = options.lineOfSight || DEFAULT_LINE_OF_SIGHT; 224 | var watchMap = this.watchingCells[channelName]; 225 | var sightArea = { 226 | minX: state.x - lineOfSight, 227 | minY: state.y - lineOfSight, 228 | maxX: state.x + lineOfSight, 229 | maxY: state.y + lineOfSight 230 | }; 231 | var minCol = Math.max(Math.floor(sightArea.minX / this.cellWidth), 0); 232 | var maxCol = Math.min(Math.floor(sightArea.maxX / this.cellWidth), this.cols - 1); 233 | var minRow = Math.max(Math.floor(sightArea.minY / this.cellHeight), 0); 234 | var maxRow = Math.min(Math.floor(sightArea.maxY / this.cellHeight), this.rows - 1); 235 | 236 | var matchedCells = {}; 237 | 238 | for (var r = minRow; r <= maxRow; r++) { 239 | for (var c = minCol; c <= maxCol; c++) { 240 | var colRowKey = c + ',' + r; 241 | matchedCells[colRowKey] = {col: c, row: r}; 242 | if (!watchMap[colRowKey]) { 243 | watchMap[colRowKey] = {col: c, row: r}; 244 | this.watchCell(channelName, c, r, handler); 245 | } 246 | } 247 | } 248 | 249 | for (var i in watchMap) { 250 | if (watchMap.hasOwnProperty(i)) { 251 | if (!matchedCells[i]) { 252 | var coords = watchMap[i]; 253 | this.unwatchCell(channelName, coords.col, coords.row, handler); 254 | delete watchMap[i]; 255 | } 256 | } 257 | } 258 | }; 259 | 260 | module.exports.ChannelGrid = ChannelGrid; 261 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/favicon.ico -------------------------------------------------------------------------------- /public/img/background-texture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/background-texture.png -------------------------------------------------------------------------------- /public/img/bot-back.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/bot-back.gif -------------------------------------------------------------------------------- /public/img/bot-front.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/bot-front.gif -------------------------------------------------------------------------------- /public/img/bot-side-left.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/bot-side-left.gif -------------------------------------------------------------------------------- /public/img/bot-side-right.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/bot-side-right.gif -------------------------------------------------------------------------------- /public/img/food-eaten-logo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/food-eaten-logo.gif -------------------------------------------------------------------------------- /public/img/forager-logo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/forager-logo.gif -------------------------------------------------------------------------------- /public/img/grass-1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/grass-1.gif -------------------------------------------------------------------------------- /public/img/grass-2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/grass-2.gif -------------------------------------------------------------------------------- /public/img/grass-3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/grass-3.gif -------------------------------------------------------------------------------- /public/img/grass-4.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/grass-4.gif -------------------------------------------------------------------------------- /public/img/iogrid.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/iogrid.gif -------------------------------------------------------------------------------- /public/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/logo.png -------------------------------------------------------------------------------- /public/img/others-back.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/others-back.gif -------------------------------------------------------------------------------- /public/img/others-front.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/others-front.gif -------------------------------------------------------------------------------- /public/img/others-side-left.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/others-side-left.gif -------------------------------------------------------------------------------- /public/img/others-side-right.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/others-side-right.gif -------------------------------------------------------------------------------- /public/img/sc-phaser-sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/sc-phaser-sample.png -------------------------------------------------------------------------------- /public/img/time-left-label.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/time-left-label.gif -------------------------------------------------------------------------------- /public/img/you-back.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/you-back.gif -------------------------------------------------------------------------------- /public/img/you-front.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/you-front.gif -------------------------------------------------------------------------------- /public/img/you-side-left.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/you-side-left.gif -------------------------------------------------------------------------------- /public/img/you-side-right.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jondubois/iogrid/5ee7e86143b30bc714ad175e11221aaa948c2bef/public/img/you-side-right.gif -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hello Phaser! 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 24 | 25 | 26 | 414 | 415 | 416 | -------------------------------------------------------------------------------- /public/rbush.min.js: -------------------------------------------------------------------------------- 1 | !function(t){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i;i="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this,i.rbush=t()}}(function(){return function t(i,n,e){function r(h,o){if(!n[h]){if(!i[h]){var s="function"==typeof require&&require;if(!o&&s)return s(h,!0);if(a)return a(h,!0);var l=new Error("Cannot find module '"+h+"'");throw l.code="MODULE_NOT_FOUND",l}var f=n[h]={exports:{}};i[h][0].call(f.exports,function(t){var n=i[h][1][t];return r(n?n:t)},f,f.exports,t,i,n,e)}return n[h].exports}for(var a="function"==typeof require&&require,h=0;h=t.minX&&i.maxY>=t.minY}function p(t){return{children:t,height:1,leaf:!0,minX:1/0,minY:1/0,maxX:-(1/0),maxY:-(1/0)}}function M(t,i,n,e,r){for(var a,h=[i,n];h.length;)n=h.pop(),i=h.pop(),n-i<=e||(a=i+Math.ceil((n-i)/e/2)*e,g(t,a,i,n,r),h.push(i,a,a,n))}i.exports=e;var g=t("quickselect");e.prototype={all:function(){return this._all(this.data,[])},search:function(t){var i=this.data,n=[],e=this.toBBox;if(!x(t,i))return n;for(var r,a,h,o,s=[];i;){for(r=0,a=i.children.length;r=0&&a[i].children.length>this._maxEntries;)this._split(a,i),i--;this._adjustParentBBoxes(r,a,i)},_split:function(t,i){var n=t[i],e=n.children.length,r=this._minEntries;this._chooseSplitAxis(n,r,e);var h=this._chooseSplitIndex(n,r,e),o=p(n.children.splice(h,n.children.length-h));o.height=n.height,o.leaf=n.leaf,a(n,this.toBBox),a(o,this.toBBox),i?t[i-1].children.push(o):this._splitRoot(n,o)},_splitRoot:function(t,i){this.data=p([t,i]),this.data.height=t.height+1,this.data.leaf=!1,a(this.data,this.toBBox)},_chooseSplitIndex:function(t,i,n){var e,r,a,o,s,l,u,c;for(l=u=1/0,e=i;e<=n-i;e++)r=h(t,0,e,this.toBBox),a=h(t,e,n,this.toBBox),o=m(r,a),s=f(r)+f(a),o=i;r--)a=t.children[r],o(f,t.leaf?s(a):a),c+=u(f);return c},_adjustParentBBoxes:function(t,i,n){for(var e=n;e>=0;e--)o(i[e],t)},_condense:function(t){for(var i,n=t.length-1;n>=0;n--)0===t[n].children.length?n>0?(i=t[n-1].children,i.splice(i.indexOf(t[n]),1)):this.clear():a(t[n],this.toBBox)},_initFormat:function(t){var i=["return a"," - b",";"];this.compareMinX=new Function("a","b",i.join(t[0])),this.compareMinY=new Function("a","b",i.join(t[1])),this.toBBox=new Function("a","return {minX: a"+t[0]+", minY: a"+t[1]+", maxX: a"+t[2]+", maxY: a"+t[3]+"};")}}},{quickselect:2}],2:[function(t,i,n){"use strict";function e(t,i,n,a,h){for(;a>n;){if(a-n>600){var o=a-n+1,s=i-n+1,l=Math.log(o),f=.5*Math.exp(2*l/3),u=.5*Math.sqrt(l*f*(o-f)/o)*(s-o/2<0?-1:1),c=Math.max(n,Math.floor(i-s*f/o+u)),m=Math.min(a,Math.floor(i+(o-s)*f/o+u));e(t,i,c,m,h)}var d=t[i],x=n,p=a;for(r(t,n,i),h(t[a],d)>0&&r(t,n,a);x0;)p--}0===h(t[n],d)?r(t,n,p):(p++,r(t,p,a)),p<=i&&(n=p+1),i<=p&&(a=p-1)}}function r(t,i,n){var e=t[i];t[i]=t[n],t[n]=e}i.exports=e},{}]},{},[1])(1)}); 2 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var argv = require('minimist')(process.argv.slice(2)); 3 | var SocketCluster = require('socketcluster').SocketCluster; 4 | var scHotReboot = require('sc-hot-reboot'); 5 | 6 | var workerControllerPath = argv.wc || process.env.SOCKETCLUSTER_WORKER_CONTROLLER; 7 | var brokerControllerPath = argv.bc || process.env.SOCKETCLUSTER_BROKER_CONTROLLER; 8 | var initControllerPath = argv.ic || process.env.SOCKETCLUSTER_INIT_CONTROLLER; 9 | var environment = process.env.ENV || 'dev'; 10 | 11 | var options = { 12 | workers: Number(argv.w) || Number(process.env.SOCKETCLUSTER_WORKERS) || 1, 13 | brokers: Number(argv.b) || Number(process.env.SOCKETCLUSTER_BROKERS) || 1, 14 | port: Number(argv.p) || Number(process.env.SOCKETCLUSTER_PORT) || Number(process.env.PORT) || 8000, 15 | // If your system doesn't support 'uws', you can switch to 'ws' (which is slower but works on older systems). 16 | wsEngine: process.env.SOCKETCLUSTER_WS_ENGINE || 'ws', 17 | appName: argv.n || process.env.SOCKETCLUSTER_APP_NAME || null, 18 | workerController: workerControllerPath || __dirname + '/worker.js', 19 | brokerController: brokerControllerPath || __dirname + '/broker.js', 20 | initController: initControllerPath || null, 21 | socketChannelLimit: Number(process.env.SOCKETCLUSTER_SOCKET_CHANNEL_LIMIT) || 1000, 22 | clusterStateServerHost: argv.cssh || process.env.SCC_STATE_SERVER_HOST || null, 23 | clusterStateServerPort: process.env.SCC_STATE_SERVER_PORT || null, 24 | clusterAuthKey: process.env.SCC_AUTH_KEY || null, 25 | clusterStateServerConnectTimeout: Number(process.env.SCC_STATE_SERVER_CONNECT_TIMEOUT) || null, 26 | clusterStateServerAckTimeout: Number(process.env.SCC_STATE_SERVER_ACK_TIMEOUT) || null, 27 | clusterStateServerReconnectRandomness: Number(process.env.SCC_STATE_SERVER_RECONNECT_RANDOMNESS) || null, 28 | crashWorkerOnError: argv['auto-reboot'] != false, 29 | // If using nodemon, set this to true, and make sure that environment is 'dev'. 30 | killMasterOnSignal: false, 31 | instanceId: 'realm-1', 32 | pubSubBatchDuration: null, 33 | environment: environment 34 | }; 35 | 36 | var SOCKETCLUSTER_OPTIONS; 37 | 38 | if (process.env.SOCKETCLUSTER_OPTIONS) { 39 | SOCKETCLUSTER_OPTIONS = JSON.parse(process.env.SOCKETCLUSTER_OPTIONS); 40 | } 41 | 42 | for (var i in SOCKETCLUSTER_OPTIONS) { 43 | if (SOCKETCLUSTER_OPTIONS.hasOwnProperty(i)) { 44 | options[i] = SOCKETCLUSTER_OPTIONS[i]; 45 | } 46 | } 47 | 48 | var masterControllerPath = argv.mc || process.env.SOCKETCLUSTER_MASTER_CONTROLLER; 49 | 50 | var start = function () { 51 | var socketCluster = new SocketCluster(options); 52 | 53 | if (masterControllerPath) { 54 | var masterController = require(masterControllerPath); 55 | masterController.run(socketCluster); 56 | } 57 | 58 | if (environment == 'dev') { 59 | // This will cause SC workers to reboot when code changes anywhere in the app directory. 60 | // The second options argument here is passed directly to chokidar. 61 | // See https://github.com/paulmillr/chokidar#api for details. 62 | console.log(` !! The sc-hot-reboot plugin is watching for code changes in the ${__dirname} directory`); 63 | scHotReboot.attach(socketCluster, { 64 | cwd: __dirname, 65 | ignored: ['public', 'node_modules', 'README.md', 'Dockerfile', 'server.js', 'broker.js', /[\/\\]\./] 66 | }); 67 | } 68 | }; 69 | 70 | var bootCheckInterval = Number(process.env.SOCKETCLUSTER_BOOT_CHECK_INTERVAL) || 200; 71 | 72 | if (workerControllerPath) { 73 | // Detect when Docker volumes are ready. 74 | var startWhenFileIsReady = (filePath) => { 75 | return new Promise((resolve) => { 76 | if (!filePath) { 77 | resolve(); 78 | return; 79 | } 80 | var checkIsReady = () => { 81 | fs.exists(filePath, (exists) => { 82 | if (exists) { 83 | resolve(); 84 | } else { 85 | setTimeout(checkIsReady, bootCheckInterval); 86 | } 87 | }); 88 | }; 89 | checkIsReady(); 90 | }); 91 | }; 92 | var filesReadyPromises = [ 93 | startWhenFileIsReady(masterControllerPath), 94 | startWhenFileIsReady(workerControllerPath), 95 | startWhenFileIsReady(brokerControllerPath), 96 | startWhenFileIsReady(initControllerPath) 97 | ]; 98 | Promise.all(filesReadyPromises).then(() => { 99 | start(); 100 | }); 101 | } else { 102 | start(); 103 | } 104 | -------------------------------------------------------------------------------- /state-manager.js: -------------------------------------------------------------------------------- 1 | var StateManager = function (options) { 2 | this.channelGrid = options.channelGrid; 3 | this.stateRefs = options.stateRefs; 4 | }; 5 | 6 | StateManager.prototype.create = function (state) { 7 | var stateCellIndex = this.channelGrid.getCellIndex(state); 8 | var stateRef = { 9 | id: state.id, 10 | tcid: stateCellIndex, // Target cell index. 11 | type: state.type, 12 | create: state 13 | }; 14 | if (state.swid != null) { 15 | stateRef.swid = state.swid; 16 | } 17 | this.stateRefs[state.id] = stateRef; 18 | return stateRef; 19 | }; 20 | 21 | // You can only update through operations which must be interpreted 22 | // by your cell controllers (cell.js). 23 | StateManager.prototype.update = function (stateRef, operation) { 24 | this.stateRefs[stateRef.id].op = operation; 25 | }; 26 | 27 | StateManager.prototype.delete = function (stateRef) { 28 | this.stateRefs[stateRef.id].delete = 1; 29 | }; 30 | 31 | module.exports.StateManager = StateManager; 32 | -------------------------------------------------------------------------------- /util.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | 3 | var Util = function (options) { 4 | this.cellData = options.cellData; 5 | }; 6 | 7 | Util.prototype.groupStates = function (stateList) { 8 | stateList.forEach(function (state) { 9 | if (!state.external) { 10 | if (!state.pendingGroup) { 11 | state.pendingGroup = {}; 12 | } 13 | stateList.forEach(function (memberState) { 14 | state.pendingGroup[memberState.id] = memberState; 15 | }); 16 | } 17 | }); 18 | }; 19 | 20 | Util.prototype.ungroupStates = function (stateList) { 21 | var self = this; 22 | 23 | stateList.forEach(function (state) { 24 | if (!state.external && state.pendingGroup) { 25 | 26 | stateList.forEach(function (memberState) { 27 | delete state.pendingGroup[memberState.id]; 28 | if (_.isEmpty(state.pendingGroup)) { 29 | delete state.pendingGroup; 30 | } 31 | }); 32 | } 33 | }); 34 | }; 35 | 36 | Util.prototype.ungroupStateFromAll = function (state) { 37 | var self = this; 38 | 39 | var groupMembers = state.pendingGroup || {}; 40 | var stateUngroupList = []; 41 | 42 | Object.keys(groupMembers).forEach(function (memberId) { 43 | var cellIndex = state.ccid; 44 | var type = state.type; 45 | 46 | var memberSimpleState = groupMembers[memberId]; 47 | if (self.cellData[cellIndex] && self.cellData[cellIndex][type]) { 48 | var memberState = self.cellData[cellIndex][type][memberId]; 49 | if (memberState) { 50 | stateUngroupList.push(memberState); 51 | } 52 | } 53 | }); 54 | self.ungroupStates(stateUngroupList); 55 | }; 56 | 57 | module.exports.Util = Util; 58 | -------------------------------------------------------------------------------- /worker.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var _ = require('lodash'); 3 | var express = require('express'); 4 | var serveStatic = require('serve-static'); 5 | var path = require('path'); 6 | var morgan = require('morgan'); 7 | var healthChecker = require('sc-framework-health-check'); 8 | var StateManager = require('./state-manager').StateManager; 9 | var uuid = require('uuid'); 10 | var ChannelGrid = require('./public/channel-grid').ChannelGrid; 11 | var Util = require('./util').Util; 12 | var SAT = require('sat'); 13 | var rbush = require('rbush'); 14 | var scCodecMinBin = require('sc-codec-min-bin'); 15 | 16 | var config = require('./config'); 17 | var CellController = require('./cell'); 18 | 19 | var WORLD_WIDTH = config.WORLD_WIDTH; 20 | var WORLD_HEIGHT = config.WORLD_HEIGHT; 21 | var WORLD_CELL_WIDTH = config.WORLD_CELL_WIDTH; 22 | var WORLD_CELL_HEIGHT = config.WORLD_CELL_HEIGHT; 23 | var WORLD_COLS = Math.ceil(WORLD_WIDTH / WORLD_CELL_WIDTH); 24 | var WORLD_ROWS = Math.ceil(WORLD_HEIGHT / WORLD_CELL_HEIGHT); 25 | var WORLD_CELLS = WORLD_COLS * WORLD_ROWS; 26 | var WORLD_CELL_OVERLAP_DISTANCE = config.WORLD_CELL_OVERLAP_DISTANCE; 27 | var WORLD_UPDATE_INTERVAL = config.WORLD_UPDATE_INTERVAL; 28 | var WORLD_STALE_TIMEOUT = config.WORLD_STALE_TIMEOUT; 29 | var SPECIAL_UPDATE_INTERVALS = config.SPECIAL_UPDATE_INTERVALS; 30 | 31 | var PLAYER_DIAMETER = config.PLAYER_DIAMETER; 32 | var PLAYER_MASS = config.PLAYER_MASS; 33 | 34 | var OUTBOUND_STATE_TRANSFORMERS = config.OUTBOUND_STATE_TRANSFORMERS; 35 | 36 | var CHANNEL_INBOUND_CELL_PROCESSING = 'internal/cell-processing-inbound'; 37 | var CHANNEL_CELL_TRANSITION = 'internal/cell-transition'; 38 | 39 | var game = { 40 | stateRefs: {} 41 | }; 42 | 43 | function getRandomPosition(spriteWidth, spriteHeight) { 44 | var halfSpriteWidth = spriteWidth / 2; 45 | var halfSpriteHeight = spriteHeight / 2; 46 | var widthRandomness = WORLD_WIDTH - spriteWidth; 47 | var heightRandomness = WORLD_HEIGHT - spriteHeight; 48 | return { 49 | x: Math.round(halfSpriteWidth + widthRandomness * Math.random()), 50 | y: Math.round(halfSpriteHeight + heightRandomness * Math.random()) 51 | }; 52 | } 53 | 54 | module.exports.run = function (worker) { 55 | console.log(' >> Worker PID:', process.pid); 56 | 57 | // We use a codec for SC to compress messages between clients and the server 58 | // to a lightweight binary format to reduce bandwidth consumption. 59 | // We should probably make our own codec (on top of scCodecMinBin) to compress 60 | // world-specific entities. For example, instead of emitting the JSON: 61 | // {id: '...', width: 200, height: 200} 62 | // We could compress it down to something like: {id: '...', w: 200, h: 200, c: 1000} 63 | worker.scServer.setCodecEngine(scCodecMinBin); 64 | 65 | var environment = worker.options.environment; 66 | var serverWorkerId = worker.options.instanceId + ':' + worker.id; 67 | 68 | var app = express(); 69 | 70 | var httpServer = worker.httpServer; 71 | var scServer = worker.scServer; 72 | 73 | if (environment == 'dev') { 74 | // Log every HTTP request. See https://github.com/expressjs/morgan for other 75 | // available formats. 76 | app.use(morgan('dev')); 77 | } 78 | app.use(serveStatic(path.resolve(__dirname, 'public'))); 79 | 80 | // Add GET /health-check express route 81 | healthChecker.attach(worker, app); 82 | 83 | httpServer.on('request', app); 84 | 85 | scServer.addMiddleware(scServer.MIDDLEWARE_SUBSCRIBE, function (req, next) { 86 | if (req.channel.indexOf('internal/') == 0) { 87 | var err = new Error('Clients are not allowed to subscribe to the ' + req.channel + ' channel.'); 88 | err.name = 'ForbiddenSubscribeError'; 89 | next(err); 90 | } else { 91 | next(); 92 | } 93 | }); 94 | 95 | scServer.addMiddleware(scServer.MIDDLEWARE_PUBLISH_IN, function (req, next) { 96 | // Only allow clients to publish to channels whose names start with 'external/' 97 | if (req.channel.indexOf('external/') == 0) { 98 | next(); 99 | } else { 100 | var err = new Error('Clients are not allowed to publish to the ' + req.channel + ' channel.'); 101 | err.name = 'ForbiddenPublishError'; 102 | next(err); 103 | } 104 | }); 105 | 106 | // This allows us to break up our channels into a grid of cells which we can 107 | // watch and publish to individually. 108 | // It handles most of the data distribution automatically so that it reaches 109 | // the intended cells. 110 | var channelGrid = new ChannelGrid({ 111 | worldWidth: WORLD_WIDTH, 112 | worldHeight: WORLD_HEIGHT, 113 | cellOverlapDistance: WORLD_CELL_OVERLAP_DISTANCE, 114 | rows: WORLD_ROWS, 115 | cols: WORLD_COLS, 116 | exchange: scServer.exchange 117 | }); 118 | 119 | var stateManager = new StateManager({ 120 | stateRefs: game.stateRefs, 121 | channelGrid: channelGrid 122 | }); 123 | 124 | if (WORLD_CELLS % worker.options.workers != 0) { 125 | var errorMessage = 'The number of cells in your world (determined by WORLD_WIDTH, WORLD_HEIGHT, WORLD_CELL_WIDTH, WORLD_CELL_HEIGHT)' + 126 | ' should share a common factor with the number of workers or else the workload might get duplicated for some cells.'; 127 | console.error(errorMessage); 128 | } 129 | 130 | var cellsPerWorker = WORLD_CELLS / worker.options.workers; 131 | 132 | var cellData = {}; 133 | var cellPendingDeletes = {}; 134 | var cellExternalStates = {}; 135 | 136 | var util = new Util({ 137 | cellData: cellData 138 | }); 139 | 140 | var cellControllers = {}; 141 | var updateIntervals = {}; 142 | var cellSpecialIntervalTypes = {}; 143 | 144 | for (var h = 0; h < cellsPerWorker; h++) { 145 | var cellIndex = worker.id + h * worker.options.workers; 146 | cellData[cellIndex] = {}; 147 | cellPendingDeletes[cellIndex] = {}; 148 | cellExternalStates[cellIndex] = {}; 149 | 150 | cellControllers[cellIndex] = new CellController({ 151 | cellIndex: cellIndex, 152 | cellData: cellData[cellIndex], 153 | cellBounds: channelGrid.getCellBounds(cellIndex), 154 | worker: worker 155 | }, util); 156 | 157 | channelGrid.watchCellAtIndex(CHANNEL_INBOUND_CELL_PROCESSING, cellIndex, gridCellDataHandler.bind(null, cellIndex)); 158 | channelGrid.watchCellAtIndex(CHANNEL_CELL_TRANSITION, cellIndex, gridCellTransitionHandler.bind(null, cellIndex)); 159 | } 160 | 161 | function applyOutboundStateTransformer(state) { 162 | var type = state.type; 163 | if (OUTBOUND_STATE_TRANSFORMERS[type]) { 164 | return OUTBOUND_STATE_TRANSFORMERS[type](state); 165 | } 166 | return state; 167 | } 168 | 169 | function setUpdateIntervals(intervalMap) { 170 | Object.keys(intervalMap).forEach(function (interval) { 171 | var intervalNumber = parseInt(interval); 172 | 173 | intervalMap[interval].forEach(function (type) { 174 | cellSpecialIntervalTypes[type] = true; 175 | }); 176 | 177 | updateIntervals[interval] = setInterval(function () { 178 | var transformedStateList = []; 179 | 180 | Object.keys(cellData).forEach(function (cellIndex) { 181 | var currentCellData = cellData[cellIndex]; 182 | 183 | intervalMap[interval].forEach(function (type) { 184 | Object.keys(currentCellData[type] || {}).forEach(function (id) { 185 | transformedStateList.push( 186 | applyOutboundStateTransformer(currentCellData[type][id]) 187 | ); 188 | }); 189 | }); 190 | }); 191 | // External channel which clients can subscribe to. 192 | // It will publish to multiple channels based on each state's 193 | // (x, y) coordinates. 194 | if (transformedStateList.length) { 195 | channelGrid.publish('cell-data', transformedStateList); 196 | } 197 | }, intervalNumber); 198 | }); 199 | } 200 | 201 | setUpdateIntervals(SPECIAL_UPDATE_INTERVALS); 202 | 203 | function getSimplifiedState(state) { 204 | return { 205 | type: state.type, 206 | x: Math.round(state.x), 207 | y: Math.round(state.y) 208 | }; 209 | } 210 | 211 | function isGroupABetterThanGroupB(groupA, groupB) { 212 | // If both groups are the same size, the one that has the leader 213 | // with the lowest alphabetical id wins. 214 | return groupA.leader.id <= groupB.leader.id; 215 | } 216 | 217 | /* 218 | Groups are not passed around between cells/processes. Their purpose is to allow 219 | states to seamlessly interact with one another across cell boundaries. 220 | 221 | When one state affects another state across cell boundaries (e.g. one player 222 | pushing another player into a different cell), there is a slight delay for 223 | the position information to be shared across processes/CPU cores; as a 224 | result of this, the states may not show up in the exact same position in both cells. 225 | When two cells report slightly different positions for the same set of 226 | states, it may cause overlapping and flickering on the front end since the 227 | front end doesn't know which data to trust. 228 | 229 | A group allows two cells to agree on which cell is responsible for broadcasting the 230 | position of states that are within the group by considering the group's average position 231 | instead of looking at the position of member states individually. 232 | */ 233 | function getStateGroups() { 234 | var groupMap = {}; 235 | Object.keys(cellData).forEach(function (cellIndex) { 236 | if (!groupMap[cellIndex]) { 237 | groupMap[cellIndex] = {}; 238 | } 239 | var currentCellData = cellData[cellIndex]; 240 | var currentGroupMap = groupMap[cellIndex]; 241 | Object.keys(currentCellData).forEach(function (type) { 242 | var cellDataStates = currentCellData[type] || {}; 243 | Object.keys(cellDataStates).forEach(function (id) { 244 | var state = cellDataStates[id]; 245 | if (state.group) { 246 | var groupSimpleStateMap = {}; 247 | Object.keys(state.group).forEach(function (stateId) { 248 | groupSimpleStateMap[stateId] = state.group[stateId]; 249 | }); 250 | 251 | var groupStateIdList = Object.keys(groupSimpleStateMap).sort(); 252 | var groupId = groupStateIdList.join(','); 253 | 254 | var leaderClone = _.cloneDeep(state); 255 | leaderClone.x = groupSimpleStateMap[leaderClone.id].x; 256 | leaderClone.y = groupSimpleStateMap[leaderClone.id].y; 257 | 258 | var group = { 259 | id: groupId, 260 | leader: state, 261 | members: [], 262 | size: 0, 263 | x: 0, 264 | y: 0, 265 | }; 266 | var expectedMemberCount = groupStateIdList.length; 267 | 268 | for (var i = 0; i < expectedMemberCount; i++) { 269 | var memberId = groupStateIdList[i]; 270 | var memberSimplifiedState = groupSimpleStateMap[memberId]; 271 | var memberState = currentCellData[memberSimplifiedState.type][memberId]; 272 | if (memberState) { 273 | var memberStateClone = _.cloneDeep(memberState); 274 | memberStateClone.x = memberSimplifiedState.x; 275 | memberStateClone.y = memberSimplifiedState.y; 276 | group.members.push(memberStateClone); 277 | group.x += memberStateClone.x; 278 | group.y += memberStateClone.y; 279 | group.size++; 280 | } 281 | } 282 | if (group.size) { 283 | group.x = Math.round(group.x / group.size); 284 | group.y = Math.round(group.y / group.size); 285 | } 286 | 287 | var allGroupMembersAreAvailableToThisCell = group.size >= expectedMemberCount; 288 | var existingGroup = currentGroupMap[groupId]; 289 | if (allGroupMembersAreAvailableToThisCell && 290 | (!existingGroup || isGroupABetterThanGroupB(group, existingGroup))) { 291 | 292 | group.tcid = channelGrid.getCellIndex(group); 293 | currentGroupMap[groupId] = group; 294 | } 295 | } 296 | }); 297 | }); 298 | }); 299 | return groupMap; 300 | } 301 | 302 | function prepareStatesForProcessing(cellIndex) { 303 | var currentCellData = cellData[cellIndex]; 304 | var currentCellExternalStates = cellExternalStates[cellIndex]; 305 | 306 | Object.keys(currentCellData).forEach(function (type) { 307 | var cellDataStates = currentCellData[type] || {}; 308 | Object.keys(cellDataStates).forEach(function (id) { 309 | var state = cellDataStates[id]; 310 | 311 | if (state.external) { 312 | if (!currentCellExternalStates[type]) { 313 | currentCellExternalStates[type] = {}; 314 | } 315 | currentCellExternalStates[type][id] = _.cloneDeep(state); 316 | } 317 | }); 318 | }); 319 | } 320 | 321 | // We should never modify states which belong to other cells or 322 | // else it will result in conflicts and lost states. This function 323 | // restores them to their pre-processed condition. 324 | function restoreExternalStatesBeforeDispatching(cellIndex) { 325 | var currentCellData = cellData[cellIndex]; 326 | var currentCellExternalStates = cellExternalStates[cellIndex]; 327 | 328 | Object.keys(currentCellExternalStates).forEach(function (type) { 329 | var externalStatesList = currentCellExternalStates[type]; 330 | Object.keys(externalStatesList).forEach(function (id) { 331 | currentCellData[type][id] = externalStatesList[id]; 332 | delete externalStatesList[id]; 333 | }); 334 | }); 335 | } 336 | 337 | function prepareGroupStatesBeforeDispatching(cellIndex) { 338 | var currentCellData = cellData[cellIndex]; 339 | var currentCellExternalStates = cellExternalStates[cellIndex]; 340 | 341 | Object.keys(currentCellData).forEach(function (type) { 342 | var cellDataStates = currentCellData[type] || {}; 343 | Object.keys(cellDataStates).forEach(function (id) { 344 | var state = cellDataStates[id]; 345 | if (state.pendingGroup) { 346 | var serializedMemberList = {}; 347 | Object.keys(state.pendingGroup).forEach(function (memberId) { 348 | var memberState = state.pendingGroup[memberId]; 349 | serializedMemberList[memberId] = getSimplifiedState(memberState); 350 | }); 351 | state.group = serializedMemberList; 352 | delete state.pendingGroup; 353 | } else if (state.group) { 354 | delete state.group; 355 | } 356 | }); 357 | }); 358 | } 359 | 360 | // Remove decorator functions which were added to the states temporarily 361 | // for use within the cell controller. 362 | function cleanupStatesBeforeDispatching(cellIndex) { 363 | var currentCellData = cellData[cellIndex]; 364 | 365 | Object.keys(currentCellData).forEach(function (type) { 366 | var cellDataStates = currentCellData[type] || {}; 367 | Object.keys(cellDataStates).forEach(function (id) { 368 | var state = cellDataStates[id]; 369 | 370 | if (state.op) { 371 | delete state.op; 372 | } 373 | }); 374 | }); 375 | } 376 | 377 | // Main world update loop. 378 | setInterval(function () { 379 | var cellIndexList = Object.keys(cellData); 380 | var transformedStateList = []; 381 | 382 | cellIndexList.forEach(function (cellIndex) { 383 | cellIndex = Number(cellIndex); 384 | prepareStatesForProcessing(cellIndex); 385 | cellControllers[cellIndex].run(cellData[cellIndex]); 386 | prepareGroupStatesBeforeDispatching(cellIndex); 387 | cleanupStatesBeforeDispatching(cellIndex); 388 | restoreExternalStatesBeforeDispatching(cellIndex); 389 | dispatchProcessedData(cellIndex); 390 | }); 391 | 392 | var groupMap = getStateGroups(); 393 | 394 | cellIndexList.forEach(function (cellIndex) { 395 | cellIndex = Number(cellIndex); 396 | var currentCellData = cellData[cellIndex]; 397 | Object.keys(currentCellData).forEach(function (type) { 398 | if (!cellSpecialIntervalTypes[type]) { 399 | var cellDataStates = currentCellData[type] || {}; 400 | Object.keys(cellDataStates).forEach(function (id) { 401 | var state = cellDataStates[id]; 402 | if (!state.group && !state.external && 403 | (!cellPendingDeletes[cellIndex][type] || !cellPendingDeletes[cellIndex][type][id])) { 404 | 405 | transformedStateList.push( 406 | applyOutboundStateTransformer(state) 407 | ); 408 | } 409 | }); 410 | } 411 | }); 412 | }); 413 | 414 | // Deletions are processed as part of WORLD_UPDATE_INTERVAL even if 415 | // that type has its own special interval. 416 | Object.keys(cellPendingDeletes).forEach(function (cellIndex) { 417 | cellIndex = Number(cellIndex); 418 | var currentCellDeletes = cellPendingDeletes[cellIndex]; 419 | Object.keys(currentCellDeletes).forEach(function (type) { 420 | var cellDeleteStates = currentCellDeletes[type] || {}; 421 | Object.keys(cellDeleteStates).forEach(function (id) { 422 | // These states should already have a delete property which 423 | // can be used on the client-side to delete items from the view. 424 | transformedStateList.push( 425 | applyOutboundStateTransformer(cellDeleteStates[id]) 426 | ); 427 | delete cellDeleteStates[id]; 428 | }); 429 | }); 430 | }); 431 | 432 | Object.keys(groupMap).forEach(function (cellIndex) { 433 | cellIndex = Number(cellIndex); 434 | var currentGroupMap = groupMap[cellIndex]; 435 | Object.keys(currentGroupMap).forEach(function (groupId) { 436 | var group = currentGroupMap[groupId]; 437 | var memberList = group.members; 438 | if (group.tcid == cellIndex) { 439 | memberList.forEach(function (member) { 440 | transformedStateList.push( 441 | applyOutboundStateTransformer(member) 442 | ); 443 | }); 444 | } 445 | }); 446 | }); 447 | 448 | // External channel which clients can subscribe to. 449 | // It will publish to multiple channels based on each state's 450 | // (x, y) coordinates. 451 | if (transformedStateList.length) { 452 | channelGrid.publish('cell-data', transformedStateList); 453 | } 454 | 455 | }, WORLD_UPDATE_INTERVAL); 456 | 457 | function forEachStateInDataTree(dataTree, callback) { 458 | var typeList = Object.keys(dataTree); 459 | 460 | typeList.forEach(function (type) { 461 | var stateList = dataTree[type]; 462 | var ids = Object.keys(stateList); 463 | 464 | ids.forEach(function (id) { 465 | callback(stateList[id]); 466 | }); 467 | }); 468 | } 469 | 470 | function updateStateExternalTag(state, cellIndex) { 471 | if (state.ccid != cellIndex || state.tcid != cellIndex) { 472 | state.external = true; 473 | } else { 474 | delete state.external; 475 | } 476 | } 477 | 478 | // Share states with adjacent cells when those states get near 479 | // other cells' boundaries and prepare for transition to other cells. 480 | // This logic is quite complex so be careful when changing any code here. 481 | function dispatchProcessedData(cellIndex) { 482 | var now = Date.now(); 483 | var currentCellData = cellData[cellIndex]; 484 | var workerStateRefList = {}; 485 | var statesForNearbyCells = {}; 486 | 487 | forEachStateInDataTree(currentCellData, function (state) { 488 | var id = state.id; 489 | var swid = state.swid; 490 | var type = state.type; 491 | 492 | if (!state.external) { 493 | if (state.version != null) { 494 | state.version++; 495 | } 496 | state.processed = now; 497 | } 498 | 499 | // The target cell id 500 | state.tcid = channelGrid.getCellIndex(state); 501 | 502 | // For newly created states (those created from inside the cell). 503 | if (state.ccid == null) { 504 | state.ccid = cellIndex; 505 | state.version = 1; 506 | } 507 | updateStateExternalTag(state, cellIndex); 508 | 509 | if (state.ccid == cellIndex) { 510 | var nearbyCellIndexes = channelGrid.getAllCellIndexes(state); 511 | nearbyCellIndexes.forEach(function (nearbyCellIndex) { 512 | if (!statesForNearbyCells[nearbyCellIndex]) { 513 | statesForNearbyCells[nearbyCellIndex] = []; 514 | } 515 | // No need for the cell to send states to itself. 516 | if (nearbyCellIndex != cellIndex) { 517 | statesForNearbyCells[nearbyCellIndex].push(state); 518 | } 519 | }); 520 | 521 | if (state.tcid != cellIndex && swid) { 522 | if (!workerStateRefList[swid]) { 523 | workerStateRefList[swid] = []; 524 | } 525 | var stateRef = { 526 | id: state.id, 527 | swid: state.swid, 528 | tcid: state.tcid, 529 | type: state.type 530 | }; 531 | 532 | if (state.delete) { 533 | stateRef.delete = state.delete; 534 | } 535 | workerStateRefList[swid].push(stateRef); 536 | } 537 | } 538 | 539 | if (state.delete) { 540 | if (!cellPendingDeletes[cellIndex][type]) { 541 | cellPendingDeletes[cellIndex][type] = {}; 542 | } 543 | cellPendingDeletes[cellIndex][type][id] = state; 544 | delete currentCellData[type][id]; 545 | } 546 | if (now - state.processed > WORLD_STALE_TIMEOUT) { 547 | delete currentCellData[type][id]; 548 | } 549 | }); 550 | 551 | var workerCellTransferIds = Object.keys(workerStateRefList); 552 | workerCellTransferIds.forEach(function (swid) { 553 | scServer.exchange.publish('internal/input-cell-transition/' + swid, workerStateRefList[swid]); 554 | }); 555 | 556 | // Pass states off to adjacent cells as they move across grid cells. 557 | var allNearbyCellIndexes = Object.keys(statesForNearbyCells); 558 | allNearbyCellIndexes.forEach(function (nearbyCellIndex) { 559 | channelGrid.publishToCells(CHANNEL_CELL_TRANSITION, statesForNearbyCells[nearbyCellIndex], [nearbyCellIndex]); 560 | }); 561 | } 562 | 563 | // Receive states which are in other cells and *may* transition to this cell later. 564 | // We don't manage these states, we just keep a copy so that they are visible 565 | // inside our cellController (cell.js) - This allows states to interact across 566 | // cell partitions (which may be hosted on a different process/CPU core). 567 | function gridCellTransitionHandler(cellIndex, stateList) { 568 | var currentCellData = cellData[cellIndex]; 569 | 570 | stateList.forEach(function (state) { 571 | var type = state.type; 572 | var id = state.id; 573 | 574 | if (!currentCellData[type]) { 575 | currentCellData[type] = {}; 576 | } 577 | var existingState = currentCellData[type][id]; 578 | 579 | if (!existingState || state.version > existingState.version) { 580 | // Do not overwrite a state which is in the middle of 581 | // being synchronized with a different cell. 582 | if (state.tcid == cellIndex) { 583 | // This is a full transition to our current cell. 584 | state.ccid = cellIndex; 585 | currentCellData[type][id] = state; 586 | } else { 587 | // This is just external state for us to track but not 588 | // a complete transition, the state will still be managed by 589 | // a different cell. 590 | currentCellData[type][id] = state; 591 | } 592 | updateStateExternalTag(state, cellIndex); 593 | } 594 | 595 | existingState = currentCellData[type][id]; 596 | existingState.processed = Date.now(); 597 | }); 598 | } 599 | 600 | // Here we handle and prepare data for a single cell within our game grid to be 601 | // processed by our cell controller. 602 | function gridCellDataHandler(cellIndex, stateList) { 603 | var currentCellData = cellData[cellIndex]; 604 | 605 | stateList.forEach(function (stateRef) { 606 | var id = stateRef.id; 607 | var type = stateRef.type; 608 | 609 | if (!currentCellData[type]) { 610 | currentCellData[type] = {}; 611 | } 612 | 613 | if (!currentCellData[type][id]) { 614 | var state; 615 | if (stateRef.create) { 616 | // If it is a stateRef, we get the state from the create property. 617 | state = stateRef.create; 618 | } else if (stateRef.x != null && stateRef.y != null) { 619 | // If we have x and y properties, then we know that 620 | // this is a full state already (probably created directly inside the cell). 621 | state = stateRef; 622 | } else { 623 | throw new Error('Received an invalid state reference'); 624 | } 625 | state.ccid = cellIndex; 626 | state.version = 1; 627 | currentCellData[type][id] = state; 628 | } 629 | var cachedState = currentCellData[type][id]; 630 | if (cachedState) { 631 | if (stateRef.op) { 632 | cachedState.op = stateRef.op; 633 | } 634 | if (stateRef.delete) { 635 | cachedState.delete = stateRef.delete; 636 | } 637 | if (stateRef.data) { 638 | cachedState.data = stateRef.data; 639 | } 640 | cachedState.tcid = channelGrid.getCellIndex(cachedState); 641 | updateStateExternalTag(cachedState, cellIndex); 642 | cachedState.processed = Date.now(); 643 | } 644 | }); 645 | } 646 | 647 | scServer.exchange.subscribe('internal/input-cell-transition/' + serverWorkerId) 648 | .watch(function (stateList) { 649 | stateList.forEach(function (state) { 650 | game.stateRefs[state.id] = state; 651 | }); 652 | }); 653 | 654 | // This is the main input loop which feeds states into various cells 655 | // based on their (x, y) coordinates. 656 | function processInputStates() { 657 | var stateList = []; 658 | var stateIds = Object.keys(game.stateRefs); 659 | 660 | stateIds.forEach(function (id) { 661 | var state = game.stateRefs[id]; 662 | // Don't include bots. 663 | stateList.push(state); 664 | }); 665 | 666 | // Publish to internal channels for processing (e.g. Collision 667 | // detection and resolution, scoring, etc...) 668 | // These states will be processed by a cell controllers depending 669 | // on each state's target cell index (tcid) within the world grid. 670 | var gridPublishOptions = { 671 | cellIndexesFactory: function (state) { 672 | return [state.tcid]; 673 | } 674 | }; 675 | channelGrid.publish(CHANNEL_INBOUND_CELL_PROCESSING, stateList, gridPublishOptions); 676 | 677 | stateList.forEach(function (state) { 678 | if (state.op) { 679 | delete state.op; 680 | } 681 | if (state.delete) { 682 | delete game.stateRefs[state.id]; 683 | } 684 | }); 685 | } 686 | 687 | setInterval(processInputStates, WORLD_UPDATE_INTERVAL); 688 | 689 | /* 690 | In here we handle our incoming realtime connections and listen for events. 691 | */ 692 | scServer.on('connection', function (socket) { 693 | 694 | socket.on('getWorldInfo', function (data, respond) { 695 | // The first argument to respond can optionally be an Error object. 696 | respond(null, { 697 | width: WORLD_WIDTH, 698 | height: WORLD_HEIGHT, 699 | cols: WORLD_COLS, 700 | rows: WORLD_ROWS, 701 | cellWidth: WORLD_CELL_WIDTH, 702 | cellHeight: WORLD_CELL_HEIGHT, 703 | cellOverlapDistance: WORLD_CELL_OVERLAP_DISTANCE, 704 | serverWorkerId: serverWorkerId, 705 | environment: environment 706 | }); 707 | }); 708 | 709 | socket.on('join', function (playerOptions, respond) { 710 | var startingPos = getRandomPosition(PLAYER_DIAMETER, PLAYER_DIAMETER); 711 | var player = { 712 | id: uuid.v4(), 713 | type: 'player', 714 | swid: serverWorkerId, 715 | name: playerOptions.name, 716 | x: startingPos.x, 717 | y: startingPos.y, 718 | diam: PLAYER_DIAMETER, 719 | mass: PLAYER_MASS, 720 | score: 0 721 | }; 722 | 723 | socket.player = stateManager.create(player); 724 | 725 | respond(null, player); 726 | }); 727 | 728 | socket.on('action', function (playerOp) { 729 | if (socket.player) { 730 | stateManager.update(socket.player, playerOp); 731 | } 732 | }); 733 | 734 | socket.on('disconnect', function () { 735 | if (socket.player) { 736 | stateManager.delete(socket.player); 737 | } 738 | }); 739 | }); 740 | }; 741 | --------------------------------------------------------------------------------