├── .gitignore ├── README.md ├── dist ├── bundle.cjs.js ├── bundle.esm.js └── bundle.umd.js ├── docs ├── css │ └── styles.css ├── dist │ └── bundle.umd.js ├── index.html └── js │ ├── setup-demo.js │ ├── setup-stats.js │ ├── setup-threejs.js │ └── setup-vanilla.js ├── package.json ├── rollup.config.js └── src └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | npm-debug.log* 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # game-loop-js :video_game: :repeat: 2 | 3 | [![npm](https://img.shields.io/npm/v/@wmcmurray/game-loop-js)](https://www.npmjs.com/package/@wmcmurray/game-loop-js) 4 | [![Language grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/wmcmurray/game-loop-js.svg?logo=lgtm&logoWidth=18&label=JS%20code%20quality)](https://lgtm.com/projects/g/wmcmurray/game-loop-js/context:javascript) 5 | 6 | [![live demo](https://img.shields.io/badge/-live%20demo%20!-springgreen?style=for-the-badge)](https://wmcmurray.github.io/game-loop-js/) 7 | 8 | - Lightweight 9 | - Built with speed in mind 10 | - Ensure **consistent frame-rates** (based on the targeted FPS of your choosing) 11 | - The targeted FPS is **tweakable on-the-fly** (while the game is running) 12 | - This implementation **does not have a requestAnimationFrame loop**, it let you implement it yourself *or not* (because in some cases it's handled by the framework, ex: threejs in a WebXR project) 13 | - "Normalize" the deltaTime at the source to make sure 1 equals 1 second (instead of 1 millisecond), because many calculations are expressed in seconds instead of milliseconds (ex: gravity force is -9.81 meters per seconds) so this prevents you from "/ 1000" everywhere in your actual code, it's done at the source, one time. 14 | 15 | 16 | ## Usage 17 | 18 | ``` 19 | npm install @wmcmurray/game-loop-js 20 | ``` 21 | 22 | ### Three.js / requestAnimationFrame() 23 | 24 | ```javascript 25 | import { createGameLoop } from '@wmcmurray/game-loop-js' 26 | 27 | const myGameLoop = function(deltaTime) { 28 | // ... 29 | }; 30 | 31 | // how to create a game loop with a targeted 60 FPS : 32 | const gameLoop = createGameLoop(myGameLoop, 60); 33 | 34 | // how to change the targeted FPS : 35 | gameLoop.fps = 144; 36 | 37 | // how to get the targeted FPS : 38 | const targetFps = gameLoop.fps; 39 | 40 | 41 | // how to register the loop : 42 | 43 | // using three.js 44 | renderer.setAnimationLoop( gameLoop.loop ); 45 | 46 | // -- or -- 47 | 48 | // using requestAnimationFrame 49 | function animate( time ) { 50 | gameLoop.loop( time ); 51 | requestAnimationFrame( animate ); 52 | } 53 | requestAnimationFrame( animate ); 54 | ``` 55 | 56 | 57 | ### Browser 58 | 59 | ```html 60 | 61 | 62 | 75 | ``` 76 | -------------------------------------------------------------------------------- /dist/bundle.cjs.js: -------------------------------------------------------------------------------- 1 | "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.createGameLoop=function(e,t=60){let o=0,r=0,s=0,n=0,p=0,u=0;function c(e){o=e,r=1e3/o}return c(t),{get fps(){return o},set fps(e){c(e)},loop(t){u=t-s,u>=r&&(p=n,n=u%r,s=t-n,u-=p,u*=.001,e(u))}}}; 2 | -------------------------------------------------------------------------------- /dist/bundle.esm.js: -------------------------------------------------------------------------------- 1 | function t(t,e=60){let n=0,o=0,r=0,f=0,p=0,u=0;function s(t){n=t,o=1e3/n}return s(e),{get fps(){return n},set fps(t){s(t)},loop(e){u=e-r,u>=o&&(p=f,f=u%o,r=e-f,u-=p,u*=.001,t(u))}}}export{t as createGameLoop}; 2 | -------------------------------------------------------------------------------- /dist/bundle.umd.js: -------------------------------------------------------------------------------- 1 | !function(e,o){"object"==typeof exports&&"undefined"!=typeof module?o(exports):"function"==typeof define&&define.amd?define(["exports"],o):o((e="undefined"!=typeof globalThis?globalThis:e||self).gameLoopJs={})}(this,(function(e){"use strict";e.createGameLoop=function(e,o=60){let t=0,n=0,f=0,i=0,s=0,p=0;function u(e){t=e,n=1e3/t}return u(o),{get fps(){return t},set fps(e){u(e)},loop(o){p=o-f,p>=n&&(s=i,i=p%n,f=o-i,p-=s,p*=.001,e(p))}}},Object.defineProperty(e,"__esModule",{value:!0})})); 2 | -------------------------------------------------------------------------------- /docs/css/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0px; 3 | overflow: hidden; 4 | background-color: #111; 5 | font-family: sans-serif; 6 | color: cyan; 7 | } 8 | 9 | canvas { 10 | width: 100% !important; 11 | height: 100% !important; 12 | } 13 | 14 | a { 15 | color: rgb(0,255,0); 16 | } 17 | 18 | #panel { 19 | position: fixed; 20 | top: 0px; 21 | right: 0px; 22 | background-color: rgba(50,50,50, 0.5); 23 | text-align: center; 24 | font-size: 20px; 25 | } 26 | #panel > .panel-item { 27 | padding: 10px 40px; 28 | cursor: pointer; 29 | transition: all 0.2s ease-out; 30 | } 31 | #panel > .panel-item:hover { 32 | background-color: rgba(0,255,0, 0.05); 33 | } 34 | #panel > .panel-item.active { 35 | background-color: rgba(255,0,255, 0.1); 36 | color: magenta; 37 | } 38 | 39 | #result { 40 | display: flex; 41 | flex-wrap: wrap; 42 | align-items: center; 43 | justify-content: center; 44 | flex-direction: column; 45 | position: fixed; 46 | top: 0px; 47 | left: 0px; 48 | width: 100%; 49 | height: 100%; 50 | font-size: 40px; 51 | } 52 | 53 | #infos { 54 | position: fixed; 55 | bottom: 0px; 56 | left: 0px; 57 | width: 100%; 58 | padding: 20px; 59 | text-align: center; 60 | } 61 | -------------------------------------------------------------------------------- /docs/dist/bundle.umd.js: -------------------------------------------------------------------------------- 1 | !function(e,o){"object"==typeof exports&&"undefined"!=typeof module?o(exports):"function"==typeof define&&define.amd?define(["exports"],o):o((e="undefined"!=typeof globalThis?globalThis:e||self).gameLoopJs={})}(this,(function(e){"use strict";e.createGameLoop=function(e,o=60){let t=0,n=0,f=0,i=0,s=0,p=0;function u(e){t=e,n=1e3/t}return u(o),{get fps(){return t},set fps(e){u(e)},loop(o){p=o-f,p>=n&&(s=i,i=p%n,f=o-i,p-=s,p*=.001,e(p))}}},Object.defineProperty(e,"__esModule",{value:!0})})); 2 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | game-loop-js 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 27 |
28 |

