├── test ├── test_helper.exs └── snake_test.exs ├── assets └── demo.gif ├── .formatter.exs ├── lib ├── snake.ex └── snake │ ├── state.ex │ ├── ui.ex │ └── game.ex ├── mix.exs ├── mix.lock ├── .gitignore ├── LICENSE ├── README.md └── config └── config.exs /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fhunleth/snake/HEAD/assets/demo.gif -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/snake_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SnakeTest do 2 | use ExUnit.Case 3 | doctest Snake 4 | 5 | test "there should be tests" do 6 | assert true 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/snake.ex: -------------------------------------------------------------------------------- 1 | defmodule Snake do 2 | @moduledoc """ 3 | Snake! 4 | """ 5 | 6 | @doc """ 7 | Play a game 8 | """ 9 | defdelegate run(), to: Snake.Game 10 | end 11 | -------------------------------------------------------------------------------- /lib/snake/state.ex: -------------------------------------------------------------------------------- 1 | defmodule Snake.State do 2 | defstruct width: 80, 3 | height: 24, 4 | game_win: nil, 5 | snake: [], 6 | direction: :down, 7 | food: nil, 8 | game_over: false, 9 | timer: nil, 10 | score: 0 11 | end 12 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Snake.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :snake, 7 | version: "0.1.0", 8 | elixir: "~> 1.6", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | def application do 15 | [ 16 | extra_applications: [:logger] 17 | ] 18 | end 19 | 20 | defp deps do 21 | [ 22 | {:ex_ncurses, "~> 0.3"}, 23 | {:logger_file_backend, "~> 0.0.10"} 24 | ] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "elixir_make": {:hex, :elixir_make, "0.4.1", "6628b86053190a80b9072382bb9756a6c78624f208ec0ff22cb94c8977d80060", [:mix], [], "hexpm"}, 3 | "ex_ncurses": {:hex, :ex_ncurses, "0.3.1", "1c6c480815c3863fe45cecf54e86dcaa6fa4484847d05c77a090e1ecb384536b", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "logger_file_backend": {:hex, :logger_file_backend, "0.0.10", "876f9f84ae110781207c54321ffbb62bebe02946fe3c13f0d7c5f5d8ad4fa910", [:mix], [], "hexpm"}, 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | snake-*.tar 24 | 25 | /snake.log 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Frank Hunleth 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Snake 2 | 3 | [![asciicast](assets/demo.gif)](https://asciinema.org/a/174483) 4 | 5 | This was intended to try out 6 | [ex_ncurses](https://github.com/jfreeze/ex_ncurses). Don't be disappointed if 7 | there are bugs or if you lose. 8 | 9 | ## Running 10 | 11 | First clone this project and do the following: 12 | 13 | ```sh 14 | mix deps.get 15 | iex -S mix 16 | iex(1)> Snake.run 17 | ``` 18 | 19 | You can control the snake using the arrow keys, VI keys (hjkl), or wasd. 20 | 21 | ## Creating the demo GIF 22 | 23 | I used [Asciinema](https://asciinema.org) to record the video and 24 | [asciicast2gif](https://github.com/asciinema/asciicast2gif) to convert it to a 25 | gif. Here's a rough commandline: 26 | 27 | ```sh 28 | asciinema rec 29 | # do stuff 30 | # CTRL+D when done. Upload video and note video number for the next line. 31 | docker run --rm -v $PWD:/data asciinema/asciicast2gif https://asciinema.org/a/174483.json assets/demo.gif 32 | ``` 33 | 34 | Oddly enough `asciicast2gif` loses the arena border. No idea why. 35 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :snake, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:snake, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | config :logger, backends: [{LoggerFileBackend, :snake_log}] 24 | 25 | config :logger, :snake_log, 26 | path: "snake.log", 27 | level: :error 28 | 29 | # It is also possible to import configuration files, relative to this 30 | # directory. For example, you can emulate configuration per environment 31 | # by uncommenting the line below and defining dev.exs, test.exs and such. 32 | # Configuration from the imported file will override the ones defined 33 | # here (which is why it is important to import them last). 34 | # 35 | # import_config "#{Mix.env}.exs" 36 | -------------------------------------------------------------------------------- /lib/snake/ui.ex: -------------------------------------------------------------------------------- 1 | defmodule Snake.UI do 2 | require Logger 3 | 4 | def init(state) do 5 | ExNcurses.initscr() 6 | 7 | win = ExNcurses.newwin(state.height - 1, state.width, 1, 0) 8 | ExNcurses.listen() 9 | ExNcurses.noecho() 10 | ExNcurses.keypad() 11 | ExNcurses.curs_set(0) 12 | 13 | %{state | game_win: win} 14 | end 15 | 16 | def fini(state) do 17 | ExNcurses.stop_listening() 18 | ExNcurses.endwin() 19 | state 20 | end 21 | 22 | def game_over(state) do 23 | center_text(state, " GAME OVER ") 24 | ExNcurses.refresh() 25 | flush_input() 26 | 27 | receive do 28 | {:ex_ncurses, :key, _} -> state 29 | end 30 | end 31 | 32 | def flush_input() do 33 | receive do 34 | {:ex_ncurses, :key, _} -> flush_input() 35 | after 36 | 100 -> :ok 37 | end 38 | end 39 | 40 | def draw_screen(state) do 41 | ExNcurses.clear() 42 | ExNcurses.mvaddstr(0, 2, "Snake") 43 | ExNcurses.wclear(state.game_win) 44 | ExNcurses.wborder(state.game_win) 45 | update_score(state) 46 | draw_snake(state, state.snake) 47 | draw_food(state) 48 | ExNcurses.refresh() 49 | ExNcurses.wrefresh(state.game_win) 50 | state 51 | end 52 | 53 | defp draw_food(state) do 54 | {x, y} = state.food 55 | ExNcurses.wmove(state.game_win, y, x) 56 | ExNcurses.waddstr(state.game_win, "*") 57 | state 58 | end 59 | 60 | defp draw_snake(state, []), do: state 61 | 62 | defp draw_snake(state, [{x, y} | rest]) do 63 | ExNcurses.wmove(state.game_win, y, x) 64 | ExNcurses.waddstr(state.game_win, "#") 65 | draw_snake(state, rest) 66 | end 67 | 68 | defp center_text(state, str) do 69 | y = div(state.height, 2) 70 | x = div(state.width - String.length(str), 2) 71 | ExNcurses.mvaddstr(y, x, str) 72 | end 73 | 74 | def update_score(state) do 75 | ExNcurses.mvaddstr(0, state.width - 20, "Score: #{state.score}") 76 | state 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/snake/game.ex: -------------------------------------------------------------------------------- 1 | defmodule Snake.Game do 2 | alias Snake.UI 3 | require Logger 4 | 5 | @tick 200 6 | 7 | defp init(state) do 8 | state 9 | |> UI.init() 10 | |> place_snake() 11 | |> place_food() 12 | end 13 | 14 | defp fini(state) do 15 | Process.cancel_timer(state.timer) 16 | flush_ticks() 17 | UI.fini(state) 18 | end 19 | 20 | defp flush_ticks() do 21 | # This cleans up our mailbox since snake is often invoked from the IEx console's process 22 | # rather than a process we can kill and have Erlang cleanup after us. 23 | receive do 24 | :tick -> flush_ticks() 25 | after 26 | 500 -> :ok 27 | end 28 | end 29 | 30 | def run() do 31 | %Snake.State{} 32 | |> init() 33 | |> UI.draw_screen() 34 | |> schedule_next_tick() 35 | |> loop() 36 | |> fini() 37 | 38 | :ok 39 | end 40 | 41 | def loop(%{game_over: true} = state) do 42 | UI.game_over(state) 43 | end 44 | 45 | def loop(state) do 46 | next_state = 47 | receive do 48 | {:ex_ncurses, :key, key} -> 49 | Logger.debug("Got key #{key} or '#{<>}''") 50 | handle_key(state, key) 51 | 52 | :tick -> 53 | state 54 | |> run_turn() 55 | |> UI.draw_screen() 56 | |> schedule_next_tick() 57 | end 58 | 59 | loop(next_state) 60 | end 61 | 62 | # vim 63 | defp handle_key(state, ?h), do: %{state | direction: :left} 64 | defp handle_key(state, ?k), do: %{state | direction: :up} 65 | defp handle_key(state, ?j), do: %{state | direction: :down} 66 | defp handle_key(state, ?l), do: %{state | direction: :right} 67 | 68 | # wasd 69 | defp handle_key(state, ?w), do: %{state | direction: :up} 70 | defp handle_key(state, ?a), do: %{state | direction: :left} 71 | defp handle_key(state, ?s), do: %{state | direction: :down} 72 | defp handle_key(state, ?d), do: %{state | direction: :right} 73 | 74 | # arrows 75 | defp handle_key(state, 259), do: %{state | direction: :up} 76 | defp handle_key(state, 260), do: %{state | direction: :left} 77 | defp handle_key(state, 258), do: %{state | direction: :down} 78 | defp handle_key(state, 261), do: %{state | direction: :right} 79 | 80 | defp handle_key(state, ?q), do: %{state | game_over: true} 81 | defp handle_key(state, _), do: state 82 | 83 | defp schedule_next_tick(state) do 84 | timer = Process.send_after(self(), :tick, @tick) 85 | %{state | timer: timer} 86 | end 87 | 88 | defp run_turn(state) do 89 | next_head = next_snake_head(state.snake, state.direction) 90 | 91 | cond do 92 | loses(state, next_head) -> 93 | %{state | game_over: true} 94 | 95 | hits_food(state, next_head) -> 96 | state 97 | |> grow_snake(next_head) 98 | |> place_food() 99 | |> incr_score() 100 | 101 | true -> 102 | state 103 | |> move_snake(next_head) 104 | end 105 | end 106 | 107 | defp place_snake(state) do 108 | snake = [{div(state.width, 2), div(state.height, 2)}] 109 | %{state | snake: snake} 110 | end 111 | 112 | defp grow_snake(state, next_head) do 113 | new_snake = [next_head | state.snake] 114 | %{state | snake: new_snake} 115 | end 116 | 117 | defp incr_score(state), do: %{state | score: state.score + 1} 118 | 119 | defp move_snake(state, next_head) do 120 | trimmed = Enum.reverse(state.snake) |> tl() |> Enum.reverse() 121 | %{state | snake: [next_head | trimmed]} 122 | end 123 | 124 | defp place_food(state) do 125 | location = {:rand.uniform(state.width - 2), :rand.uniform(state.height - 3)} 126 | 127 | if hits_snake(state.snake, location) do 128 | place_food(state) 129 | else 130 | %{state | food: location} 131 | end 132 | end 133 | 134 | defp loses(state, next_head) do 135 | hits_wall(state, next_head) || hits_snake(state.snake, next_head) 136 | end 137 | 138 | defp hits_wall(_state, {0, _y}), do: true 139 | defp hits_wall(_state, {_x, 0}), do: true 140 | defp hits_wall(%{width: width} = _state, {x, _y}) when x == width - 1, do: true 141 | defp hits_wall(%{height: height} = _state, {_x, y}) when y == height - 2, do: true 142 | defp hits_wall(_state, _snake_head), do: false 143 | 144 | defp hits_food(%{food: location} = _state, location), do: true 145 | defp hits_food(_state, _snake_head), do: false 146 | 147 | defp hits_snake(snake, location) do 148 | Enum.member?(snake, location) 149 | end 150 | 151 | defp next_snake_head([{x, y} | _] = _snake, :up), do: {x, y - 1} 152 | defp next_snake_head([{x, y} | _] = _snake, :down), do: {x, y + 1} 153 | defp next_snake_head([{x, y} | _] = _snake, :left), do: {x - 1, y} 154 | defp next_snake_head([{x, y} | _] = _snake, :right), do: {x + 1, y} 155 | end 156 | --------------------------------------------------------------------------------