Learn RxJS
Search…
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.

Example Code

Game Loop
1
import { BehaviorSubject, Observable, of, fromEvent } from 'rxjs';
2
import { buffer, bufferCount, expand, filter, map, share, tap, withLatestFrom } from 'rxjs/operators';
3
4
import { IFrameData } from './frame.interface';
5
import { KeyUtil } from './keys.util';
6
import { clampMag, runBoundaryCheck, clampTo30FPS } from './game.util';
7
8
const boundaries = {
9
left: 0,
10
top: 0,
11
bottom: 300,
12
right: 400
13
};
14
const bounceRateChanges = {
15
left: 1.1,
16
top: 1.2,
17
bottom: 1.3,
18
right: 1.4
19
}
20
const baseObjectVelocity = {
21
x: 30,
22
y: 40,
23
maxX: 250,
24
maxY: 200
25
};
26
27
const gameArea: HTMLElement = document.getElementById('game');
28
const fps: HTMLElement = document.getElementById('fps');
29
30
/**
31
* This is our core game loop logic. We update our objects and gameState here
32
* each frame. The deltaTime passed in is in seconds, we are givent our current state,
33
* and any inputStates. Returns the updated Game State
34
*/
35
const update = (deltaTime: number, state: any, inputState: any): any => {
36
//console.log("Input State: ", inputState);
37
if(state['objects'] === undefined) {
38
state['objects'] = [
39
{
40
// Transformation Props
41
x: 10, y: 10, width: 20, height: 30,
42
// State Props
43
isPaused: false, toggleColor: '#FF0000', color: '#000000',
44
// Movement Props
45
velocity: baseObjectVelocity
46
},
47
{
48
// Transformation Props
49
x: 200, y: 249, width: 50, height: 20,
50
// State Props
51
isPaused: false, toggleColor: '#00FF00', color: '#0000FF',
52
// Movement Props
53
velocity: {x: -baseObjectVelocity.x, y: 2*baseObjectVelocity.y} }
54
];
55
} else {
56
57
state['objects'].forEach((obj) => {
58
// Process Inputs
59
if (inputState['spacebar']) {
60
obj.isPaused = !obj.isPaused;
61
let newColor = obj.toggleColor;
62
obj.toggleColor = obj.color;
63
obj.color = newColor;
64
}
65
66
// Process GameLoop Updates
67
if(!obj.isPaused) {
68
69
// Apply Velocity Movements
70
obj.x = obj.x += obj.velocity.x*deltaTime;
71
obj.y = obj.y += obj.velocity.y*deltaTime;
72
73
// Check if we exceeded our boundaries
74
const didHit = runBoundaryCheck(obj, boundaries);
75
// Handle boundary adjustments
76
if(didHit){
77
if(didHit === 'right' || didHit === 'left') {
78
obj.velocity.x *= -bounceRateChanges[didHit];
79
} else {
80
obj.velocity.y *= -bounceRateChanges[didHit];
81
}
82
}
83
}
84
85
// Clamp Velocities in case our boundary bounces have gotten
86
// us going tooooo fast.
87
obj.velocity.x = clampMag(obj.velocity.x, 0, baseObjectVelocity.maxX);
88
obj.velocity.y = clampMag(obj.velocity.y, 0, baseObjectVelocity.maxY);
89
});
90
}
91
92
return state;
93
}
94
95
/**
96
* This is our rendering function. We take the given game state and render the items
97
* based on their latest properties.
98
*/
99
const render = (state: any) => {
100
const ctx: CanvasRenderingContext2D = (<HTMLCanvasElement>gameArea).getContext('2d');
101
// Clear the canvas
102
ctx.clearRect(0, 0, gameArea.clientWidth, gameArea.clientHeight);
103
104
// Render all of our objects (simple rectangles for simplicity)
105
state['objects'].forEach((obj) => {
106
ctx.fillStyle = obj.color;
107
ctx.fillRect(obj.x, obj.y, obj.width, obj.height);
108
});
109
};
110
111
112
113
/**
114
* This function returns an observable that will emit the next frame once the
115
* browser has returned an animation frame step. Given the previous frame it calculates
116
* the delta time, and we also clamp it to 30FPS in case we get long frames.
117
*/
118
const calculateStep: (prevFrame: IFrameData) => Observable<IFrameData> = (prevFrame: IFrameData) => {
119
return Observable.create((observer) => {
120
121
requestAnimationFrame((frameStartTime) => {
122
// Millis to seconds
123
const deltaTime = prevFrame ? (frameStartTime - prevFrame.frameStartTime)/1000 : 0;
124
observer.next({
125
frameStartTime,
126
deltaTime
127
});
128
})
129
})
130
.pipe(
131
map(clampTo30FPS)
132
)
133
};
134
135
// This is our core stream of frames. We use expand to recursively call the
136
// `calculateStep` function above that will give us each new Frame based on the
137
// window.requestAnimationFrame calls. Expand emits the value of the called functions
138
// returned observable, as well as recursively calling the function with that same
139
// emitted value. This works perfectly for calculating our frame steps because each step
140
// needs to know the lastStepFrameTime to calculate the next. We also only want to request
141
// a new frame once the currently requested frame has returned.
142
const frames$ = of(undefined)
143
.pipe(
144
expand((val) => calculateStep(val)),
145
// Expand emits the first value provided to it, and in this
146
// case we just want to ignore the undefined input frame
147
filter(frame => frame !== undefined),
148
map((frame: IFrameData) => frame.deltaTime),
149
share()
150
)
151
152
// This is our core stream of keyDown input events. It emits an object like `{"spacebar": 32}`
153
// each time a key is pressed down.
154
const keysDown$ = fromEvent(document, 'keydown')
155
.pipe(
156
map((event: KeyboardEvent) => {
157
const name = KeyUtil.codeToKey(''+event.keyCode);
158
if (name !== ''){
159
let keyMap = {};
160
keyMap[name] = event.code;
161
return keyMap;
162
} else {
163
return undefined;
164
}
165
}),
166
filter((keyMap) => keyMap !== undefined)
167
);
168
169
// Here we buffer our keyDown stream until we get a new frame emission. This
170
// gives us a set of all the keyDown events that have triggered since the previous
171
// frame. We reduce these all down to a single dictionary of keys that were pressed.
172
const keysDownPerFrame$ = keysDown$
173
.pipe(
174
buffer(frames$),
175
map((frames: Array<any>) => {
176
return frames.reduce((acc, curr) => {
177
return Object.assign(acc, curr);
178
}, {});
179
})
180
);
181
182
// Since we will be updating our gamestate each frame we can use an Observable
183
// to track that as a series of states with the latest emission being the current
184
// state of our game.
185
const gameState$ = new BehaviorSubject({});
186
187
// This is where we run our game!
188
// We subscribe to our frames$ stream to kick it off, and make sure to
189
// combine in the latest emission from our inputs stream to get the data
190
// we need do perform our gameState updates.
191
frames$
192
.pipe(
193
withLatestFrom(keysDownPerFrame$, gameState$),
194
// HOMEWORK_OPPORTUNITY: Handle Key-up, and map to a true KeyState change object
195
map(([deltaTime, keysDown, gameState]) => update(deltaTime, gameState, keysDown)),
196
tap((gameState) => gameState$.next(gameState))
197
198
)
199
.subscribe((gameState) => {
200
render(gameState);
201
});
202
203
204
// Average every 10 Frames to calculate our FPS
205
frames$
206
.pipe(
207
bufferCount(10),
208
map((frames) => {
209
const total = frames
210
.reduce((acc, curr) => {
211
acc += curr;
212
return acc;
213
}, 0);
214
215
return 1/(total/frames.length);
216
})
217
).subscribe((avg) => {
218
fps.innerHTML = Math.round(avg) + '';
219
})
Copied!

supporting js

html

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

Operators Used

Last modified 1yr ago