This is a web application written using the Phoenix web framework.
- Use
mix precommitalias when you are done with all changes and fix any pending issues - Use the already included and available
:req(Req) library for HTTP requests, avoid:httpoison,:tesla, and:httpc. Req is included by default and is the preferred HTTP client for Phoenix apps
This repo keeps gamend_core and gamend_web reusable under apps/, while the runnable host app now lives at the repository root.
apps/gamend_core: domain code, contexts, schema, migrations, shared business logic.apps/gamend_web: reusable web/UI library (controllers, LiveViews, components, templates) and static assets underapps/gamend_web/priv/static.- repository root: runnable host that starts the supervision tree and is the intended extension point for forks (routing, boot-time config, branding, root assets/static/theme/data).
- Dev entrypoint is the root host app:
mix dev.startstarts the repository-rootgamend_hostproject. - The Phoenix endpoint module is still
GamendWeb.Endpointfor compatibility (static paths, existing modules, UI library), but it is started by the host OTP application.
Routing is host-controlled without making gamend_web depend on gamend_host at compile time:
- Host router:
GamendHost.Routerinlib/gamend_host/router.ex. - Endpoint dispatch:
GamendWeb.Endpointuses a small dispatch plug that readsApplication.get_env(:gamend_web, :router, GamendWeb.Router)and calls that module.- This avoids warnings like “
GamendHost.Router.call/2 is undefined” when compilinggamend_webin isolation.
- This avoids warnings like “
- Host boot-time config:
GamendHost.ApplicationsetsApplication.put_env(:gamend_web, :router, GamendHost.Router, persistent: true)before starting the endpoint.
Fork guidance:
-
Add/remove routes by editing
lib/gamend_host/router.ex. -
If you remove upstream routes but the upstream UI still uses route helpers (
~p"...") pointing atGamendWeb.Router, you can end up generating links for routes that the host no longer serves. If you want strict route removal, forks should adjust the UI accordingly (or provide replacement routes that match the UI’s expectations).### Phoenix v1.8 guidelines -
Always begin your LiveView templates with
<Layouts.app flash={@flash} ...>which wraps all inner content -
The
MyAppWeb.Layoutsmodule is aliased in themy_app_web.exfile, so you can use it without needing to alias it again -
Anytime you run into errors with no
current_scopeassign:- You failed to follow the Authenticated Routes guidelines, or you failed to pass
current_scopeto<Layouts.app> - Always fix the
current_scopeerror by moving your routes to the properlive_sessionand ensure you passcurrent_scopeas needed
- You failed to follow the Authenticated Routes guidelines, or you failed to pass
-
Phoenix v1.8 moved the
<.flash_group>component to theLayoutsmodule. You are forbidden from calling<.flash_group>outside of thelayouts.exmodule -
Out of the box,
core_components.eximports an<.icon name="hero-x-mark" class="w-5 h-5"/>component for for hero icons. Always use the<.icon>component for icons, never useHeroiconsmodules or similar -
Always use the imported
<.input>component for form inputs fromcore_components.exwhen available.<.input>is imported and using it will will save steps and prevent errors -
If you override the default input classes (
<.input class="myclass px-2 py-1 rounded-lg">)) class with your own values, no default classes are inherited, so your custom classes must fully style the input
-
Use Tailwind CSS classes and custom CSS rules to create polished, responsive, and visually stunning interfaces.
-
Tailwindcss v4 no longer needs a tailwind.config.js and uses a new import syntax in
app.css:@import "tailwindcss" source(none); @source "../css"; @source "../js"; # NOTE: in this repo (umbrella split), web UI code lives under apps/gamend_web @source "../../apps/gamend_web/lib"; -
Always use and maintain this import syntax in the app.css file for projects generated with
phx.new -
Never use
@applywhen writing raw css -
Always manually write your own tailwind-based components instead of using daisyUI for a unique, world-class design
-
Out of the box only the app.js and app.css bundles are supported
- You cannot reference an external vendor'd script
srcor linkhrefin the layouts - You must import the vendor deps into app.js and app.css to use them
- Never write inline <script>custom js</script> tags within templates
- You cannot reference an external vendor'd script
- Produce world-class UI designs with a focus on usability, aesthetics, and modern design principles
- Implement subtle micro-interactions (e.g., button hover effects, and smooth transitions)
- Ensure clean typography, spacing, and layout balance for a refined, premium look
- Focus on delightful details like hover effects, loading states, and smooth page transitions
This application uses both session-based authentication (for browser flows) and JWT authentication (for API flows).
- Always handle authentication flow at the router level with proper redirects
- Always be mindful of where to place routes.
phx.gen.authcreates multiple router plugs andlive_sessionscopes:- A plug
:fetch_current_scope_for_userthat is included in the default browser pipeline - A plug
:require_authenticated_userthat redirects to the log in page when the user is not authenticated - A
live_session :current_userscope - for routes that need the current user but don't require authentication, similar to:fetch_current_scope_for_user - A
live_session :require_authenticated_userscope - for routes that require authentication, similar to the plug with the same name - In both cases, a
@current_scopeis assigned to the Plug connection and LiveView socket - A plug
redirect_if_user_is_authenticatedthat redirects to a default path in case the user is authenticated - useful for a registration page that should only be shown to unauthenticated users
- A plug
- Always let the user know in which router scopes,
live_session, and pipeline you are placing the route, AND SAY WHY phx.gen.authassigns thecurrent_scopeassign - it does not assign acurrent_userassign- Always pass the assign
current_scopeto context modules as first argument. When performing queries, usecurrent_scope.userto filter the query results - To derive/access
current_userin templates, always use the@current_scope.user, never use@current_userin templates or LiveViews - Never duplicate
live_sessionnames. Alive_session :current_usercan only be defined once in the router, so all routes for thelive_session :current_usermust be grouped in a single block - Anytime you hit
current_scopeerrors or the logged in session isn't displaying the right content, always double check the router and ensure you are using the correct plug andlive_sessionas described below
LiveViews that require login should always be placed inside the existing live_session :require_authenticated_user block:
scope "/", AppWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{GamendWeb.UserAuth, :require_authenticated}] do
# phx.gen.auth generated routes
live "/users/settings", UserLive.Settings, :edit
live "/users/settings/confirm_email/:token", UserLive.Settings, :confirm_email
# our own routes that require logged in user
live "/", MyLiveThatRequiresAuth, :index
end
end
Controller routes must be placed in a scope that sets the :require_authenticated_user plug:
scope "/", AppWeb do
pipe_through [:browser, :require_authenticated_user]
get "/", MyControllerThatRequiresAuth, :index
end
LiveViews that can work with or without authentication, always use the existing :current_user scope, ie:
scope "/", MyAppWeb do
pipe_through [:browser]
live_session :current_user,
on_mount: [{GamendWeb.UserAuth, :mount_current_scope}] do
# our own routes that work with or without authentication
live "/", PublicLive
end
end
Controllers automatically have the current_scope available if they use the :browser pipeline.
API routes use JWT tokens via Guardian for stateless authentication:
-
API login endpoint (
POST /api/v1/login) returns an access token (15 minutes) and a refresh token (30 days);POST /api/v1/refreshexchanges a valid refresh token for a new access token -
OAuth API endpoints also return JWT tokens
-
Protected API routes use the
:api_authpipeline which:- Verifies JWT tokens from
Authorization: Bearer <token>header - Loads the user and assigns
current_scopeto the connection - Returns 401 errors for invalid/missing tokens
- Verifies JWT tokens from
-
Verification loads the user from the database and compares the token's
"tv"claim againstusers.token_version— bumping the version (password change, email change,Accounts.revoke_all_tokens/1) revokes all previously issued access and refresh tokens -
Always use the
:api_authpipeline for API routes that require authentication:scope "/api/v1", GamendWeb.Api.V1, as: :api_v1 do pipe_through [:api, :api_auth] get "/me", MeController, :show end -
In API controllers, access the authenticated user via
conn.assigns.current_scope.user(Guardian pipeline sets this) -
Guardian implementation is in
apps/gamend_web/lib/gamend_web/auth/guardian.ex -
Guardian pipeline is in
apps/gamend_web/lib/gamend_web/auth/pipeline.ex
- User updates (both
update_userandupdate_user_display_name) run through thebefore_user_update(user, attrs)hook — return{:ok, attrs}to allow (optionally modified) or{:error, reason}to block. After update,after_user_updated(user)fires asynchronously.
- Lobbies are stored in the
lobbiestable with fields:id,name,title,host_id,hostless,max_users,is_hidden,is_locked,password_hash,metadata,inserted_at,updated_at. - Membership is now stored on the
userstable via a nullablelobby_idfield: a user can be in at most one lobby at a time. - Hidden lobbies are never returned from public list APIs. Hostless lobbies are allowed as a server-managed concept.
- API endpoints live under
/api/v1/lobbiesand require authentication for creating (host becomes owner), joining, leaving, updating, and kicking users. Listing lobbies is public but excludes hidden lobbies.
- Groups have three types:
"public"(anyone can join directly),"private"(users request to join, admins approve),"hidden"(invite-only, never shown in public listings). - Group context:
Gamend.Groups(invites live inGamend.Groups.Invites, join requests inGamend.Groups.JoinRequests; the public API is re-exported fromGamend.Groups) — key functions:list_groups/2,create_group/2,join_group/2(public),request_join/2(private),invite_to_group/3,leave_group/2,kick_member/3,promote_member/3,demote_member/3,approve_request/3,reject_request/3. - Group membership is stored in the
group_memberstable with arolefield ("admin"or"member"). The group creator becomes admin automatically. - Join requests are stored in the
group_join_requeststable withstatus:"pending","approved","rejected". - Group invites are stored in the dedicated
group_invitestable with fields:id,group_id,sender_id,recipient_id,status("pending","accepted","declined","cancelled"),inserted_at,updated_at. An informational notification is also sent, but the invite record is independent — deleting notifications does not affect pending invites. - Inviting a blocked user (or a user who blocked you) returns
{:error, :blocked}. - Group creation runs through the
before_group_create(user, attrs)hook — return{:ok, attrs}to allow or{:error, reason}to block. After creation,after_group_create(group)fires asynchronously. Group joining runs through thebefore_group_join(user, group, opts)hook. Group updates run throughbefore_group_update(group, attrs)— return{:ok, attrs}to allow (optionally modified) or{:error, reason}to block. After update,after_group_updated(group)fires asynchronously. - Group after-hooks (all fire-and-forget via
Gamend.Async.run):after_group_join(user_id, group)fires after any successful join (public, invite accept, request approval).after_group_leave(user_id, group_id)fires after a user leaves.after_group_kick(admin_id, target_id, group_id)fires after a kick.after_group_deleted(group)fires after a group is deleted (including auto-delete when last member leaves). - API endpoints live under
/api/v1/groups. Admin API under/api/v1/admin/groups.
- Parties are ephemeral groups of users (2-10 members by default). They exist only while members are online and are not persisted long-term.
- Party context:
Gamend.Parties— key functions:create_party/1,invite_to_party/2,cancel_party_invite/2,accept_party_invite/2,decline_party_invite/2,list_party_invitations/1,leave_party/2,kick_member/3,promote_leader/3,disband_party/2. - A user can only be in one party at a time. Creating a new party automatically leaves any existing party.
- Users join parties via invite: the party leader sends an invite by user_id (only to friends or users in a shared group); the recipient then accepts or declines.
- Invite mechanism: Invites are stored in the dedicated
party_invitestable with fields:id,party_id,sender_id,recipient_id,status("pending","accepted","declined","cancelled"),inserted_at,updated_at. An informational notification is also sent, but the invite record is independent — deleting notifications does not affect pending invites.invite_to_party/2(leader only, target must be friend or shared group member),accept_party_invite/2(joins the party and marks invite as accepted),decline_party_invite/2(marks invite as declined),cancel_party_invite/2(leader deletes pending invite).list_party_invitations/1returns pending PartyInvite records. - Joining is exclusively invite-based.
- Parties can create/join lobbies as a group:
create_lobby_with_party/2,join_lobby_with_party/2. These check that no party member is already in another lobby. - Party hooks:
before_party_create(user, attrs)— return{:ok, attrs}to allow or{:error, reason}to block.after_party_create(party)fires asynchronously.before_party_update(party, attrs)— return{:ok, attrs}to allow or{:error, reason}to block.after_party_updated(party)fires asynchronously. - Party after-hooks (all fire-and-forget via
Gamend.Async.run):after_party_join(user, party)fires after invite accept.after_party_leave(user, party_id)fires after a user leaves.after_party_kick(target, leader, party)fires after a kick.after_party_disband(party)fires after party is disbanded. - API endpoints live under
/api/v1/parties.
- Friend context:
Gamend.Friends— key functions:send_request/2,accept_request/2,decline_request/2,remove_friend/2,block_user/2,unblock_user/2,blocked?/2,friends?/2. blocked?/2is a public function that checks if either user has blocked the other (bidirectional). It is used by Groups and Chat to prevent inviting/messaging blocked users.friends?/2checks whether two users have an accepted friendship.- Friend requests between blocked users are automatically rejected.
- API endpoints:
/api/v1/friends,/api/v1/friends/block.
- Chat context:
Gamend.Chat— key functions:send_message/2,list_messages/3,list_friend_messages/3,mark_read/4,count_unread/3,count_unread_friend/2,get_message/1,delete_messages/2. - Chat types:
"lobby"(messages within a lobby),"group"(messages within a group),"friend"(DMs between friends). - Messages stored in
chat_messagestable withsender_id,content,metadata,chat_type,chat_ref_id. - Read tracking stored in
chat_read_cursorstable withuser_id,chat_type,chat_ref_id,last_read_message_id. - Access validation: lobby chat requires lobby membership, group chat requires group membership, friend chat requires accepted friendship and no blocking.
- Hooks:
before_chat_message(user, attrs)pipeline hook for filtering/moderation — return{:ok, attrs}to allow,{:error, reason}to block.after_chat_message(message)fires asynchronously. - Caching: uses Nebulex version-based caching for
list_messages(60s TTL). - API endpoints:
GET /api/v1/chat/messages,POST /api/v1/chat/messages,POST /api/v1/chat/read,GET /api/v1/chat/unread.
- Quest context:
Gamend.Quests— key functions:create_quest/1,update_quest/2,delete_quest/1,get_quest/1,get_quest_by_key/1,list_quests/1,report_event/4,claim/3,list_user_quests/2,list_user_completions/2,claimable_count/1,admin_complete/2,admin_reset/2,admin_claim/2. - Three orthogonal dimensions, so any combination works:
reset(never/daily/weekly/monthly/interval+reset_interval_days) drivesperiod_keybucketing — a new period means a freshquest_progressrow, nothing fires at midnight;starts_at/ends_atmake it an event;prerequisite_quest_keymakes it a chain (hidden and frozen until unlocked).categoryis a free-form UI label with no engine behavior — achievements arereset: "never", category: "achievement". - Definitions live in the
queststable (keyunique,reset,reset_interval_days,category,objectivesjsonb list of{event, target, params},rewardsjsonb list of{type, code, amount},auto_claim,hidden,active); per-user state inquest_progress(user_id,quest_key,period_key,objective_progressmap of objective index → count,statusactive/completed/claimed, unique on(user_id, quest_key, period_key)). - Progress is server-authoritative: there is no public endpoint to advance a quest. Core wires common events (
login,chat_message,score_submitted,lobby_joined,match_won); games callQuests.report_event(user_id, event, amount, meta)from hooks for custom events. Objectiveparamsmust all match the event meta. - Rewards pay exactly once: claiming is an atomic
completed → claimedtransition, grants go through Economy/Inventory with per-entry idempotency keys ("quest:<progress_id>:<index>"), andrecover_pending_rewards/1heals crashed grants.auto_claimquests pay on completion. - Hooks:
before_quest_claim(user_id, quest, progress)(veto-only pipeline, skipped for auto-claim),after_quest_completed(progress),after_quest_claimed(progress)— all async viaGamend.Async.run. On completion aquest_completednotification is created (quests categorised "achievement" get "Achievement unlocked" wording). - Realtime user-channel events:
quest_progress(user topic only),quest_completed,quest_claimed. - Definitions are cached (
Gamend.Cache, version-bumped); completed/claimed periods are remembered in a done-marker cache so post-completion events skip the DB entirely. - API endpoints:
GET /api/v1/me/quests(auth),POST /api/v1/me/quests/:key/claim(auth),GET /api/v1/questsandGET /api/v1/quests/user/:user_id(public catalog/completions, gated byLIST_QUESTS_ENABLED). Admin API under/api/v1/admin/quests(CRUD,GET /progress, POST grant/reset/claim,GET /:key/funnel). Public LiveView at/quests; admin page at/admin/quests.
- Push context:
Gamend.Push— token registry (register_token/2upsert,list_tokens/2,delete_token/2, softdisable_token/1) + server-authoritative delivery (send_to_user/3,send_to_users/3; no public send endpoint). - Delivery routes per token off
push_tokens.provider("fcm" | "apns") through Pigeon dispatchers; unconfigured providers fall back to the zero-configLogprovider. One Oban job per token on thepushqueue (GAMEND_PUSH_QUEUE_CONCURRENCY); large fan-outs expand viaFanoutWorker. - Hooks:
before_push_send/2(per-recipient veto/rewrite pipeline),after_push_sent/3(per-token outcome).Notificationsbridges committed notifications to push for offline users via the cacheduser_has_live_tokens?/1. - API:
POST/GET /api/v1/me/push_tokens,DELETE /api/v1/me/push_tokens/:id; admin under/api/v1/admin/push/*; admin page at/admin/push. Stale tokens prune viaGAMEND_RETENTION_PUSH_TOKENS_DAYS(default 270).
-
Notification context:
Gamend.Notifications— key functions:admin_create_notification/3,create_chat_notification/3,send_notification/2,delete_notification_by/3,delete_notifications/2. -
Schema:
id,sender_id,recipient_id,title,content,metadata(map),read(boolean), timestamps. Upserts on(sender_id, recipient_id, title). -
Friend notifications are created fire-and-forget at the event source in
Gamend.Friends(viaGamend.Async.run), like all other domains — there is no PubSub-subscribing notifier process. -
All notifications carry a
metadata.typestring tag for client-side routing/filtering. The complete list of notification types:Friends:
friend_request— new incoming friend requestfriend_accepted— your friend request was acceptedfriend_rejected— your friend request was rejected
Groups:
group_invite— invited to join a groupgroup_invite_accepted— your group invite was acceptedgroup_invite_declined— your group invite was declinedgroup_join_request— someone requested to join your group (sent to admins)group_join_request_approved— your group join request was approvedgroup_join_request_rejected— your group join request was rejectedgroup_kicked— you were removed from a groupgroup_promoted— you were promoted to admingroup_demoted— you were demoted to member
Parties:
party_invite— invited to join a partyparty_invite_accepted— your party invite was acceptedparty_invite_declined— your party invite was declinedparty_kicked— you were removed from a party
Lobbies:
lobby_kicked— you were removed from a lobby
Achievements:
achievement_unlocked— you unlocked an achievement
Chat (via
create_chat_notification, includesmessage_countin metadata):chat_friend— new friend DM messageschat_group— new group chat messageschat_lobby— new lobby chat messageschat_party— new party chat messages
-
Invite cancellation retracts the original invite notification via
delete_notification_by/3(friends, groups, parties). -
API endpoints:
GET /api/v1/notifications,POST /api/v1/notifications,PUT /api/v1/notifications/:id/read,DELETE /api/v1/notifications.
- PubSub topics follow the pattern
"resource:<id>"for instance topics and"resources"for collection topics:"lobby:<id>"/"lobbies"— lobby events (member join/leave, updates)"group:<id>"/"groups"— group events (member join/leave, requests, updates)"party:<id>"/"parties"— party events (member join/leave, updates)"user:<id>"— user-specific events (friend requests, notifications, friend DM chat, achievement unlock/progress)"achievements"— global achievement events (definition changes, unlocks for admin dashboards)"chat:lobby:<id>"— lobby chat messages"chat:group:<id>"— group chat messages"chat:friend:<low>:<high>"— friend DM chat (sorted user id pair)
- WebSocket channels mirror PubSub topics:
UserChannel,LobbyChannel,LobbiesChannel,GroupChannel,GroupsChannel,PartyChannel. - LiveViews subscribe to PubSub topics directly (not via channels) using context
subscribe_*functions.
- WebRTC provides low-latency DataChannels alongside the existing WebSocket. The server acts as a WebRTC peer (not P2P between clients).
- Required dependency:
ex_webrtcandex_sctpare required deps ingamend_web. WebRTC signaling handlers are always compiled in. - Rust requirement:
ex_sctp(required for DataChannels) compiles a Rust NIF. Local dev needsrustup. Docker/CI need Rust toolchain added. - Signaling over UserChannel: No new channel. The existing authenticated
UserChannelhandles SDP/ICE exchange via events:- Client → Server:
"webrtc:offer","webrtc:ice","webrtc:send","webrtc:close" - Server → Client:
"webrtc:answer","webrtc:ice","webrtc:data","webrtc:channel_open","webrtc:channel_closed","webrtc:state"
- Client → Server:
- Auth inherited from WebSocket: The
PeerConnectionis created inside an authenticated channel process. No separate WebRTC auth. - One PeerConnection per user: Managed by
GamendWeb.WebRTCPeerGenServer, linked to the channel process. Stored in channel assigns as:webrtc_peer. - WebSocket stays open: Both transports coexist. WebSocket handles signaling/notifications/chat. WebRTC handles game-specific data.
- DataChannel strategy: Clients create named channels —
"events"(reliable, ordered) for important game events,"state"(unreliable, unordered) for high-frequency position sync. - Config:
config :gamend_web, :webrtc, enabled: true, ice_servers: [%{urls: "stun:stun.l.google.com:19302"}] - Client helpers:
assets/js/webrtc.js(browser),clients/gamend_template/GamendWebRTC.gd(Godot) - Design document:
docs/webrtc-design.md
- App cache is
Gamend.Cache(Nebulex 3, multilevel: local L1 + optional Redis/partitioned L2). Nebulex 3 returns{:ok, value}tuples — useGamend.Cache.get!/1(raw value,nilon miss) orfetch/1, never bareget/1compared against raw values. - Read caching uses version keys: cache keys embed a
*_cache_version(...)counter read viaget!(...) || 1; invalidation =incrthe counter. Data entries must carry a TTL (typically 60s) — that TTL is the cross-instance staleness bound. - When a stale read would be incorrect (not merely briefly outdated) — cached users gating auth, sessions, tokens, KV values — invalidate with
Gamend.Cache.invalidate/1(delete + PubSub broadcast;Gamend.Cache.Syncevicts the key from other instances' L1) instead ofdelete/1. - Cache user structs only via
Accounts.cache_user/1; neverCache.putthe{:accounts, :user, id}key directly.
- Used for atomic join/leave/create operations on lobbies, groups, and parties.
- Namespace convention: lobby → 1, group → 2, party → 3.
- Implemented via
Gamend.Repo.advisory_lock/2wrapping the operation in a transaction.
-
Pattern: Use opt-in page & page_size query params for list endpoints. This is a simple limit/offset style pagination.
-
Defaults: If not provided, default page = 1 and page_size = 25 unless an endpoint documents a different default.
-
Response shape: list endpoints must return a JSON object containing
data(array) andmeta(object).metaSHOULD include:- page: current page (integer)
- page_size: the page size used (integer)
- count: number of items on the returned page
- total_count: total number of matching items (use domain-level count helpers)
- total_pages: number of pages (computed: ceil(total_count / page_size))
- has_more: boolean (true when page < total_pages — exact, not a heuristic)
-
Domain responsibilities: Add a lightweight count helper for list endpoints (eg. Accounts.count_search_users/1, Friends.count_friends_for_user/1, Lobbies.count_list_lobbies/1). Controllers should use these helpers to populate
total_countwithout pulling all rows. -
UI & LiveViews: When rendering paginated lists in LiveViews or admin UIs, show "page X / Y (N total)" using the
metafields and disable/enable the Prev/Next buttons based on page/total_pages.
Example response:
{
"data": [...],
"meta": {
"page": 1,
"page_size": 25,
"count": 25,
"total_count": 946,
"total_pages": 38,
"has_more": true
}
}
-
Elixir lists do not support index based access via the access syntax
Never do this (invalid):
i = 0 mylist = ["blue", "green"] mylist[i]Instead, always use
Enum.at, pattern matching, orListfor index based list access, ie:i = 0 mylist = ["blue", "green"] Enum.at(mylist, i) -
Elixir variables are immutable, but can be rebound, so for block expressions like
if,case,cond, etc you must bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:# INVALID: we are rebinding inside the `if` and the result never gets assigned if connected?(socket) do socket = assign(socket, :val, val) end # VALID: we rebind the result of the `if` to a new variable socket = if connected?(socket) do assign(socket, :val, val) end -
Never nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
-
Never use map access syntax (
changeset[:field]) on structs as they do not implement the Access behaviour by default. For regular structs, you must access the fields directly, such asmy_struct.fieldor use higher level APIs that are available on the struct if they exist,Ecto.Changeset.get_field/2for changesets -
Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common
Time,Date,DateTime, andCalendarinterfaces by accessing their documentation as necessary. Never install additional dependencies unless asked or for date/time parsing (which you can use thedate_time_parserpackage) -
Don't use
String.to_atom/1on user input (memory leak risk) -
Predicate function names should not start with
is_and should end in a question mark. Names likeis_thingshould be reserved for guards -
Elixir's builtin OTP primitives like
DynamicSupervisorandRegistry, require names in the child spec, such as{DynamicSupervisor, name: MyApp.MyDynamicSup}, then you can useDynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec) -
Use
Task.async_stream(collection, callback, options)for concurrent enumeration with back-pressure. The majority of times you will want to passtimeout: :infinityas option
- Read the docs and options before using tasks (by using
mix help task_name) - To debug test failures, run tests in a specific file with
mix test test/my_test.exsor run all previously failed tests withmix test --failed mix deps.clean --allis almost never needed. Avoid using it unless you have good reason
-
Remember Phoenix router
scopeblocks include an optional alias which is prefixed for all routes within the scope. Always be mindful of this when creating routes within a scope to avoid duplicate module prefixes. -
You never need to create your own
aliasfor route definitions! Thescopeprovides the alias, ie:scope "/admin", AppWeb.Admin do pipe_through :browser live "/users", UserLive, :index endthe UserLive route would point to the
AppWeb.Admin.UserLivemodule -
Phoenix.Viewno longer is needed or included with Phoenix, don't use it
- Always preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the
message.user.email - Remember
import Ecto.Queryand other supporting modules when you writeseeds.exs Ecto.Schemafields always use the:stringtype, even for:text, columns, ie:field :name, :stringEcto.Changeset.validate_number/2DOES NOT SUPPORT the:allow_niloption. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed- You must use
Ecto.Changeset.get_field(changeset, :field)to access changeset fields - Fields which are set programatically, such as
user_id, must not be listed incastcalls or similar for security purposes. Instead they must be explicitly set when creating the struct
-
Phoenix templates always use
~Hor .html.heex files (known as HEEx), never use~E -
Always use the imported
Phoenix.Component.form/1andPhoenix.Component.inputs_for/1function to build forms. Never usePhoenix.HTML.form_fororPhoenix.HTML.inputs_foras they are outdated -
When building forms always use the already imported
Phoenix.Component.to_form/2(assign(socket, form: to_form(...))and<.form for={@form} id="msg-form">), then access those forms in the template via@form[:field] -
Always add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (
<.form for={@form} id="product-form">) -
For "app wide" template imports, you can import/alias into the
my_app_web.ex'shtml_helpersblock, so they will be available to all LiveViews, LiveComponent's, and all modules that douse MyAppWeb, :html(replace "my_app" by the actual app name) -
Elixir supports
if/elsebut **does NOT supportif/else iforif/elsif. Never useelse iforelseifin Elixir, always usecondorcasefor multiple conditionals.Never do this (invalid):
<%= if condition do %> ... <% else if other_condition %> ... <% end %>Instead always do this:
<%= cond do %> <% condition -> %> ... <% condition2 -> %> ... <% true -> %> ... <% end %> -
HEEx require special tag annotation if you want to insert literal curly's like
{or}. If you want to show a textual code snippet on the page in a<pre>or<code>block you must annotate the parent tag withphx-no-curly-interpolation:<code phx-no-curly-interpolation> let obj = {key: "val"} </code>Within
phx-no-curly-interpolationannotated tags, you can use{and}without escaping them, and dynamic Elixir expressions can still be used with<%= ... %>syntax -
HEEx class attrs support lists, but you must always use list
[...]syntax. You can use the class list syntax to conditionally add classes, always do this for multiple class values:<a class={[ "px-2 text-white", @some_flag && "py-5", if(@other_condition, do: "border-red-500", else: "border-blue-100"), ... ]}>Text</a>and always wrap
if's inside{...}expressions with parens, like done above (if(@other_condition, do: "...", else: "..."))and never do this, since it's invalid (note the missing
[and]):<a class={ "px-2 text-white", @some_flag && "py-5" }> ... => Raises compile syntax error on invalid HEEx attr syntax -
Never use
<% Enum.each %>or non-for comprehensions for generating template content, instead always use<%= for item <- @collection do %> -
HEEx HTML comments use
<%!-- comment --%>. Always use the HEEx HTML comment syntax for template comments (<%!-- comment --%>) -
HEEx allows interpolation via
{...}and<%= ... %>, but the<%= %>only works within tag bodies. Always use the{...}syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. Always interpolate block constructs (if, cond, case, for) within tag bodies using<%= ... %>.Always do this:
<div id={@id}> {@my_assign} <%= if @some_block_condition do %> {@another_assign} <% end %> </div>and Never do this – the program will terminate with a syntax error:
<%!-- THIS IS INVALID NEVER EVER DO THIS --%> <div id="<%= @invalid_interpolation %>"> {if @invalid_block_construct do} {end} </div>
- Never use the deprecated
live_redirectandlive_patchfunctions, instead always use the<.link navigate={href}>and<.link patch={href}>in templates, andpush_navigateandpush_patchfunctions LiveViews - Avoid LiveComponent's unless you have a strong, specific need for them
- LiveViews should be named like
AppWeb.WeatherLive, with aLivesuffix. When you go to add LiveView routes to the router, the default:browserscope is already aliased with theAppWebmodule, so you can just dolive "/weather", WeatherLive - Remember anytime you use
phx-hook="MyHook"and that js hook manages its own DOM, you must also set thephx-update="ignore"attribute - Never write embedded
<script>tags in HEEx. Instead always write your scripts and hooks in theassets/jsdirectory and integrate them with theassets/js/app.jsfile
-
Always use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
- basic append of N items -
stream(socket, :messages, [new_msg]) - resetting stream with new items -
stream(socket, :messages, [new_msg], reset: true)(e.g. for filtering items) - prepend to stream -
stream(socket, :messages, [new_msg], at: -1) - deleting items -
stream_delete(socket, :messages, msg)
- basic append of N items -
-
When using the
stream/3interfaces in the LiveView, the LiveView template must 1) always setphx-update="stream"on the parent element, with a DOM id on the parent element likeid="messages"and 2) consume the@streams.stream_namecollection and use the id as the DOM id for each child. For a call likestream(socket, :messages, [new_msg])in the LiveView, the template would be:<div id="messages" phx-update="stream"> <div :for={{id, msg} <- @streams.messages} id={id}> {msg.text} </div> </div> -
LiveView streams are not enumerable, so you cannot use
Enum.filter/2orEnum.reject/2on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you must refetch the data and re-stream the entire stream collection, passing reset: true:def handle_event("filter", %{"filter" => filter}, socket) do # re-fetch the messages based on the filter messages = list_messages(filter) {:noreply, socket |> assign(:messages_empty?, messages == []) # reset the stream with the new messages |> stream(:messages, messages, reset: true)} end -
LiveView streams do not support counting or empty states. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
<div id="tasks" phx-update="stream"> <div class="hidden only:block">No tasks yet</div> <div :for={{id, task} <- @stream.tasks} id={id}> {task.name} </div> </div>The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
-
Never use the deprecated
phx-update="append"orphx-update="prepend"for collections
-
Phoenix.LiveViewTestmodule andLazyHTML(included) for making your assertions -
Form tests are driven by
Phoenix.LiveViewTest'srender_submit/2andrender_change/2functions -
Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
-
Always reference the key element IDs you added in the LiveView templates in your tests for
Phoenix.LiveViewTestfunctions likeelement/2,has_element/2, selectors, etc -
Never tests again raw HTML, always use
element/2,has_element/2, and similar:assert has_element?(view, "#my-form") -
Instead of relying on testing text content, which can change, favor testing for the presence of key elements
-
Focus on testing outcomes rather than implementation details
-
Be aware that
Phoenix.Componentfunctions like<.form>might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be -
When facing test failures with element selectors, add debug statements to print the actual HTML, but use
LazyHTMLselectors to limit the output, ie:html = render(view) document = LazyHTML.from_fragment(html) matches = LazyHTML.filter(document, "your-complex-selector") IO.inspect(matches, label: "Matches")
If you want to create a form based on handle_event params:
def handle_event("submitted", params, socket) do
{:noreply, assign(socket, form: to_form(params))}
end
When you pass a map to to_form/1, it assumes said map contains the form params, which are expected to have string keys.
You can also specify a name to nest the params:
def handle_event("submitted", %{"user" => user_params}, socket) do
{:noreply, assign(socket, form: to_form(user_params, as: :user))}
end
When using changesets, the underlying data, form params, and errors are retrieved from it. The :as option is automatically computed too. E.g. if you have a user schema:
defmodule MyApp.Users.User do
use Ecto.Schema
...
end
And then you create a changeset that you pass to to_form:
%MyApp.Users.User{}
|> Ecto.Changeset.change()
|> to_form()
Once the form is submitted, the params will be available under %{"user" => user_params}.
In the template, the form form assign can be passed to the <.form> function component:
<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
<.input field={@form[:field]} type="text" />
</.form>
Always give the form an explicit, unique DOM ID, like id="todo-form".
Always use a form assigned via to_form/2 in the LiveView, and the <.input> component in the template. In the template always access forms this:
<%!-- ALWAYS do this (valid) --%>
<.form for={@form} id="my-form">
<.input field={@form[:field]} type="text" />
</.form>
And never do this:
<%!-- NEVER do this (invalid) --%>
<.form for={@changeset} id="my-form">
<.input field={@changeset[:field]} type="text" />
</.form>
- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
- Never use
<.form let={f} ...>in the template, instead always use<.form for={@form} ...>, then drive all form references from the form assign as in@form[:field]. The UI should always be driven by ato_form/2assigned in the LiveView module that is derived from a changeset
When adding a new feature or domain resource, evaluate which of the following need updating. Not every feature requires all items — check based on whether the feature needs web UI, API, admin, or docs.
- Context module (
apps/gamend_core/lib/gamend/): domain logic, Ecto schemas, changesets, queries - Migrations (
apps/gamend_core/priv/repo/migrations/): database schema changes - Tests: context tests + controller/LiveView tests. Run
mix precommitwhen done
- API controller (
apps/gamend_web/lib/gamend_web/controllers/api/v1/): REST endpoints - OpenApiSpex (inline
operation/2macros in the controller): document request/response schemas. The spec is generated at runtime from code — no standalone YAML/JSON file - API JSON view/serialization: ensure response shapes follow pagination convention when listing (
data+meta) - Router (
apps/gamend_web/lib/gamend_web/router/shared.exfor API routes,router/browser_routes.exfor browser routes; the host router islib/gamend_host/router.ex): add routes in the appropriate scope (:apipublic or:api_authauthenticated or:api_adminadmin)
- Admin API controller (
apps/gamend_web/lib/gamend_web/controllers/api/v1/admin/): admin-only endpoints - Admin LiveView (
apps/gamend_web/lib/gamend_web/live/admin_live/): admin dashboard page for managing the resource - Admin route (router, inside
live_session :require_authenticated_admin): add LiveView route
- Public LiveView (
apps/gamend_web/lib/gamend_web/live/): public-facing page (e.g., GroupsLive, LeaderboardsLive) - Nav link (
apps/gamend_web/lib/gamend_web/components/layouts.ex): add navigation link in all 4 locations (desktop auth, desktop unauth, mobile auth, mobile unauth) - Route (router, inside
live_session :current_userfor public or:require_authenticated_userfor auth-only)
- PubSub (in the context module):
broadcast/3calls +subscribe_*/0functions. Follow topic naming:"resource:<id>"and"resources" - WebSocket channel (
apps/gamend_web/lib/gamend_web/channels/): if clients need socket-based real-time updates - Channel route (in
user_socket.ex): register the channel
- Public docs guide (
apps/gamend_web/lib/gamend_web/live/public_docs/): add a.html.heextemplate — it is auto-discovered viaembed_templates - Sidebar entry (
apps/gamend_web/lib/gamend_web/live/public_docs.ex): add link in the docs sidebar navigation - This instructions file (
.github/copilot-instructions.md): update domain-specific sections with the new feature's conventions
- Use advisory locks (namespace 1=lobby, 2=group, 3=party) for atomic join/leave operations
- Add
blocked?/2checks on invite operations to prevent inviting blocked users - Add lightweight
count_*helpers for paginated list endpoints - Context list functions should support keyword options:
:page,:page_size,:sort_byand relevant filters - Always update
CHANGELOG.mdwhen adding new features or making significant changes to existing functionality, especially if they affect configuration or public APIs.
apps/
gamend_core/ # Domain logic (contexts, schemas, migrations)
lib/gamend/
accounts.ex # User accounts context
friends.ex # Friends & blocking context
groups.ex # Groups context
lobbies.ex # Lobbies context
parties.ex # Parties context
leaderboards.ex # Leaderboards context
achievements.ex # Achievements context
notifications.ex # Notifications context
kv.ex # Key-value storage context
hooks.ex # Server scripting/hooks context
gamend_web/ # Web layer (controllers, LiveViews, channels)
lib/gamend_web/
controllers/api/v1/ # Public API controllers (14 controllers)
controllers/api/v1/admin/ # Admin API controllers (8 controllers)
channels/ # WebSocket channels (6 channels) + webrtc_peer.ex
live/ # LiveViews (public + user auth)
live/admin_live/ # Admin dashboard LiveViews (10 modules)
live/public_docs/ # Documentation guide templates (20 guides)
components/ # Shared components (layouts.ex, core_components.ex)
auth/ # Guardian JWT (guardian.ex, pipeline.ex)
gamend_host/ # Runnable host app (router, boot config)
assets/ # JS, CSS, vendor deps (incl. webrtc.js)
config/ # Environment configs
docs/ # Design documents (webrtc-design.md)