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
- Client → “I want to move north” → Channel
- Channel → forward to GameServer
- GameServer → validate, compute new state, write to ETS
- GameServer → broadcast state via PubSub
- Channel → push to all clients
- 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/1directly) - GenServer serializes writes (prevents race conditions)
compute_move/2— Server calculates the result, client doesn’t send coordinatesvalid_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:
- Client sends
"move"withdirection - Channel calls
GameServer.player_move/3 - GameServer validates, computes position, writes to ETS, broadcasts via PubSub
- All channels (including sender) receive PubSub message
- 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/1anytime (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:
- Tick-based broadcasting: Don’t react to every input immediately, buffer inputs and process in batches (next guide)
- Process-per-room: Instead of one GenServer for all rooms, use a
DynamicSupervisorto spawn one GenServer per room - Cache in channel state: Store player position in
socket.assignsto 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.