Elixir와 Liveview로 Game of Islands 만들기

발행: (2025년 12월 17일 오후 11:51 GMT+9)
13 min read
원문: Dev.to

Source: Dev.to

위의 링크에 있는 전체 텍스트를 제공해 주시면, 해당 내용을 한국어로 번역해 드리겠습니다. 코드 블록, URL 및 마크다운 형식은 그대로 유지하면서 번역해 드릴 수 있습니다. 부탁드립니다.

Source:

Islands Duel이란?

Islands Duel은 고전 Battleship 게임과 유사한 2인용 전략 게임입니다.

  • 설정 – 각 플레이어는 5개의 숨겨진 섬이 배치된 11 × 11 보드를 가지고 있습니다.
  • 게임 진행 – 플레이어는 차례대로 상대방 보드의 좌표를 추측합니다.
  • 맞힘 또는 빗남 – 섬의 일부가 들어 있는 셀을 추측하면 **맞힘(hit)**이며, 그렇지 않으면 **빗남(miss)**입니다.
  • 소멸 – 섬의 모든 셀이 맞히면 그 섬은 소멸(forested)(파괴)됩니다.
  • 승리 – 상대방의 모든 섬을 소멸시킨 첫 번째 플레이어가 승리합니다!

Islands Duel Demo

기술적인 세부 사항을 살펴보기 전에, 여기서 게임을 직접 체험해 볼 수 있습니다:

https://islands-duel.fly.dev/

그리고 이 게시물에 대한 코드는 여기에서 확인할 수 있습니다:

https://github.com/hungle00/islands_duel

왜 이걸 만들었는가

저는 Lance Halvorsen의 책 Functional Web Development with Elixir, OTP, and Phoenix를 읽으며 Elixir를 배우고 있었습니다. 이 책은 게임 엔진을 만드는 방법을 가르치지만, 완전한 웹 UI는 제공하지 않습니다. 그래서 저는 Phoenix LiveView를 사용해 직접 UI를 만들기로 했습니다!

왜 LiveView인가?

LiveView를 사용하면 JavaScript를 전혀 작성하지 않고 인터랙티브하고 실시간인 웹 애플리케이션을 만들 수 있습니다. 모든 로직은 Elixir 서버에서 실행되며, LiveView가 WebSocket을 통해 UI를 자동으로 브라우저와 동기화합니다. 이 게임의 경우, 게임 로직에 JavaScript 코드를 한 줄도 쓰지 않았습니다—클릭, 보드 업데이트, 플레이어 동기화 모두 순수 Elixir로 처리됩니다. 이것이 바로 LiveView의 마법입니다!

단계별 게임 만들기

제가 이 게임을 어떻게 만들었는지 차근차근 설명해 드릴게요. Elixir가 처음이라도 걱정 마세요—각 단계를 쉽게 풀어 설명합니다.

단계 1: 사용자가 게임에 참여하도록 하기

  1. 플레이어 1이 **“게임 시작”**을 클릭 → 고유한 game_id가 생성된 새로운 게임이 만들어집니다.
  2. 플레이어 1game_id플레이어 2에게 공유합니다.
  3. 플레이어 2game_id를 입력 → 같은 게임에 참여합니다.

User joins game

사용자가 게임 페이지에 접속하면, 게임이 존재하는지 확인합니다. 존재하지 않으면 게임을 만들고, 그 후 플레이어를 게임에 추가합니다.

# Check if game exists
game_pid = GenServer.whereis(Game.via_tuple(game_id))

if game_pid == nil do
  # Game doesn't exist, create it and join as Player 1
  GameSupervisor.start_game(game_id)
  Game.add_player(Game.via_tuple(game_id), username)
else
  # Game exists, join as Player 2
  Game.add_player(Game.via_tuple(game_id), username)
end

GameSupervisor란?

GameSupervisor는 모든 게임을 감시하는 매니저라고 생각하면 됩니다. 각 게임은 별도의 프로세스(앱 안의 작은 프로그램)로 실행됩니다. 슈퍼바이저는 플레이어가 게임을 시작할 때 새로운 게임 프로세스를 만들고, 실행 중인 모든 게임을 추적합니다.

단계 2: mount를 사용해 게임 화면 설정하기

플레이어가 게임 페이지를 열면 LiveView가 mount라는 함수를 호출합니다. 이것을 설정 단계라고 생각하면 됩니다—플레이어가 볼 모든 것을 준비합니다.

