Setup & Guides
Platform setup, OAuth providers, email, error monitoring, and server hooks
Deployment
Deploy your own game server instance using the starter repository. The quickest path is Docker Compose — clone, configure, and run.
1. Clone the starter repository
The starter repo contains a pre-configured Docker Compose setup with the game server, PostgreSQL, and optional Redis for caching.
# Clone the starter repository
git clone https://github.com/appsinacup/gamend_starter.git
cd gamend_starter
2. Configure environment variables
Copy the example environment file and edit it to set your secrets and configuration.
# Create .env from example
cp .env.example .env
Key variables to set:
| Variable | Description |
|---|---|
| SECRET_KEY_BASE | 64-byte hex secret for session signing. Generate with: mix phx.gen.secret |
| DATABASE_URL | PostgreSQL connection string (pre-configured for the Docker Compose DB) |
| PHX_HOST | Your public hostname (e.g. play.example.com) |
| GUARDIAN_SECRET_KEY | Secret for signing JWT API tokens |
See the .env.example file for the full list of available environment variables including OAuth providers, email, rate limiting, and more.
3. Start the server
Start everything with Docker Compose:
docker compose up -d
The server will be available at http://localhost:4000 by default. Database migrations run automatically on startup.
4. Verify the deployment
Check that the server is running:
# Health check
curl http://localhost:4000/api/v1/health
# View logs
docker compose logs -f game_server
Production recommendations
- Use a reverse proxy (nginx, Caddy) with TLS termination
- Set PHX_HOST to your actual domain
- Configure OAuth providers for social login (see provider guides above)
- Enable email delivery via SMTP for password resets and notifications
- Set up Redis for distributed caching when running multiple instances (see Scaling guide)
- Review rate limiting settings for your expected traffic
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 │ │
│ │ Achievements │ Chat │ 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}"
Achievements ──────► "user:{id}" ──────► UserChannel, LiveViews
Achievements ──────► "achievements" ─────► Admin LiveViews
Entity relationships (simplified)
users ─────┬──── lobby_id ──────────► lobbies
│ │
├──── party_id ──────────► parties
│ └── party_invites table
│ (sender_id → recipient_id,
│ party_id, status)
│
├──── friendships ◄─────► friendships
│
├──── group_members ─────► groups
│ ├── group_join_requests
│ └── group_invites table
│ (sender_id → recipient_id,
│ group_id, status)
│
├──── leaderboard_records ► leaderboards
│
├──── notifications (send / receive)
│ Invites are stored in dedicated tables
│ (group_invites, party_invites) and are
│ independent of notifications.
│
├──── chat_messages (sender_id ─► messages)
│ chat_type: lobby | group | friend
│ chat_ref_id ─► lobby/group/user
│
├──── chat_read_cursors (unread tracking)
│
├──── user_achievements ──► achievements
│ (progress, unlocked_at)
│
└──── 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.
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)
metadata : jsonb/map (arbitrary party metadata)
inserted_at : utc_datetime
updated_at : utc_datetime
)
Party membership is tracked via the
party_id
field on the users table. Party invites are stored in the dedicated
party_invites
table — independent from notifications. An informational notification is also sent when an invite is created, but deleting the notification does not affect the invite.
Party Invites
party_invites (
id : integer (primary key)
party_id : integer (FK to parties)
sender_id : integer (FK to users, the inviting leader)
recipient_id : integer (FK to users, the invited user)
status : string ("pending" | "accepted" | "declined" | "cancelled")
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
)
Achievements
achievements (
id : integer (primary key)
slug : string (unique identifier, e.g. "first_lobby")
title : string (display name)
description : string (condition to unlock)
icon_url : string (optional)
sort_order : integer (display order, default: 0)
hidden : boolean (only shown after unlock, default: false)
progress_target : integer (1 = one-shot, >1 = multi-step)
metadata : jsonb (optional custom data)
inserted_at : utc_datetime
updated_at : utc_datetime
)
user_achievements (
id : integer (primary key)
user_id : integer (FK to users)
achievement_id : integer (FK to achievements)
progress : integer (current progress, default: 0)
unlocked_at : utc_datetime (nil until unlocked)
metadata : jsonb (optional)
inserted_at : utc_datetime
updated_at : utc_datetime
-- unique index on (user_id, achievement_id)
)
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. context data)
inserted_at : utc_datetime
updated_at : utc_datetime
)
Well-known notification titles:
"New Friend Request" — metadata: {}
"New Group Invite" — informational (invite stored in group_invites table)
"New Party Invite" — informational (invite stored in party_invites table)
"Group Notification" — metadata: { group_id, group_name }
"New messages from ..." — metadata: { chat_type, group_id|lobby_id }
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
)
group_invites (
id : integer (primary key)
group_id : integer (FK to groups)
sender_id : integer (FK to users, the inviting admin)
recipient_id : integer (FK to users, the invited user)
status : string ("pending" | "accepted" | "declined" | "cancelled")
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
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).
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)
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 }
// - 'member_updated' => { user_id, display_name, profile_url, metadata, is_online }
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);
}
WebSocket Channels
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, notification,
friend_online, friend_offline,
new_chat_message,
chat_message_updated,
chat_message_deleted,
achievement_unlocked,
achievement_progress,
group_invite_accepted,
group_invite_cancelled,
group_join_approved,
group_join_rejected,
party_invite_accepted,
party_invite_declined,
party_invite_cancelled,
incoming_request, outgoing_request,
friend_accepted, friend_rejected,
request_cancelled, friend_removed,
friend_blocked, friend_unblocked
LobbyChannel "lobby:{id}" updated, user_joined, user_left,
user_kicked, host_changed,
member_online, member_offline,
member_updated,
new_chat_message,
chat_message_updated,
chat_message_deleted
LobbiesChannel "lobbies" lobby_created, lobby_updated,
lobby_deleted,
lobby_membership_changed
GroupChannel "group:{id}" updated, member_joined, member_left,
member_kicked, member_promoted,
member_demoted,
member_online, member_offline,
member_updated,
join_request_approved,
join_request_rejected,
new_chat_message,
chat_message_updated,
chat_message_deleted
GroupsChannel "groups" group_created, group_updated,
group_deleted
PartyChannel "party:{id}" updated, member_joined, member_left,
disbanded,
member_online, member_offline,
member_updated,
new_chat_message,
chat_message_updated,
chat_message_deleted
Event payload reference
Detailed payload shapes for every event pushed from the server to the client, grouped by channel.
UserChannel
user:{user_id}
| Event | When | Payload |
|---|---|---|
| updated | Profile changes, on join | {id, email, display_name, metadata, lobby_id, party_id, is_online, last_seen_at, linked_providers, has_password} |
| notification | New notification / all on join | {id, sender_id, recipient_id, title, content, metadata, inserted_at} |
| friend_online | A friend came online | {user_id, is_online: true} |
| friend_offline | A friend went offline | {user_id, is_online: false} |
| new_chat_message | Friend DM received | {id, content, metadata, sender_id, chat_type, chat_ref_id, inserted_at} |
| chat_message_updated | Friend DM edited | {id, content, metadata, sender_id, chat_type, chat_ref_id, inserted_at} |
| chat_message_deleted | Friend DM deleted | {id} |
| achievement_unlocked | Achievement fully unlocked | {id, user_id, achievement_id, progress, unlocked_at, metadata, inserted_at, updated_at} |
| achievement_progress | Progress incremented toward an achievement | {id, user_id, achievement_id, progress, unlocked_at, metadata, inserted_at, updated_at} |
| group_invite_accepted | Someone accepted your group invite | {group_id} |
| group_invite_cancelled | Group deleted — pending invites cancelled | {group_id, group_name} |
| group_join_approved | Your group join request was approved | {group_id} |
| group_join_rejected | Your group join request was declined | {group_id} |
| party_invite_accepted | Someone accepted your party invite | {party_id, user_id} |
| party_invite_declined | Someone declined your party invite | {party_id, user_id} |
| party_invite_cancelled | Party leader cancelled your invite | {party_id, user_id} |
| incoming_request | Friend request received | {id, requester_id, target_id, status} |
| outgoing_request | Friend request sent (echo) | {id, requester_id, target_id, status} |
| friend_accepted | Friend request accepted | {id, requester_id, target_id, status} |
| friend_rejected | Friend request rejected | {id, requester_id, target_id, status} |
| request_cancelled | Friend request cancelled | {id, requester_id, target_id, status} |
| friend_removed | Friendship removed | {id, requester_id, target_id, status} |
| friend_blocked | User blocked | {id, requester_id, target_id, status} |
| friend_unblocked | User unblocked | {id, requester_id, target_id, status} |
The UserChannel also accepts a "call_hook" push from the client to invoke server-side plugin hooks with a reply.
LobbyChannel
lobby:{lobby_id}
| Event | When | Payload |
|---|---|---|
| updated | Lobby settings changed, on join | {id, title, host_id, hostless, max_users, is_hidden, is_locked, metadata} |
| user_joined | A user joined the lobby | {user_id} |
| user_left | A user left the lobby | {user_id} |
| user_kicked | A user was kicked | {user_id} |
| host_changed | Lobby host changed | {new_host_id} |
| member_online | A lobby member came online | {user_id, display_name, profile_url, metadata, is_online} |
| member_offline | A lobby member went offline | {user_id, display_name, profile_url, metadata, is_online} |
| member_updated | A lobby member was updated | {user_id, display_name, profile_url, metadata, is_online} |
| new_chat_message | New lobby chat message | {id, content, metadata, sender_id, chat_type, chat_ref_id, inserted_at} |
| chat_message_updated | Lobby chat message edited | {id, content, metadata, sender_id, chat_type, chat_ref_id, inserted_at} |
| chat_message_deleted | Lobby chat message deleted | {id} |
LobbiesChannel
lobbies
| Event | When | Payload |
|---|---|---|
| lobby_created | New lobby created | {id, title, host_id, hostless, max_users, is_hidden, is_locked, metadata, is_passworded} |
| lobby_updated | Lobby settings changed | {id, title, host_id, hostless, max_users, is_hidden, is_locked, metadata, is_passworded} |
| lobby_deleted | Lobby deleted | {id} |
| lobby_membership_changed | Member count changed | {id} |
GroupChannel
group:{group_id}
| Event | When | Payload |
|---|---|---|
| updated | Group settings changed, on join | {id, title, description, type, max_members, creator_id, metadata} |
| member_joined | New member joined | {group_id, user_id} |
| member_left | Member left | {group_id, user_id} |
| member_kicked | Member kicked | {group_id, user_id} |
| member_promoted | Member promoted to admin | {group_id, user_id} |
| member_demoted | Admin demoted to member | {group_id, user_id} |
| member_online | A group member came online | {user_id, is_online: true} |
| member_offline | A group member went offline | {user_id, is_online: false} |
| join_request_approved | Join request approved by admin | {group_id, user_id} |
| join_request_rejected | Join request rejected by admin | {group_id, user_id} |
| member_updated | A group member was updated | {user_id, display_name, profile_url, metadata, is_online} |
| new_chat_message | New group chat message | {id, content, metadata, sender_id, chat_type, chat_ref_id, inserted_at} |
| chat_message_updated | Group chat message edited | {id, content, metadata, sender_id, chat_type, chat_ref_id, inserted_at} |
| chat_message_deleted | Group chat message deleted | {id} |
GroupsChannel
groups
| Event | When | Payload |
|---|---|---|
| group_created | New group created (excludes hidden) | {id, title, description, type, max_members, creator_id, metadata} |
| group_updated | Group settings changed (excludes hidden) | {id, title, description, type, max_members, creator_id, metadata} |
| group_deleted | Group deleted | {id} |
PartyChannel
party:{party_id}
| Event | When | Payload |
|---|---|---|
| updated | Party settings changed, on join | {id, leader_id, max_size, code, metadata} |
| member_joined | User joined the party | {user_id} |
| member_left | User left the party | {user_id} |
| disbanded | Party was disbanded | {party_id} |
| member_online | A party member came online | {user_id, display_name, profile_url, metadata, is_online} |
| member_offline | A party member went offline | {user_id, display_name, profile_url, metadata, is_online} |
| member_updated | A party member was updated | {user_id, display_name, profile_url, metadata, is_online} |
| new_chat_message | New party chat message | {id, content, metadata, sender_id, chat_type, chat_ref_id, inserted_at} |
| chat_message_updated | Party chat message edited | {id, content, metadata, sender_id, chat_type, chat_ref_id, inserted_at} |
| chat_message_deleted | Party chat message deleted | {id} |
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.
Achievement Notifications
When a user unlocks an achievement, a notification is automatically created:
- Title: "Achievement Unlocked"
- Content: The achievement's title
-
Metadata:
{type: "achievement_unlocked", achievement_id, achievement_slug}
Progress increments do not generate notifications — only the final unlock does. The notification is a self-notification (sender = recipient) since it is system-generated.
WebRTC DataChannels
WebRTC DataChannel support provides low-latency, optionally unreliable data transport alongside the existing WebSocket. The server acts as a WebRTC peer (not peer-to-peer between clients). Both transports coexist — WebSocket handles signaling, notifications, and chat while WebRTC handles high-frequency game data.
Rust required: ex_sctp compiles a Rust NIF, so a Rust toolchain is required. Local dev needs rustup installed. Docker/CI images must include the Rust toolchain.
Architecture overview
Client Server (ex_webrtc)
│ │
│── WS connect (JWT auth) ──────────>│ existing flow
│── WS join "user:<id>" ───────────>│ existing flow
│ │
│── WS push "webrtc:offer" ────────>│ Server creates PeerConnection
│<── WS push "webrtc:answer" ───────│ Server sends SDP answer
│── WS push "webrtc:ice" ──────────>│ ICE candidate exchange
│<── WS push "webrtc:ice" ──────────│ ICE candidate exchange
│ │
│══ DataChannel "events" ═══════════│ reliable, ordered
│══ DataChannel "state" ════════════│ unreliable, unordered
│ │
│── WS still open ─────────────────>│ notifications, chat, etc.
Key design decisions
- Signaling over existing UserChannel — No new channel needed. SDP/ICE exchange happens via the already-authenticated WebSocket.
- Auth inherited from WebSocket — The PeerConnection is created inside the authenticated channel process. No separate WebRTC auth.
- One PeerConnection per user — Spawned on first "webrtc:offer". Linked to the channel process (auto-terminates when WS disconnects).
- WebSocket stays open — Both transports coexist. Client chooses which to use for game data.
Setup
Dependencies are included in mix.exs:
# In apps/game_server_web/mix.exs {:ex_webrtc, "~> 0.16.0"}, {:ex_sctp, "~> 0.1.2"} # Requires Rust toolchain
Configure ICE servers in config/config.exs:
config :game_server_web, :webrtc,
enabled: true,
ice_servers: [%{urls: "stun:stun.l.google.com:19302"}]
# Optional TURN for restrictive NATs:
# ice_servers: [
# %{urls: "stun:stun.l.google.com:19302"},
# %{urls: "turn:your-server:3478", username: "u", credential: "p"}
# ]
DataChannel strategy
| Channel label | Ordered | Reliable | Use case |
|---|---|---|---|
"events" |
Yes | Yes | Important game events (scores, spawns, deaths) |
"state" |
No | No | High-frequency state sync (positions, rotations) |
Signaling events
| Direction | Event | Payload |
|---|---|---|
| Client → Server | "webrtc:offer" |
{"sdp": "...", "type": "offer"} |
| Server → Client | "webrtc:answer" |
{"sdp": "...", "type": "answer"} |
| Both | "webrtc:ice" |
{"candidate": "...", "sdpMid": "...", "sdpMLineIndex": 0} |
| Client → Server | "webrtc:send" |
{"channel": "events", "data": "..."} |
| Server → Client | "webrtc:data" |
{"channel": "events", "data": "..."} |
| Client → Server | "webrtc:close" |
{} |
| Server → Client | "webrtc:state" |
{"state": "connected|failed|closed"} |
| Server → Client | "webrtc:channel_open" |
{"channel": "events"} |
| Server → Client | "webrtc:channel_closed" |
{} |
JavaScript client
Import GameWebRTC from assets/js/webrtc.js:
import { GameWebRTC } from "./webrtc"
import { _ensureSocket } from "./lobbies"
const socket = _ensureSocket()
const userChannel = socket.channel("user:123", {token: myToken})
userChannel.join()
const webrtc = new GameWebRTC(userChannel, {
dataChannels: [
{ label: "events", ordered: true },
{ label: "state", ordered: false, maxRetransmits: 0 },
],
onData: (label, data) => {
console.log(`Received on ${label}:`, data)
},
onChannelOpen: (label) => console.log(`Channel ${label} open`),
onStateChange: (state) => console.log(`WebRTC state: ${state}`),
})
await webrtc.connect()
webrtc.send("events", JSON.stringify({ type: "move", x: 10, y: 20 }))
webrtc.close()
Godot client
Use GamendWebRTC.gd from the gamend_template:
var realtime := GamendRealtime.new(token)
var user_channel := realtime.add_channel("user:%d" % user_id)
# wait for join...
var webrtc := GamendWebRTC.new(user_channel, {
"enable_logs": true
})
add_child(webrtc)
webrtc.data_received.connect(_on_data)
webrtc.connected.connect(_on_connected)
webrtc.connect_webrtc()
func _on_data(label: String, data: PackedByteArray):
print("Recv on %s: %s" % [label, data.get_string_from_utf8()])
func _on_connected():
webrtc.send_text("events", '{"type":"move","x":10,"y":20}')
Server-side components
- WebRTCPeer — GenServer managing ExWebRTC.PeerConnection per user. Handles SDP negotiation, ICE exchange, and DataChannel lifecycle.
- UserChannel — Extended with WebRTC signaling handlers. Handles webrtc:offer, webrtc:ice, webrtc:send, and webrtc:close events.
Deployment
- Docker: Add Rust toolchain to Dockerfile (ex_sctp compiles a Rust NIF).
- Server needs UDP ports accessible for WebRTC media/data transport.
- Fly.io: ex_webrtc includes ExWebRTC.ICE.FlyIpFilter for correct public IP binding.
- TURN server recommended for clients behind restrictive NATs/firewalls.
Disabling WebRTC at runtime
Set enabled: false in config to reject offers at runtime. The server will continue to work normally with WebSocket only.
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
Achievements Browse Achievements
Achievements let you define goals for players and track their progress. Each achievement has a unique slug, optional point value, and a progress target. Achievements can be unlocked instantly or incrementally.
Key Concepts
-
Slug:
Unique string identifier (e.g.
first_kill,reach_level_10) - Progress Target: Number of increments required to unlock (default 1 = instant unlock)
- Hidden: Hidden achievements are excluded from public listings until unlocked
- Metadata: Store arbitrary JSON data on both achievements and per-user progress
Elixir Context Functions
# Create an achievement (admin)
GameServer.Achievements.create_achievement(%{
slug: "first_win",
title: "First Victory",
description: "Win your first match",
progress_target: 1,
hidden: false,
metadata: %{"category" => "combat"}
})
# Create a multi-step achievement
GameServer.Achievements.create_achievement(%{
slug: "win_50_matches",
title: "Veteran",
description: "Win 50 matches",
progress_target: 50
})
# Instant unlock (sets progress = target, unlocked_at = now)
GameServer.Achievements.unlock_achievement(user_id, "first_win")
# Increment progress (auto-unlocks when progress >= target)
GameServer.Achievements.increment_progress(user_id, "win_50_matches", 1)
# Admin grant/revoke
GameServer.Achievements.grant_achievement(user_id, achievement_id)
GameServer.Achievements.revoke_achievement(user_id, achievement_id)
# Query user achievements
GameServer.Achievements.list_user_achievements(user_id, page: 1, page_size: 25)
GameServer.Achievements.unlock_percentage("first_win")
REST API Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/v1/achievements |
Optional | List all achievements (paginated, excludes hidden) |
GET |
/api/v1/achievements/:slug |
No | Get achievement by slug |
GET |
/api/v1/achievements/me |
Required | List your unlocked achievements |
GET |
/api/v1/achievements/user/:user_id
|
No | List a user's unlocked achievements |
Admin API Endpoints
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/admin/achievements |
List all achievements (includes hidden) |
POST |
/api/v1/admin/achievements |
Create achievement |
PATCH |
/api/v1/admin/achievements/:id |
Update achievement |
DELETE |
/api/v1/admin/achievements/:id |
Delete achievement |
POST |
/api/v1/admin/achievements/grant |
Grant achievement to user |
POST |
/api/v1/admin/achievements/revoke |
Revoke achievement from user |
POST |
/api/v1/admin/achievements/unlock |
Unlock achievement for user (instant) |
POST |
/api/v1/admin/achievements/increment |
Increment achievement progress for user |
Response Format
// GET /api/v1/achievements?page=1&page_size=25
{
"data": [
{
"id": 1,
"slug": "first_win",
"title": "First Victory",
"description": "Win your first match",
"progress_target": 1,
"hidden": false,
"icon_url": null,
"metadata": {"category": "combat"},
"progress": 0,
"unlocked_at": null
}
],
"meta": {
"page": 1,
"page_size": 25,
"count": 1,
"total_count": 1,
"total_pages": 1,
"has_more": false
}
}
// GET /api/v1/achievements/me
{
"data": [
{
"achievement_id": 1,
"slug": "first_win",
"title": "First Victory",
"progress": 1,
"progress_target": 1,
"unlocked_at": "2026-03-16T12:30:00Z"
}
],
"meta": { ... }
}
Hooks & Real-time
-
Hook:
after_achievement_unlocked(user_id, achievement)fires asynchronously on every unlock -
Notification:
An
achievement_unlockednotification is created on unlock. Hidden achievements show "Secret Achievement Unlocked" as content -
PubSub:
achievements:user:<id>for per-user events,achievementsfor global definition changes
Best Practices
-
Use descriptive slugs like
first_win,reach_level_50,collect_100_gems -
Use
increment_progress/3for gradual achievements (e.g. "Win 50 matches") -
Use
unlock_achievement/2for one-time achievements (e.g. "Complete tutorial") -
Mark secret achievements as
hidden: true— they won't appear in public lists until unlocked -
Use the
after_achievement_unlockedhook to reward players (currency, items, etc.) -
Assign
sort_ordervalues to control display ordering in your game UI
Admin Panel & SDK
-
Admin Dashboard:
Manage achievements at
/admin/achievements— full CRUD, grant/revoke, bulk delete, unlock stats -
SDK Stubs:
The
GameServer.Achievementscontext and structs (Achievement,UserAchievement) are available in the SDK package for type-safe development - OpenAPI: All endpoints are documented with OpenApiSpex — auto-generated Godot/JS clients include achievements support
Chat
The chat system supports messaging within lobbies, groups, and between friends (direct messages). Messages can be sent, edited, deleted, and support read cursors for tracking unread counts. The hook pipeline enables moderation and filtering. Notifications are sent automatically for new messages.
Data Schema
Chat messages are stored in the chat_messages table:
chat_messages ┌──────────────┬──────────┬────────────────────────────────────────────┐ │ Column │ Type │ Description │ ├──────────────┼──────────┼────────────────────────────────────────────┤ │ 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_read_cursors ┌─────────────────────┬──────────┬────────────────────────────────────┐ │ Column │ Type │ Description │ ├─────────────────────┼──────────┼────────────────────────────────────┤ │ 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 │ └─────────────────────┴──────────┴────────────────────────────────────┘
Chat Types
- lobby — Messages sent within a lobby. Requires the sender to be a member of the lobby.
- group — Messages sent within a group. Requires the sender to be a member of the group.
- friend — Direct messages between two friends. Requires an accepted friendship and neither user has blocked the other.
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
Sending a Message
POST /api/v1/chat/messages
# Request body { "chat_type": "lobby", # "lobby" | "group" | "friend" "chat_ref_id": "42", # lobby id, group id, or friend's user id "content": "Hello everyone!", "metadata": {"color": "red"} # optional JSON metadata } # Response (201 Created) { "id": 1, "content": "Hello everyone!", "metadata": {"color": "red"}, "chat_type": "lobby", "chat_ref_id": "42", "sender_id": 7, "inserted_at": "2026-01-15T10:30:00Z", "updated_at": "2026-01-15T10:30:00Z" }
Listing Messages
GET /api/v1/chat/messages
— Returns paginated messages for a chat. For friend chats, messages in both directions are returned.
# Query parameters ?chat_type=lobby&chat_ref_id=42&page=1&page_size=25 # Response { "data": [ ... ], "meta": { "page": 1, "page_size": 25, "count": 25, "total_count": 148, "total_pages": 6, "has_more": true } }
Getting a Single Message
GET /api/v1/chat/messages/:id
— Fetch a single message by ID. Useful for refreshing a cached message after receiving an update notification.
Editing a Message
PATCH /api/v1/chat/messages/:id
— Update the content or metadata of a message you sent. Only the sender can edit their own messages. Returns 403 if not the sender.
# Request body { "content": "Updated message text", "metadata": {"edited": true} } # Response (200 OK) — full updated message # The updated_at field will differ from inserted_at for edited messages
Deleting a Message
DELETE /api/v1/chat/messages/:id
— Permanently delete a message you sent. Only the sender can delete their own messages. Returns 403 if not the sender.
Read Cursors & Unread Counts
Track which messages a user has read with read cursors. The server stores the last-read message ID per user per chat.
# Mark as read: POST /api/v1/chat/read { "chat_type": "lobby", "chat_ref_id": "42", "message_id": 150 } # Get unread count: GET /api/v1/chat/unread ?chat_type=lobby&chat_ref_id=42 # Response { "unread_count": 12 }
Architecture & Message Flow
The following diagram shows the flow when a chat message is sent:
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})
Real-time Events
Messages are broadcast in real-time via PubSub. WebSocket channels automatically forward these events to connected clients.
Chat Type PubSub Topic Channel
───────── ──────────── ───────
lobby chat:lobby:{lobby_id} LobbyChannel
group chat:group:{group_id} GroupChannel
friend chat:friend:{lo}:{hi} UserChannel
+ user:{recipient_id}
Events pushed to clients:
─────────────────────────
"new_chat_message" → Full message object (on send)
"chat_message_updated" → Full message object (on edit)
"chat_message_deleted" → { id: message_id } (on delete)
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.
Automatic Notifications
When a new chat message is sent, a notification is automatically created for each recipient:
- Friend DM: Title: "New message from {sender_name}", content: message preview (100 chars)
- Group message: Title: "New message in {group_name}", content: "{sender}: preview". Sent to all group members except sender.
- Lobby message: Title: "New message in {lobby_name}", content: "{sender}: preview". Sent to all lobby members except sender.
Notifications use upsert semantics — multiple messages from the same sender update the existing notification with the latest content rather than creating duplicates.
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) # Get a single message GameServer.Chat.get_message(message_id) # Update your own message (ownership enforced) GameServer.Chat.update_message(user_id, message_id, %{"content" => "edited"}) # Delete your own message (ownership enforced) GameServer.Chat.delete_own_message(user_id, message_id) # Mark messages as read (upsert cursor) GameServer.Chat.mark_read(user_id, "lobby", lobby_id, last_message_id) # Count unread messages GameServer.Chat.count_unread(user_id, "lobby", lobby_id) # Count unread friend DMs GameServer.Chat.count_unread_friend(user_id, friend_id) # Batch unread counts for all friends/groups GameServer.Chat.count_unread_friends_batch(user_id, friend_ids) GameServer.Chat.count_unread_groups_batch(user_id, group_ids)
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 — ideal for profanity filters, rate limiting, or content moderation.
# 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 # After hook fires asynchronously (logging, analytics, etc.) @impl true def after_chat_message(message) do Logger.info("Chat message #{message.id} sent by #{message.sender_id}") :ok end
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.
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
Notifications
The notification system delivers real-time and persistent notifications for social events (friend requests, group invites, party actions), chat messages, and custom payloads. Every system-generated notification includes a metadata.type tag for client-side routing and filtering.
API Endpoints
GET /api/v1/notifications List own notifications (paginated) POST /api/v1/notifications Send a notification to a friend PUT /api/v1/notifications/:id/read Mark a notification as read DELETE /api/v1/notifications Delete notifications by IDs
Notification Schema
{
"id": 1,
"sender_id": 42,
"sender_name": "SomePlayer",
"recipient_id": 7,
"title": "New Group Invite",
"content": "You've been invited to join Cool Guild",
"metadata": { "type": "group_invite", "group_id": 5 },
"inserted_at": "2026-02-22T12:00:00Z"
}
Notification Types (metadata.type)
All system-generated notifications include a type string in metadata for client-side routing. Below is the full list grouped by domain.
Friends
┌──────────────────────┬──────────────────────────────────────────────┐ │ Type │ Description │ ├──────────────────────┼──────────────────────────────────────────────┤ │ friend_request │ New incoming friend request │ │ friend_accepted │ Your friend request was accepted │ │ friend_declined │ Your friend request was declined │ └──────────────────────┴──────────────────────────────────────────────┘
Groups
┌──────────────────────┬──────────────────────────────────────────────┐ │ Type │ Description │ ├──────────────────────┼──────────────────────────────────────────────┤ │ group_invite │ Invited to join a group │ │ group_invite_accepted│ Your group invite was accepted │ │ group_invite_declined│ Your group invite was declined │ │ group_join_request │ Someone requested to join your group (admin) │ │ group_join_approved │ Your group join request was approved │ │ group_join_declined │ Your group join request was declined │ │ group_kicked │ You were removed from a group │ │ group_promoted │ You were promoted to admin │ │ group_demoted │ You were demoted to member │ └──────────────────────┴──────────────────────────────────────────────┘
Parties
┌──────────────────────┬──────────────────────────────────────────────┐ │ Type │ Description │ ├──────────────────────┼──────────────────────────────────────────────┤ │ party_invite │ Invited to join a party │ │ party_invite_accepted│ Your party invite was accepted │ │ party_invite_declined│ Your party invite was declined │ │ party_kicked │ You were removed from a party │ └──────────────────────┴──────────────────────────────────────────────┘
Lobbies
┌──────────────────────┬──────────────────────────────────────────────┐ │ Type │ Description │ ├──────────────────────┼──────────────────────────────────────────────┤ │ lobby_kicked │ You were removed from a lobby │ └──────────────────────┴──────────────────────────────────────────────┘
Chat
Chat notifications include a message_count field in metadata indicating how many unread messages triggered the notification.
┌──────────────────────┬──────────────────────────────────────────────┐ │ Type │ Description │ ├──────────────────────┼──────────────────────────────────────────────┤ │ chat_friend │ New friend DM messages │ │ chat_group │ New group chat messages │ │ chat_lobby │ New lobby chat messages │ │ chat_party │ New party chat messages │ └──────────────────────┴──────────────────────────────────────────────┘
Behaviour Notes
- Notifications upsert on (sender_id, recipient_id, title) — sending the same notification again updates the existing one.
- Cancelling a friend request, group invite, or party invite automatically retracts (deletes) the original notification.
- Notifications are delivered in real-time via PubSub on the "user:<id>" topic and persisted to the database.
- Custom notifications can be sent between friends via POST /api/v1/notifications with any title, content, and metadata.
Real-time (WebSocket)
Connect to the UserChannel to receive notifications in real-time. Notifications are broadcast on the "user:<user_id>" topic.
// JavaScript — join the user channel
const channel = socket.channel("user:" + userId, {});
channel.on("notification", (payload) => {
console.log("New notification:", payload.type, payload);
});
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.
Reacting to achievement unlocks
The after_achievement_unlocked/2 hook fires asynchronously whenever a user unlocks an achievement. Use it to reward players with in-game currency, items, or trigger other game logic:
@impl true
def after_achievement_unlocked(user_id, achievement) do
# Grant coins based on achievement metadata
user = GameServer.Accounts.get_user(user_id)
coins = get_in(user.metadata, ["coins"]) || 0
reward = get_in(achievement.metadata, ["coin_reward"]) || 50
GameServer.Accounts.update_user(user, %{
metadata: Map.put(user.metadata || %{}, "coins", coins + reward)
})
:ok
end
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.
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/my_config.en.json
With the following:
{
"title": "My Game",
"tagline": "Play together",
"css": "/assets/css/theme/theme.css",
"logo": "/theme/logo.png",
"banner": "/theme/banner.png",
"nav_links": [
{ "label": "Discord", "href": "https://discord.gg/example", "external": true, "auth": "any" },
{ "label": "Dashboard", "href": "/dashboard", "auth": "authenticated" },
{ "label": "Admin Tools", "href": "/admin/tools", "auth": "admin" }
]
}
2 Configure the app to use it
Point the runtime configuration at the JSON file:
THEME_CONFIG=theme/my_config.json
Only locale-suffixed config files are loaded. For example, if THEME_CONFIG is set to theme/my_config.json, the server will load theme/my_config.en.json for English, theme/my_config.es.json for Spanish, etc. The base file (without locale suffix) is never loaded directly — it serves only as a naming template. When THEME_CONFIG is not set, no theme is loaded.
3 Add navigation links
The nav_links array in your theme JSON lets you add custom links to the navigation bar. Each link appears in both the desktop and mobile navs.
| Property | Type | Description |
|---|---|---|
label |
string | Text displayed in the nav bar |
href |
string | URL — can be an absolute path (internal) or a full URL (external) |
external |
boolean | When true, opens in a new tab with rel="noopener noreferrer" |
auth |
string |
Visibility level:
|
Example: adding a Discord link visible to everyone and a dashboard link visible only to logged-in users:
"nav_links": [
{ "label": "Discord", "href": "https://discord.gg/example", "external": true, "auth": "any" },
{ "label": "Dashboard", "href": "/dashboard", "auth": "authenticated" }
]
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.
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.com Return 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:
APPLE_WEB_CLIENT_ID="com.yourcompany.gameserver.web"
APPLE_IOS_CLIENT_ID="com.yourcompany.gameserver.ios"
APPLE_TEAM_ID="A1B2C3D4E5"
APPLE_KEY_ID="ABC123XYZ"
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----"
"MIGTAgEAMBMGByq...your key content..."
"-----END PRIVATE KEY-----"
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
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.
# Start deps (Postgres + Redis):
docker compose up
# In your app env:
CACHE_MODE=multi
CACHE_L2=redis
CACHE_REDIS_URL="redis://redis:6379/0"
If you want to use
CACHE_L2=partitioned
under Compose, you also need to configure Erlang distribution + node discovery for your app containers.
Rate Limiting
HTTP rate limiting is enforced per client IP using the Hammer library (ETS backend). It protects against brute-force attacks and API abuse.
Configuration is via environment variables. The server reads the real client IP from CF-Connecting-IP (Cloudflare), Fly-Client-IP, or X-Forwarded-For headers.
# General: 120 req / 60000ms
# Auth (login/register): 10 req / 60000ms
# WebSocket: 60 msg / 10000ms
# WebRTC DC: 300 msg / 10000ms
# Set via RATE_LIMIT_* env vars
RATE_LIMIT_HTTP_GENERAL_LIMIT=120
RATE_LIMIT_HTTP_GENERAL_WINDOW=60000
RATE_LIMIT_HTTP_AUTH_LIMIT=10
RATE_LIMIT_HTTP_AUTH_WINDOW=60000
RATE_LIMIT_WEBSOCKET_LIMIT=60
RATE_LIMIT_WEBSOCKET_WINDOW=10000
RATE_LIMIT_WEBRTC_LIMIT=300
RATE_LIMIT_WEBRTC_WINDOW=10000
When a client exceeds the limit, the server returns HTTP 429 Too Many Requests with a Retry-After header.
WebSocket & WebRTC rate limiting
WebSocket channel messages are rate-limited per user (60 messages per 10 seconds by default). When exceeded, the channel is closed with a "rate_limited" error. WebRTC DataChannel messages have a separate, higher limit (300 messages per 10 seconds). Exceeding it disconnects the WebRTC peer connection. Unrecognized WebSocket events also close the channel immediately to prevent abuse.
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:
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