Server Authority

The server owns the canonical game state. Clients send intents, not positions.

Why server authority matters

In multiplayer games, you can’t trust client data:

  • Players can modify client code to cheat (teleport, speed hacks, wall hacks)
  • Network latency creates conflicting state between clients
  • Race conditions happen when two players act simultaneously

Server-authoritative design: Clients send what they want to do (“move forward”), the server validates and computes the result, then broadcasts the canonical state.


The pattern

  1. Client → “I want to move north” → Channel
  2. Channel → forward to GameServer
  3. GameServer → validate, compute new state, write to ETS
  4. GameServer → broadcast state via PubSub
  5. Channel → push to all clients
  6. Clients → update local stores, re-render

The server is the single source of truth.


Server: GameServer GenServer

Create server/lib/tostada/game/game_server.ex:

defmodule Tostada.Game.GameServer do
  @moduledoc """
  Server-authoritative game state for a single room.
  Stores state in ETS for fast reads, coordinates writes via GenServer.
  """

  use GenServer
  require Logger

  alias Phoenix.PubSub

  @table_name :game_rooms
  @pubsub Tostada.PubSub

  ## Client API

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @doc """
  Get the current game state for a room.
  Reads directly from ETS (fast path).
  """
  def get_room_state(room_id) do
    case :ets.lookup(@table_name, room_id) do
      [{^room_id, state}] -> {:ok, state}
      [] -> {:error, :not_found}
    end
  end

  @doc """
  Player attempts to move. Server validates and updates.
  """
  def player_move(room_id, user_id, direction) do
    GenServer.call(__MODULE__, {:player_move, room_id, user_id, direction})
  end

  @doc """
  Initialize a new game room.
  """
  def create_room(room_id) do
    GenServer.call(__MODULE__, {:create_room, room_id})
  end

  ## Server Callbacks

  @impl true
  def init(_opts) do
    # Create ETS table for storing room state
    :ets.new(@table_name, [:set, :public, :named_table, read_concurrency: true])
    Logger.info("GameServer started with ETS table #{@table_name}")
    {:ok, %{}}
  end

  @impl true
  def handle_call({:create_room, room_id}, _from, state) do
    initial_state = %{
      room_id: room_id,
      players: %{},
      started_at: System.system_time(:second)
    }

    :ets.insert(@table_name, {room_id, initial_state})
    {:reply, {:ok, initial_state}, state}
  end

  @impl true
  def handle_call({:player_move, room_id, user_id, direction}, _from, state) do
    case :ets.lookup(@table_name, room_id) do
      [] ->
        {:reply, {:error, :room_not_found}, state}

      [{^room_id, room_state}] ->
        # Get current player position or default
        player = Map.get(room_state.players, user_id, %{x: 0, y: 0, z: 0})

        # Validate and compute new position
        new_position = compute_move(player, direction)

        # Validate new position (example: bounds checking)
        if valid_position?(new_position) do
          # Update player in room state
          updated_players = Map.put(room_state.players, user_id, new_position)
          updated_room = %{room_state | players: updated_players}

          # Write to ETS
          :ets.insert(@table_name, {room_id, updated_room})

          # Broadcast to all clients via PubSub
          PubSub.broadcast(@pubsub, "game:#{room_id}", {:player_moved, user_id, new_position})

          {:reply, {:ok, new_position}, state}
        else
          {:reply, {:error, :invalid_position}, state}
        end
    end
  end

  ## Helpers

  defp compute_move(%{x: x, y: y, z: z}, direction) do
    case direction do
      "north" -> %{x: x, y: y, z: z - 1}
      "south" -> %{x: x, y: y, z: z + 1}
      "east" -> %{x: x + 1, y: y, z: z}
      "west" -> %{x: x - 1, y: y, z: z}
      _ -> %{x: x, y: y, z: z}
    end
  end

  defp valid_position?(%{x: x, z: z}) do
    # Example: 20x20 grid
    x >= -10 and x <= 10 and z >= -10 and z <= 10
  end
end

Key points:

  • ETS table for fast concurrent reads (channels can call get_room_state/1 directly)
  • GenServer serializes writes (prevents race conditions)
  • compute_move/2 — Server calculates the result, client doesn’t send coordinates
  • valid_position?/1 — Server validates, preventing impossible moves
  • PubSub broadcast — All channels subscribed to game:#{room_id} receive updates

Server: Add to supervision tree

Edit server/lib/tostada/application.ex:

def start(_type, _args) do
  children = [
    TostadaWeb.Telemetry,
    Tostada.Repo,
    {DNSCluster, query: Application.get_env(:tostada, :dns_cluster_query) || :ignore},
    {Phoenix.PubSub, name: Tostada.PubSub},
    TostadaWeb.Presence,
    Tostada.Game.GameServer,  # <- Add this line
    TostadaWeb.Endpoint
  ]

  opts = [strategy: :one_for_one, name: Tostada.Supervisor]
  Supervisor.start_link(children, opts)