mount에서 하는 일

  • 플레이어 이름과 게임 ID를 가져옵니다.
  • 게임 상태(누구 차례인지, 플레이어 이름 등)를 로드합니다.
  • 화면에 두 개의 보드를 그립니다.
def mount(_params, session, socket) do
  game_id  = session["game_id"]
  username = session["current_user"].name

  # Save this info so we can use it later
  socket =
    socket
    |> assign(:game_id, game_id)
    |> assign(:username, username)
    |> assign(:player1_name, nil)
    |> assign(:player2_name, nil)
    |> assign(:player_turn, nil)

  # Only do "live" stuff when the browser is connected
  if connected?(socket) do
    socket = join_game(socket, game_id, username)
    {:ok, socket}
  else
    {:ok, socket}
  end
end

connected?(socket)가 왜 필요할까?

LiveView는 두 번 실행됩니다: 처음에는 초기 페이지 로드를 위한 HTML을 생성하고, 두 번째는 브라우저가 WebSocket으로 연결될 때입니다. 우리는 브라우저가 실제로 연결된 순간에만 게임에 참여하고 싶으므로, 초기 HTML 생성 단계에서는 이 코드를 실행하지 않습니다.

보드 그리기

템플릿(.heex 파일)에서는 간단한 루프를 이용해 11 × 11 격자 두 개를 그립니다:

<%= for {row, row_idx} <- Enum.with_index(@board) do %>
  <%= for {cell, col_idx} <- Enum.with_index(row) do %>
    <div
      phx-click="cell_click"
      phx-value-row={row_idx}
      phx-value-col={col_idx}
      class={"cell #{cell_class(cell)}"}>
    </div>
  <% end %>
<% end %>

각 셀은 phx-click="cell_click"을 가지고 있어 클릭될 때 LiveView가 handle_event 함수를 호출하도록 하고, phx-value는 해당 셀의 좌표를 전달합니다.

단계 3: handle_event를 사용해 플레이어 클릭 처리하기

플레이어가 상대방 보드의 셀을 클릭하면 LiveView가 그 클릭을 포착하고 handle_event를 호출합니다. 여기서 추측(guess)을 처리합니다:

def handle_event("cell_click", %{"row" => row, "col" => col}, socket) do
  game_id        = socket.assigns.game_id
  current_player = socket.assigns.player_role

  # Ask the game engine: is the guess a hit or a miss?
  case Game.make_guess(game_id, current_player, {row, col}) do
    {:hit, updated_state} ->
      socket
      |> assign(:game_state, updated_state)
      |> push_event("guess_result", %{result: "hit", row: row, col: col})

    {:miss, updated_state} ->
      socket
      |> assign(:game_state, updated_state)
      |> push_event("guess_result", %{result: "miss", row: row, col: col})
  end
end

Game.make_guess/3은 … (다음 섹션에서 계속)

게임 엔진에 구현된 함수는 좌표가 섬에 닿는지를 판단하고, 내부 상태를 업데이트한 뒤 새로운 상태와 결과를 반환합니다. 그 후 LiveView는 이벤트를 클라이언트로 푸시하여 UI가 셀의 모습을 업데이트할 수 있게 합니다.

마무리

  • LiveView를 사용하면 모든 게임 로직을 서버에 유지하여 맞춤형 JavaScript가 필요 없게 됩니다.
  • GenServer 프로세스(게임당 하나)가 핵심 게임 상태를 관리하고, Supervisor가 해당 프로세스들을 지속적으로 유지하고 정리합니다.
  • 그 결과, 고전 보드 게임 같은 부드럽고 실시간의 2인 플레이 경험을 브라우저에서 즐길 수 있습니다.

데모를 직접 사용해 보고, 소스 코드를 탐색해 보세요. 그리고 Islands Duel에 자신만의 변형을 자유롭게 실험해 보세요!

히트인가 미스인가?

case Game.guess_coordinate(Game.via_tuple(game_id), current_player, row, col) do
  {:hit, island, win_status} ->
    # We hit an island!
    socket = mark_cell_as_hit(socket, row, col)
    {:noreply, socket}

  {:miss, :none, :no_win} ->
    # We missed
    socket = mark_cell_as_miss(socket, row, col)
    {:noreply, socket}

  :error ->
    # Not our turn or invalid move
    {:noreply, put_flash(socket, :error, "Not your turn!")}
