Notesclub

Multiplayer pong game from scratch

pong.livemd

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, &amp;Game.Paddle.on_key(&amp;1, event))
  end

  def on_key(state, :right, event) do
    update_in(state.right_paddle, &amp;Game.Paddle.on_key(&amp;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, &amp;(&amp;1 + 1)))

      {:won, :right} ->
        reset(update_in(state.right_score, &amp;(&amp;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 its start_link/2 function. This function will spawn a new process and then invoke the init/1 function as a callback

  • On init, we should receive both frame and keyboard control as options. Then, instead of using Kino.Control.stream/1, we will use Kino.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, &amp;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, &amp;(&amp;1.origin == origin)) do
      update_in(state.game, &amp;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, &amp;(&amp;1.origin == origin)) do
      update_in(state.game, &amp;Game.State.on_key(&amp;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.