end

Now GameServer starts automatically when your app boots.


Server: Wire GameChannel to GameServer

Update server/lib/tostada_web/channels/game_channel.ex:

defmodule TostadaWeb.GameChannel do
  use TostadaWeb, :channel
  alias TostadaWeb.Presence
  alias Tostada.Game.GameServer

  @impl true
  def join("game:" <> room_id, _payload, socket) do
    # Ensure room exists
    case GameServer.get_room_state(room_id) do
      {:error, :not_found} ->
        GameServer.create_room(room_id)

      {:ok, _state} ->
        :ok
    end

    # Subscribe to PubSub for this room
    Phoenix.PubSub.subscribe(Tostada.PubSub, "game:#{room_id}")

    send(self(), :after_join)
    {:ok, assign(socket, :room_id, room_id)}
  end

  @impl true
  def handle_info(:after_join, socket) do
    user_id = socket.assigns.user_id
    room_id = socket.assigns.room_id

    {:ok, _} = Presence.track(socket, user_id, %{
      online_at: inspect(System.system_time(:second))
    })

    push(socket, "presence_state", Presence.list(socket))

    # Send current game state
    case GameServer.get_room_state(room_id) do
      {:ok, state} -> push(socket, "game_state", state)
      _ -> :ok
    end

    {:noreply, socket}
  end

  @impl true
  def handle_in("move", %{"direction" => direction}, socket) do
    user_id = socket.assigns.user_id
    room_id = socket.assigns.room_id

    case GameServer.player_move(room_id, user_id, direction) do
      {:ok, new_position} ->
        # GameServer already broadcasted via PubSub, no need to broadcast again here
        {:reply, {:ok, %{position: new_position}}, socket}

      {:error, reason} ->
        {:reply, {:error, %{reason: reason}}, socket}
    end
  end

  @impl true
  def handle_info({:player_moved, user_id, position}, socket) do
    # Received from PubSub broadcast
    push(socket, "player_moved", %{user_id: user_id, position: position})
    {:noreply, socket}
  end
end

Flow:

  1. Client sends "move" with direction
  2. Channel calls GameServer.player_move/3
  3. GameServer validates, computes position, writes to ETS, broadcasts via PubSub
  4. All channels (including sender) receive PubSub message
  5. Channels push to their respective clients

Client: Send intents, not positions

Update client/src/lib/game-store.ts:

export function sendMove(direction: 'north' | 'south' | 'east' | 'west'): void {
  if (!gameChannel) return;

  gameChannel
    .push('move', { direction })
    .receive('ok', (resp) => {
      console.log('Move accepted:', resp);
    })
    .receive('error', (err) => {
      console.error('Move rejected:', err);
    });
}

Notice: client sends direction (intent), not {x, y, z} (position). The server calculates the outcome.


Testing validation

Try sending an invalid move from the browser console:

// This will be rejected by the server
sendMove("teleport"); // Not in ["north", "south", "east", "west"]

The server’s compute_move/2 function will ignore it, returning the player’s current position unchanged.

Try moving out of bounds:

// Move 20 times north from origin
for (let i = 0; i < 20; i++) sendMove("north");

The server’s valid_position?/1 check will reject moves beyond the grid boundary.


Why ETS + GenServer?

ETS (Erlang Term Storage):

  • In-memory key-value store
  • Concurrent reads without blocking
  • Channels can call GameServer.get_room_state/1 anytime (reads from ETS, no GenServer bottleneck)

GenServer:

  • Serializes writes (one mutation at a time)
  • Prevents race conditions (two players can’t simultaneously overwrite each other)
  • Coordinates complex state transitions

This is a common Elixir pattern: fast reads, serialized writes.


What about very fast games?

For games with 60+ tick rates (fast-paced action), reading from ETS on every channel message can still become a bottleneck. Solutions:

  1. Tick-based broadcasting: Don’t react to every input immediately, buffer inputs and process in batches (next guide)
  2. Process-per-room: Instead of one GenServer for all rooms, use a DynamicSupervisor to spawn one GenServer per room
  3. Cache in channel state: Store player position in socket.assigns to avoid ETS lookups on every message

For most games (turn-based, lobby-based, moderate action), ETS + single GenServer works great.


Next steps

  • Tick loop: Process inputs at fixed intervals instead of reacting to each message
  • Input buffering: Queue inputs and drain them in batches
  • Persistence: Write game state to database on room close or periodic snapshots

The server is now the authority. Clients can’t cheat by sending fake coordinates.