Skip to content

Instantly share code, notes, and snippets.

@HurricanKai
Created June 20, 2022 11:48
Show Gist options
  • Save HurricanKai/98555c11bbf46d01537830876cff118b to your computer and use it in GitHub Desktop.
Save HurricanKai/98555c11bbf46d01537830876cff118b to your computer and use it in GitHub Desktop.
A working snake game made with Elixir in Livebook

Snake

Mix.install([
  {:kino, "~> 0.6.1"}
])

The Game

defmodule Match.State.Player do
  defstruct [:headX, :headY, :tail, :direction, :dead]

  def step(%Match.State.Player{
        headX: headX,
        headY: headY,
        tail: tail,
        direction: direction,
        dead: dead
      })
      when not dead do
    %{x: offsetX, y: offsetY} =
      case direction do
        :up -> %{x: 0, y: -1}
        :down -> %{x: 0, y: +1}
        :right -> %{x: +1, y: 0}
        :left -> %{x: -1, y: 0}
        _ -> %{x: 0, y: 0}
      end

    newHeadX = headX + offsetX
    newHeadY = headY + offsetY

    dead = tail |> Enum.any?(fn %{x: tx, y: ty} -> newHeadX == tx and newHeadY == ty end)

    tail = unless dead, do: stepTail(%{x: headX, y: headY}, tail), else: tail

    %Match.State.Player{
      headX: newHeadX,
      headY: newHeadY,
      tail: tail,
      direction: direction,
      dead: dead
    }
  end

  def step(state) do
    state
  end

  defp stepTail(new, [old | rest]) do
    [new | stepTail(old, rest)]
  end

  defp stepTail(_new, []) do
    # to drop last element in tail
    []
  end

  def kill(state) do
    %Match.State.Player{state | dead: true}
  end
end
ExUnit.start(autorun: false)

defmodule Match.State.Player.Tests do
  use ExUnit.Case, async: true
  require Match.State.Player

  test "tail is incremented correctly" do
    player = %Match.State.Player{
      headX: 1,
      headY: 10,
      tail: [%{x: 1, y: 9}, %{x: 2, y: 9}, %{x: 2, y: 8}],
      direction: :down,
      dead: false
    }

    newPlayer = Match.State.Player.step(player)

    assert is_list(newPlayer.tail)
    assert [%{x: 1, y: 10}, %{x: 1, y: 9}, %{x: 2, y: 9}] = newPlayer.tail
  end

  test "dead player is not incremented" do
    player = %Match.State.Player{
      headX: 1,
      headY: 10,
      tail: [%{x: 1, y: 9}, %{x: 2, y: 9}, %{x: 2, y: 8}],
      direction: :down,
      dead: true
    }

    newPlayer = Match.State.Player.step(player)

    assert is_list(newPlayer.tail)
    assert [%{x: 1, y: 9}, %{x: 2, y: 9}, %{x: 2, y: 8}] = newPlayer.tail
  end

  test "self-collision kills" do
    player = %Match.State.Player{
      headX: 0,
      headY: 1,
      tail: [%{x: 1, y: 1}, %{x: 1, y: 0}, %{x: 0, y: 0}],
      direction: :up,
      dead: false
    }

    newPlayer = Match.State.Player.step(player)

    assert newPlayer.dead
  end
end

