Minesweeper - built with liveview and other recent tools

TLDR: https://minesweeper.fly.dev built with liveview and other recent tools.

Most relevant code at mine_sweeper/lib/mine_sweeper_web/live/session_live at main · princemaple/mine_sweeper · GitHub


Hi All,

My most familiar stack is building frontend with Angular and using phoenix as an API backend. I briefly tried liveview when it first came out. That was pre-heex, pre-component era. I’ve been wanting to try the shiny new stuff and stay up to date.

I could’ve chosen a different game if I simply wanted to build something interactive and realtime. I chose minesweeper because 1. it’s one of my favorite games, 2. it requires a lot of state keeping and inter-component communication (I initially thought each component would be a separate process) and 3. it requires quite a bit interaction (event handling). I thought it’s a good game to build to test all these things out.

Plus, there has been a couple other things on my listing waiting to be checked out.

  1. fly.io deployment
    (I’ve used the one-click to launch livebook. It’s great)
  2. phoenix with esbuild and tailwind
    (they are not new to me, I just haven’t built a phoenix app that uses them to build the assets)

So, I decided to build this minesweeper game with liveview, with the assets built with esbuild & tailwind, and deployed to fly.io


A few notes:

  1. as previously mentioned, I initially thought each component was a separate process. That wasn’t the case. So there isn’t really much “inter-component communication”. I built the game with GenServers, while liveview and components were solely used to render and interact.
  2. for in heex seems to update all or nothing. I thought it would be able to only update the ones with thier assigns changed. Easy workaround though, just check the new assigns with the assigns on socket.
  3. somewhat often, changes in assets, sometimes also in GenServers, don’t get reflected in the running app. Not sure if building on Window directly had something in it. I normally dev with docker and this is not an issue.

Overall everything went pretty smoothly.
GitHub - phoenixframework/esbuild: An installer for esbuild and GitHub - phoenixframework/tailwind: An installer for tailwind do a very good job getting me started, and provide very sensible docs and defaults. Deploying to fly.io was also easy, fly launch made it almost a no brainer. It generates a very good dockerfile and other relevant deployment related files. Heex is great to great to work with. Components do a good job separating small parts of the UI, encapsulating both their rendering and logic, allowing easy reuse.

I feel like I’ve got an OK understanding of latest liveview.
Things left to explore later: I did try out deploying to multiple regions on fly.io, which was easy, but I didn’t know how its routing works and whether you could reliably hit your closest server, so I reverted back to single server. Maybe a fun thing to do is to deploy a multi continent cluster and have the games shown globally. (oh, did I mention that you can enter others’ games and mess with help them)

Reviews and comments are welcome. Questions too!

Stay safe.

21 Likes

You should be able to use stateful components within for and get granular updates.

3 Likes

:wink: Thanks. Yep, that’s what I did.

^ This is my “workaround”. I hope it’s what you meant.

^ I was hoping this would cause only individual cell gets the update call, but whenever the cache busting map gets any update, the whole for updates (i.e. every single cell component gets the update call)

Awesome project, congrats!

I had the same question before and ended up using the following approach with a CDN test tool to confirm that it was routed to the nearest region.

# config.exs
config :my_app, region: System.get_env("FLY_REGION", "unknown")

# my_plug.ex
conn |> put_resp_header("x-fly-region", Application.get_env(:region))

Anycast is amazing.

4 Likes

Thanks! :heart_eyes_cat:

I wonder if every field being a separate genserver isn’t an overkill. But I assume it’s only for education purposes.

Now I’d be interested in opposite extreme where each game is just a single genserver with list state and simple css grid and you could squeeze all that fun in 10 LOC :slightly_smiling_face:

1 Like

Yes. It’s intentionally over-engineered :slight_smile:

2 Likes

I’d like to see that 10loc version …

defmodule Minesweeper do
  def neighbours({x, y}) do
    [
      {x + 1, y - 1},
      {x + 1, y},
      {x + 1, y + 1},
      {x, y - 1},
      {x, y + 1},
      {x - 1, y - 1},
      {x - 1, y},
      {x - 1, y + 1}
    ]
    |> Enum.filter(fn {x, y} -> x in 0..8 and y in 0..8 end)
  end

  def numbers(mines) do
    Enum.flat_map(mines, &neighbours/1) |> Enum.frequencies()
  end

  # --- the view

  def cell(coord, numbers, mines) do
    if(coord in mines) do
      "M"
    else
      "#{Map.get(numbers, coord, " ")}"
    end
  end

  def print_board(numbers, mines) do
    for y <- 0..8 do
      for x <- 0..8 do
        cell({x,y}, numbers, mines)
      end
    end
  end