end

클릭 데이터가 함수에 전달되는 방식

LiveView는 템플릿을 Elixir 코드와 연결하는 바인딩이라는 특수 HTML 속성을 제공합니다:

BindingDescription
phx-click="cell_click"이 요소가 클릭되면 LiveView에서 handle_event("cell_click", …)를 호출합니다.
phx-value-row={row}row 값을 클릭 이벤트에 첨부합니다
phx-value-col={col}col 값을 클릭 이벤트에 첨부합니다

이 값들은 자동으로 수집되어 handle_event의 두 번째 매개변수(%{"row" => "5", "col" => "3"})로 전달됩니다. JavaScript가 필요 없습니다—LiveView가 모든 것을 처리합니다!

Step 4: handle_info를 사용해 화면 업데이트하기

Player 1이 움직임을 만들 때, Player 2는 어떻게 보나요? 두 사람은 서로 다른 브라우저에 있습니다! LiveView는 이를 위해 handle_info/2 콜백을 제공합니다. 이 콜백은 시스템의 다른 부분으로부터 메시지를 받습니다. Player 1이 추측을 하면, 우리는 Player 2의 LiveView에 메시지를 보내고, handle_info/2가 이를 처리합니다:

def handle_info({:guess_result, result}, socket) do
  # Another player made a guess, update our screen
  socket =
    socket
    |> update_board_with_result(result)
    |> put_flash(:info, "#{result.player_name} guessed (#{result.row}, #{result.col})")

  {:noreply, socket}
end

다른 예시: Player 2가 게임에 참여하면, Player 1에게 알립니다:

def handle_info({:player_added, %{username: username}}, socket) do
  # A new player joined the game!
  socket =
    socket
    |> load_game_state(socket.assigns.game_id)
    |> put_flash(:info, "#{username} joined the game!")

  {:noreply, socket}
end

단계 5: PubSub을 사용하여 두 플레이어 연결하기

PubSub은 그룹 채팅과 같습니다:

  1. Subscribe – 플레이어가 게임에 참여하면 해당 게임 채널에 “구독”합니다.
  2. Broadcast – 무언가 일어날 때(추측, 플레이어 참여 등) 우리는 구독한 모든 사람에게 메시지를 “방송”합니다.

구독하기 (mount/3 내부)

if connected?(socket) do
  # Join the game's "chat room"
  topic = "game:#{game_id}"
  Phoenix.PubSub.subscribe(IslandsDuel.PubSub, topic)
end

방송하기 (추측 후)

# Tell everyone in this game what happened
Phoenix.PubSub.broadcast(
  IslandsDuel.PubSub,
  "game:#{game_id}",
  {:guess_result, %{player: current_player, row: row, col: col, result: :hit}}
)

방송을 하면, "game:#{game_id}"에 구독된 모든 플레이어handle_info/2 콜백에서 메시지를 받습니다. 이렇게 두 플레이어가 동기화됩니다!

흐름

플레이어 1이 셀을 클릭함

handle_event가 추측을 처리함

broadcast가 모든 플레이어에게 메시지를 보냄

플레이어 2의 handle_info가 이를 수신함

플레이어 2의 화면이 자동으로 업데이트됨

Conclusion

Islands Duel을 만들면서 LiveView가 실시간 기능을 놀라울 정도로 간단하게 만든다는 것을 배웠습니다. 복잡한 JavaScript를 작성하는 대신 우리는 단순히:

  • mount/3를 사용해 페이지를 설정합니다.
  • handle_event/3를 사용해 사용자의 행동을 처리합니다.
  • handle_info/2 + PubSub을 사용해 플레이어 간을 동기화합니다.

Elixir를 배우면서 재미있는 프로젝트를 원한다면 간단한 멀티플레이어 게임을 만들어 보세요. LiveView, PubSub, 실시간 웹 앱에 대해 배우면서 즐거운 시간을 보낼 수 있습니다!

게임 엔진 코드는 책 Functional Web Development with Elixir, OTP, and Phoenix (Pragmatic Bookshelf)에서 가져왔습니다. Elixir를 제대로 배우고 싶다면 강력히 추천합니다.

행복한 코딩 되세요! 🎮

Back to Blog

관련 글

더 보기 »