Elixir와 Liveview로 Game of Islands 만들기
Source: Dev.to
위의 링크에 있는 전체 텍스트를 제공해 주시면, 해당 내용을 한국어로 번역해 드리겠습니다. 코드 블록, URL 및 마크다운 형식은 그대로 유지하면서 번역해 드릴 수 있습니다. 부탁드립니다.
Source:
Islands Duel이란?
Islands Duel은 고전 Battleship 게임과 유사한 2인용 전략 게임입니다.
- 설정 – 각 플레이어는 5개의 숨겨진 섬이 배치된 11 × 11 보드를 가지고 있습니다.
- 게임 진행 – 플레이어는 차례대로 상대방 보드의 좌표를 추측합니다.
- 맞힘 또는 빗남 – 섬의 일부가 들어 있는 셀을 추측하면 **맞힘(hit)**이며, 그렇지 않으면 **빗남(miss)**입니다.
- 소멸 – 섬의 모든 셀이 맞히면 그 섬은 소멸(forested)(파괴)됩니다.
- 승리 – 상대방의 모든 섬을 소멸시킨 첫 번째 플레이어가 승리합니다!
기술적인 세부 사항을 살펴보기 전에, 여기서 게임을 직접 체험해 볼 수 있습니다:
그리고 이 게시물에 대한 코드는 여기에서 확인할 수 있습니다:
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이 **“게임 시작”**을 클릭 → 고유한
game_id가 생성된 새로운 게임이 만들어집니다. - 플레이어 1이
game_id를 플레이어 2에게 공유합니다. - 플레이어 2가
game_id를 입력 → 같은 게임에 참여합니다.

사용자가 게임 페이지에 접속하면, 게임이 존재하는지 확인합니다. 존재하지 않으면 게임을 만들고, 그 후 플레이어를 게임에 추가합니다.
# 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 속성을 제공합니다:
| Binding | Description |
|---|---|
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은 그룹 채팅과 같습니다:
- Subscribe – 플레이어가 게임에 참여하면 해당 게임 채널에 “구독”합니다.
- 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를 제대로 배우고 싶다면 강력히 추천합니다.
행복한 코딩 되세요! 🎮
