Multiplayer pong game from scratch
Mix.install([
{:kino, "~> 0.8.0"}
])
Introduction
In this notebook, we are going to learn more about
Kino.Control
and Kino.Image
.
Specifically, we will be building the the Pong
game directly in Livebook. Not only that, we will actually
make it multiplayer!
Painting the scene
The first step in our game is to define our scene. The
scene has a width
, height
, and several game objects
that we want to render into the scene. For rendering
the objects, we will use a custom protocol function,
called Scene.Renderer.to_svg/1
. This will allow us
to define as many game objects as we want and teach the
scene how to render them, without changing the scene code:
defmodule Scene do
@moduledoc """
The scene for the game objects.
"""
defstruct [:width, :height, :objects]
def new(width, height) do
%__MODULE__{width: width, height: height, objects: []}
end
def add(scene, object) do
update_in(scene.objects, fn objects -> [object | objects] end)
end
def to_svg(scene) do
svgs = Enum.map(scene.objects, &Scene.Renderer.to_svg(&1))
"""
#{svgs}
"""
end
end
To render the scene, we will convert it to svg
and
use Kino.Image.new/1
to render it in Livebook:
Scene.new(80, 10)
|> Scene.to_svg()
|> Kino.Image.new(:svg)
Since our scene has no objects, we only see an empty white canvas.
To address this, let’s define two shapes and alongside the
Scene.Renderer
protocol for them.
Let’s first define the protocol:
defprotocol Scene.Renderer do
def to_svg(object)
end
Our first shape is a rectangle:
defmodule Scene.Rect do
defstruct [:x, :y, :width, :height, fill: "black"]
def new(x, y, width, height, opts \\ []) do
struct!(%Scene.Rect{x: x, y: y, width: width, height: height}, opts)
end
defimpl Scene.Renderer do
def to_svg(%Scene.Rect{x: x, y: y, width: width, height: height, fill: fill}) do
"""
"""
end
end
end
Our second shape is text:
defmodule Scene.Text do
defstruct [:x, :y, :text, font_size: 1, fill: "black"]
def new(x, y, text, opts \\ []) do
struct!(%Scene.Text{x: x, y: y, text: text}, opts)
end
defimpl Scene.Renderer do
def to_svg(%Scene.Text{x: x, y: y, text: text, fill: fill, font_size: font_size}) do
"""
#{text}
"""
end
end
end
Now let’s paint a new scene with the rectangle and some text:
Scene.new(80, 10)
|> Scene.add(Scene.Rect.new(0, 0, 80, 10, fill: "#d4f1f477"))
|> Scene.add(Scene.Text.new(40, 4, "Hello world!", font_size: 3))
|> Scene.to_svg()
|> Kino.Image.new(:svg)
Perfect.
Animating scene objects
So far we are able to paint static objects in the scene. However, all games have dynamic objects that move on the scene as time passes. In our case, the dynamic objects are the paddles and the ball.
For such, we will define an Animation
protocol with a single
function called step
. It receives the object, the scene, and
is responsible for animating the object one step at a time.
defprotocol Animation do
def step(object, scene)
end
Now let’s define our first game object, the Game.Ball
struct.
The ball will have a radius r
, coordinates x
and y
, velocities
dx
and dy
, and implement both Scene.Renderer
and Animation
protocols.
defmodule Game.Ball do
defstruct [:r, :x, :y, :dx, :dy]
def new(r, x, y, opts \\ []) do
struct!(%Game.Ball{r: r, x: x, y: y}, opts)
end
defimpl Scene.Renderer do
def to_svg(%Game.Ball{x: x, y: y, r: r}) do
"""
"""
end
end
defimpl Animation do
# On each step, increase x by dx, and y by dy.
def step(ball, _scene) do
%{ball | x: ball.x + ball.dx, y: ball.y + ball.dy}
end
end
end
We can now paint the ball in the scene:
Scene.new(80, 10)
|> Scene.add(Game.Ball.new(2, 40, 4))
|> Scene.to_svg()
|> Kino.Image.new(:svg)
However, we can also animate the ball over the scene using Kino.Frame
.
Let’s define the frame, the scene, and the ball:
# The frame we will render scene images on top of
frame = Kino.Frame.new() |> Kino.render()
# The scene with a static background
scene =
Scene.new(80, 20)
|> Scene.add(Scene.Rect.new(0, 0, 80, 20, fill: "#d4f1f477"))
# The ball which we will animate
ball = Game.Ball.new(2, 2, 4, dx: 1, dy: 1)
Now we will instantiate a Kino.Control.interval/1
and use it to
generate a stream of events, one every 33ms (which gives 30 frames per second).
We will take 20 of those events. On each events, we will paint the
scene, update the frame, and then animate the ball for the next event:
Kino.Control.interval(33)
|> Kino.Control.stream()
|> Stream.take(20)
|> Enum.reduce(ball, fn _counter, ball ->
# Build the image
image = scene |> Scene.add(ball) |> Scene.to_svg() |> Kino.Image.new(:svg)
# Update the frame
Kino.Frame.render(frame, image)
# Animate the ball
Animation.step(ball, scene)
end)
Feel free to tweak the parameters above and see how it affects the ball movements.
You may also have noticed that, at the moment, the ball simply moves out of the scene when it reaches the edges, which is not what we would expect in practice. We will tackle this later once we add collision detection. For now, let’s learn how to capture the user keyboard and use it to control the paddles.
Capturing the user keyboard
Each player in a Pong game must use the arrow up and arrow down
keys to control the paddle. We can achieve this by using
Kino.Control.keyboard/1
. Let’s instantiate it:
keyboard = Kino.Control.keyboard([:status, :keyup, :keydown])
The above renders a keyboard icon. Once you click it, Livebook will start capturing keyboard events. We are interested in three different events:
-
:status
- it is emitted whenever the keyboard is activated/deactivated -
:keydown
- whenever a key is pressed down -
:keyup
- whenever a key is released up
Let’s use Kino.Control.stream/1
to capture and print those events.
In particular, we want to stream while the keyboard is enabled. Once
the keyboard is disabled (i.e. we get a :status
event with the
:enabled
key set to false
), the stream stops:
stream =
keyboard
|> Kino.Control.stream()
|> Stream.take_while(fn
%{type: :status, enabled: false} -> false
_event -> true
end)
for event <- stream do
IO.inspect(event)
end
Once you execute the cell above, it will start consuming the event stream. Click on the keyboard icon and watch it print keyboard events as you press different keys. When you are done, disable the keyboard control. Now we know how to capture keyboard events and how they look like, we are ready to implement the paddles.
Controlling the paddle with keypresses
The paddle will be similar to the ball: it is its own struct
that can be rendered and animated. However, the paddle should
also be able to handle keyboard events. In particular, a paddle
only moves up or down, and only while a key is pressed. Since
there is additional logic here, let’s first define Game.Paddle
and the implement the protocols later:
defmodule Game.Paddle do
defstruct [:x, :y, :width, :height, dy: 0]
@dy 4
def new(x, y, width, height) do
%Game.Paddle{x: x, y: y, width: width, height: height}
end
def on_key(paddle, %{key: "ArrowUp", type: :keydown}), do: %{paddle | dy: -@dy}
def on_key(paddle, %{key: "ArrowUp", type: :keyup}), do: %{paddle | dy: 0}
def on_key(paddle, %{key: "ArrowDown", type: :keydown}), do: %{paddle | dy: @dy}
def on_key(paddle, %{key: "ArrowDown", type: :keyup}), do: %{paddle | dy: 0}
def on_key(paddle, _), do: paddle
end
The paddle moves up while the “ArrowUp” key is pressed, and down while the “ArrowDown” key is pressed. We will render the paddle as a rectangle:
defimpl Scene.Renderer, for: Game.Paddle do
def to_svg(%Game.Paddle{x: x, y: y, width: width, height: height}) do
"""
"""
end
end
And animate it over the y
dimension, with the addition
that the paddle must never leave the scene:
defimpl Animation, for: Game.Paddle do
def step(paddle, scene) do
%{paddle | y: clip(paddle.y + paddle.dy, 0, scene.height - paddle.height)}
end
defp clip(value, min, max), do: value |> max(min) |> min(max)
end
We are ready to give it a try!
Mixing control streams
To animate the paddle, we will need two control streams: the keyboard control and an interval to refresh the paddle as we keep holding the arrow keys up and down. Let’s put the relevant pieces in place:
# The frame we will render scene images on top of
frame = Kino.Frame.new() |> Kino.render()
# The scene with a static background
scene =
Scene.new(200, 50)
|> Scene.add(Scene.Rect.new(0, 0, 200, 50, fill: "#d4f1f477"))
# The keyboard control
keyboard = Kino.Control.keyboard([:status, :keyup, :keydown]) |> Kino.render()
# The refresh interval
interval = Kino.Control.interval(33)
# The paddle which we will animate
paddle = Game.Paddle.new(0, 21, 2, 8)
Now we want to capture all keyboard events plus animate
the paddle every 33ms. We can do this by passing a list
of controls to Kino.Control.stream/1
. As before, we
still want to stop the stream as soon as the keyboard is
disabled:
[keyboard, interval]
|> Kino.Control.stream()
|> Stream.take_while(fn
%{type: :status, enabled: false} -> false
_event -> true
end)
|> Enum.reduce(paddle, fn event, paddle ->
# Build the image
image = scene |> Scene.add(paddle) |> Scene.to_svg() |> Kino.Image.new(:svg)
# Update the frame
Kino.Frame.render(frame, image)
# Input the event into the paddle and animate it
paddle
|> Game.Paddle.on_key(event)
|> Animation.step(scene)
end)
Press the keyboard icon and you should be able to control the paddle. In particular, as long as you hold the arrow up or arrow down keys, the paddle should move, without exiting the scene. Once you are done, disable the keyboard.
With all of the game objects in place, let’s implement collision detection between the scene, the ball, and the paddles.
Collision detection
As the ball moves around, it may collide with different elements. Let’s describe those scenarios below:
-
If the ball hits either the top or the bottom of the scene, it should reverse its
y
direction -
If the ball hits any of the paddles, it should reverse its
x
direction -
If the ball hits either the left or right side of the scene, it means the opposite paddle won
For simplicity, we will assume that:
-
The paddles are positioned to the absolute left and absolute right of the scene
-
For collision detection with the paddle, we will assume the ball is a square. In practice this is enough unless the ball is much bigger than the paddle
With this in mind, let’s see the collision code. Our function
will either return {:cont, ball}
or {:won, :left | :right}
:
defmodule Game.Collision do
def bounce(ball, left_paddle, right_paddle, scene) do
ball
|> bounce_y(scene)
|> bounce_x(left_paddle, right_paddle, scene)
end
defp bounce_y(ball, _scene) when ball.y - ball.r <= 0 do
%{ball | y: ball.r, dy: -ball.dy}
end
defp bounce_y(ball, scene) when ball.y + ball.r >= scene.height do
%{ball | y: scene.height - ball.r, dy: -ball.dy}
end
defp bounce_y(ball, _scene), do: ball
defp bounce_x(ball, left, _right, _scene) when ball.x - ball.r <= left.width do
if collides_vertically?(ball, left) do
{:cont, %{ball | x: ball.r + left.width, dx: -ball.dx}}
else
{:won, :right}
end
end
defp bounce_x(ball, _left, right, scene) when ball.x + ball.r >= scene.width do
if collides_vertically?(ball, right) do
{:cont, %{ball | x: scene.width - ball.r - right.width, dx: -ball.dx}}
else
{:won, :left}
end
end
defp bounce_x(ball, _left, _right, _scene), do: {:cont, ball}
defp collides_vertically?(ball, paddle) do
ball.y - ball.r < paddle.y + paddle.height and ball.y + ball.r > paddle.y
end
end
To ensure we got the different scenarios right, let’s add some tests:
ExUnit.start(autorun: false)
defmodule Game.CollisionTest do
use ExUnit.Case, async: true
@scene Scene.new(200, 100)
# Position the left paddle on the middle
@left Game.Paddle.new(0, 40, 2, 20)
# Position the right paddle on the middle
@right Game.Paddle.new(198, 40, 2, 20)
def bounce(ball), do: Game.Collision.bounce(ball, @left, @right, @scene)
test "no bouncing" do
ball = Game.Ball.new(10, _x = 100, _y = 50, dx: 10, dy: 10)
assert bounce(ball) == {:cont, ball}
end
describe "bounces on top" do
test "with perfect collision" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 100, _y = 10, dy: -10))
assert ball.dy == 10
assert ball.y == 10
end
test "with ball slightly outside of scene" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 100, _y = 5, dy: -10))
assert ball.dy == 10
assert ball.y == 10
end
end
describe "bounces on bottom" do
test "with perfect collision" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 100, _y = 90, dy: 10))
assert ball.dy == -10
assert ball.y == 90
end
test "with ball slightly outside of scene" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 100, _y = 95, dy: 10))
assert ball.dy == -10
assert ball.y == 90
end
end
describe "bounces on left" do
test "with perfect collision on the paddle center" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 2 + 10, _y = 50, dx: -10))
assert ball.dx == 10
assert ball.x == 12
end
test "with ball slightly inside the paddle" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 5, _y = 50, dx: -10))
assert ball.dx == 10
assert ball.x == 12
end
test "with the ball bottom touching the paddle top" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 5, _y = 31, dx: -10))
assert ball.dx == 10
assert ball.x == 12
end
test "with the ball top touching the paddle bottom" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 5, _y = 69, dx: -10))
assert ball.dx == 10
assert ball.x == 12
end
test "with the ball top perfectly missing the paddle bottom" do
assert bounce(Game.Ball.new(10, _x = 5, _y = 70, dx: -10)) == {:won, :right}
end
test "with the ball bottom perfectly missing the paddle top" do
assert bounce(Game.Ball.new(10, _x = 5, _y = 30, dx: -10)) == {:won, :right}
end
end
describe "bounces on right" do
test "with perfect collision on the paddle center" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 200 - 2 - 10, _y = 50, dx: 10))
assert ball.dx == 10
assert ball.x == 188
end
test "with ball slightly inside the paddle" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 195, _y = 50, dx: 10))
assert ball.dx == -10
assert ball.x == 188
end
test "with the ball bottom touching the paddle top" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 195, _y = 31, dx: 10))
assert ball.dx == -10
assert ball.x == 188
end
test "with the ball top touching the paddle bottom" do
{:cont, ball} = bounce(Game.Ball.new(10, _x = 195, _y = 69, dx: 10))
assert ball.dx == -10
assert ball.x == 188
end
test "with the ball top perfectly missing the paddle bottom" do
assert bounce(Game.Ball.new(10, _x = 195, _y = 70, dx: 10)) == {:won, :left}
end
test "with the ball bottom perfectly missing the paddle top" do
assert bounce(Game.Ball.new(10, _x = 195, _y = 30, dx: 10)) == {:won, :left}
end
end
end
ExUnit.run()
All tests should pass! We are now ready to put all pieces
together. We will do so in two steps. First we will create
the Game.State
, which will keep all objects and know how
to dispatch events to them. Then we will create the
Game.Server
that will hold the state, control the frame,
keyboards, and refresh rate.
Encapsulating the game state
The Game.State
is a module with functions to start a new
game, receive paddle events, and animate its objects.
The Game.State
itself should hold the scene, the ball,
and both paddles. It should also hold the score and a
status which control if the game is running or idle.
The game is idle before it starts and also after each
player scores.
defmodule Game.State do
@moduledoc """
Represents the paddles game state and rules.
"""
@w 400
@h 200
@ball_r 8
@paddle_w 2
@paddle_h 40
@paddle_y div(@h - @paddle_h, 2)
defstruct [:ball, :left_paddle, :left_score, :right_paddle, :right_score, :status, :scene]
@doc """
Returns initial game state.
"""
def new() do
scene =
Scene.new(@w, @h)
|> Scene.add(Scene.Rect.new(0, 0, @w, @h, fill: "#d4f1f477"))
reset(%__MODULE__{scene: scene, left_score: 0, right_score: 0})
end
defp reset(state) do
%{
state
| status: :idle,
ball: new_ball(),
left_paddle: Game.Paddle.new(0, @paddle_y, @paddle_w, @paddle_h),
right_paddle: Game.Paddle.new(@w - @paddle_w, @paddle_y, @paddle_w, @paddle_h)
}
end
defp new_ball() do
Game.Ball.new(
@ball_r,
div(@w, 2),
div(@h, 2),
# Each new ball goes in a random direction
dx: Enum.random([3, -3]),
dy: Enum.random([2, -2])
)
end
@doc """
Marks the game as running.
"""
def start(state) do
%{state | status: :running}
end
@doc """
Applies the event to the given paddle.
"""
def on_key(state, :left, event) do
update_in(state.left_paddle, &Game.Paddle.on_key(&1, event))
end
def on_key(state, :right, event) do
update_in(state.right_paddle, &Game.Paddle.on_key(&1, event))
end
@doc """
Performs a single step within the game by updating object positions.
"""
def step(%{status: :running} = state) do
%Game.State{
ball: ball,
left_paddle: left_paddle,
right_paddle: right_paddle,
scene: scene
} = state
ball = Animation.step(ball, scene)
left_paddle = Animation.step(left_paddle, scene)
right_paddle = Animation.step(right_paddle, scene)
case Game.Collision.bounce(ball, left_paddle, right_paddle, scene) do
{:cont, ball} ->
%{state | ball: ball, left_paddle: left_paddle, right_paddle: right_paddle}
{:won, :left} ->
reset(update_in(state.left_score, &(&1 + 1)))
{:won, :right} ->
reset(update_in(state.right_score, &(&1 + 1)))
end
end
def step(state), do: state
@doc """
Returns an SVG representation of the game board.
"""
def to_svg(state) do
%Game.State{
ball: ball,
left_paddle: left_paddle,
left_score: left_score,
right_paddle: right_paddle,
right_score: right_score,
scene: scene
} = state
text = Scene.Text.new(div(@w, 2), 4, "#{left_score} : #{right_score}", font_size: 10)
scene
|> Scene.add(ball)
|> Scene.add(left_paddle)
|> Scene.add(right_paddle)
|> Scene.add(text)
|> Scene.to_svg()
end
end
The most complex function above is Game.State.step/1
. It
animates the ball and the paddles. Then it checks for collisions.
Depending on the result of the collision, it resets the game
state and bumps the relevant score.
Let’s render the game statically and verify everything is in place:
Game.State.new()
|> Game.State.to_svg()
|> Kino.Image.new(:svg)
Looks good! Now let’s animate it over a frame until one of the paddles score:
# The frame we will render scene images on top of
frame = Kino.Frame.new() |> Kino.render()
# The scene with a static background
game = Game.State.new()
# Every 33ms, paint a new frame until it completes a round
Kino.Control.interval(33)
|> Kino.Control.stream()
|> Enum.reduce_while(Game.State.start(game), fn _, game ->
if game.status == :running do
game = Game.State.step(game)
Kino.Frame.render(frame, game |> Game.State.to_svg() |> Kino.Image.new(:svg))
{:cont, game}
else
{:halt, game}
end
end)
Perfect. Now we are ready to wrap it all up with the Game.Server
.
Running the game server
The job of the Game.Server
is to wire everything together.
It will render the keyboard and the frame. However, there is
one important difference. So far, we have been using streams
to drive our objects and animations. Streams are fine for quick
examples but, now that we have both state
and events
, our
code will be cleaner if we organize it inside an Elixir process.
In Elixir, we often implement those processes using an abstraction
called GenServer
.
So that’s what we will use below. We won’t explain all parts that
make a GenServer
, but here are the relevant bits:
-
A
GenServer
is typically started via itsstart_link/2
function. This function will spawn a new process and then invoke theinit/1
function as a callback -
On
init
, we should receive bothframe
andkeyboard
control as options. Then, instead of usingKino.Control.stream/1
, we will useKino.Control.subscribe/2
to receive the control events as messages -
Each message the process receives will be handled by
handle_info/2
-
To implement the tick, we will configure the process to send itself a message every 33 milliseconds
Let’s see those concepts in place:
defmodule Game.Server do
@moduledoc """
The game server, handles rendering, timing, and interactions.
"""
use GenServer
@tick_time_ms 33
@doc """
Starts the server and renders the initial UI.
"""
def start_link(opts) do
GenServer.start_link(__MODULE__, opts)
end
@impl true
def init(opts) do
# Subscribe to keyboard events. All events will be
# wrapped in a tuple with :keyboard as key
Kino.Control.subscribe(opts[:keyboard], :keyboard)
state = %{
frame: opts[:frame],
game: nil,
players: []
}
{:ok, render(state)}
end
@impl true
def handle_info({:keyboard, event}, state) do
{:noreply, handle_keyboard(state, event)}
end
@impl true
def handle_info(:tick, state) do
state = update_in(state.game, &Game.State.step/1)
state =
case state.game.status do
:idle -> state
:running -> schedule_tick(state)
end
{:noreply, render(state)}
end
# A player enabled the keyboard
defp handle_keyboard(state, %{type: :status, enabled: true, origin: origin}) do
case state.players do
# First player joined
[] ->
%{state | players: [%{side: :left, origin: origin}]}
|> render()
# Second player joined
[player] ->
%{state | players: [player, %{side: :right, origin: origin}], game: Game.State.new()}
|> render()
# Someone else tried to join, ignore them!
_ ->
state
end
end
# There is a keyboard event but no game yet, ignore it
defp handle_keyboard(state, _event) when state.game == nil do
state
end
# There is a game and it is idle, a keypress from a player will start the game
defp handle_keyboard(state, %{type: :keydown, origin: origin})
when state.game.status == :idle do
if Enum.any?(state.players, &(&1.origin == origin)) do
update_in(state.game, &Game.State.start/1)
|> schedule_tick()
else
state
end
end
# All other keypress go to one of the paddles based on the player
defp handle_keyboard(state, %{origin: origin} = event) do
if player = Enum.find(state.players, &(&1.origin == origin)) do
update_in(state.game, &Game.State.on_key(&1, player.side, event))
else
state
end
end
# Ignore any event that does not match the clauses above
defp handle_keyboard(state, _event) do
state
end
defp schedule_tick(state) do
Process.send_after(self(), :tick, @tick_time_ms)
state
end
defp render(state) do
output = paint(state)
Kino.Frame.render(state.frame, output)
state
end
defp paint(%{players: []}) do
Kino.Markdown.new("Waiting for players. Enable your keyboard to join **left**!")
end
defp paint(%{players: [_]}) do
Kino.Markdown.new("Waiting for another player. Enable your keyboard to join **right**!")
end
defp paint(state) do
state.game |> Game.State.to_svg() |> Kino.Image.new(:svg)
end
end
The server is in place! Let’s show the keyboard and the frame,
then start a Game.Server
passing them as arguments:
keyboard = Kino.Control.keyboard([:status, :keydown, :keyup]) |> Kino.render()
frame = Kino.Frame.new() |> Kino.render()
Kino.start_child({Game.Server, keyboard: keyboard, frame: frame})
Kino.nothing() # Do not print anything after
We are ready to play! To verify it for yourself, copy and paste this URL in a separate browser tab and toggle the keyboard mode in both tabs. It may be challenging to act as two players at the same time, but let’s say it’s a part of the game! Once you are done, remember to toggle off the keyboard mode.
If you want one additional challenge, you will notice that, once you disable the keyboard, the game continues running. Could you change the code above to stop the game once any player toggles the keyboard off?
In any case, this was a fun and long ride! The next guide should be much shorter and we will learn how to create our own kinos using Elixir and JavaScript.