├── .gitignore ├── README.md ├── example └── usage.js ├── index.js ├── lib └── gameloop.js ├── package.json └── test ├── memory.js └── simple.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-gameloop [![npm version](https://badge.fury.io/js/node-gameloop.svg)](https://badge.fury.io/js/node-gameloop) 2 | 3 | A game loop designed by [timetocode](https://github.com/timetocode) for NodeJS applications. Uses a combination of `setTimeout` and `setImmediate` to achieve accurate update ticks with minimal CPU usage. 4 | 5 | This repo adds `npm` module support and an API that allows it to be called from client code. 6 | 7 | ```sh 8 | npm install --save node-gameloop 9 | ``` 10 | 11 | ## Example 12 | 13 | `node-gameloop` uses an API very similar to `setTimeout`/`setInterval`, returning an ID that can be used to clear the game loop later. 14 | 15 | ```js 16 | const gameloop = require('node-gameloop'); 17 | 18 | // start the loop at 30 fps (1000/30ms per frame) and grab its id 19 | let frameCount = 0; 20 | const id = gameloop.setGameLoop(function(delta) { 21 | // `delta` is the delta time from the last frame 22 | console.log('Hi there! (frame=%s, delta=%s)', frameCount++, delta); 23 | }, 1000 / 30); 24 | 25 | // stop the loop 2 seconds later 26 | setTimeout(function() { 27 | console.log('2000ms passed, stopping the game loop'); 28 | gameloop.clearGameLoop(id); 29 | }, 2000); 30 | ``` 31 | 32 | ## API 33 | 34 | ```js 35 | var gameloop = require('node-gameloop'); 36 | ``` 37 | 38 | Return | Function | Params | Description 39 | --- | --- | --- | --- 40 | number `id` | `setGameLoop` | (function `update(delta)`,
[float `targetDeltaMs`]) | Sets and runs a game loop at a target delta (in milliseconds) [defaults to 30fps]. Runs function `update` with a parameter delta (time in seconds from last update). Returns the game loop ID used in `clearGameLoop` 41 | void | `clearGameLoop` | (number `id`) | Clears and stops a given game loop. Will cancel the loop immediately and will not wait for current frame to finish. 42 | -------------------------------------------------------------------------------- /example/usage.js: -------------------------------------------------------------------------------- 1 | const gameloop = require('..'); 2 | 3 | // start the loop at 30 fps (1000/30ms per frame) and grab its id 4 | let frameCount = 0; 5 | const id = gameloop.setGameLoop(function(delta) { 6 | // `delta` is the delta time from the last frame 7 | console.log('Hi there! (frame=%s, delta=%s)', frameCount++, delta); 8 | }, 1000 / 30); 9 | 10 | // stop the loop 2 seconds later 11 | setTimeout(function() { 12 | console.log('2000ms passed, stopping the game loop'); 13 | gameloop.clearGameLoop(id); 14 | }, 2000); -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/gameloop'); -------------------------------------------------------------------------------- /lib/gameloop.js: -------------------------------------------------------------------------------- 1 | // Taken and modified from https://github.com/timetocode/node-game-loop 2 | // Thanks to https://github.com/norlin/node-gameloop for making this faster 3 | 4 | let activeLoops = []; 5 | 6 | const getLoopId = (function() { 7 | let staticLoopId = 0; 8 | 9 | return function() { 10 | return staticLoopId++; 11 | } 12 | })(); 13 | 14 | function getNano() { 15 | var hrtime = process.hrtime(); 16 | return (+hrtime[0]) * s2nano + (+hrtime[1]); 17 | } 18 | 19 | const s2nano = 1e9; 20 | const nano2s = 1 / s2nano; // avoid a divide later, although maybe not nessecary 21 | const ms2nano = 1e6; 22 | 23 | /** 24 | * Create a game loop that will attempt to update at some target `tickLengthMs`. 25 | * 26 | * `tickLengthMs` defaults to 30fps (~33.33ms). 27 | * 28 | * Internally, the `gameLoop` function created has two mechanisms to update itself. 29 | * One for coarse-grained updates (with `setTimeout`) and one for fine-grained 30 | * updates (with `setImmediate`). 31 | * 32 | * On each tick, we set a target time for the next tick. We attempt to use the coarse- 33 | * grained "long wait" to get most of the way to our target tick time, then use the 34 | * fine-grained wait to wait the remaining time. 35 | */ 36 | module.exports.setGameLoop = function(update, tickLengthMs = 1000 / 30) { 37 | let loopId = getLoopId(); 38 | activeLoops.push(loopId); 39 | 40 | // expected tick length 41 | const tickLengthNano = tickLengthMs * ms2nano; 42 | 43 | // We pick the floor of `tickLengthMs - 1` because the `setImmediate` below runs 44 | // around 16ms later and if our coarse-grained 'long wait' is too long, we tend 45 | // to miss our target framerate by a little bit 46 | const longwaitMs = Math.floor(tickLengthMs - 1); 47 | const longwaitNano = longwaitMs * ms2nano; 48 | 49 | let prev = getNano(); 50 | let target = getNano(); 51 | 52 | let frame = 0; 53 | 54 | const gameLoop = function() { 55 | frame++; 56 | 57 | const now = getNano(); 58 | 59 | if (now >= target) { 60 | const delta = now - prev; 61 | 62 | prev = now; 63 | target = now + tickLengthNano; 64 | 65 | // actually run user code 66 | update(delta * nano2s); 67 | } 68 | 69 | // do not go on to renew loop if no longer active 70 | if (activeLoops.indexOf(loopId) === -1) { 71 | return; 72 | } 73 | 74 | // re-grab the current time in case we ran update and it took a long time 75 | const remainingInTick = target - getNano(); 76 | if (remainingInTick > longwaitNano) { 77 | // unfortunately it seems our code/node leaks memory if setTimeout is 78 | // called with a value less than 16, so we give up our accuracy for 79 | // memory stability 80 | setTimeout(gameLoop, Math.max(longwaitMs, 16)); 81 | } else { 82 | setImmediate(gameLoop); 83 | } 84 | } 85 | 86 | // begin the loop! 87 | gameLoop(); 88 | 89 | return loopId; 90 | }; 91 | 92 | module.exports.clearGameLoop = function(loopId) { 93 | // remove the loop id from the active loops 94 | activeLoops.splice(activeLoops.indexOf(loopId), 1); 95 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-gameloop", 3 | "version": "0.1.5", 4 | "description": "game loop designed for node applications, not for the browser", 5 | "main": "index.js", 6 | "directories": { 7 | "example": "example" 8 | }, 9 | "scripts": { 10 | "test": "node test/*", 11 | "postpublish": "./node_modules/.bin/srun --script=postpublish" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/tangmi/node-gameloop.git" 16 | }, 17 | "keywords": [ 18 | "gameloop", 19 | "game", 20 | "loop", 21 | "node", 22 | "application", 23 | "app", 24 | "desktop" 25 | ], 26 | "author": "tangmi", 27 | "license": "Artistic License 2.0", 28 | "bugs": { 29 | "url": "https://github.com/tangmi/node-gameloop/issues" 30 | }, 31 | "homepage": "https://github.com/tangmi/node-gameloop", 32 | "devDependencies": { 33 | "pidusage": "latest", 34 | "srun": "latest" 35 | } 36 | } -------------------------------------------------------------------------------- /test/memory.js: -------------------------------------------------------------------------------- 1 | const pidusage = require('pidusage'); 2 | const gameloop = require('..'); 3 | 4 | let fps = 60; 5 | let intervalMs = 1000 / fps; 6 | 7 | const loop = gameloop.setGameLoop(function(delta) {}, intervalMs); 8 | 9 | let testCount = 0; 10 | 11 | let memorySamples = []; 12 | 13 | const count = 25; 14 | console.log(`collecting samples for ${count} seconds`) 15 | const interval = setInterval(function() { 16 | testCount += 1; 17 | 18 | pidusage.stat(process.pid, function(err, result) { 19 | memorySamples.push(result.memory); 20 | 21 | if (testCount == count) { 22 | let hasReduction = false 23 | 24 | console.log('memory usage:'); 25 | console.log(memorySamples[0]); 26 | for (var i = 1; i < memorySamples.length; i++) { 27 | var memorySample = memorySamples[i]; 28 | var delta = memorySample - memorySamples[i - 1]; 29 | console.log(`${memorySample} (delta: ${delta})`); 30 | 31 | if (delta < 0) { 32 | hasReduction = true; 33 | } 34 | } 35 | 36 | if (hasReduction) { 37 | console.log('memory likely not leaking! :)'); 38 | } else { 39 | console.log('memory may be leaking!'); 40 | process.exit(1); 41 | } 42 | 43 | gameloop.clearGameLoop(loop); 44 | clearInterval(interval); 45 | } 46 | }); 47 | }, 1000); 48 | -------------------------------------------------------------------------------- /test/simple.js: -------------------------------------------------------------------------------- 1 | const pidusage = require('pidusage'); 2 | const gameloop = require('..'); 3 | 4 | let totalDelta = 0; 5 | let testRuns = 0; 6 | 7 | let fps = 60; 8 | let intervalMs = 1000 / fps; 9 | 10 | const loop = gameloop.setGameLoop(function(delta) { 11 | console.log(`delta=${delta}`); 12 | 13 | totalDelta += delta; 14 | testRuns += 1; 15 | }, intervalMs); 16 | 17 | let testCount = 0; 18 | let totalCpu = 0; 19 | 20 | const interval = setInterval(function() { 21 | testCount += 1; 22 | 23 | pidusage.stat(process.pid, function(err, result) { 24 | totalCpu += result.cpu; 25 | 26 | if (testCount == 10) { 27 | let avgDelta = totalDelta / testRuns; 28 | let targetDelta = 29 | console.log(`Test runs: ${testRuns}`); 30 | console.log(`target delta : ${intervalMs/1000}s`); 31 | console.log(`average delta: ${avgDelta}s`); 32 | console.log(`average cpu over ${testCount} seconds: ${totalCpu / testCount}%`); 33 | 34 | gameloop.clearGameLoop(loop); 35 | clearInterval(interval); 36 | } 37 | }); 38 | }, 1000); 39 | --------------------------------------------------------------------------------