Breakout! with RxJS
CreateA workshop in vanilla TypeScript.
Getting started
git clone git@github.com: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
Development
This project uses Snowpack. Run npm start
to start a dev server and http://localhost:8080/
will be opened automatically. You can use types, settings and utils from the shared
folder. Take care not to import from the finished
folder as that would make this workshop too easy
Theory and exercises
Slides with theory are made using slidev. Show the slides with npm run slides
or see them online.
After the slides, some exercises can be done by writing code in the exercises
folder. To execute an exercise, run npx ts-node exercises/01.ts
(replace 01.ts
with whatever file you want executed). A message shows whether you wrote the expected code.
Finished game
With the dev server running, go to http://localhost:8080/finished/ (including the trailing slash unfortunately) to see what you'll be making. Or see it online.
Resources
📜 RTFM: RxJS API docs🌳 Find operators: Operator decision tree
Creating the game
9 steps in (more or less) increasing difficulty.
Step 1: Render a paddle that "follows" the mouse
- 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 shared/settings.ts). - Subscribe to the observable returned from
createPaddleStream()
in index.ts. Also importrenderPaddle()
and use it to draw the paddle on screen. - Use
canvasContext.clearRect(0, 0, canvas.width, canvas.height)
before rendering start with a "clean slate" on each frame. - Use the
clamp()
util to prevent the paddle to go off screen (note that it returns a function). - Optional: make the paddle start in the middle of the screen (before any mouse events have fired).
Step 2: Place a ball on the paddle
- 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 in index.ts; choose a suitable creation operator. - Before any rendering happens, update the ball's
x
position to the paddle's center top position on each emit. Do this by mutatingball
in-place (I know this isn't pure, but it's very performant and simple). Use thecenterTopOfPaddle()
util. - Change
renderBall()
in ball.ts to usedrawCircle()
(from shared/utils.ts) andBALL_COLOR
(from shared/settings.ts) to render the ball. ImportrenderBall()
in index.ts to draw the ball on screen.
Step 3: Detach the ball on click
- Change
createBallStream()
so that it starts listening to a single mouse click event, that's 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 (after a click), it should leave the paddle and move up. Check the ball's speed in index.ts before its position is updated. If speed > 0 use the
nextBallPosition()
util to update the ball's position, else: use thecenterTopOfPaddle()
util as you already did. - 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 making
ticks$
an observable that emits everyTICK_INTERVAL
(use the correct operator). Combine this observable into your "paddle and ball" stream. Also pass a scheduler to the operator that internally usesrequestAnimationFrame()
. Do you know why? - Notice that the ball now moves faster when the mouse moves. How come? Fix it by limiting the stream's "throughput" (the amount of events per unit of time). Start by looking for the correct operator, then where to use it. Use the same scheduler as you did to create
ticks$
. - 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.
- Optional: add the CSS class
hide-cursor
to the canvas when the ball speed > 0, remove the class otherwise.
Step 4: Make the ball bounce
- 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 = ball.direction * -1 + 180
. Would it better to do this before or after the ball's position is updated? - 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
. - If may want to refactor your code in index.ts now. E.g.: move the rendering of entities to a separate function and rearrange your conditionals to reduce nesting.
- 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 near the left of right edge of the paddle, it should go West or East. If the ball hits the center of the paddle, it should go North. For this linear interpolation is needed and there's a util called
lerp()
for that. First pass it the boundaries (useFAR_LEFT_BOUNCE_DIRECTION
andFAR_RIGHT_BOUNCE_DIRECTION
), that returns a function that needs a value between0
and1
and will return the new ball direction. The value between0
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
.
Step 5: Add blocks
- 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
loops, but using Array methods instead). 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. - Import
renderBricks()
in index.ts to draw the bricks on screen.
Now may be a good time to learn about Subjects!
Step 6: Break bricks when the ball collides
- 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 there's a collisiongetBrickCollision()
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, change the ball's direction similar to when the ball hits the left or right screen sides or when the ball hits the top of the screen. Also increase the ball's speed by multiplying it with
BALL_SPEED_INCREASE
. - When the ball hits a brick, the brick should be removed. First rename
createBricksStream()
tocreateBricksSubject()
and make it return a Subject (what kind?) instead of an observable. A new array of bricks (with the collided brick removed) can now be send tobricks$
.
Step 7: Miss the paddle, reset the ball
- Use the
hasBallPassedPaddle()
util in index.ts to determine if the ball moved below the paddle. When this happens, return from the function because the ball needs to be reset and all the collision detection can be skipped. - To reset the ball, start by renaming
createBallStream()
tocreateBallSubject()
, comment-out the existing code and make it create and return a BehaviorSubject withinitialBall
as initial value instead. - Obviously, clicking now won't make the ball leave the paddle anymore. To fix this again, a
ball
with a speed > 0 should be emitted to the BehaviorSubject when the user clicks, but only if the ball's speed is currently 0. Emitting a value to a Subject is as simple as using the Subject as an observer to an observable. We already have the Subject, our commented-out code is (nearly) the observable we need. So, "un-comment" the commented-out code and subscribe the BehaviorSubject to it. - To reset the ball, the BehaviorSubject needs to be send the initial ball (which has a speed of 0). Do this in index.ts where
hasBallPassedPaddle()
returnstrue
. See what happens when the ball now passes the paddle. Why isn't the ball being reset? It has to do with thetake()
operator, if that's removed, the ball is reset, but now the ball is reset on every click. Fix this problem with an operator that doesn't complete the stream (liketake()
does). - Do you still need the
startWith()
operator?
Step 8: Keeping score
- Change
createScoreSubject()
in score.ts so that it simply returns a Subject (what kind?) 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). CallrenderScore()
in index.ts. - Make sure to update the score by adding
BRICK_SCORE
when a brick is "popped".
Step 9: Add lives and game over
- Change
createLivesSubject()
in lives.ts so that it simply returns a Subject (what kind?) with the amount of lives that's passed to it. - Change
renderLives()
in lives.ts to show the amount of lives on screen. Use thedrawText()
util. CallrenderLives()
in index.ts. - In index.ts, when the ball passes the paddle, use the
lives$
Subject to update the amount of lives. - The ball is now always reset, even if there aren't any lives left. Fix this.
- See what happens when the final life is lost. Let's fix this by completing the main stream when
lives === 0
. (or put another way: keep the stream going whilelives > 0
). - The completion of the main stream can be used to display a "Game over" message. Use the
drawGameOver()
util for this.
Step 10: …?
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