Both elapsed time should stay in sync.

29 |

The cube should rotate 90°/sec consistently across time and frame-rates.

30 |

( Note : you might not be able to go higher than 60 fps if your monitor does not support a higher refresh-rate )

31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /docs/js/setup-demo.js: -------------------------------------------------------------------------------- 1 | const resultDomElem = document.getElementById('result'); 2 | const resultTimeGame = document.createElement('div'); 3 | const resultTimeInterval = document.createElement('div'); 4 | resultDomElem.appendChild(resultTimeGame); 5 | resultDomElem.appendChild(resultTimeInterval); 6 | 7 | function selectProperItemInPanel() { 8 | document.querySelectorAll('.panel-item').forEach(function(item) { 9 | if(Number(item.attributes['data-fps'].value) === gameLoop.fps) { 10 | item.classList.add('active') 11 | } else { 12 | item.classList.remove('active') 13 | } 14 | }); 15 | } 16 | selectProperItemInPanel(); 17 | 18 | // panel click listener 19 | document.addEventListener('click', function (evt) { 20 | if(evt.target.matches('.panel-item')) { 21 | gameLoop.fps = Number(evt.target.attributes['data-fps'].value); 22 | selectProperItemInPanel(); 23 | } 24 | }, false); 25 | -------------------------------------------------------------------------------- /docs/js/setup-stats.js: -------------------------------------------------------------------------------- 1 | const stats = new Stats(); 2 | document.body.appendChild(stats.dom); 3 | -------------------------------------------------------------------------------- /docs/js/setup-threejs.js: -------------------------------------------------------------------------------- 1 | const renderer = new THREE.WebGLRenderer({ 2 | antialias: true, 3 | }); 4 | renderer.setPixelRatio( window.devicePixelRatio ); 5 | renderer.setSize( window.innerWidth, window.innerHeight ); 6 | document.body.appendChild( renderer.domElement ); 7 | 8 | const scene = new THREE.Scene(); 9 | const cube = new THREE.Mesh( new THREE.BoxBufferGeometry( 1, 1, 1 ), new THREE.MeshNormalMaterial() ); 10 | cube.position.y = 0.2; 11 | cube.rotation.x = 0.5; 12 | scene.add( cube ); 13 | 14 | const camera = new THREE.PerspectiveCamera( 70, window.innerWidth / window.innerHeight, 1, 1000 ); 15 | camera.position.z = 2; 16 | 17 | // window resize listener 18 | window.addEventListener('resize', function() { 19 | camera.aspect = window.innerWidth / window.innerHeight; 20 | camera.updateProjectionMatrix(); 21 | renderer.setSize( window.innerWidth, window.innerHeight ); 22 | }, false); 23 | 24 | // ----- 25 | 26 | let gameTimeElapsed = 0; 27 | // let intervalTimeElapsed = 0; 28 | 29 | const cubeRotationRadian = 90 * (Math.PI / 180); // 90 degrees 30 | const cubeRotationPerSeconds = cubeRotationRadian; 31 | const easing = function (t) { return t<.5 ? 4*t*t*t : (t-1)*(2*t-2)*(2*t-2)+1 }; 32 | 33 | let isFirst = true; 34 | const gameLoop = gameLoopJs.createGameLoop(function(deltaTime) { 35 | gameTimeElapsed += deltaTime; 36 | cube.rotation.y = cubeRotationRadian * easing(gameTimeElapsed % 1); 37 | 38 | renderer.render( scene, camera ); 39 | stats.update(); 40 | 41 | if(Math.floor(gameTimeElapsed)) { 42 | resultTimeGame.innerHTML = Math.floor(gameTimeElapsed) + ' sec (game)'; 43 | } 44 | 45 | if(isFirst) { 46 | isFirst = false; 47 | const firstA = Math.floor(gameTimeElapsed); 48 | 49 | // sync my interval with the game loop 50 | setTimeout(function() { 51 | const intervalStartedAt = Date.now(); 52 | const displayIntervalTime = function() { 53 | // resultTimeInterval.innerHTML = Math.floor(++intervalTimeElapsed + firstA) + ' sec (interval)'; 54 | resultTimeInterval.innerHTML = Math.floor( ((Date.now() - intervalStartedAt) * 0.001) + firstA + 1 ) + ' sec (interval)'; 55 | }; 56 | setInterval(displayIntervalTime, 1000); 57 | displayIntervalTime(); 58 | }, 1000 * (1 - (gameTimeElapsed - firstA))); 59 | } 60 | }, 60); 61 | 62 | // start the demo at the exact start of a second 63 | setTimeout(function() { 64 | renderer.setAnimationLoop( gameLoop.loop ); 65 | }, 1000 - (new Date()).getMilliseconds()); 66 | -------------------------------------------------------------------------------- /docs/js/setup-vanilla.js: -------------------------------------------------------------------------------- 1 | let timeElapsed = 0; 2 | 3 | const gameLoop = gameLoopJs.createGameLoop(function(deltaTime) { 4 | timeElapsed += deltaTime; 5 | 6 | document.getElementById('result').innerHTML = Math.round(timeElapsed * 1000) / 1000; 7 | 8 | stats.update(); 9 | }, 60); 10 | 11 | // start the demo 12 | function onAnimationFrame( time ) { 13 | gameLoop.loop( time ); 14 | requestAnimationFrame( onAnimationFrame ); 15 | } 16 | requestAnimationFrame( onAnimationFrame ); 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wmcmurray/game-loop-js", 3 | "version": "1.0.0", 4 | "description": "Reliable and versatile game loop implementation", 5 | "main": "dist/bundle.cjs.js", 6 | "module": "dist/bundle.esm.js", 7 | "browser": "dist/bundle.umd.js", 8 | "scripts": { 9 | "build": "rollup -c", 10 | "dev": "rollup -c -w", 11 | "test": "http-server ./docs/ --silent -c-1 -o /index.html" 12 | }, 13 | "files": [ 14 | "dist" 15 | ], 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/wmcmurray/game-loop-js.git" 19 | }, 20 | "keywords": [ 21 | "game", 22 | "loop", 23 | "gameloop", 24 | "renderloop", 25 | "requestAnimationFrame" 26 | ], 27 | "author": "William McMurray ", 28 | "license": "ISC", 29 | "bugs": { 30 | "url": "https://github.com/wmcmurray/game-loop-js/issues" 31 | }, 32 | "homepage": "https://github.com/wmcmurray/game-loop-js", 33 | "devDependencies": { 34 | "http-server": "^0.12.3", 35 | "rollup": "^2.32.0", 36 | "rollup-plugin-copy": "^3.3.0", 37 | "rollup-plugin-terser": "^7.0.2" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { terser } from 'rollup-plugin-terser' 2 | import copy from 'rollup-plugin-copy' 3 | import pkg from './package.json' 4 | 5 | export default { 6 | input: 'src/index.js', 7 | output: [ 8 | // UMD build (browser-friendly) 9 | { 10 | file: pkg.browser, 11 | format: 'umd', 12 | name: 'gameLoopJs', 13 | }, 14 | 15 | // CommonJS build (for Node) 16 | { 17 | file: pkg.main, 18 | format: 'cjs', 19 | exports: 'named', 20 | }, 21 | 22 | // ES module build (for bundlers) 23 | { 24 | file: pkg.module, 25 | format: 'es', 26 | }, 27 | ], 28 | plugins: [ 29 | terser(), 30 | copy({ 31 | targets: [ 32 | // move the UMD build into the docs folder (so it can be used by the live demo) 33 | { 34 | src: pkg.browser, 35 | dest: 'docs/dist', 36 | }, 37 | ], 38 | }), 39 | ], 40 | }; 41 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Wraps a loop function so it can be executed at a specific frame-rate 3 | * func {Function} = The function you want to execute each frames 4 | * fps {Number} = The targeted frame rate 5 | */ 6 | export function createGameLoop(func, fps = 60) { 7 | let targetFps = 0, fpsInterval = 0; 8 | let lastTime = 0, lastOverTime = 0, prevOverTime = 0, deltaTime = 0; 9 | 10 | function updateFps(value) { 11 | targetFps = value; 12 | fpsInterval = 1000 / targetFps; 13 | } 14 | 15 | updateFps(fps); 16 | 17 | return { 18 | // getter/setter for targeted frame rate 19 | get fps() { 20 | return targetFps; 21 | }, 22 | set fps(value) { 23 | updateFps(value); 24 | }, 25 | 26 | // the frame-capped loop function 27 | loop(time) { 28 | deltaTime = time - lastTime; 29 | 30 | if(deltaTime >= fpsInterval) { 31 | prevOverTime = lastOverTime; 32 | lastOverTime = deltaTime % fpsInterval; 33 | lastTime = time - lastOverTime; 34 | 35 | // keep time elapsed in sync with real life 36 | deltaTime -= prevOverTime; 37 | 38 | // "normalize" the delta time (so 1 equals to 1 second) 39 | deltaTime *= 0.001; 40 | 41 | func(deltaTime); 42 | } 43 | }, 44 | }; 45 | } 46 | --------------------------------------------------------------------------------