end
# @mines [{6,0}, {0,1}, {1,2}, {3,2}, {8,4}, {2,5}, {1,6}, {4,6}, {2,7}, {7,8}]
# Minesweeper.print_board(Minesweeper.numbers(@mines), @mines) |> IO.inspect()
[
  ["1", "1", " ", " ", " ", "1", "M", "1", " "],
  ["M", "2", "2", "1", "1", "1", "1", "1", " "],
  ["2", "M", "2", "M", "1", " ", " ", " ", " "],
  ["1", "1", "2", "1", "1", " ", " ", "1", "1"],
  [" ", "1", "1", "1", " ", " ", " ", "1", "M"],
  ["1", "2", "M", "2", "1", "1", " ", "1", "1"],
  ["1", "M", "3", "3", "M", "1", " ", " ", " "],
  ["1", "2", "M", "2", "1", "1", "1", "1", "1"],
  [" ", "1", "1", "1", " ", " ", "1", "M", "1"]
]
3 Likes

Brilliant work! I really like how you did the neighbours function with filter, that’s shrewd :slight_smile:

Late to the party but for funsies :smile:

def neighbours2({x, y} = orig) do
  for x_mod <- [1, 0, -1],
    y_mod <- [-1, 0, 1],
    x_new = x + x_mod,
    y_new = y + y_mod,
    x_new in 0..8 and y_new in 0..8 and {x_new, y_new} != orig do
      {x_new, y_new}
  end
end
5 Likes

I just thought about this unfinished business.

I think the last remaining problem is to find the clusters of adjacent empty fields. (reminder: when you click an empty field - no mine and no adjacent mine - all adjacent empty fields are also revealed.)

There must be some math for that. Any ideas?

Probably a breadth-first graph algorithm leveraging the neighbours function for guiding the recursion.

Here’s a non-Elixir maze-finding example implementation that could maybe help? Just have to gloss over all the Rust type system discussions How to Learn Rust with Tim McNamara – Functional Futures - YouTube

1 Like

This is what I came up with.

@board %{
  0 => %{0 => 1, 1 => 1, 2 => 0, 3 => 0, 4 ...},
  1 => %{0 => 9, 1 => 2, 2 => 2, 3 => 1, 4 ...},
  ...
}
def find_cluster(start, board),
  do: _find_cluster([start], board, [start]) |> Enum.uniq()

def _find_cluster([h | t], board, found) do
  found_ =
    Minesweeper.neighbours(h)
    |> Enum.filter(fn coord -> coord not in found end)
    |> Enum.filter(fn {x, y} -> get_in(board, [y, x]) == 0 end)

  _find_cluster(t, board, found) ++ _find_cluster(found_, board, found ++ found_)
end

def _find_cluster([], _board, found), do: found

I changed the print_board function so that it takes the cluster (found are marked a 8) and the startpoint (7). On the left the initial board with no clusters, empty cells are marked with 0.

start = {8, 0}
cluster = find_cluster(start, @board)
assert Minesweeper.print_board(Minesweeper.numbers(@mines), @mines, [], nil) ==
             Minesweeper.print_board(Minesweeper.numbers(@mines), @mines, cluster, start)

image

FYI When dealing with multi dimensional arrays I normally just use a flat tuple key map. It saves you from a lot of head scratching issues.

2 Likes

indeed, would be simpler here. Getting rid of get_in for example. Also I don’t have the weird swap of x,y->y,x

So clear and clean in the end. :+1:

I was also going to suggest a tuple key map for the found accumulator, or probably more ideal a MapSet.

Especially when dealing with a sparse, larger board the Enum.filter(fn coord -> coord not in found end) grows the search time non-linearly.

You could also drop the _find_cluster(t, board, found) ++ _find_cluster(found_, board, found ++ found_) in favour of just returning _find_cluster(found_, board, **updated found MapSet**), and set up the recursion with do: ([start] ++ _find_cluster([start], board, MapSet.new([start]))) |> Enum.uniq().

I probably missed some brackets

1 Like

FYI My game initialization code is here mine_sweeper/game_server.ex at bbaafba72905f1fffcd4dfc02432b07bffc0e549 · princemaple/mine_sweeper · GitHub

which I forgot to link to in the original post.

1 Like

You’re right. I’ll do that as soon as I have at least 1000 paying customers playing at a time and keep it as simple as possible for now. :wink:

I do not understand this part …? What should I do?

sorry for littering your thread, maybe this could be split …?

Not at all. Feel free to carry on.

1 Like