Learn RxJS
Search
K

Game Loop

This recipe demonstrates one way you might create a Game Loop as a combined set of streams. The recipe is intended to highlight how you might re-think existing problems with a reactive approach. In this recipe we provide the overall loop as a stream of frames and their deltaTimes since the previous frames. Combined with this is a stream of user inputs, and the current gameState, which we can use to update our objects, and render to to the screen on each frame emission.
Ultimate RxJS

Example Code

Game Loop
import { BehaviorSubject, Observable, of, fromEvent } from 'rxjs';
import { buffer, bufferCount, expand, filter, map, share, tap, withLatestFrom } from 'rxjs/operators';
import { IFrameData } from './frame.interface';
import { KeyUtil } from './keys.util';
import { clampMag, runBoundaryCheck, clampTo30FPS } from './game.util';
const boundaries = {
left: 0,
top: 0,
bottom: 300,
right: 400
};
const bounceRateChanges = {
left: 1.1,
top: 1.2,
bottom: 1.3,
right: 1.4
}
const baseObjectVelocity = {
x: 30,
y: 40,
maxX: 250,
maxY: 200
};
const gameArea: HTMLElement = document.getElementById('game');
const fps: HTMLElement = document.getElementById('fps');
/**
* This is our core game loop logic. We update our objects and gameState here
* each frame. The deltaTime passed in is in seconds, we are givent our current state,
* and any inputStates. Returns the updated Game State
*/
const update = (deltaTime: number, state: any, inputState: any): any => {
//console.log("Input State: ", inputState);
if(state['objects'] === undefined) {
state['objects'] = [
{
// Transformation Props
x: 10, y: 10, width: 20, height: 30,
// State Props
isPaused: false, toggleColor: '#FF0000', color: '#000000',
// Movement Props
velocity: baseObjectVelocity
},
{
// Transformation Props
x: 200, y: 249, width: 50, height: 20,
// State Props
isPaused: false, toggleColor: '#00FF00', color: '#0000FF',
// Movement Props
velocity: {x: -baseObjectVelocity.x, y: 2*baseObjectVelocity.y} }
];
} else {
state['objects'].forEach((obj) => {
// Process Inputs
if (inputState['spacebar']) {
obj.isPaused = !obj.isPaused;
let newColor = obj.toggleColor;
obj.toggleColor = obj.color;
obj.color = newColor;
}
// Process GameLoop Updates
if(!obj.isPaused) {
// Apply Velocity Movements
obj.x = obj.x += obj.velocity.x*deltaTime;
obj.y = obj.y += obj.velocity.y*deltaTime;
// Check if we exceeded our boundaries
const didHit = runBoundaryCheck(obj, boundaries);
// Handle boundary adjustments
if(didHit){
if(didHit === 'right' || didHit === 'left') {
obj.velocity.x *= -bounceRateChanges[didHit];
} else {
obj.velocity.y *= -bounceRateChanges[didHit];
}
}
}
// Clamp Velocities in case our boundary bounces have gotten
// us going tooooo fast.
obj.velocity.x = clampMag(obj.velocity.x, 0, baseObjectVelocity.maxX);
obj.velocity.y = clampMag(obj.velocity.y, 0, baseObjectVelocity.maxY);
});
}
return state;
}
/**
* This is our rendering function. We take the given game state and render the items
* based on their latest properties.
*/
const render = (state: any) => {
const ctx: CanvasRenderingContext2D = (<HTMLCanvasElement>gameArea).getContext('2d');
// Clear the canvas
ctx.clearRect(0, 0, gameArea.clientWidth, gameArea.clientHeight);
// Render all of our objects (simple rectangles for simplicity)
state['objects'].forEach((obj) => {
ctx.fillStyle = obj.color;
ctx.fillRect(obj.x, obj.y, obj.width, obj.height);
});
};
/**
* This function returns an observable that will emit the next frame once the
* browser has returned an animation frame step. Given the previous frame it calculates
* the delta time, and we also clamp it to 30FPS in case we get long frames.
*/
const calculateStep: (prevFrame: IFrameData) => Observable<IFrameData> = (prevFrame: IFrameData) => {
return Observable.create((observer) => {
requestAnimationFrame((frameStartTime) => {
// Millis to seconds
const deltaTime = prevFrame ? (frameStartTime - prevFrame.frameStartTime)/1000 : 0;
observer.next({
frameStartTime,
deltaTime
});
})
})
.pipe(
map(clampTo30FPS)
)
};
// This is our core stream of frames. We use expand to recursively call the
// `calculateStep` function above that will give us each new Frame based on the
// window.requestAnimationFrame calls. Expand emits the value of the called functions
// returned observable, as well as recursively calling the function with that same
// emitted value. This works perfectly for calculating our frame steps because each step
// needs to know the lastStepFrameTime to calculate the next. We also only want to request
// a new frame once the currently requested frame has returned.
const frames$ = of(undefined)
.pipe(
expand((val) => calculateStep(val)),
// Expand emits the first value provided to it, and in this
// case we just want to ignore the undefined input frame
filter(frame => frame !== undefined),
map((frame: IFrameData) => frame.deltaTime),
share()
)
// This is our core stream of keyDown input events. It emits an object like `{"spacebar": 32}`
// each time a key is pressed down.
const keysDown$ = fromEvent(document, 'keydown')
.pipe(
map((event: KeyboardEvent) => {
const name = KeyUtil.codeToKey(''+event.keyCode);
if (name !== ''){
let keyMap = {};
keyMap[name] = event.code;
return keyMap;
} else {
return undefined;
}
}),
filter((keyMap) => keyMap !== undefined)
);
// Here we buffer our keyDown stream until we get a new frame emission. This
// gives us a set of all the keyDown events that have triggered since the previous
// frame. We reduce these all down to a single dictionary of keys that were pressed.
const keysDownPerFrame$ = keysDown$
.pipe(
buffer(frames$),
map((frames: Array<any>) => {
return frames.reduce((acc, curr) => {
return Object.assign(acc, curr);
}, {});
})
);
// Since we will be updating our gamestate each frame we can use an Observable
// to track that as a series of states with the latest emission being the current
// state of our game.
const gameState$ = new BehaviorSubject({});
// This is where we run our game!
// We subscribe to our frames$ stream to kick it off, and make sure to
// combine in the latest emission from our inputs stream to get the data
// we need do perform our gameState updates.
frames$
.pipe(
withLatestFrom(keysDownPerFrame$, gameState$),
// HOMEWORK_OPPORTUNITY: Handle Key-up, and map to a true KeyState change object
map(([deltaTime, keysDown, gameState]) => update(deltaTime, gameState, keysDown)),
tap((gameState) => gameState$.next(gameState))
)
.subscribe((gameState) => {
render(gameState);
});
// Average every 10 Frames to calculate our FPS
frames$
.pipe(
bufferCount(10),
map((frames) => {
const total = frames
.reduce((acc, curr) => {
acc += curr;
return acc;
}, 0);
return 1/(total/frames.length);
})
).subscribe((avg) => {
fps.innerHTML = Math.round(avg) + '';
})

supporting js

html

<canvas width="400px" height="300px" id="game"></canvas>
<div id="fps"></div>
<p class="instructions">
Each time a block hits a wall, it gets faster. You can hit SPACE to pause the
boxes. They will change colors to show they are paused.
</p>

Operators Used