A workshop in vanilla TypeScript.
git clone [email protected]:flauwekeul/workshop-rxjs-breakout.git
cd workshop-rxjs-breakout
npm install
- Clone the project from GitHub: https://github.com/flauwekeul/workshop-rxjs-breakout
cd
into the folder- Install dependencies
See the slides with npm run slides
or see them online.
The exercises
folder contains 4 exercises to practice your RxJS skills. To see if you've supplied a correct answer, run npm run exercise:<n>
(where <n>
is the number of the exercise (without the leading 0)).
Tip: tap()
is a convenient operator for logging values in your stream.
The trainer is there to help out, but there are also these resources:
- 📜 RxJS API docs
- 🌳 Operator decision tree
- 🧑🏫 Learn RxJS (
⚠️ partially outdated)
You're going to create the game by following the steps below. It may be more fun to team up with one or two partners, but you can go solo as well. Also, you can choose to do the workshop together with the trainer.
If you can't keep up or fall in a rabbit hole deep in a plate of spaghetti code 🐇🕳️🍝, there are branches for each step. To go to the end of step 2 for example, run git checkout step-2
.
Run npm start
to start a dev server and visit http://localhost:5173/
to see the fruits of your labour. You can import types, constants and utils from the common
folder.
- Change
createPaddleStream()
in paddle.ts so that it returns an observable that emits the mouse's x position and the (static) paddle's y position. - Change
renderPaddle()
in paddle.ts to use thedrawRectangle()
util andPADDLE_WIDTH
,PADDLE_HEIGHT
andPADDLE_COLOR
settings (from common/settings.ts). - Subscribe to
paddle$
inmain()
and make surerenderState()
is called every timepaddle$
emits a value. UserenderPaddle()
inrenderState()
to render the paddle on screen. You need to persuade TypeScript you're passing an object of typeGameState
. - Use
canvasContext.clearRect(0, 0, canvas.width, canvas.height)
inrenderState()
to start with a "clean slate" on each frame (do this before rendering anything). - Use the
clamp()
util inrenderPaddle()
to prevent the paddle to go off screen. Pass it the lower and upper bound it should clamp the paddle between (hint: the minimum and maximum x positions the paddle may have). It then returns a function that accepts the paddle position (hint: use the paddle's center) and it will return the paddle position clamped between the lower and upper bound. - Optional: make the paddle start in the middle of the screen (before any mouse events have fired).
- Change
createBallStream()
in ball.ts so that it wraps the initial ball in an Observable and returns it. - Subscribe to both the
paddle$
and theball$
observables inmain()
; choose a suitable creation operator. - Change
renderBall()
in ball.ts to usedrawCircle()
(from common/utils.ts) andBALL_COLOR
(from common/settings.ts) to render the ball. UserenderBall()
inrenderState()
to draw the ball on screen. - In
nextState()
use the utilscreateNextBall()
andcenterTopOfPaddle()
to update the ball's x position based on the paddle's x position. Use the appropriate operator in your pipeline to callnextState()
.
- Change
createBallStream()
so that it starts listening to mouse click events, that are mapped to a ball object with a speed set toBALL_INITIAL_SPEED
. - Notice that nothing is rendered until a click event happens. How come? Fix it.
- If the ball's speed === 0, it should just stay on the paddle. If its speed > 0 (after a click) it should leave the paddle and move up. Add this logic to
nextState()
. - When you now click, the ball stops moving some pixels above the paddle. Why is that? For now we'll fix it by mutating the ball in-place (with
Object.assign()
for example). We'll remove this side-effect and make all functions pure later! - Notice that the ball now only moves when the mouse moves. Why is this? Try to figure this out before moving on. Fix it by using an observable that emits every animation frame (find the RxJS function that does this).
- Notice that the ball now moves faster when the mouse moves. How come? Fix it by making the observable that emits every animation frame the main source observable and combine the latest emitted value from
paddle$
andball$
into it. - Optional: add the CSS class
hide-cursor
to the canvas when the ball speed > 0, remove the class otherwise. - Notice that each time you click, the ball position is reset to that of the paddle. This shouldn't happen. Fix it by only taking a single click event.
- There's still a bug left: when you click, the ball always starts its upward journey from its initial position (the center of the screen). Why does this happen? We could fix this by mutating
ball
some more, but let's start doing proper state management and learn about Subjects! (cue for the trainer to explain Subjects.)
- Rename
createPaddleStream()
tocreatePaddleSubject()
and make it use a BehaviorSubject. Make it "listen to"mousemove
events. - Do the same for
createBallStream()
: rename tocreateBallSubject()
, make it use a BehaviorSubject and make it "listen to"click
events. - Do you still need the
startWith()
operator? - The fix from step 3.8 doesn't work anymore, why? Replace the operator with a better one.
- Remove the mutation in
nextState()
making it a pure function that always returns a newGameState
. - Use
updateState()
to "send" the newpaddle
andball
states topaddle$
andball$
respectively.
- When the ball's speed > 0 and it's touching or passed the "ceiling" (top of screen), the ball's upward motion should become a downward motion. Use the
hasBallTouchedTop()
util and when it returnstrue
, this code flips the ball's vertical motion:ball.direction * -1 + 180
. - Similarly, when the ball touches or passes the sides of the screen, its horizontal motion should be "flipped". Use the
hasBallTouchedSide()
util and simply invert the ball's direction to make it bounce off the walls (ball.direction * -1
). - Then the ball needs to bounce off the paddle when it hits. Flip the ball's vertical motion when the
hasBallTouchedPaddle()
returnstrue
(same logic as when the ball touches the top of the screen). - Optional: give the player more control over the ball by changing its direction depending on where the ball hits the paddle. If the ball hits the far left or right edge of the paddle, the ball should have almost no upward motion. If the ball hits the exact center of the paddle, it should have only upward motion. You need linear interpolation for this and there's a util called
lerp()
that helps gives you that. First pass it the boundaries (useFAR_LEFT_BOUNCE_DIRECTION
andFAR_RIGHT_BOUNCE_DIRECTION
) and it returns a function that needs a value between0
and1
which will return the new ball direction. Here'slerp()
's signature:The value betweenfunction lerp(leftBoundary: number, rightBoundary: number): (value: number /* 0 - 1 */) => number /* ball direction */
0
and1
is the normalized x position where the ball hits the paddle (because that determines the new direction). To get this normalized value use(ball.x - paddle.x) / PADDLE_WIDTH
.
- Change
createBricksStream()
in bricks.ts so that it calls thebrickBuilder()
util. It returns a function that accepts a column and row which returns a brick. - Create a "wall of bricks" by looping from
0
toBRICK_ROWS
(rows) and inside that loop from0
toBRICKS_PER_ROW
(cols). Bonus points if you can keep it declarative (without usingfor
/while
loops). The result should be a flat array of bricks wrapped in an observable. - Change
renderBricks()
in bricks.ts to use thedrawRectangle()
util andBRICK_COLOR_MAP
andBRICK_STROKE_COLOR
settings to render each brick. Note thatBRICK_COLOR_MAP
is an object; to setfill
useBRICK_COLOR_MAP[brick.color]
. Also, you may want to setstrokeWidth
to3
so that each brick has some spacing around it. - Add the
bricks$
observable inmain()
and userenderBricks()
inrenderState()
to render the bricks on screen.
- Use the
getBrickCollision()
util to determine if the ball has collided with a brick. Do this where you also check for wall and paddle collisions. When a collision happened,getBrickCollision()
returns an object with two props:brickIndex
: the index of the collided brickhasCollidedVertically
: whether the collision took place on the vertical sides of a brick. If this isfalse
, the collision was on the top or bottom of a brick. The ball needs to change direction differently depending on this value.
- When the ball hits a brick, create a new ball with its direction changed as when it hits the left or right side of the screen or when it hits the top of the screen (use
hasCollidedVertically
). Also make sure the new ball's speed is multiplied byBALL_SPEED_INCREASE
. - When the ball hits a brick, the brick should be removed. Create a new array of bricks with this brick removed. Make
nextState()
return a new game state with the new ball from the previous step and bricks from this step. - Persist the updated bricks by renaming
createBricksStream()
tocreateBricksSubject()
and make it return a BehaviorSubject. Persist the bricks state as you do for paddle and ball inupdateState()
.
- Change
createLivesSubject()
in lives.ts so that it simply returns a BehaviorSubject with a value oflives
. - Change
renderLives()
in lives.ts, use thedrawText()
util. - Add
lives$
tomain()
and userenderLives()
inrenderState()
. - Use the
hasBallMissedPaddle()
util innextState()
to determine if the ball moved below the paddle. When this happens, the ball needs to be reset. Optional: fix the glitch when you reset the ball to justinitialBall
. - Also subtract
1
fromlives
and return this fromnextState()
. - Persist the lives in
updateState()
. - See what happens when the final life is lost. Let's fix this by completing the main stream when you're out of lives (or put another way: keep the stream going while
lives > 0
). Also complete the stream when there are no more bricks left.
- Change
createScoreSubject()
in score.ts so that it simply returns a BehaviorSubject with the score that's passed to it. - Change
renderScore()
in score.ts to show the score on screen. Use thedrawText()
util (and optionallyformatNumber()
to format the score). - Add
score$
tomain()
and userenderScore()
inrenderState()
. - Make sure to update the score by adding
BRICK_SCORE
when a brick is "popped" innextState()
. Persist the score inupdateState()
. - The completion of the main stream can be used to display a "Game over" message. Use the
drawGameOver()
util for this.
This concludes the workshop, but there's plenty more to do of course! Some suggestions:
- Some buttons to pause/restart the game
- Actually keeping score (e.g. in localStorage)
- Power-ups (that randomly fall from popped bricks)
- Keyboard controls
- Fancy graphics with animations
- Sounds
- Multiplayer