Game Loop
Last updated
Last updated
By @barryrowe
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.
( StackBlitz )
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) + '';
})
<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>