ExUnit.run()
defmodule Match.State do
  defstruct [:player, :fruitX, :fruitY, :width, :height]

  def new(width, height) do
    %{x: fruitX, y: fruitY} = get_new_fruit_location(width, height, [])
    player = %Match.State.Player{headX: 3, headY: 3, direction: :up, tail: [], dead: false}
    %Match.State{player: player, fruitX: fruitX, fruitY: fruitY, width: width, height: height}
  end

  def step(%Match.State{
        player: player,
        fruitX: fruitX,
        fruitY: fruitY,
        width: width,
        height: height
      }) do
    newPlayer = Match.State.Player.step(player)

    newPlayer =
      if newPlayer.headX < 0 or newPlayer.headX > width or newPlayer.headY < 0 or
           newPlayer.headY > height do
        Match.State.Player.kill(newPlayer)
      else
        newPlayer
      end

    %{x: fruitX, y: fruitY, player: newPlayer} =
      if fruitX == newPlayer.headX and fruitY == newPlayer.headY do
        %{x: newFruitX, y: newFruitY} = get_new_fruit_location(width, height, player.tail)

        newPlayer = %Match.State.Player{
          newPlayer
          | tail:
              newPlayer.tail ++
                [List.last(player.tail, %{x: newPlayer.headX, y: newPlayer.headY})]
        }

        %{x: newFruitX, y: newFruitY, player: newPlayer}
      else
        %{x: fruitX, y: fruitY, player: newPlayer}
      end

    %Match.State{player: newPlayer, fruitX: fruitX, fruitY: fruitY, width: width, height: height}
  end

  defp get_new_fruit_location(width, height, tail) do
    # Select new X & Y, that are within the field but not already overlapping with the snake
    newFruitX =
      0..width
      |> Enum.filter(fn x ->
        not (tail
             |> Enum.map(fn %{x: x, y: _y} -> x end)
             |> Enum.any?(fn x2 -> x == x2 end))
      end)
      |> Enum.random()

    newFruitY =
      0..height
      |> Enum.filter(fn y ->
        not (tail
             |> Enum.map(fn %{x: _x, y: y} -> y end)
             |> Enum.any?(fn y2 -> y == y2 end))
      end)
      |> Enum.random()

    %{x: newFruitX, y: newFruitY}
  end

  def to_svg(state) do
    """
    <svg version="1.1"
     viewBox="0 0 #{state.width + 1} #{state.height + 1}"
     xmlns="http://www.w3.org/2000/svg">

      <rect x="#{state.fruitX}" y="#{state.fruitY}" width="1" height="1" fill="red" />
      <rect x="#{state.player.headX}" y="#{state.player.headY}" width="1" height="1" fill="blue" />

      #{state.player.tail |> Enum.map(fn t -> '<rect x="#{t.x}" y="#{t.y}" width="1" height="1" fill="green" />' end) |> Enum.join("\n")}

    </svg>
    """
  end
end
Match.State.new(10, 10) |> Match.State.to_svg() |> Kino.Image.new(:svg)
frame = Kino.Frame.new()
frame
state = Match.State.new(10, 10)

Kino.Control.interval(500)
|> Kino.Control.stream()
|> Stream.take(5)
|> Enum.reduce_while(state, fn _counter, state ->
  image = state |> Match.State.to_svg() |> Kino.Image.new(:svg)

  Kino.Frame.render(frame, image)

  state = Match.State.step(state)
  if state.player.dead, do: {:halt, state}, else: {:cont, state}
end)
frame = Kino.Frame.new()
frame
state = Match.State.new(25, 25)

Kino.Control.interval(500)
|> Kino.Control.stream()
|> Stream.take(5)
|> Enum.reduce_while(state, fn _counter, state ->
  image = state |> Match.State.to_svg() |> Kino.Image.new(:svg)

  Kino.Frame.render(frame, image)

  state = Match.State.step(state)
  if state.player.dead, do: {:halt, state}, else: {:cont, state}
end)

User Input

defmodule Match.State.Player.InputControl do
  def on_key(player, %{key: "w", type: :keydown}), do: %{player | direction: :up}
  def on_key(player, %{key: "s", type: :keydown}), do: %{player | direction: :down}
  def on_key(player, %{key: "a", type: :keydown}), do: %{player | direction: :left}
  def on_key(player, %{key: "d", type: :keydown}), do: %{player | direction: :right}
  def on_key(player, _), do: player
end
frame = Kino.Frame.new() |> Kino.render()

match = Match.State.new(25, 25)

# The keyboard control
keyboard = Kino.Control.keyboard([:status, :keydown]) |> Kino.render()

# The refresh interval
interval = Kino.Control.interval(500)
[keyboard, interval]
|> Kino.Control.stream()
# Wait for keyboard enable
|> Stream.drop_while(fn
  %{type: :status, enabled: true} -> false
  _event -> true
end)
# While keyboard is enabled
|> Stream.take_while(fn
  %{type: :status, enabled: false} -> false
  _event -> true
end)
# Update Game
|> Enum.reduce_while(match, fn event, match ->
  match =
    if event.type == :interval do
      image = match |> Match.State.to_svg() |> Kino.Image.new(:svg)

      Kino.Frame.render(frame, image)

      Match.State.step(match)
    else
      if event.type == :keydown do
        %Match.State{match | player: Match.State.Player.InputControl.on_key(match.player, event)}
      else
        match
      end
    end

  if match.player.dead, do: {:halt, match}, else: {:cont, match}
end)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment