Setup & Guides
Platform setup, OAuth providers, email, error monitoring, and server hooks
JavaScript Client SDK View on NPM
1 Install the SDK
Install the package via npm:
2 Initialize the Client
Import and configure the API client:
const { ApiClient, HealthApi, AuthenticationApi, UsersApi } = require('@ughuuu/game_server');
// Initialize the API client
const apiClient = new ApiClient();
apiClient.basePath = 'http://localhost:4000';
3 Health Check
Test your connection with a health check:
const healthApi = new HealthApi(apiClient);
const healthResponse = await healthApi.index();
console.log('Server is healthy:', healthResponse);
4 Authentication
The API uses JWT tokens. Here's how to authenticate:
Email/Password Login:
const authApi = new AuthenticationApi(apiClient);
const loginResponse = await authApi.login({
loginRequest: {
email: 'user@example.com',
password: 'password123'
}
});
const { access_token, refresh_token, user_id } = loginResponse.data;
OAuth Flow (Discord, Google, Facebook):
// Step 1: Get authorization URL
const authResponse = await authApi.oauthRequest('discord');
const authUrl = authResponse.authorization_url;
const sessionId = authResponse.session_id;
// Step 2: Open URL in browser for user to authenticate
window.open(authUrl, '_blank');
// Step 3: Poll for completion
let sessionData;
do {
await new Promise(resolve => setTimeout(resolve, 1000));
sessionData = await authApi.oauthSessionStatus(sessionId);
} while (sessionData.status === 'pending');
if (sessionData.status === 'completed') {
const { access_token, refresh_token, user_id } = sessionData.data;
console.log('OAuth successful!');
}
Using Access Tokens:
// Set authorization header for authenticated requests
apiClient.defaultHeaders = {
'Authorization': `Bearer ${access_token}`
};
5 API Usage Examples
Get User Profile:
const usersApi = new UsersApi(apiClient);
const userProfile = await usersApi.getCurrentUser(`Bearer ${access_token}`);
console.log('User:', userProfile.data);
Refresh Token:
const refreshResponse = await authApi.refreshToken({
refreshTokenRequest: {
refresh_token: refresh_token
}
});
const newAccessToken = refreshResponse.data.access_token;
Logout:
await authApi.logout(`Bearer ${access_token}`);
console.log('Logged out successfully');
6 Error Handling
Handle common errors appropriately:
try {
const result = await someApiCall();
} catch (error) {
if (error.status === 401) {
// Token expired, refresh or re-authenticate
console.log('Token expired');
} else if (error.status === 403) {
// Forbidden - insufficient permissions
console.log('Access denied');
} else if (error.status === 404) {
// Resource not found
console.log('Not found');
} else {
// Other errors
console.error('API Error:', error);
}
}
7 Lobby Management
Work with lobbies for multiplayer matchmaking:
List Available Lobbies
const { LobbiesApi } = require('@ughuuu/game_server');
const lobbiesApi = new LobbiesApi(apiClient);
// List all public lobbies - note: the SDK returns the array directly
const lobbies = await lobbiesApi.listLobbies();
console.log('Available lobbies:', lobbies);
// Search lobbies by name/title
const searchResults = await lobbiesApi.listLobbies({ q: 'deathmatch' });
console.log('Search results:', searchResults);
Create a Lobby
// Create a new lobby (requires authentication via Authorization: Bearer <token>)
const newLobby = await lobbiesApi.createLobby({
title: 'My Game Room',
max_users: 4,
is_hidden: false,
metadata: { game_mode: 'deathmatch' }
});
// SDK returns the created lobby object directly
console.log('Created lobby:', newLobby);
Join / Leave
// Join a lobby by ID (authenticated)
await lobbiesApi.joinLobby(lobbyId);
// Join a password-protected lobby
await lobbiesApi.joinLobby(lobbyId, { password: 'secret123' });
// Leave the current lobby (authenticated)
await lobbiesApi.leaveLobby();
console.log('Left the lobby');
Update & Kick (host only)
// Update lobby settings (host only)
await lobbiesApi.updateLobby({
title: 'Updated Room Name',
max_users: 8,
is_locked: true
});
// Kick a user (host only)
await lobbiesApi.kickUser(123);
console.log('User kicked from lobby');
Friends
// Send a friend request
const { FriendsApi } = require('@ughuuu/game_server');
const friendsApi = new FriendsApi(apiClient);
await friendsApi.create({ target_user_id: someOtherUserId });
// List my friends
const friends = await friendsApi.listFriends();
console.log('Friends:', friends);
// Subscribe to real-time friend events via phoenix channels on socket
const socket = new Socket('/socket', { params: { token: accessToken } });
socket.connect();
const userChannel = socket.channel('user:' + userId, {});
await userChannel.join();
userChannel.on('friend_blocked', payload => console.log('Blocked:', payload));
userChannel.on('friend_unblocked', payload => console.log('Unblocked:', payload));
Realtime / subscribing to events
// The server exposes real-time events via Phoenix channels on the same socket
// (you must pass a valid JWT token when connecting).
// Using the phoenix JS client (https://www.npmjs.com/package/phoenix)
import { Socket } from 'phoenix';
const socket = new Socket('/socket', { params: { token: accessToken } });
socket.connect();
// === per-user updates ===
// join the "user:<userId>" topic to receive events about that user
const userChannel = socket.channel('user:' + userId, {});
await userChannel.join();
userChannel.on('updated', (payload) => {
console.log('My metadata changed', payload);
});
// === per-lobby updates ===
// join "lobby:<lobbyId>" to receive membership and lobby events
const lobbyChannel = socket.channel('lobby:' + lobbyId, {});
await lobbyChannel.join();
// Events forwarded from the server on the lobby:<id> channel:
// - 'user_joined' => { user_id }
// - 'user_left' => { user_id }
// - 'user_kicked' => { user_id }
// - 'updated' => lobby object (full lobby payload, contains fields like title, max_users, metadata, etc.)
// - 'host_changed' => { new_host_id }
lobbyChannel.on('user_joined', ({ user_id }) => {
console.log('User joined lobby', user_id)
})
lobbyChannel.on('user_left', ({ user_id }) => {
console.log('User left lobby', user_id)
})
lobbyChannel.on('user_kicked', ({ user_id }) => {
console.warn('User kicked from lobby', user_id)
})
// updated contains the full, serialized lobby object. To detect specific
// field changes (title changed, max_users changed, etc.) compare with the
// previous lobby state you have stored in the client.
let currentLobbyState = null
lobbyChannel.on('updated', (lobby) => {
console.log('Lobby updated', lobby)
if (currentLobbyState) {
if (lobby.title !== currentLobbyState.title) {
console.log('Lobby title changed:', currentLobbyState.title, '->', lobby.title)
}
if (lobby.max_users !== currentLobbyState.max_users) {
console.log('Lobby max_users changed:', currentLobbyState.max_users, '->', lobby.max_users)
}
// Add any other field checks you care about here (is_locked, metadata, host_id, ...)
}
// Save latest state
currentLobbyState = lobby
})
lobbyChannel.on('user_left', ({ user_id }) => console.log('User left', user_id));
lobbyChannel.on('user_kicked', ({ user_id }) => console.warn('You were kicked', user_id));
lobbyChannel.on('host_changed', ({ new_host_id }) => {
console.log('Lobby host changed ->', new_host_id)
})
lobbyChannel.on('updated', (lobby) => console.log('Lobby updated', lobby));
lobbyChannel.on('host_changed', ({ new_host_id }) => console.log('Host changed', new_host_id));
8 Leaderboards
Display leaderboards and player rankings (read-only from client, scores are submitted server-side):
List Leaderboards
const { LeaderboardsApi } = require('@ughuuu/game_server');
const leaderboardsApi = new LeaderboardsApi(apiClient);
// List all leaderboards (paginated)
const leaderboards = await leaderboardsApi.listLeaderboards({ page: 1, pageSize: 25 });
console.log('Leaderboards:', leaderboards.data);
// Filter to only active leaderboards
const activeOnly = await leaderboardsApi.listLeaderboards({ active: true });
Get Leaderboard Records
// Get top records for a leaderboard
const records = await leaderboardsApi.getLeaderboardRecords('weekly_score_2024_w48', {
page: 1,
pageSize: 25
});
console.log('Top players:', records.data);
// Each record includes: rank, user_id, display_name, score, metadata
// Get records around a specific user (for context)
const aroundMe = await leaderboardsApi.getRecordsAroundUser('weekly_score_2024_w48', userId, {
limit: 5
});
console.log('Players around me:', aroundMe.data);
Get My Record (requires auth)
// Get current user's record with rank
const myRecord = await leaderboardsApi.getMyRecord('weekly_score_2024_w48');
if (myRecord.data) {
console.log('My rank:', myRecord.data.rank);
console.log('My score:', myRecord.data.score);
}
Godot Client SDK View on Godot Asset Library
1 Get the Asset
Download the Godot asset from the Asset Library (in Godot look for the "Gamend - Game Server" addon):
2 Quick integration
Typical usage inside Godot GDScript (pseudocode / example):
var gamend_api:= GamendApi.new()
var access_token := ""
var refresh_token := ""
# This function will be reused in future examples
func print_error_or_result(response: GamendResult):
if response.error:
print(response.error)
else:
print(response.response)
func _ready() -> void:
var response :GamendResult= await gamend_api.health_index().finished
print_error_or_result(response)
3 Authentication
Authenticate using the same JWT-based API flow as other SDKs (get token from server login / OAuth).
var gamend_api:= GamendApi.new()
var access_token := ""
var refresh_token := ""
func _ready() -> void:
# Request OAuth URL, open browser and login
do_discord_auth()
gamend_api.authorize(access_token)
func do_discord_auth():
var response = await gamend_api.authenticate_oauth_request(GamendApi.PROVIDER_DISCORD).finished
print_error_or_result(response)
var authorization_url = response.response.data.authorization_url
var session_id :String = response.response.data.session_id
# Opening Auth URL
OS.shell_open(authorization_url)
for i in 60:
print("CHECKING SESSION: ", session_id)
response = await gamend_api.authenticate_oauth_session_status(session_id).finished
print_error_or_result(response)
if response.response.data.status == "completed":
break
access_token = response.response.data.data.access_token
refresh_token = response.response.data.data.refresh_token
4 Call Authenticated APIs
After logging in, you can now call any RPC or other protected functions.
var gamend_api:= GamendApi.new()
func _ready() -> void:
# From previous example
gamend_api.authorize(access_token)
# ...
response = await gamend_api.users_get_current_user().finished
print_error_or_result(response)
var call_hook := CallHookRequest.new()
call_hook.plugin = "polyglot_hook"
call_hook.fn = "hello"
call_hook.args = ["1"]
response = await gamend_api.hooks_call_hook(call_hook).finished
print_error_or_result(response)
Server-side scripting & hooks Scripting Interface
The application exposes a lightweight server-side scripting surface via the
GameServer.Hooks
behaviour. Hooks let you run custom code on lifecycle events (eg. user register/login, lobby create/update) and optionally expose RPC functions.
Add a lifecycle callback
Implement the behaviour in a hooks module:
# your_hook_module.ex
defmodule MyApp.HooksImpl do
@behaviour GameServer.Hooks
@impl true
def after_user_register(user) do
# safe database update (non-blocking in hooks is recommended)
GameServer.Accounts.update_user(user, %{metadata: Map.put(user.metadata || %{}, "from_hook", true)})
:ok
end
@impl true
def after_user_updated(user) do
# React to any user profile change (metadata, display name, etc.)
:ok
end
end
Loading hooks via OTP plugins
Hooks are loaded from OTP plugin applications under
modules/plugins/*. You can override the plugins directory using:
GAME_SERVER_PLUGINS_DIR=modules/plugins
Each plugin is an OTP app directory with an
ebin
folder containing a
.app
file and compiled
.beam
modules. The plugin's
.app
env must include a
hooks_module
entry pointing at the module name.
Gating resource creation with before hooks
"Before" hooks let you block operations or modify attributes before they are persisted. For example,
before_group_create/2
receives the full user struct and the group attributes map, so you can check metadata (coins, level, etc.) to decide whether the user is allowed to create a group:
@impl true
def before_group_create(user, attrs) do
coins = get_in(user.metadata, ["coins"]) || 0
if coins >= 50 do
{:ok, attrs}
else
{:error, :not_enough_coins}
end
end
Other "before" hooks follow the same pattern:
before_lobby_create/1, before_lobby_join/3, before_group_join/3. Return {:ok, attrs} (or the appropriate tuple) to allow, {:error, reason} to reject.
Exposing an RPC function
Hooks modules can also export arbitrary functions:
defmodule MyApp.HooksImpl do
@behaviour GameServer.Hooks
def hello_world(name) do
{:ok, "Hello, #{name}!"}
end
end
curl -X POST https://your-game-server.com/api/v1/hooks/call \
-d '{"plugin":"polyglot_hook","fn":"hello_world","args":["Alice"]}'
Best practices & pitfalls
-
Keep hooks fast and resilient — avoid long blocking work in the main request path. Use
Task.startfor background processing. -
When returning values from lifecycle hooks, prefer a
{:ok, map}shape for "before" hooks that may modify attrs. Return{:error, reason}to reject flows; domain code will convert to{:hook_rejected, reason}. - Do not return structs as hook results intended to be used as params — always return plain maps when you intend to pass modified params into changesets.
-
Tests that modify global plugin configuration (eg.
GAME_SERVER_PLUGINS_DIR) should run serially (async: false) and restore env viaon_exitto avoid cross-test races. -
Be careful modifying user or lobby data from hooks — reuse high-level domain functions (eg.
GameServer.Accounts.update_user/2,GameServer.Lobbies.update_lobby/2) so changes are validated and broadcast consistently.
Leaderboards Browse Leaderboards
Leaderboards allow you to rank players based on scores. Scores are submitted server-side only (authoritative mode) ensuring fair competition. Each leaderboard acts as a season with optional start/end dates.
Key Concepts
-
Sort Order:
desc(highest first) orasc(lowest first) -
Operators:
set(replace),best(only if better),incr(add),decr(subtract) -
Seasons:
Each leaderboard is a season. Set
ends_atto mark as ended - Metadata: Store additional JSON data on leaderboards and individual records
Server-Side Score Submission (Elixir)
Scores are submitted server-side only to prevent cheating. Call the context functions directly from your game logic:
Elixir Context Functions
# Create a new leaderboard (admin)
GameServer.Leaderboards.create_leaderboard(%{
slug: "weekly_score_2024_w48",
title: "Weekly High Scores",
sort_order: :desc,
operator: :best,
starts_at: ~U[2024-11-25 00:00:00Z],
metadata: %{"prize" => "Gold Badge"}
})
# Submit a score (server-side only)
# First fetch the active leaderboard (by slug) then submit using its integer id
leaderboard = GameServer.Leaderboards.get_active_leaderboard_by_slug("weekly_score_2024_w48")
if leaderboard do
GameServer.Leaderboards.submit_score(
leaderboard.id, # leaderboard id (integer)
user_id, # user_id
9500, # score
%{"level" => 15} # optional metadata
)
end
# List records with pagination
GameServer.Leaderboards.list_records(leaderboard.id, page: 1, page_size: 25)
# Get user's record with rank
GameServer.Leaderboards.get_user_record(leaderboard.id, user_id)
# Get records around a user (for context display)
GameServer.Leaderboards.list_records_around_user(leaderboard.id, user_id, limit: 5)
# End a leaderboard (marks it as finished)
GameServer.Leaderboards.end_leaderboard(leaderboard)
Best Practices
-
Use descriptive slugs like
weekly_score_2024_w48orseason_3_pvp -
Set
starts_atfor scheduled leaderboards -
Use
operator: :bestfor high score boards,:incrfor cumulative -
Store extra context in
metadata(achievements, levels, etc.) - Create new leaderboards for new seasons instead of resetting
-
Use
/records/around/:user_idto show player context in the rankings
Configure Theme
You can provide simple runtime theming configuration using a JSON file. This lets you customize basic branding (title, tagline) and reference an external stylesheet (css) plus assets (logo, banner).
2 Configure theming JSON
Place a JSON file somewhere in your project, for example:
theme/default_config.json
With the following:
{
"title": "My Game",
"tagline": "Play together",
"css": "/assets/css/theme/theme.css",
"logo": "/theme/logo.png",
"banner": "/theme/banner.png"
}
2 Configure the app to use it
Point the runtime configuration at the JSON file:
THEME_CONFIG=theme/default_config.json
Optional: you can provide per-locale theme configs by adding a language suffix. For example, if THEME_CONFIG points to theme/default_config.json and the active locale is 'en', the server will prefer theme/default_config.en.json when present (falling back to the base file).
Architecture
High-level overview of how the platform is structured — from clients down to the database and external services.
System overview
┌─────────────────────────────────────────────────────────────┐
│ CLIENTS │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Godot SDK │ │ JS SDK │ │ Web Browser │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
└─────────┼─────────────────┼─────────────────┼───────────────┘
│ REST + WS │ REST + WS │ HTTP
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ GAME SERVER │
│ │
│ ┌───────────────────── Web Layer ───────────────────────┐ │
│ │ REST API (/api/v1) │ WebSocket Channels │ Admin │ │
│ │ (Controllers + │ (Lobby, User, │ UI │ │
│ │ OpenApiSpex) │ other channels) │ (Live) │ │
│ └──────────┬───────────┴──────────┬───────────┴────┬────┘ │
│ │ │ │ │
│ ┌──────── Auth ──────────────────────────────────────────┐ │
│ │ Guardian (JWT) │ Sessions │ Ueberauth (OAuth) │ │
│ └──────────┬───────┴──────┬─────┴──────────┬─────────────┘ │
│ │ │ │ │
│ ┌───────── Business Layer (Contexts) ────────────────────┐ │
│ │ │ │
│ │ Accounts │ Lobbies │ Parties │ Friends │ │
│ │ Groups │ Leaderboards │ Notifications │ │
│ │ Hooks (server scripting) │ KV Storage │ │
│ │ │ │
│ └──────────┬─────────────────────┬───────────────────────┘ │
│ │ │ │
│ ┌──────── Infrastructure ────────┴───────────────────────┐ │
│ │ PubSub (real-time) │ Cache (Nebulex) │ Scheduler │ │
│ └──────────┬───────────┴──────────┬────────┴─────────────┘ │
└─────────────┼──────────────────────┼────────────────────────┘
│ │
┌─────────────┼──────────────────────┼───────────────────────┐
│ ▼ EXTERNAL ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Database │ │ Redis │ │ OAuth │ │
│ │ SQLite / PG │ │ (optional) │ │ Providers │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Email SMTP │ │ Sentry │ │
│ └──────────────┘ └──────────────┘ │
└────────────────────────────────────────────────────────────┘
Request flow
Client ──► Endpoint ──► Router ──► Pipeline (auth) ──► Controller/LiveView
│
▼
Context module
(business logic)
│
▼
Ecto / Repo
│
▼
Database
Real-time (PubSub topics)
Publishers Topics Subscribers
────────── ────── ───────────
Lobbies module ──────► "lobby:{id}" ──────► LobbyChannel
Lobbies module ──────► "lobbies" ──────► LobbiesChannel, LiveViews
Parties module ──────► "party:{id}" ──────► UserChannel
Parties module ──────► "parties" ──────► LiveViews
Friends module ──────► "user:{id}" ──────► UserChannel
Accounts module ──────► "user:{id}" ──────► UserChannel
Groups module ──────► "group:{id}" ──────► LiveViews
Groups module ──────► "groups" ──────► LiveViews
Notifications ──────► "user:{id}" ──────► UserChannel
Chat module ──────► "chat:lobby:{id}" ─► LobbyChannel
Chat module ──────► "chat:group:{id}" ─► GroupChannel
Chat module ──────► "chat:friend:{lo}:{hi}" UserChannel
+ "user:{id}"
Entity relationships (simplified)
users ─────┬──── lobby_id ──────────► lobbies
│ │
├──── party_id ──────────► parties
│ │
├──── friendships ◄─────► friendships
│
├──── group_members ─────► groups
│
├──── leaderboard_records ► leaderboards
│
├──── notifications (send / receive)
│
├──── chat_messages (sender_id ─► messages)
│ chat_type: lobby | group | friend
│ chat_ref_id ─► lobby/group/user
│
├──── chat_read_cursors (unread tracking)
│
└──── users_tokens, oauth_sessions
Umbrella structure
game_server/ ├── apps/ │ ├── game_server_core/ # Domain: contexts, schemas, migrations │ ├── game_server_web/ # Web: controllers, LiveViews, channels, components │ └── game_server_host/ # Host: supervision tree, routing, boot config ├── assets/ # JS, CSS, vendor deps ├── config/ # Env configs (dev, test, prod, runtime) ├── modules/ # Runtime hook scripts (server scripting) ├── clients/ # Godot SDK, JS SDK └── sdk/ # Elixir SDK stubs for hooks
Key technologies
- Framework: Phoenix 1.8 + LiveView
- Language: Elixir 1.19 / Erlang OTP
- Database: SQLite3 (default) / PostgreSQL (optional)
- Real-time: Phoenix Channels + PubSub
- Auth: Guardian (JWT), Ueberauth (OAuth), Sessions
- Cache: Nebulex (L1 local, optional L2 Redis/partitioned)
- Scheduling: Quantum (cron-like)
- API docs: OpenApiSpex (Swagger UI)
- CSS: Tailwind CSS 4
- Monitoring: Sentry + Telemetry
Chat: Message Flow
The following diagram shows the flow when a chat message is sent, edited, or deleted:
Client Server Recipients
────── ────── ──────────
POST /chat/messages ──► 1. Validate access
2. Run before_chat_message hook
3. Insert into DB
4. Invalidate Nebulex cache
5. PubSub broadcast ─────────► WebSocket push
6. Async: after_chat_message "new_chat_message"
hook + send notifications ──► "notification" event
PATCH /chat/messages/:id ► 1. Verify ownership (sender_id)
2. Update content/metadata
3. Invalidate cache
4. PubSub broadcast ────────► "chat_message_updated"
DELETE /chat/messages/:id ► 1. Verify ownership
2. Delete from DB
3. Invalidate cache
4. PubSub broadcast ───────► "chat_message_deleted"
(payload: {id})
Chat: API Endpoints
All chat endpoints require authentication (Bearer token). Base path:
/api/v1
Method Path Description ────── ──── ─────────── POST /chat/messages Send a message GET /chat/messages List messages (paginated) GET /chat/messages/:id Get a single message by ID PATCH /chat/messages/:id Update your own message DELETE /chat/messages/:id Delete your own message POST /chat/read Mark messages as read GET /chat/unread Get unread message count
Chat: Elixir Context Functions
The Chat context module provides functions for server-side chat operations:
# Send a message (validates access, runs hook pipeline, broadcasts, notifies)
GameServer.Chat.send_message(%{user: user}, %{
"chat_type" => "lobby",
"chat_ref_id" => lobby_id,
"content" => "Hello!",
"metadata" => %{"color" => "blue"}
})
# List messages (paginated, cached with 60s TTL)
GameServer.Chat.list_messages("lobby", lobby_id, page: 1, page_size: 50)
# List friend messages (bidirectional)
GameServer.Chat.list_friend_messages(user_a_id, user_b_id, page: 1)
# Update/delete your own message (ownership enforced)
GameServer.Chat.update_message(user_id, message_id, %{"content" => "edited"})
GameServer.Chat.delete_own_message(user_id, message_id)
# Mark messages as read (upsert cursor) / count unread
GameServer.Chat.mark_read(user_id, "lobby", lobby_id, last_message_id)
GameServer.Chat.count_unread(user_id, "lobby", lobby_id)
Chat: Hook Pipeline (Moderation)
Chat messages pass through the hook pipeline before being persisted. Use the before_chat_message hook to filter, transform, or reject messages.
# In your hooks module (implements GameServer.Hooks behaviour)
@impl true
def before_chat_message(user, attrs) do
content = attrs["content"] || ""
cond do
String.length(content) > 500 -> {:error, :message_too_long}
contains_profanity?(content) -> {:ok, Map.put(attrs, "content", censor(content))}
true -> {:ok, attrs}
end
end
@impl true
def after_chat_message(message) do
Logger.info("Chat message #{message.id} sent by #{message.sender_id}")
:ok
end
Chat: Caching
Message listings are cached using Nebulex with version-based invalidation. When a message is sent, edited, or deleted, the cache version for that chat is incremented, automatically invalidating stale cached results. Cache TTL is 60 seconds.
Custom Host / Fork Guide
Umbrella architecture overview
The project uses an umbrella split to keep the domain logic, web UI, and the runnable application separate. This allows you to create custom hosts — standalone OTP applications that start the server with your own routes, pages, and supervision tree additions.
apps/
game_server_core/ # Domain logic (schemas, contexts, migrations)
# No web dependency. Reusable across hosts.
game_server_web/ # UI library (controllers, LiveViews, components,
# channels, endpoint). Does NOT start itself.
game_server_host/ # Default runnable host. Starts the supervision tree.
# Extension point for forks.
How the router dispatch works
The endpoint doesn't hardcode a router. Instead, it reads the router module from application config at runtime:
# GameServerWeb.Endpoint reads this at request time:
router = Application.get_env(:game_server_web, :router, GameServerWeb.Router)
# The host app sets it at boot:
Application.put_env(:game_server_web, :router, MyHost.Router, persistent: true)
This means the host controls routing without game_server_web knowing about it. You can add, remove, or replace any routes.
Creating a custom host step-by-step
1. Create the app directory
apps/my_word_game/
lib/my_word_game/
application.ex # OTP application — starts supervision tree
router.ex # Your custom routes
mix.exs # Depends on game_server_core + game_server_web
2. Define mix.exs
defmodule MyWordGame.MixProject do
use Mix.Project
def project do
[
app: :my_word_game,
version: "1.0.0",
elixir: "~> 1.19",
elixirc_paths: ["lib"],
start_permanent: Mix.env() == :prod,
deps: deps(),
build_path: "../../_build",
config_path: "../../config/config.exs",
deps_path: "../../deps",
lockfile: "../../mix.lock"
]
end
def application do
[mod: {MyWordGame.Application, []}, extra_applications: [:logger, :runtime_tools]]
end
defp deps do
[
{:game_server_core, in_umbrella: true},
{:game_server_web, in_umbrella: true},
{:phoenix, "~> 1.8"},
{:phoenix_live_view, "~> 1.1"}
]
end
end
3. Define the router
Add your custom routes at the top, then forward everything else to the upstream router. Routes are matched top-down, so your custom routes take priority.
defmodule MyWordGame.Router do
use Phoenix.Router
import Phoenix.LiveView.Router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {GameServerWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug GameServerWeb.UserAuth, :fetch_current_scope_for_user
end
# Your custom game pages (auth required)
scope "/game", MyWordGame do
pipe_through [:browser, GameServerWeb.UserAuth, :require_authenticated_user]
live_session :game,
on_mount: [{GameServerWeb.UserAuth, :require_authenticated}] do
live "/play", GameLive, :play
live "/lobby/:id", LobbyGameLive, :show
end
end
# Delegate all standard routes to the upstream router
forward "/", GameServerWeb.Router
end
4. Define the application
Copy the supervision tree from GameServerHost.Application and add your own children. The key line is the put_env that sets your router.
defmodule MyWordGame.Application do
use Application
@impl true
def start(_type, _args) do
# Tell the endpoint to use YOUR router
Application.put_env(:game_server_web, :router, MyWordGame.Router, persistent: true)
# Initialize ETS for schedule callbacks
GameServer.Schedule.start_link()
children = [
# Standard infrastructure (same as GameServerHost)
GameServerWeb.Telemetry,
GameServer.Repo,
{GameServer.Cache, []},
{Task.Supervisor, name: GameServer.TaskSupervisor},
{DNSCluster, query: Application.get_env(:game_server_web, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: GameServer.PubSub},
GameServerWeb.AdminLogBuffer,
GameServer.Hooks.PluginManager,
GameServerWeb.Endpoint,
GameServer.Notifications.FriendNotifier,
GameServer.Schedule.Scheduler,
# Your custom game-specific children
# MyWordGame.GameSupervisor,
# {Registry, keys: :unique, name: MyWordGame.GameRegistry}
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyWordGame.Supervisor)
end
end
5. Start your host
# Dev
elixir --sname my_game -S mix phx.server --app my_word_game
# Or create a start.sh script:
#!/bin/sh
exec elixir --sname my_game -S mix phx.server --app my_word_game
What you get vs what you customize
| From game_server_web (out of the box) | You customize in your host |
|---|---|
| Auth (login, register, OAuth, JWT) | Game-specific LiveViews and pages |
| User settings, friends, groups, chat | Custom routes (add, remove, replace) |
| Admin dashboard | Extra supervision tree children |
| All API endpoints (REST + OpenAPI spec) | Custom GenServers or game processes |
| Chat, notifications, leaderboards | Custom WebSocket channels |
| WebSocket channels + PubSub | Boot-time configuration |
Serializing concurrent operations with GameServer.Lock
When multiple players trigger the same RPC concurrently (e.g. guessing a word, claiming a reward), you need to serialize access to shared state. GameServer.Lock wraps your code in a database advisory lock so only one process at a time executes for a given (namespace, resource_id) pair.
# In your hooks module RPC:
def rpc("word_guessed", [word], caller) do
lobby_id = caller.lobby_id
GameServer.Lock.serialize("word_guessed", lobby_id, fn ->
# Only one process per lobby reaches here at a time
{:ok, entry} = GameServer.KV.get("game_state", lobby_id: lobby_id)
new_val = Map.update(entry.value, "guessed", [word], &[word | &1])
GameServer.KV.put("game_state", new_val, %{}, lobby_id: lobby_id)
end)
end
Multi-node safe: Because the lock lives in PostgreSQL (pg_advisory_xact_lock), it works correctly across multiple application nodes sharing the same database. On SQLite (dev/test), all writes are already serialized.
The namespace can be a predefined atom (
:lobby,
:group,
:party
) or any arbitrary string. Strings are hashed to stable integers to avoid collisions.
Tips & gotchas
- Your custom routes are matched before upstream routes because they appear first in the router. If you want to replace an upstream page (e.g. the home page), just define the same path.
-
Use
forward \"/\", GameServerWeb.Routeras the last line to delegate all unmatched routes. Remove it if you want to strip the upstream UI entirely. - The upstream UI uses route helpers pointing at GameServerWeb.Router. If you remove routes that the UI links to, you'll get dead links — adjust the UI templates or provide replacement routes.
-
To add your LiveViews alongside the upstream nav, you can customize
layouts.exin your host or override it via a template in your host's priv/static. - All SDK context modules (Accounts, Lobbies, KV, Lock, etc.) work the same in any host — they operate on the shared database.
Authentication API Docs
The platform supports multiple authentication methods. All API authentication uses JWT tokens (access + refresh). Browser sessions use cookie-based session tokens.
Supported methods
- Email / Password — traditional registration with confirmation emails
- Magic link — passwordless login via email link
- Device token — anonymous / guest authentication via unique device IDs
- OAuth — Discord, Google, Apple, Facebook, Steam
JWT token flow (Email / Password / Device)
1. LOGIN
Client ──► POST /api/v1/login ──► Verify credentials
(email + password) │
▼
◄── { access_token, refresh_token } ◄─ Guardian signs JWT
2. AUTHENTICATED REQUEST
Client ──► GET /api/v1/me ──► Guardian verifies token
Authorization: Bearer {token} │
▼
◄── { user data } ◄── Load user from claims
3. TOKEN REFRESH
Client ──► POST /api/v1/refresh ──► Guardian exchanges token
{ refresh_token } │
▼
◄── { access_token, refresh_token } ◄─ New token pair
Access tokens are short-lived (15 min). Refresh tokens last 30 days. Both are stateless JWTs — no database lookup on each request.
OAuth — browser redirect (polling)
For game clients that can't handle OAuth natively. The client opens a browser, then polls for the result.
Client ──► GET /api/v1/auth/{provider}
◄── { session_id, auth_url }
Client ──► Opens auth_url in browser
Browser ──► OAuth Provider ──► User authenticates
Provider ──► Callback to server
Server stores result in DB
Client ──► GET /api/v1/auth/session/{session_id} (poll)
◄── { status: "pending" } (repeat)
◄── { status: "completed", access_token, refresh_token }
OAuth — direct code exchange
For clients that handle OAuth natively (mobile SDKs, Steam auth tickets). No browser or polling needed.
Client ──► Initiates OAuth via native SDK
Provider ──► Returns authorization code to client
Client ──► POST /api/v1/auth/{provider}/callback { code: "..." }
◄── { access_token, refresh_token, user }
Provider linking
Users can link multiple OAuth providers to a single account and unlink them later. The user table stores provider IDs as nullable fields (discord_id, google_id, apple_id, facebook_id, steam_id, device_id).
Real-time Updates
Real-time features use Phoenix PubSub to broadcast events. Domain modules publish to named topics; WebSocket channels and LiveViews subscribe to receive instant updates.
WebSocket channels
Clients connect via WebSocket and join channels to receive real-time events. Six channel types are available:
-
UserChannel
(
user:{user_id}) — personal channel: friend online/offline, notifications, user profile updates -
LobbyChannel
(
lobby:{lobby_id}) — per-lobby: member join/leave/kick, lobby settings, host changes -
LobbiesChannel
(
lobbies) — global lobby list: lobby created/updated/deleted/membership changed -
GroupChannel
(
group:{group_id}) — per-group: member join/leave/kick, promote/demote, join request decisions -
GroupsChannel
(
groups) — global group list: group created/updated/deleted (excludes hidden) -
PartyChannel
(
party:{party_id}) — per-party: member join/leave, party settings, disbanded
PubSub topic map
Channel Topic Client events (push)
─────── ───── ────────────────────
UserChannel "user:{id}" updated (profile changes)
notification (new notification)
friend_online, friend_offline
LobbyChannel "lobby:{id}" user_joined, user_left,
user_kicked, updated,
host_changed
LobbiesChannel "lobbies" lobby_created, lobby_updated,
lobby_deleted,
lobby_membership_changed
GroupChannel "group:{id}" member_joined, member_left,
member_kicked, member_promoted,
member_demoted, updated,
join_request_approved,
join_request_rejected
GroupsChannel "groups" group_created, group_updated,
group_deleted
PartyChannel "party:{id}" member_joined, member_left,
updated, disbanded
Chat-specific topics (broadcast via existing channels):
─────────────────────────────────────────────────────────
LobbyChannel "chat:lobby:{id}" new_chat_message,
chat_message_updated,
chat_message_deleted
GroupChannel "chat:group:{id}" new_chat_message,
chat_message_updated,
chat_message_deleted
UserChannel "chat:friend:{lo}:{hi}" new_chat_message,
+ "user:{recipient_id}" chat_message_updated,
chat_message_deleted
How it works
Domain module (e.g. Lobbies)
│
├── performs DB operation
│
└── Phoenix.PubSub.broadcast("lobby:42", {:lobby_user_joined, user})
│
▼
┌──────────────────┐
│ Phoenix PubSub │ (in-memory, distributed in cluster)
└──────┬───────────┘
│
┌────┴────┐
▼ ▼
LobbyChannel Admin LiveView
(sends JSON (updates UI
to client) via stream)
Notes
- All broadcasts are fire-and-forget — subscribers don't acknowledge receipt
- In a cluster, PubSub automatically distributes messages across nodes via pg2/Phoenix.PubSub.PG2
- WebSocket connections are authenticated via JWT token on join
- Friend DMs are broadcast to both the sorted-pair topic and each user's personal topic, so the recipient receives the message even without subscribing to the friend chat topic directly.
- Clients that cache messages locally can update in-place: on "chat_message_updated", match by message ID and replace content/metadata. On "chat_message_deleted", remove the message by ID.
Chat Notifications
When a new chat message is sent, a notification is automatically created for recipients:
- Friend DM: One consolidated notification per user: "New messages from friends"
- Group message: One notification per group: "New messages from {group_name}". Sent to all group members except sender.
- Lobby message: One notification per lobby: "New messages from {lobby_name}". Sent to all lobby members except sender.
Notifications use upsert semantics — multiple messages update the existing notification with the latest content rather than creating duplicates.
Data Schema API Docs
This section describes the main database table shapes used by the platform - starting with the
users
table and the important fields you may rely on.
Users table
users (
id : integer (primary key)
email : string (unique, nullable for provider-only accounts)
hashed_password: string (bcrypt hash, nullable for OAuth-only accounts)
authenticated_at: utc_datetime (last sudo login)
discord_id : string (nullable)
google_id : string (nullable)
facebook_id : string (nullable)
steam_id : string (nullable)
apple_id : string (nullable)
device_id : string (nullable)
profile_url : string (avatar/profile image URL)
display_name : string (human-friendly display name)
is_admin : boolean
metadata : map (JSON/Map for arbitrary user metadata)
lobby_id : integer (foreign key to lobbies, nullable)
party_id : integer (foreign key to parties, nullable)
confirmed_at : utc_datetime
inserted_at : utc_datetime
updated_at : utc_datetime
)
Friends
friendships (
id : integer (primary key)
requester_id: integer (user id of who made the request)
target_id : integer (user id of the target)
status : string ("pending" | "accepted" | "rejected" | "blocked")
inserted_at : utc_datetime
updated_at : utc_datetime
)
Lobbies
lobbies (
id : integer (primary key)
title : string (display title)
host_id : integer (user id of host, nullable for hostless)
hostless : boolean (server-managed hostless lobbies)
max_users : integer (maximum number of members)
is_hidden : boolean (not returned by public lists)
is_locked : boolean (fully locked - prevents joins)
password_hash: string (bcrypt hash, optional: requires password to join)
metadata : jsonb/map (searchable metadata)
inserted_at : utc_datetime
updated_at : utc_datetime
)
Parties
parties (
id : integer (primary key)
leader_id : integer (user id of the party leader/creator)
max_size : integer (maximum number of members, default: 4)
code : string (unique 6-character join code, auto-generated)
metadata : jsonb/map (arbitrary party metadata)
inserted_at : utc_datetime
updated_at : utc_datetime
)
Leaderboards
leaderboards (
id : integer (primary key)
slug : string (unique identifier, e.g. "weekly_score_2024_w48")
title : string (display name)
description : text (optional)
sort_order : enum (asc, desc) - default: desc
operator : enum (set, best, incr, decr) - default: best
starts_at : utc_datetime (optional)
ends_at : utc_datetime (optional, null = active)
metadata : jsonb (optional)
inserted_at : utc_datetime
updated_at : utc_datetime
)
leaderboard_records (
id : bigint (primary key)
leaderboard_id : integer (FK to leaderboards)
user_id : bigint (FK to users)
score : bigint
metadata : jsonb (optional)
inserted_at : utc_datetime
updated_at : utc_datetime
)
Notifications
notifications (
id : integer (primary key)
sender_id : integer (FK to users)
recipient_id : integer (FK to users)
title : string (notification type/category)
content : string (message body)
metadata : jsonb/map (optional, e.g. invite data)
inserted_at : utc_datetime
updated_at : utc_datetime
)
Groups
groups (
id : integer (primary key)
title : string (display name, unique)
description : string (optional)
type : string ("public" | "private" | "hidden")
max_members : integer (default: 100)
metadata : jsonb/map (optional)
creator_id : integer (FK to users)
inserted_at : utc_datetime
updated_at : utc_datetime
)
group_members (
id : integer (primary key)
group_id : integer (FK to groups)
user_id : integer (FK to users)
role : string ("admin" | "member")
inserted_at : utc_datetime
updated_at : utc_datetime
)
group_join_requests (
id : integer (primary key)
group_id : integer (FK to groups)
user_id : integer (FK to users)
status : string ("pending" | "accepted" | "rejected")
inserted_at : utc_datetime
updated_at : utc_datetime
)
Notes / behavior
-
Password auth:
Accounts created only via OAuth commonly have no
hashed_password. In that case password-based login does not work (we treat oauth-only accounts as passwordless unless a password is explicitly set by the user). -
Display name:
The
display_nameis a human-friendly name and may be populated from OAuth providers (when available). The app avoids overwriting a user-provided display name when linking providers. -
Profile image:
The
profile_urlis used for avatars and may be populated from provider responses (Google picture, Facebook picture.data.url, Discord CDN). -
Metadata:
The JSON
metadatafield is for arbitrary application data (e.g. display preferences). It's returned by the public API atGET /api/v1/me.
Persisted data and tables
The platform stores several tables worth of data that client integrations may need to be aware of.
Users
The users
table is the primary identity store and contains
fields like email, hashed_password,
provider ids (discord_id, google_id, etc.), profile_url, display_name,
metadata
and admin flags and timestamps.
User tokens (users_tokens)
users_tokens (
id : integer (primary key)
token : binary (hashed for email tokens; raw for session tokens)
context : string ("session", "login", "change:..." etc.)
sent_to : string (email address for email/magic link tokens)
authenticated_at: utc_datetime (when this session was created/used)
user_id : integer (foreign key to users)
inserted_at : utc_datetime
)
The app persists session tokens to let users view/expiration/and revoke individual sessions. Email/magic-link tokens are hashed when stored for safety.
OAuth sessions (oauth_sessions)
oauth_sessions (
id : integer (primary key)
session_id: string (unique id used by SDKs to poll/signal status)
provider : string ("discord" | "google" | "facebook" | "apple" | "steam")
status : string ("pending" | "completed" | "failed")
data : jsonb/map (provider response, debug info, or result payload)
inserted_at: utc_datetime
updated_at: utc_datetime
)
OAuth sessions are tracked in the DB to support reliable polling flows from client SDKs and to provide safe, multi-step authorization from popups and mobile apps.
JWT tokens (access + refresh)
The API issues JSON Web Tokens (JWTs) for API authentication. Access tokens are short-lived and refresh tokens are longer-lived (configurable). The server uses Guardian for signing and verification. Refresh tokens are stateless JWTs (no DB lookup) while session tokens and email tokens are persisted where needed.
Chat messages (chat_messages)
chat_messages (
id : integer (primary key)
content : string (message text, 1-4096 chars)
metadata : map/json (arbitrary JSON: attachments, type, etc.)
sender_id : integer (FK → users.id)
chat_type : string ("lobby" | "group" | "friend")
chat_ref_id : integer (lobby_id, group_id, or friend's user_id)
inserted_at : datetime (created timestamp)
updated_at : datetime (updated timestamp, differs when edited)
)
Chat messages support three types: lobby (requires lobby membership), group (requires group membership), and friend (requires accepted friendship and no blocks). Edit/delete is restricted to the message sender.
Chat read cursors (chat_read_cursors)
chat_read_cursors (
id : integer (primary key)
user_id : integer (FK → users.id)
chat_type : string ("lobby" | "group" | "friend")
chat_ref_id : integer (reference ID)
last_read_message_id: integer (last message the user has read)
inserted_at : datetime (created timestamp)
updated_at : datetime (updated timestamp)
)
Read cursors track which messages a user has already seen in each chat. Used to compute unread counts per chat. Upserted on mark-read operations.
Chat access rules
- Lobby chat: User must currently be in the lobby (user.lobby_id matches)
- Group chat: User must be a member of the group
- Friend chat: Users must have an accepted friendship and neither can have blocked the other
- Edit/Delete: Only the message sender can modify or delete their own messages (returns 403 otherwise)
- Messages have a maximum content length of 4096 characters
- Metadata is optional and stored as a JSON map
Apple Sign In Setup Apple Developer Portal
1 Apple Developer Account
You need an Apple Developer Account ($99/year)
2 Create App ID
Go to Certificates, Identifiers & Profiles
- Click the "+" button to create a new identifier
- Select "App IDs" and click Continue
- Select "App" type and click Continue
- Enter a description (e.g., "Game Server")
- Enter a Bundle ID (e.g., com.yourcompany.gameserver)
- Scroll down and check "Sign in with Apple"
- Click Continue and Register
3 Create Service ID (Client ID)
Back in Certificates, Identifiers & Profiles:
- Click "+" to create new identifier
- Select "Services IDs" and click Continue
- Enter description (e.g., "Game Server Web")
- Enter identifier (e.g., com.yourcompany.gameserver.web) - This is your CLIENT_ID
- Check "Sign in with Apple"
- Click "Configure" next to Sign in with Apple
- Select your App ID as the Primary App ID
-
Add these domains and redirect URLs:
Domain: example.comReturn URL: https://example.com/auth/apple/callback - Click Save, then Continue, then Register
4 Create Private Key
In Certificates, Identifiers & Profiles, go to Keys:
- Click "+" to create a new key
- Enter a name (e.g., "Game Server Sign in with Apple Key")
- Check "Sign in with Apple"
- Click "Configure" next to Sign in with Apple
- Select your App ID as the Primary App ID
- Click Save, then Continue
- Click Register
- Download the .p8 file - you can only download this once!
- Note the Key ID (e.g., ABC123XYZ) shown on the confirmation page
5 Get Your Team ID
Find your Team ID:
- Go to Membership Details
- Your Team ID is listed there (10 characters, e.g., A1B2C3D4E5)
6 Configure Environment Variables
Set these environment variables:
7 Test Apple Sign In
After deploying with the secrets:
- Go to your app's login page
- Click "Sign in with Apple"
- Authorize the application with your Apple ID
- You should be redirected back and logged in
Mobile app links / .well-known
1 Where to put the files
Place them under the web app's static folder so they are served at the web root:
Example files are included in the repo with a .example suffix.
2 Serving rules & notes
- Served at: https://your-domain/.well-known/assetlinks.json and https://your-domain/.well-known/apple-app-site-association
- After adding or updating these files, restart or redeploy so they are included in the release
Steam OpenID Setup Steam Dev Portal
1 Get a Steam Web API Key
Visit the Steam Web API page at https://steamcommunity.com/dev and register your domain to get an API key.
2 Configure Redirect Domain
Steam uses OpenID for sign-in. When registering your domain at
steamcommunity.com/dev
, enter your domain (e.g.,
example.com
for production or
localhost:4000
for development).
3 Configure Environment Variables
Set the following environment variable:
4 Test Steam Login
After configuring the API key:
- Go to your app's login page
- Click "Sign in with Steam"
- Authorize with your Steam account
- You should be redirected back and logged in
Note:
For linking Steam to an existing account, go to
/users/settings
and click "Link Steam".
Discord OAuth Setup Discord Developer Portal
1 Create Discord Application
Go to the Discord Developer Portal
- Click "New Application" in the top right
- Give your app a name (e.g., "Game Server")
- Go to the "OAuth2" → "General" tab
2 Configure Redirect URIs
In the OAuth2 General settings, add these redirect URIs:
These are the URLs Discord will redirect users back to after authorization.
3 Get Application Credentials
From the OAuth2 General tab, copy these values:
4 Configure Application Secrets
Set these environment variables:
5 Test Discord Login
After deploying with the secrets:
- Go to your app's login page
- Click "Sign in with Discord"
- Authorize the application on Discord
- You should be redirected back and logged in
Google OAuth Setup Google Cloud Console
1 Create Google Cloud Project
Go to the Google Cloud Console
- Click "Select a project" at the top
- Click "New Project"
- Enter a project name (e.g., "Game Server")
- Click "Create"
2 Enable People API
In your Google Cloud project:
- Go to "APIs & Services" → "Library"
- Search for "Google People API"
- Click on it and click "Enable"
3 Configure OAuth Consent Screen
Go to "APIs & Services" → "OAuth consent screen":
- Select "External" user type
- Click "Create"
- Fill in app name (e.g., "Game Server")
- Add your email as user support email
- Add authorized domains (e.g., example.com)
- Add developer contact email
- Click "Save and Continue"
- Add scopes: email, profile
- Click "Save and Continue"
- Add test users if needed (optional for development)
4 Create OAuth Credentials
Go to "APIs & Services" → "Credentials":
- Click "Create Credentials" → "OAuth client ID"
- Select "Web application"
- Enter a name (e.g., "Game Server Web")
-
Add authorized redirect URIs:
Development: http://localhost:4000/auth/google/callbackProduction: https://example.com/auth/google/callback - Click "Create"
- Copy the Client ID and Client Secret
5 Configure Environment Variables
Set these environment variables:
6 Test Google Login
After deploying with the secrets:
- Go to your app's login page
- Click "Sign in with Google"
- Choose your Google account
- You should be redirected back and logged in
Facebook OAuth Setup Facebook Developers Portal
1 Create Facebook App
Go to the Facebook Developers Portal
- Click "My Apps" in the top right
- Click "Create App"
- Select the use case that fits your needs (often "Other" or "Authenticate and request data from users with Facebook Login")
- Click "Next"
- Select app type (usually "Business" for most web apps, or "None" if available)
- Click "Next"
- Enter app name (e.g., "Game Server")
- Enter contact email
- Click "Create App"
2 Add Facebook Login Product
In your Facebook App dashboard:
- Find "Facebook Login" in the product list
- Click "Set Up"
- Select "Web" as the platform
- Enter your site URL (e.g., https://example.com)
- Click "Save" and continue
3 Configure OAuth Redirect URIs
Go to "Facebook Login" → "Settings":
-
Add these Valid OAuth Redirect URIs:
Development: http://localhost:4000/auth/facebook/callbackProduction: https://example.com/auth/facebook/callback - Click "Save Changes"
4 Get App Credentials
Go to "Settings" → "Basic":
- Copy the "App ID" (this is your Client ID)
- Click "Show" next to "App Secret" and copy it (this is your Client Secret)
5 Make App Public (Production)
For production use, switch to live mode:
- Complete all required fields in "Settings" → "Basic"
- Add a Privacy Policy URL
- Add a Terms of Service URL (optional)
- Select a category for your app
- Toggle the switch at the top from "Development" to "Live"
6 Configure Environment Variables
Set these environment variables:
7 Test Facebook Login
After deploying with the secrets:
- Go to your app's login page
- Click "Sign in with Facebook"
- Authorize the application with your Facebook account
- You should be redirected back and logged in
Email Setup Email Implementation Docs
Choose an Email Provider
Recommended providers:
Configure Email Secrets
Set these environment variables based on your provider:
Important — From address & domain verification
Many email providers require that the "From" address or sending domain be verified in your SMTP provider dashboard before they'll accept or relay mail (you may see errors like "450 domain not verified"). Configure
SMTP_FROM_NAME
and
SMTP_FROM_EMAIL
so that your messages use a verified sender and avoid delivery rejections.
If you're not sure what to use, set
SMTP_FROM_EMAIL
to an address in a domain you control (eg.
no-reply@yourdomain.com
) and verify that domain with your provider.
Tip: you can review and test the current runtime SMTP settings in the admin Admin • Configuration page.
For other providers, adjust the SMTP settings accordingly. The app will automatically detect when email is configured.
Sentry Setup Sentry Dashboard
1 Create Sentry Project
Go to the Sentry Dashboard
- Sign up or log in to Sentry
- Create a new project
- Select "Phoenix" or "Elixir" as the platform
- Name your project (e.g., "Game Server")
2 Get Your DSN
After creating the project, copy the DSN from the settings:
- Go to Project Settings → Client Keys (DSN)
- Copy the DSN value
3 Set Environment Variable
Set the SENTRY_DSN environment variable:
4 Deploy and Test
After deploying with the DSN:
- Deploy your application
- Check the admin config page - Sentry should show as "Configured"
-
Test error reporting by running:
mix sentry.send_test_event - Check your Sentry dashboard for the test event
Cache Setup View Effective Config
Defaults
By default, production runs a single-level local cache (fastest for a single instance).
Tip:
CACHE_L2
only matters when
CACHE_MODE=multi.
Single Instance (recommended default)
Use a single local cache level:
Multiple Instances (near-cache)
Enable a two-level cache: L1 local + L2 shared/sharded.
Redis is shared across nodes. Partitioned requires Erlang clustering between nodes.
Partitioned Cache Setup (Erlang cluster for partitioned L2)
If you use
CACHE_L2=partitioned, nodes must be able to connect to each other via Erlang distribution.
Notes:
All nodes must share the same
RELEASE_COOKIE, and each node must have a unique
RELEASE_NODE. If you use
CACHE_L2=redis, you typically do not need Erlang clustering.
Scaling Verify Runtime
1 instance vs multiple instances
Single instance is the simplest and the default. When you scale out to multiple instances, you typically need:
- A shared database (PostgreSQL recommended)
- A shared cache (Redis recommended since L2 doesn't require Erlang distribution)
Docker Compose (local / self-host)
Docker Compose is a simple way to run the app with PostgreSQL and Redis.
If you want to use
CACHE_L2=partitioned
under Compose, you also need to configure Erlang distribution + node discovery for your app containers.
PostgreSQL Setup Download PostgreSQL
Database URL Configuration
Set the DATABASE_URL environment variable:
The app will automatically detect PostgreSQL when DATABASE_URL is set or when POSTGRES_HOST and POSTGRES_USER environment variables are configured.
Individual Environment Variables (Alternative)
You can also set individual database connection variables:
Deployment Considerations
Popular PostgreSQL hosting options: