[added] A board can hold many rankings, and can be hidden.
leaderboard_records.key(default"", migrationLeaderboardKeysAndHidden): a record is unique per user (or label) and key, and every read ranks within one key, so one board keeps a best per player per key (a host's game settings, say) instead of a board for each.submit_score/5andsubmit_label_score/5takekey:, as doget_user_record/3,get_label_record/3,list_records/2,count_records/2,list_records_around_user/3anddelete_user_record/3.list_records/2withkey: :allreads every key together; withbest_per_user: trueeach player's best of them once, ranked.:metais now a map of fields (%{"game" => "match", "lang" => "es_es"}, every one must match; was one{key, value}tuple), ranked within the filter.leaderboards.hidden: a real board, read by id or slug, left out oflist_leaderboards/1,list_leaderboard_groups/1, their counts (passinclude_hidden: true), the public/leaderboardspage and the API index; the admin page lists it, badged, with each record's key. Every existing board and record keeps key""and ranks as before.[added] The theme follows the account.
PUT /preferences(key,value, browser session and CSRF) saves a preference the page sets to the signed-in account (Accounts.Preferences.put_client/3): core'stheme(dark,light, orsystem, which forgets it) and what a host allows inconfig :gamend_core, :client_preferences(key to allowed values). A visitor gets 204 and nothing saved.GamendWeb.Plugs.ColorModenow runs after the scope is fetched and renders a signed-in reader's saved theme over thephx_themecookie, also asdata-theme-saved, whichtheme-init.jscopies into the browser; the switcher saves through the endpoint.[fixed] Two group admins removing each other can no longer leave the group with none.
Groups.kick_member/3anddemote_member/3checked "is the target the last admin?" and then wrote, with no lock between, so two admins demoting or kicking each other at once both passed the check and both landed: a group nobody could manage again. Kick, promote and demote now read the rows again and write under the group's lock (Lock.serialize(:group, …), as leaving already did), deciding the admin's rights, the target's membership and the last-admin rule as they are at that moment; thebefore_group_kickhook still runs before the lock, the notification and broadcasts after it. The same change ends a crash: a kick or promotion racing the target's own leave raisedEcto.StaleEntryErroron the deleted row, and now answers{:error, :not_member}.Gamend.GroupAdminRaceTestraces these moves directly; against the old code its admin tests failed every run. Found byGamend.MembershipConcurrencyTest, which raised on it about twice in a hundred runs.[fixed] Accepting a party invite no longer fails when a kick lands first.
Parties.accept_party_invite/2leaves the player's current party before joining the new one, using the user it read at the start. A kick or a disband that took the player out in between made that leave answer:not_in_party, and the accept failed with{:leave_failed, :not_in_party}, a reason no client knows, though the player was already where the join needed them. That answer now counts as left. Found byGamend.MembershipConcurrencyTest, which failed on it about once in a few full runs.[breaking] Registering takes no password; the emailed code sets it.
POST /api/v1/registertook an email and a password, and the confirmation link kept the password. Anyone could register someone else's address with a password of their own; once the owner opened the email, the account they confirmed and went on to use still let that person in with it (account pre-hijacking). Registering now takesemailand an optionalusername, and nothing else. The confirmation email carries the link and a six-digit code.POST /api/v1/register/confirm(email,code,password) confirms the email, sets the password and answers a session as login does: the code proves the inbox, so the password chosen with it is the owner's. A wrong, spent or expired code, an unknown address and an account already confirmed all answer401 invalid_code; wrong codes count toward the per-address lockout (auth.lockout_attempts, shared with passwords), and the one that locks it (429 account_locked) voids the code. A refused password is422and costs no attempt.POST /api/v1/register/resend(email) emails a new code, only the newest works, at most one email per account a minute, and answers{"ok": true}for any address. The link still works and never keeps a password set before the address was proved: confirming by link, as by a login link, removes it, which also covers accounts registered before this change and guest accounts given an email. The link's page no longer confirms on open:/users/confirm/:tokenis a LiveView (UserLive.Confirmation,:confirm_email) whose button posts toPOST /users/confirm, so a mail scanner that opens every link confirms nothing and spends nothing. The first account a game client registers becomes the admin and confirms by email like any other; the browser form still confirms it at once and signs it in. Core:Accounts.register_user_with_password_and_deliver/3is gone;register_unconfirmed_user_and_deliver/3,confirm_user_by_code/3,resend_confirmation/3andget_user_by_confirm_token/1are new; a:user_notifiermodule'sdeliver_confirmation_instructionsnow takes(user, url, code). SDKs: C++Auth::register_email(email[, username], done),confirm_registration,resend_confirmation; Godotauthenticate_register_email(email, username)(renamed fromauthenticate_register, so an old(email, password)call fails instead of sending the password as the username),authenticate_confirm_registration,authenticate_resend_confirmation; Balaurclient::register_email(node, email, username),auth::confirm_registration,client::resend_confirmation.[removed] Three admin events no page sends.
AdminLive.Config'sprefill_argsandshow_docsandAdminLive.Index'sset_tab: no template or script pushed them any more. Ahandle_eventclause is part of a public callback, so the compiler never reports one as unused.[changed] Websockets deflate at level 4.
HostRuntimegives both Bandit listenerswebsocket_options: [deflate_options: [level: 4]]; zlib's default (6) was ~9% of a visit's server CPU on a LiveView site. A recorded visit (six page joins, ~50 game and flashcard events, 779 KB) sent 88 KB for 16.9 ms of deflate at 6 and 96 KB for 9.9 ms at 4; levels 1-3 save a little more and send 17-28% more. Measured on the wire: a 76 KB join goes out as 11.3 KB (10.6 at 6). Production config only, like the rest of the listener options.[changed] Flag codes and icon names are checked byte by byte, not by regex.
flag/1's code check andDynamicIcon's name whitelist ran a regex for every flag and icon on every render (a page with language menus draws ~160 flags); a regex literal costs ~1.4 µs a call on OTP 28, the byte walk ~0.5. Same rules:[a-z0-9-]{2,6}and[a-zA-Z0-9_-]+.[changed] The LiveView socket encodes with Elixir's
JSON. A join reply is the whole rendered page as JSON (76-172 KB on a typical host page), and its encoding was the largest cost of a join after rendering.GamendWeb.LiveSerializeris Phoenix's V2 serializer with text frames encoded byJSON(OTP's:json) instead ofPhoenix.json_library(): on real join replies it took 55-95% of Jason's time (0.6-1.2 ms against 0.8-1.8 ms), and the frames decode to the same terms. Only the/livesocket uses it:Phoenix.json_library/0stays Jason, since structs across the app implement onlyJason.Encoder, and a payload carrying one falls back to Jason rather than failing. Binary frames and decoding are unchanged. Poison, also in the tree, was the slowest (twice Jason).[fixed] A new guest account counts against the IP's rate limit.
UserAuth.ensure_user/1makes an account inside a LiveView event, over the socket, where the HTTP rate limiter never looks, so one page's session could join and save and make accounts as fast as a script could send. It now counts in the normal per-IP bucket (general_limitpergeneral_window_ms, 240 a minute by default, the one page loads get) and answers{:error, :rate_limited}past it;ensure_user_conn/1does the same. Not the registration bucket: a school class or a mobile carrier shares one address, and that bucket also gates the login form. The IP is the one the page was rendered for (LiveHelpers.client_ip_session/1, afterRealIp), recorded at mount as:client_ipon a signed-out connected socket, since connect info is gone by the time an event asks. A host's callers already carry on without an account on any error.[changed] The quest page reads a player's progress once.
Quests.user_quest_page/2returns the page oflist_user_quests/2, the count ofcount_user_quests/2and the tabs ofvisible_categories/1from one read;QuestsLiveandGET /api/v1/me/questsuse it. Each of the three also reads less: this period's rows and the done prerequisites are one query now, not two./questswent from 19-21 queries a visit to 10. Same entries, counts and tabs (Gamend.QuestsTest).[changed] Settings reads only the open tab.
/users/settingsloaded every tab's data (friends, groups, payments, wallet, items, data, devices, API tokens) on every render, about 60 queries a visit for the one tab on screen. Each tab'sassign_defaultsnow sets empty values and the settings LiveView reads a tab's data when it opens (load_tab/2, fromhandle_params/3). A friend or group event reloads its tab only while it is open; a friend going online was eight queries whatever tab was showing.[fixed] A language switch is always a page load. The language menu's links carry
data-no-live-nav:live_nav.jshad moved/x?setlang=enfrom an unprefixed page in another language over the socket, whereLocalePathnever runs, so choosing English did nothing.[fixed] One
<h1>on the docs, guides and markdown pages. They passed their own<h1>inside.header, which draws one; the parser closed the outer at the inner, leaving an empty heading before the real one. They pass the title as text now.[changed]
flag/1serves a host's WebP for a flag that has one. A flag with a coat of arms is tens of KB of SVG for a 1em icon and takes milliseconds to draw; a plain one is a few hundred bytes. The component lists the host'spriv/static/flags/*.webponce (digested copies skipped) and points every code that has one at it, the SVG otherwise. A host with no WebPs is unchanged.flag_url/1follows the same pick.[added] A plain link moves between LiveView pages over the open websocket. Phoenix does this for
<.link navigate>into the samelive_session, but navbar, footer and menu links are plain<a href>, since the same components render on controller pages, so every click loaded the page and opened a new socket: 420-560 ms on production before the next page answered, against 130-150 ms over the socket.GamendWeb.LiveNavbuilds the router's GET routes once, in the router's order, each with itslive_session(nil for a controller), plus the locale prefixes.GamendWeb.Plugs.LiveNavTableserves them at/gamend/live-nav.json?v=<version>, cached for a year. A LiveView page that keeps its socket gets<meta name="gamend-live-nav">naming the table and its session.live_nav.jsthen marks a clicked plain link as a live link when the first route matching its target is a LiveView of the same session and the locale prefix is unchanged. A download, a new tab, a hash,data-no-live-navor a modifier key keeps the browser's own behaviour, and a wrong guess costs one round trip (LiveView loads a page it cannot mount). On the server the old page's process stops on every navigation. On the client every hook in core and the reference host removes what it adds indestroyed, so nothing builds up across pages.[changed] A signed-in page no longer re-reads the same rows. Wallet balances, entitlements and KV keys that have no row now come from
Gamend.Cachelike the user and KV values already did.Economy.balances/1fetches every currency in one query andbalance/2reads from it;change_balance/4evicts.Payments.has_entitlement?/2andentitlement_ever?/2read one cached list of the user's rows ({key, status, expires_at}) and decide what is active against the clock at read time, so a cached row stops counting the second it ends;after_entitlement_changed/1, which every entitlement write calls, evicts.KV.get/2caches a missing key too, only outside a transaction and only when the scope's entries version did not move during the read. Every write evicts its key on this node at once, not in the async task, so a writer reads back what it wrote. A KV write now bumps the scope's version before re-caching. Inside a transaction all three skip the cache both ways. On a host page that was three queries per render (the session, and the coin badge in the desktop and the mobile nav) and is now one, the session lookup, which stays uncached on purpose: it is what makes a revoked session stop working. Pinned byGamend.UserReadCacheTest, on a real cache instance.[fixed] The quests page walked every chain once per quest, twice.
QuestsLive.chain_positions/1walked each quest's prerequisite line to its depth and again to its root, quadratic in the line: a 107-tier unit chain cost ~12,000 steps and 20 ms of every render, dead and connected. Each line is walked once now and memoised (chain_index/1). A line with a cycle still falls back to the per-key walk, so every answer is unchanged.[fixed] The root layout no longer rebuilds the theme on every LiveView page.
OnMount.Themeassigns the resolved theme, and LiveView hands that assign back to the root layout and toprepare_app_assigns/1, which treated it as a per-request overlay and re-translated the whole config, about 400 gettext calls a page.HostLayouts.resolve_theme/2returns the cached theme when the "overlay" is that theme itself; the identity check is cheap because both sides are the same:persistent_termliteral. Controller pages that passtheme:(PageController.home) get the same.[added] A signed-out visitor can get an anonymous account on the website, the way a game client does.
GamendWeb.UserAuth.ensure_user/1(a LiveView) andensure_user_conn/1(a controller) return the caller's account and, for a signed-out visitor, make one withAccounts.find_or_create_from_device/2on a device id the server makes up (web:and 24 random bytes), so it is an ordinary device account: the same hooks, the sameUser.anonymous?/1, the same 90-dayanonymous_users_dayssweep, and nothing whiledevice_auth_enabledis off. The page decides when: a host calls it where something is about to be saved, so a crawler or a reader who only looks never gets a row. A LiveView cannot write the session cookie, so the page pushesgamend:anonymous_sessionwith the session token encrypted (Phoenix.Token, five minutes),app.js(anonymous_session.js) posts it toPOST /users/anonymous_session(AnonymousSessionController,UserAuth.put_anonymous_session/2), which writes the session and the remember-me cookie, and then reconnects the socket so every page after it mounts with the account:mount_current_scopefalls back to the socket's connect-time session (the fresh cookie) when the page's own session, rendered before the account existed, has no user. A real account the browser is already signed in with is never replaced.Scope.anonymous?/1tells a guest from a signed-in caller. Registering as a guest puts the email on the account they already have (Accounts.upgrade_anonymous_user_and_deliver/4, the same queued confirmation email), so it keeps everything; Google and the other providers already linked to the current account and still do. Signing in to an account that already exists keeps that account as it is and deletes the guest one (log_in_user/3, throughAccounts.delete_user/1); a provider identity that belongs to another account signs a guest in to it instead of reporting a link conflict. The navbar shows a guest "Guest", Register and Log in, never Log out, which would lose the account.UserAuth.require_registered_user/2is the plug for a feature a host keeps for real accounts: a guest is sent to register, a signed-out visitor to log in. The quests page's status filters carry an icon each.[fixed] A theme reload can no longer be undone by a read that started before it.
Gamend.Theme.JSONConfigcached whatever a read put last, and the reload event hasGamendWeb.ResponsiveImagesread the file in its own process, so a read of the old file that finished afterreload/0put it back and the site served it until the next reload. Cached entries, raw and translated, now carry the reload generation they were read under, and an older one is a miss.[added] The slow query and the long transaction, named in the log.
Gamend.Repo.SlowLog, attached at boot byGamendWeb.HostSupervision.init_runtime/1, logs a query that runs or waits for a connection longer thanGAMEND_DB_SLOW_QUERY_MS(1000), and a transaction that holds its connection frombegintocommit/rollbacklonger thanGAMEND_DB_SLOW_TRANSACTION_MS(2000), each with the table, the SQL (trimmed) and the first frames outside Ecto and DBConnection. On SQLite that transaction holds the one write lock, so this is what a "database is locked" was waiting for; the error itself only ever names the waiter.0turns either off. The handler compares two integers per query unless something was slow.[added] Pruning a KV key family by age.
Gamend.KV.prune_prefix(prefix, days)deletes the entries whose key starts withprefix(matched literally) and that have not been written fordaysdays, in batches, invalidating their caches and listings asdelete/2does. A host or plugin keeping history in KV (one row per day) registers it with the retention sweep in one line,Gamend.Retention.register_kv_prefix(:daily_results, "daily:", days), wheredaysis a number or a function read at each sweep (a setting the host declares withGamend.Settings.Provider, so an operator can change it without a deploy).0keeps everything.[added] Notification choices. A user picks, per group and per channel (on the site, email, phone), which notifications they get, plus three switches: all email off, all phone off, everything off.
Gamend.Notifications.Preferencesholds them in a new privateusers.preferencesmap (migration20261001090000_add_preferences_to_users,Gamend.Accounts.Preferences), kept out ofmetadatabecause metadata is sent to friends, lobbies and parties. Core's groups are account and security (email, cannot be turned off), friends and groups, chat, and quests; a host adds its own withconfig :gamend_core, :notification_groups(key,label,defaults,configurable,types,signup). Existing pushes follow the recipient's choice for their type's group (group_for_type/1); a moderator's notice has no group and is always pushed.Gamend.Notifications.notify/3sends a server notification through the channels chosen: the in-app row, a push, and an email queued asGamendWeb.Workers.NotificationEmail, which checks the choice again when it sends and carries a one-clickList-Unsubscribe(RFC 8058). Its link opens/notifications/unsubscribe/:token(a signed token, no sign-in): the GET page changes nothing, the POST turns the group's email or all email off, and has no CSRF pipeline so a mail client's own Unsubscribe button works. Settings has a Notifications tab; the registration form offers eachsignup: truegroup's email, unticked. A notification'smetadata["url"], a path on the site, becomes its Open button.[added] A user's time zone and language. The browser sends its time zone on the LiveView connect (
app.js), andGamendWeb.UserAuthsaves it with the page's locale inusers.preferenceswhen either changes.Gamend.Accounts.TimeZoneanswers a user'slocal/2,local_date/2,local_hour/2andday_start/2(UTC when the zone is unknown), with thetzdatabase passed explicitly, so a host needs no global:time_zone_database.Gamend.Accounts.Preferences.locale/1is the language to write an email in. A user can pick their zone by hand on the Notifications tab (TimeZone.choose/2, fromTimeZone.names/0, the IANA zones oftz'szone1970.tab); the browser then stops replacing it until they choose automatic again.[breaking] A game hook that raises answers 500, with no detail.
POST /api/v1/hooks/callanswered a plugin's crash 400exceptionwith the exception's message in the body, so a server bug read as the client's fault and the message (a query, a constraint name) went to the player. It is 500exceptionnow, andGamend.Hookslogs the raise aterrorwith its stack trace, which the old warning line never had. A missing function clause counts as the caller'sfunction_clause(400) only when the hook's own clauses refused the arguments; one from deeper in the plugin is a crash. The channel'scall_hookreplies with the same codes (GamendWeb.HookErrors) instead of aninspected tuple:{error: "not_enough_gold", message: "3"}for a tagged refusal,{error: "exception"}for a crash; the WebRTC data channel'shook_errorsaysexceptiontoo.[added] One rule for raising and returning errors. "Errors" in CONTRIBUTING.md: an expected failure (client input, a race) returns
{:error, reason}and its@specholds; a bug raises and is not rescued, wrapped or turned into{:error, exception}; a!lookup never runs where a miss is an answer.mix gamend.api.lintchecks the last one as R17:get_*!andRepolookups with!in an API controller, a channel, a LiveViewhandle_event/handle_info/handle_asyncor an Obanperform. Hosts run the lint too, so it holds there.[fixed] A row deleted under a request answers its code instead of crashing.
Groups.update_group/3,set_icon_url/3,delete_group/2,admin_delete_group/1andapprove_join_request/2checked, thenget_group!, and raised when the group went in between, though their specs promised tuples; they answer{:error, :not_found}(404 over the API), and a group updated from a stale cached copy does too. The lobby API'skickraised on an unknowntarget_user_idand now answers 404, as it does for a player not seated in the lobby (422unexpected_errorbefore); a caller whose lobby was deleted since they were read gets 400not_in_lobbyfrom every lobby endpoint, wherewith_lobby/2answered 404 andwith_party/2400 for the same race. Joininggroup:<id>for a group deleted mid-join is refused rather than crashing the join.Accounts.delete_user/1anddelete_user_token/1answer{:error, :not_found}for a row already gone (404 from the admin API), andDELETE /api/v1/admin/sessions/:idanswers 404 for an id that is not a UUID, not 400. Each has a test.[fixed] Admin pages keep running when a row is gone. Every console action on a group, party, lobby, leaderboard, record, user or session looked its row up with
get_*!and crashed the page into a remount when another admin, the player or a sweep had deleted it; they flash "It no longer exists" (GamendWeb.AdminLive.Shared.with_record/3), and a bulk delete counts a row already gone as deleted.Leaderboards.get_record/1is the tuple twinget_record!/1lacked, which the admin record API had rescuedEcto.NoResultsErrorto get.[fixed] A failed lobby delete is no longer a 422.
Lobbies.delete_lobby/1rescued every exception into{:error, exception}, so a database outage reached the player as 422unexpected_errorand was never logged as an error; it raises now (500, logged), and its spec is{:error, :not_found | {:hook_rejected, _}}.POST /api/v1/lobbies/disbandanswers a hook's veto 403rejected, and documents no 422.Accounts.delete_user/1's best-effort cleanups (leave the party and lobby, group and friend-chat cleanup) still never stop the delete, but log a failure with its stack trace instead ofrescue _ -> :ok.[fixed] Local storage lists only the prefix it is asked for, and survives a file vanishing.
Gamend.Storage.Local.list/1andusage/1walked the whole storage root and filtered by prefix afterwards, so the retention sweep ofavatars/statted every cached PDF; they walk the directory the prefix names (avatars/user-a/walks that directory,pdf/eswalkspdf). A file deleted between the walk and its stat (a concurrent delete, macOS's.DS_Store) is skipped instead of raising, which failed the wholeorphaned_avatarsretention class.[fixed] One X in a search box.
search_input/1hides the browser's own clear button fortype="search"when it draws its own X, so the palette (and every box withclose) no longer shows two.[fixed] A lobby, party, group or tournament deleted mid-request answers its code, not 500. A write that read its parent and then pointed a row at it raised
Ecto.ConstraintErrorwhen the parent was deleted in between; SQLite names no constraint, soforeign_key_constraint/3could not catch it.Repo.rescue_foreign_key/2and the newRepo.rescue_stale/2answer{:error, :not_found}(or the site's own code) for lobby join, quick join (which moves on to the next candidate), update, state and WebRTC config; opening a ready check; party join, invite and seating a party in a lobby; group join, join request, approval and invite; tournament join, withdrawal, delete and state changes; matchmaking tickets and match assignment (the tickets go back in the queue); chat read cursors and reports; quest progress. Tournament join, withdrawal and draw read the tournament again underRepo.lock_rows/2(FOR SHARE/FOR UPDATEon Postgres), so no entry lands in a drawn, cancelled or deleted tournament. Admin tournament delete and cancel answer 404 when another delete got there first. Each site has a test that deletes the parent between the read and the write.[fixed] A lobby or party is never led by someone outside it. Leave and kick decided who was host or leader from a read taken before the lock: a member leaving just as the host handed them the lobby left as a plain member, and a kick could remove the player the lobby was just handed to, leaving a host (or party leader) who was not a member. Both now decide on the rows inside the lobby's or party's
Gamend.Lock.serialize/3, unseat a player only while still seated there, and party disband and admin delete take the same lock. Disbanding a lobby already gone answers 400not_in_lobby(422 before), and kicking from a party disbanded meanwhile 400not_in_party.membership_concurrency_test.exsruns eight players joining, leaving, kicking and disbanding at once and checks capacity, leadership and that everyone can still take a seat.[fixed] Chat goes with its lobby, party or group. The last member leaving a lobby, a party disbanded or deleted, and a group emptied now delete their messages and read cursors, and retention's new
orphaned_chatclass removes chat whose parent is gone, such as a message sent as its lobby was deleted.[added] A group admin can delete a group with its members in it.
DELETE /api/v1/groups/:id(delete_groupin the SDKs), andGroups.delete_group/2no longer refuses withhas_members: it needed an admin who was a member and no members at once, so it never succeeded. Every other member gets agroup_deletednotification, a new code inGamend.Notifications.Types; the admin console andDELETE /api/v1/admin/groups/:idsend it too.
September 2026
[added]
panel/1,eyebrow/1,page_title/1andsearch_input/1, and a boxedempty_state/1. The pieces a host kept redrawing with its own classes: a box set on the page (compact,title,tag), a group's small uppercase heading, the page<h1>(header/1draws it now, a step smaller on a phone), and a search box with its magnifier and an optional X (closeattributes). The site search palette usessearch_input/1, so its magnifier and the host's cannot drift.empty_state/1takes an:actionsslot andcompact, its icon is optional, and it draws a dashed box rather than a bare block, on the changelog and blog empty pages too./uishows them all and uses them itself.[added] A page of the site's building blocks at
/ui. Buttons (roles, icons, icon-only, sizes, states, colours), badges, form controls, alerts, surfaces, the theme's colours, type, and loading, progress, tabs and a dropdown, each captioned with the classes that draw it, so a new screen copies them rather than inventing a look.PageController.ui/2withPageHTML.ui_section/1,ui_row/1andspecimen/1;noindex, follow, since it is a reference. Linked from the footer of the default theme and the starter's; a host adds/uito its own footer. It spells every class out in full, so Tailwind generates them, and uses no component a host mayexclude.[changed] Secondary buttons are
btn-surface, not a white outline. Every neutralbtn-outlinein core's templates, the navbar,.header's Back and the admin pages included, isbtn-surface: the page's colour behind a darkbase-300hairline, the presentation page's default button look. It is a class a host's stylesheet declares, a@utilitythat sets only the resting--btn-bgand--btn-borderthe way daisyUI'sbtn-outlinedoes, so a button's hover, focus, disabled state and size arebtn's own (assets/css/app.csshas it). A host that has not declared it shows daisyUI's plainbtn. A coloured outline (btn-outline btn-error) is unchanged. The phone's language button in the navbar isbtn-ghost(one icon), and the search palette's magnifier shows again: daisyUI's positioned.inputhad been painting over it.[changed] A post's way back is the breadcrumb. The post page's meta row opened with a "Blog /" link, a second trail under the layout's breadcrumb; it shows the date and reading time only now.
[added] Gamend as a download, run with a
gamendcommand. Every push to main publishes the server on theserver-latestrelease for macOS on Apple silicon and Linux x86_64 and arm64, in SQLite and-postgresbuilds (gamend-<os>-<arch>[-postgres].tar.gz), with OpenSSL and libsrtp linked in so nothing else needs installing;rel/install.shdownloads the right one and linksgamendinto~/.local/bin. A project is the foldergamendruns in: its.env, theme, markdown,static/, plugins and SQLite database.bin/gamend(a release overlay) runs the server withstart,daemon,stop,restart,remoteandreload(which re-reads the theme and markdown on the running server), and everything else throughGamendWeb.CLIunder the mix task's own name:db.setup,db.migrate,db.rollback(--step,--to,--all),db.reset,db.seed(new here as a mix alias ofhost.seedtoo),demo.seedandplugin.bundle.gamend startercopies a starter project in without overwriting anything:default(priv/starter/default),website(the gamend.org site, published asgamend-website.tar.gz), or any folder,.tar.gzor URL, and writes a.envwith a fresh secret. Each project gets its own node name and a private cookie in.gamend/, and the node listens on loopback only, since the cookie inside a download is the same for everyone. Theactions/setup-gamendaction installs, starts and seeds a server in CI and setsGAMEND_URL, for a game client's end-to-end tests.mix demo.seedis nowGamend.DemoSeed.run/1behind a thin task, andGamend.Releasegainedrollback/1anddropdb/0. Guide: Download and run.[changed] A release runs from the directory it is started in.
bin/gamend_hostanchored the SQLite database (db/) and the GeoIP file (data/) to the release's own root and read the theme from the build machine's checkout by absolute path, while the markdown content, the plugins dir andpriv/storagealready resolved against the working directory. All of them resolve against the working directory now, and a release reads that directory's.envasmix phx.serverdoes in dev (real environment variables still win), so a release unpacked anywhere keeps a project's data and customisations in the folder it is started from. A release started from somewhere other than its own root finds its database there; setGAMEND_DB_SQLITE_PATHto keep the old one. The Dockerreleaseimage starts from its own root, so nothing moves there, and it now carries the theme,CHANGELOG.md,ROADMAP.md,blog/,priv/docsand the plugins dir, which it had left out: it served no theme, no changelog, blog or guides, and loaded no plugins. The boot warning about ephemeral storage fires only for a path inside the release.[added] A project's own static files are served ahead of the engine's. Someone running a release from a project folder could not add an image, a
game/export or a favicon, because static files came only from the release'spriv/static. The endpoint now serves the foldersGAMEND_CONTENT_STATIC_DIRSlists first (defaultstatic,priv/static, relative to the working directory), for the top-level entries of:host_static_pathsand neverassets/, with the same cache headers as the built-in files; a folder that is an app's ownpriv/staticis skipped.GamendWeb.ProjectStatic.path_for/1answers which file serves a URL, and everything that reads a static file by URL goes through it (image dimensions, srcset variants,GamendWeb.SRI,theme.css, the dark banner,.well-known, admin diagnostics), so a page links and hashes the project's file rather than the engine's at the same path. A width variant or a generated WebP only counts when it sits beside its original, so a replaced image never borrows the engine's smaller copies.GamendWeb.SRI.integrity/1isnilfor a path the overlay can answer: its hash is cached until a reload while the overlay is read per request, and a file changed in between would otherwise be blocked by the browser. See the theme guide.[added] Missing
widthsvariants are cut at runtime. The cutting inmix host.responsive_imagesmoved toGamendWeb.ResponsiveImages, which the task now calls, and a supervised process (inGamendWeb.HostSupervision.children/1) uses it to cut the variants a project's own images are missing, next to the original, at boot and afterGamend.Theme.JSONConfig.reload/0(which now emits[:gamend, :theme, :reload]). It needs ImageMagick (magick, orconvertoutside Windows); without it the server logs once at:infoand pages serve the originals. It never writes inside the release, and cached presentation pages re-render once variants are cut (GamendWeb.ProjectStatic.generation/0). The same reload first runsGamendWeb.ProjectStatic.reload/0, which resolves the static folders again and rebuilds the?v=hashes and thetheme.csslink, so a folder created or a file replaced since boot shows without a restart; the telemetry handler only sends the process a message, so the caller ofreload/0never waits on it or sees it fail.[added] A release builds plugins without Mix. The downloadable engine and the
releaseimage ship the Elixir compiler but nomix, so the admin Config page's Build bundle was disabled there.Gamend.Hooks.PluginBuildernow falls back to building in-process (Gamend.Hooks.PluginBuilder.InProcess) whenmixis not on the PATH: it reads the plugin'smix.exswithout running it, transpilesscripts/*.gdintogen/for a GDScript plugin, checks that every runtime dependency ships with the engine or sits prebuilt indeps/<dep>/ebin(naming the one that does not), compiles into a temporary directory and swaps it in asebin/with the.appmix plugin.bundlewrites, so a failed build keeps the old bundle.hooks_moduleis detected (use Gamend.Hooks, or the GDScript script named after the plugin) whenmix.exsdoes not spell it literally, and a module the server already has is refused. A loaded plugin is stopped for the build and started again on the result (PluginManager.suspend/1,resume/1).available?/0is true in a release now,mode/0says which build runs,build/2takesmode: :mix | :in_process, andbuild_all/0builds every plugin. The engine gaineduse Gamend.Hooks(Gamend.Hooks.Defaults, the SDK's defaults verbatim, with a test that fails when they drift), since an in-process build compiles against the engine rather than the SDK. The host now depends ongamend_plugin_toolsat runtime, so the GDScript transpiler ships in the release;Gamend.GDScript.compile_all/2takes:rootandGamend.GDScript.source_path/1is public. The bundledwebrtc_lobby_hookdropped its unusedbuntdependency, which the engine does not ship. Limits: no Hex dependencies beyond the engine's, no Erlang sources or Gleam, noconfig/config.exs, and protocols are consolidated. See Building a plugin.[fixed] The error page's links were English in every language.
GamendWeb.ErrorHTMLlooked the:error_page_linkslabels up in core's own catalogue, which holds none of a host's navigation, so from/fr/…a 404 offered "Dictionary" and "Play". It now looks them up in the host's backend (:host_gettext_backend) in the reader's locale, and keeps the literal if anything goes wrong.[fixed] The retention sweep no longer locks a SQLite database.
Gamend.Retentionpruned old lobby snapshots and events in oneDELETEeach, and kept a flagged run's history with a correlatedNOT EXISTSthat re-scanned the lobby's snapshots for every row. SQLite has one writer, so on a large history (115k snapshots on a dev database) the statement held the write lock past the 15 s checkout timeout: every other write in the app queued behind it and page loads hung, then the sweep rolled back and did the same at the next run. It now deletes in batches of 500, each its own statement, and finds the flagged runs once (lobby_id NOT INa subquery). A sweep cut short keeps what it deleted.[added] Grant an entitlement without a purchase.
Payments.grant_entitlement/3upserts a user's(user, key)row for a trial, a reward or a support gesture, with:expires_atand:metadata. It never shortens an active grant: a row with no end keeps no end, and a later end wins.Payments.entitlement_ever?/2answers whether a user has ever held a key (a once-per-account trial), andget_user_entitlement_by_key/2reads the row whatever its state.[fixed] A menu lit two links for one page.
/docsand/docs/referencein one dropdown both matched/docs/reference/body2d, because a link is active when the path starts with it. Among the links of one menu, desktop or mobile, only the longest match is active now; a page only the broad link matches still lights it.[fixed] A post showed its picture twice. The post page put the frontmatter
imageabove the body, and a post whose body opens with the same picture (x.webpfor anx.pngcover) showed it again.GamendWeb.ContentPages.cover/2leaves the cover to the body when the body shows it; the cards and the link preview still use it.[changed] The blog index is a grid.
/bloglays its cards out two to a row on a tablet and three on a desktop, each picture above its text, in a wider frame (max-w-6xl); a phone gets one column. It was one card to a row, the picture beside the text. There is one layout for every host and no setting: a"blog": {"layout": ...}key in the theme JSON is ignored, andGamendWeb.ContentPages.blog_index/1takes nogrid?. The newest post's picture is fetched first (loading="eager" fetchpriority="high"), the rest lazily. The Roadmap and Changelog links beside the title wrap below it on a phone instead of squeezing it.[added] Every post has a picture, and a host can serve smaller copies of it. A post's
:imageis its frontmatterimage, else the first picture in its body, at the URL the body serves it from (a relative path becomes/content/blog/…, asGamend.Content.Markdown.image_src/2resolves it;Markdown.first_image/2finds it, skipping code blocks). So a post without a cover gets a card picture, a feed image and a link preview. A relative frontmatterimageresolves the same way; an absolute one is still used as written.Gamend.Content.register_path/2takesimage_url: {module, function}, called asfunction(url, use)for each picture a collection serves itself::pagefor the rendered HTML (guides too) and a post's cover,:cardfor the post's new:card_image, which the index shows.Content.image_url/3applies it.post_render:now runs on a blog post's HTML as it does on a guide's. Polyglot Pirates used to do all this in a blog page of its own, and renders core's now.[fixed] A post's opening paragraph was cut, or shown twice. Without a
description, the post page opened with the excerpt, the first paragraph cut to 200 characters, while the body dropped that whole paragraph, so the rest of it was never shown. It opens with the paragraph in full now. A post with a<!-- truncate -->marker and no description printed its first paragraph twice, above the body and in it; the marker now decides only the excerpt, and the body drops the paragraph as it does for any other post (lede_in_body?is true without a description). The picture a post opens with, when no cover sits above it, is fetched first (GamendWeb.ContentPages.eager_opening_image/1), and a cover isfetchpriority="high".[fixed] The reading time was always in English. "N min read" on the blog was an
ngettextcall no catalogue carried. It isgettext("%{count} min read")now, translated in every locale.[fixed] A blog card with an author link split in two. Each card on
/blogis one link, and an author with aurlput a second link inside it, which HTML does not allow: the browser closed the card's link early, so the text fell outside it and the card's layout broke. The index names authors as plain text now; the post itself still links them.[breaking] Registering with an email is no longer a sign-in, and a password signs in only once its email is confirmed.
POST /api/v1/registeranswered201with a full session while the confirmation email was still in the queue, as if it were device login, and password login worked the same on an address nobody had confirmed, so anyone could register any address and play as it. The two flows are now separate: device login still creates an account and signs it in, while registering creates the account, queues the email and answers201with the account underdata(Registration:user_id,username,display_name,email_confirmed), never tokens.POST /api/v1/loginanswers403 email_not_confirmeduntil the emailed link is opened, and the browser password form says to confirm first; both only after the right password, so a guesser learns nothing.Accounts.authenticate_by_password/2returns{:error, :email_not_confirmed}, andget_user_by_email_and_password/2nil. The first account is confirmed as it is created (email_confirmed: true), on the API as in the browser, so it logs in at once. An emailed login link still confirms, as it proves the inbox; on an account registered with a password it used to crash, and now confirms and removes the password, which whoever registered the address chose before anyone proved they own it (the page the link opens says so; the confirmation email's link keeps it). The SDKs follow: Godot'sauthenticate_registerno longer keeps a session, the C++Auth::register_emailtakes a plainCallbackand answers the account without touching the session, and Balaur'sclient::register_emailis a REST call rather thangamend::register.[fixed] A provider sign-in that claims an unconfirmed account no longer keeps its password. Signing in with a provider asserting a verified email links that provider to the account holding the address, and confirms it. When that account had never been confirmed, it kept the password whoever registered the address had chosen, so someone who registered a player's address first could still sign in with it once the player claimed the account with Google, Discord or any other provider. As with an emailed login link, the password is now removed and every session, access and refresh token the account held is revoked (
token_versionbumped). Linking to a confirmed account is unchanged.[fixed] The Hex packages compile as the version they were published as. Each package's
mix.exstook its version from the environment of whoever compiled it:GAMEND_CONTENT_APP_VERSIONfor gamend_core and gamend_web,APP_VERSIONfor gamend_sdk and gamend_plugin_tools. A host whose Dockerfile exportsGAMEND_CONTENT_APP_VERSION=1.0.0(gamend_starter's did) built the engine as 1.0.0, and the SDK pair, whose@versionpublishing never stamped, built as 1.0.26 whereverAPP_VERSIONwas unset. Either one failed a host's>= 1.0.1266requirement with "the dependency does not match the requirement". The publish job now writes the release's version into all fourmix.exsfiles and removes the env lookup before anything is published; this repository's own image and docs still take the CI version from the environment.[fixed]
GamendWeb.BrotliCompressorno longer fails the digest whenbrotliis not installed. Its docs promised that a missing binary keeps the gzip, butSystem.cmd/3raises:enoentfor a command it cannot find, so a host that lists it in:phoenix, :static_compressorshadmix phx.digest, and with itmix assets.deploy, crash on any machine withoutbrotlionPATH. It now looks the binary up first and returns:errorwhen it is missing, so the digest writes only the.gzfiles.[fixed] Slow-request log lines carried players' names and emails. Sign in with Apple posts its callback with a
userfield holding the player's email and full name (on their first sign-in only), and that callback is routinely slow, so each new Apple player's details were written to the warning log, the admin Logs buffer, the log file and anything shipping logs off the host.GamendWeb.Plugs.RequestTimernow redactsuser, any key containingemailorphone, andfirst_name,last_name,full_name(and their unseparated spellings), as it already did credentials. Keys that merely contain a word, such asuser_countorname, stay readable.[added] Search finds guide sections, and knows what they open with. The palette offered only whole guides and posts, so a word that lived in a guide's body (usernames being UTF-8, say) found nothing. Every
##and###of every guide is now a row, linked to its heading, with its guide's title and the section's first sentence as the subtitle; the palette matches subtitle words below titles and keywords, and a query with a separator (utf-8) as a phrase. Guides and blog posts also contribute their frontmatterkeywords:. The content rows are built once per locale and kept until the content reloads (Gamend.Content.memoize/2,Gamend.Content.doc_sections/2), the browser normalizes each row once instead of on every keystroke, and the index answers304to a browser whose copy is current (ETag). How long the browser keeps it before asking isGAMEND_SEARCH_INDEX_MAX_AGE_SECONDS(default 600).[added] Per-account lockout after failed passwords. The per-IP auth limit did nothing against guesses at one account spread across many addresses. Failed passwords are now also counted per email address:
GAMEND_AUTH_LOCKOUT_ATTEMPTS(default 10,0disables) withinGAMEND_AUTH_LOCKOUT_WINDOW_MINUTES(15) lock password sign-in for that address forGAMEND_AUTH_LOCKOUT_MINUTES(15). While locked the password is not checked;POST /api/v1/loginanswers429 account_lockedwithRetry-After, and the browser form says so. The count is keyed by a SHA-256 of the address, not by account, so an unregistered address locks the same way and the lock reveals nothing; a correct password clears it. Emailed login links and provider sign-ins still work, so nobody can lock a player out. New tablelogin_lockouts, pruned by the retention class of the same name;Accounts.authenticate_by_password/2says why a sign-in failed, andget_user_by_email_and_password/2goes through it. Admin → Users shows a lock with Unlock.[added] A grace period before a deleted account is gone. With
GAMEND_AUTH_DELETION_GRACE_DAYSset (default0, delete at once),DELETE /api/v1/meand Delete account schedule the deletion that many days out (users.deletion_scheduled_at) and sign the account out everywhere. Until then API sign-ins answer403 deletion_scheduled, so a game client signing in on its own cannot undo it; signing in on the website keeps the account, and so does Keep account in Admin → Users, where a scheduled account is marked Deleting. The retention classscheduled_deletionsdeletes it on the day throughAccounts.delete_user/1. Admin deletions and inactivity sweeps never wait.Accounts.request_deletion/1,cancel_deletion/1,deletion_scheduled?/1; every API sign-in now asksGamendWeb.Auth.Tokens.refusal/1.[fixed] Abandoned lobbies and seats are released within a minute of their window.
GAMEND_RETENTION_ABANDONED_LOBBY_MINUTESand_PARTY_MINUTESsay 15 minutes, but the sweep that applies them ran every six hours, so a disconnected player could sit onalready_in_lobbyfor up to 6h15m. The classes that free live state (offline lobby and party seats, abandoned parties, abandoned lobbies) now also run everyGAMEND_RETENTION_LIVE_INTERVAL_SECONDS(default 60,0leaves them to the full sweep). The full sweep's cadence and batch size are settings too:GAMEND_RETENTION_INTERVAL_HOURS(6) andGAMEND_RETENTION_BATCH_SIZE(500).[fixed] Avatars and icons on a private S3 bucket stopped loading after an hour. With no
GAMEND_STORAGE_PUBLIC_URL,Storage.url/1answered a link signed for an hour, and that is what uploads saved asprofile_urlandicon_url. It now answers/storage/<key>, which redirects to a freshly signed link (GAMEND_STORAGE_SIGNED_URL_SECONDS, default 3600, cached for half of it).Storage.url(key, signed: true)gives the signed link itself, for display only. A migration rewrites the signed links already stored inusers,groups,quests,leaderboardsandtournaments./storage/*keyserves onlyavatars/andicons/, so a host that hands out the URL of any other key from a private bucket must now ask forStorage.url(key, signed: true), or the link is a 404. The prefixes are now a setting,GAMEND_STORAGE_PUBLIC_PREFIXES(defaultavatars/,icons/), so a host can serve keys of its own through the same route on local disk and S3 alike; add one only for keys with an avatar's randomness.[fixed] An upload ticket said 600 seconds and accepted 900. Both are now
GAMEND_STORAGE_UPLOAD_TTL_SECONDS(default 600), on local and S3 tickets alike.[fixed] A slow payment or OAuth provider held a request open for about a minute. Receipt checks (Apple, Google Play, Steam), OAuth code exchanges, Google ID-token checks and avatar mirroring used Req's defaults: 15 seconds a try, and three retries for a GET. They now go through
Gamend.HTTP:GAMEND_HTTP_CLIENT_TIMEOUT_MS(default 10000) a try andGAMEND_HTTP_CLIENT_RETRIES(default 1) retries of a GET.[added] Settings for values that were fixed in code. Each keeps its old value by default.
- Browser auth:
GAMEND_AUTH_SESSION_DAYS(14; the remember-me cookie follows it, and a session renews at half of it),GAMEND_AUTH_MAGIC_LINK_MINUTES(15, at most 60),GAMEND_AUTH_CONFIRM_EMAIL_DAYS(7),GAMEND_AUTH_CHANGE_EMAIL_DAYS(7),GAMEND_AUTH_SUDO_MODE_MINUTES(10; submitting the form gets ten minutes more, the 20sudo_mode?/1always allowed). GAMEND_AUTH_API_TOKEN_MAX_DAYS(0, no cap): caps a personal API token's lifetime, removes "never", and ends older tokens that many days after their creation.- Background jobs:
GAMEND_JOBS_QUEUE_DEFAULT,_HOOKS,_MAILERS,_STORAGE,_WEBHOOKS(10, 20, 5, 5, 10) andGAMEND_JOBS_PRUNE_AFTER_DAYS(7), applied byJobs.oban_config/0over the compiled Oban config.GAMEND_PUSH_QUEUE_CONCURRENCYmoved there fromHostRuntime. - Cache:
GAMEND_CACHE_MAX_ENTRIES(1000000) andGAMEND_CACHE_MAX_MEMORY_MB(500) per node, andGAMEND_CACHE_TTL_MS(60000) for every entity cache, read throughGamend.Cache.ttl/0. - Hooks:
GAMEND_HOOKS_CALL_TIMEOUT_MS(60000),GAMEND_HOOKS_CALL_TIMEOUT_IN_TRANSACTION_MS(5000),GAMEND_HOOKS_SLOW_THRESHOLD_MS(200). - Presence:
GAMEND_PRESENCE_INTERVAL_MS(120000) andGAMEND_PRESENCE_STALE_THRESHOLD_S(300). A connected socket's heartbeat follows the threshold (three fifths of it, at most every 3 minutes, as before). GAMEND_TOURNAMENTS_TICK_INTERVAL_SECONDS(30).- WebRTC:
GAMEND_WEBRTC_STUN_URLSand a TURN relay withGAMEND_WEBRTC_TURN_URLS,_TURN_USERNAME,_TURN_CREDENTIAL, for the server's own peer. GAMEND_HTTP_MAX_BODY_BYTES(1048576): the largest request body parsed.Plug.Parsersis built at runtime for it.- Game socket:
GAMEND_REALTIME_SOCKET_TIMEOUT_MS(300000), how long a silent socket stays open, andGAMEND_REALTIME_SOCKET_MAX_FRAME_BYTES(131072).socket/3fixes a transport's options when the endpoint compiles, so/socketis now declared with no transport (Phoenix still supervises it) and the endpoint's first plug serves/socket/websocketwith options built at runtime. It calls the samePhoenix.Transports.WebSocketplug the macro would;GamendWeb.GameSocketTestupgrades through the real endpoint, so a Phoenix change there fails the suite. - Peer-to-peer signaling rate limits:
GAMEND_RATELIMIT_SIGNALING_WS_LIMIT/_WINDOW_MS(300 / 10s) andGAMEND_RATELIMIT_SIGNALING_ICE_LIMIT/_WINDOW_MS(150 / 30s). The admin rate-limiting page reads them. GAMEND_LIMITS_MATCHMAKING_DEFAULT_MIN_PLAYERS(2) and_MAX_PLAYERS(5), for a ticket that leaves its size out.
- Browser auth:
[fixed] A post that opens with an image repeated its first paragraph. The show page drops the body's first paragraph when it is the lede, but only looked at the first
<p>, which for such a post is the image. It now takes the first paragraph with text, as the lede itself does.[fixed] Test suites ran with the cache on. Outside prod,
HostRuntimecopiedGAMEND_CACHE_ENABLEDintobypass_modeboth ways, and the setting defaults to on, so every test config'sbypass_mode: truewas overwritten withfalse. Only turning the cache off is copied now; otherwise the compiled config decides.[changed] Background workers a host's test suite turns off. Each runs outside the SQL sandbox, holds a pooled connection or sweeps on a timer, and a suite in sandbox auto mode can run out of connections to them. Gamend's own test configs set all of these, and a host's
config/test.exsshould too:Gamend.Tournaments.Ticker,Gamend.Matchmaking.Worker,Gamend.Chat.Moderation.SyncandGamend.Accounts.StalePresenceSweeperunder:gamend_core, plus two new switches,config :gamend_core, Gamend.Retention, enabled: false(both retention cycles; the full sweep's first run, five minutes after boot, landed inside long suites) andconfig :gamend_web, GamendWeb.IpBanSync, enabled: false(the IP-ban boot load). Each takesenabled: false. The boot log's JWT line now reads the token lifetimes from their settings; it read the Guardianttlkey, which is gone.[changed] Config keys replaced by those settings. The undeclared app-env keys
:hooks_call_timeout,:hooks_call_timeout_in_transactionand:slow_hook_threshold_msunder:gamend_coreare gone: setcall_timeout_ms,call_timeout_in_transaction_msandslow_threshold_msonGamend.Hooks.PluginManager. The signaling keys:signaling_ws_windowand:signaling_ice_windoware now:signaling_ws_window_msand:signaling_ice_window_ms.config/host_config.exsno longer setsice_serversfor:webrtc; a host that sets it still overrides the WebRTC settings.[added] Token lifetimes are settings.
GAMEND_AUTH_ACCESS_TOKEN_TTL_MINUTES(default 15) andGAMEND_AUTH_REFRESH_TOKEN_TTL_DAYS(default 30) set how long API access and refresh tokens last. Both were literals: the access TTL in the Guardian config (inHostRuntimefor prod, and again inconfig/dev.exsandconfig/test.exs), the refresh TTL at every call that signed one, andexpires_in: 900in the login, refresh and OAuth answers.GamendWeb.Auth.Guardiannow takes itstoken_ttlfromGamendWeb.Auth.Tokens.ttls/0, so every token signed, anywhere, follows the settings, andexpires_inis derived from the same value. A value below 1 counts as 1. Thettlkey is gone from the Guardian config; a host that still sets one is ignored, because the per-type TTL wins over it. Tokens already issued keep the lifetime they were signed with. The Godot and C++ SDKs schedule their refresh fromexpires_inand need no change.[changed] Lobby passwords are hashed with Argon2id. A join attempt against a password-protected lobby spent ~250ms of CPU on bcrypt at cost 12, and a join is something any signed-in player can repeat. New lobby passwords are hashed with
Gamend.Accounts.PasswordHash, as account passwords are (~24ms); existing bcrypt hashes still verify.[changed] IPv6 bans cover the /64.
GamendWeb.Plugs.IpBan.ban/2stores an IPv6 address under its /64 (2001:db8::1is listed as2001:db8::/64), since a subscriber can move to another address inside the /64. An IPv4 ban is unchanged. Bans stored per IPv6 address before this still match and still lift.[fixed] The rate limiter runs before the request body is parsed. It came after
Plug.Parsers, so a request it was about to refuse had its body (up to 1 MB of JSON or multipart) read and decoded first. It now runs right after the IP ban, with CORS ahead of it so a 429 still carries the headers a web client needs to read it. It strips a locale prefix itself, since it now runs beforeLocalePath.[fixed] An email send gives up after 30 seconds. gen_smtp waits up to 20 minutes for each reply from the relay, and that is not configurable, so a hung relay hung the magic-link request, email change or mail job that was sending.
UserNotifiernow runs the send in a task limited by the newGAMEND_MAIL_SEND_TIMEOUT_MS(default 30000).[fixed] A cache entry invalidated inside a transaction is invalidated again after the commit. Until the commit, a concurrent read still sees the old row and could cache it back, on any node, where it stayed until the TTL.
Gamend.Cache.invalidate/1andbump_version/1evict immediately, so the transaction reads its own writes, and once more after the commit.[fixed] The tournament tick holds no transaction on Postgres either.
Lock.exclusive/3takes a session-level advisory lock on one connection and runs the tick on it, so each tournament commits on its own and releases its rows at once. Before, one transaction held every row the tick touched until it finished. If the process dies, the connection closes and the lock goes with it.[fixed] The plugin SDK had no default for
before_group_join/3. Its__using__defined every callback but that one, against the rule for SDK callbacks. A plugin fell through to core's default anyway, which returns the same value. The injected defaults are now split across three quoted blocks,default_callbacks,more_default_callbacksandoverridable_callbacks, one block of that length being more than credo allows.[added] Stripe Managed Payments.
GAMEND_PAYMENTS_STRIPE_MANAGED_PAYMENTS=true(default off) makes Stripe the merchant of record: every Checkout Session carriesmanaged_payments[enabled], so Stripe charges and remits the buyer's VAT or sales tax, handles disputes and sends the receipts (from Link). The Checkout Session call is raised to API version2025-03-31.basilwhen the configuredGAMEND_PAYMENTS_STRIPE_API_VERSIONis older, as Managed Payments requires; every other call keeps the configured version (ProviderConfig.stripe_checkout_api_version/0). None of the parameters Managed Payments rejects is sent by core. Before switching it on: accept the Managed Payments terms and give every product an eligible tax code in the Stripe Dashboard.[fixed] Slow work no longer runs while a transaction holds the database. On SQLite the repo has one connection and every transaction takes the write lock, so whatever runs inside a transaction runs while every other request waits. Several paths did slow work there. Joining a password-protected lobby ran the password check (bcrypt, ~250ms) and the
before_lobby_joinhook inside the lobby's lock, so one player sending wrong passwords could stall the whole server. Group join, lobby metadata merges (Lobbies.merge_metadata/2) and the payment metadata written on entitlement changes ran theirbefore_*hook inside the lock. Lobby deletion gathered its snapshot and deleted KV entries one statement at a time. The tournament tick held one transaction across every active tournament. Broadcasts, hook tasks and notifications fired from inside transactions all over core, before the write was visible and even when it then rolled back. Now:Gamend.AfterCommitholds effects until the commit and drops them on rollback. Every transaction in core opens through it, andGamend.Lock.serialize/3uses it too.Gamend.Broadcast.publish/2andGamend.Async.run/1wait for the commit when called inside a transaction, and replace the directPhoenix.PubSub.broadcastcalls in core. A test fails on a bareRepo.transactionorPhoenix.PubSub.broadcastin core.before_*hooks and password checks run before the lock, which then re-checks only what a concurrent writer could change. A full lobby or group is still refused without calling the hook.- Merges that need the hook's answer on the value the lock protects are optimistic: they write only if the value is unchanged and retry otherwise. They also stopped reading through the cache, which could lose a concurrent merge.
- The tournament tick takes the new
Lock.exclusive/3, so each tournament commits on its own. This also fixes effects queued in a tick that raised: they no longer fire on the next tick. - On Postgres,
serialize/3waits on a node-local mutex before taking a connection. Previously one slow holder and nine waiters on the same lobby emptied a pool of ten.
[added] Stripe's customer portal, from account settings. The Payments tab of
/users/settingsshows Manage billing to an account that has paid through Stripe; it opens Stripe's hosted portal (Stripe.BillingPortal.Session), where the buyer cancels, changes card and downloads invoices, and returns to the tab.Payments.stripe_customer_id/1finds the account's customer on its newest Stripe purchase (the stored checkout session, or subscription);Payments.create_stripe_billing_portal/2opens the session. Checkout now keeps one customer per account: a returning buyer's checkout reuses theircus_id (set server-side, never taken from the client), and a one-off payment asks Stripe to create one (customer_creation: "always"), which subscription mode already did — without it a one-off buyer had no customer and so no portal and no receipts in it. Configure the portal once in the Stripe dashboard (Settings → Billing → Customer portal) before using it in live mode.[added] Host hook modules.
config :gamend_core, :host_hook_modules, [MyApp.Hooks]adds a host app's modules to the lifecycle hooks, after:hooks_moduleand before plugins. A host that needs one event,after_user_deletedsay, no longer has to take over:hooks_module, which made its module the primary for every fan-out hook in place ofGamend.Hooks.Default. A host module exports what it implements and is called only for that;Gamend.Hooks.call/3still reaches:hooks_modulealone.[added] Host plugs, ahead of the site.
config :gamend_web, :host_plugs, [MyApp.GamesHost, {MyApp.Other, opts}]runs a host app's own plugs right afterForceSSL, before the canonical-host redirect, the security headers, static files, the session and the trailing-slash redirect. A second host name the app answers — a game CDN, a status page — no longer has to fight every one of those: a halted conn ends the request there. Each plug isinit/1ed once per configuration and cached in:persistent_term. Unset, nothing changes.[changed] Sign-up no longer waits on the mail server.
POST /api/v1/registerand the browser form sent the confirmation email over SMTP inside the transaction that inserted the user. On SQLite the repo has a single connection, so for the length of every SMTP session, often a second or two, no other request could read or write, and a burst of sign-ups (10 a minute per IP) could stall the whole server. The email is now a job on themailersqueue (Gamend.Accounts.ConfirmationMailer), enqueued in that transaction, so a committed account always has its email queued and the transaction holds the database for two inserts. The job mints the token itself, so no token sits in the jobs table. A failed send is retried with backoff, and a job past 60 seconds is killed, so a hung mail relay costs a queue slot rather than the database. Because the response no longer waits, the503 email_delivery_failedanswer is gone: the account is kept and the email retried. Abefore_user_registerplugin that refuses the sign-up now answers403 registration_refused, with its message when it returns a string; before, it was reported as that 503. The password is also hashed once per sign-up instead of twice: the tentative user handed tobefore_user_registerplugins was hashed as well, and no longer is (nor carries the plaintext).[fixed] An IPv6 client was rate-limited per address. One IPv6 subscriber is routinely handed a /64, 2^64 addresses, so the per-IP limits (10 sign-ups or logins a minute, 240 requests) did not hold for anyone on IPv6. HTTP and LiveView limits now key IPv6 by /64 (
GamendWeb.RateLimit.ip_key/1). An IPv4-mapped address (::ffff:1.2.3.4) is keyed as its IPv4, so IPv4 clients behind a dual-stack listener keep their own buckets. The captcha still sees the exact address.[added] Personal API tokens. A script or CI job had only
POST /api/v1/login: a password (which a social-login account does not have) for a fifteen-minute token. Settings → API tokens now makes a namedgamend_pat_…token that lasts 30, 90 or 365 days, or never, accepted as a Bearer token on every route an access token reaches (GamendWeb.Auth.ApiTokenAuth, first in both API pipelines, handing Guardian the claims an access token carries so the deactivation check still applies). Only a SHA-256 is stored and the token is shown once. A password or email change, or signing out everywhere, retires every token made before it — each remembers thetoken_versionit was made under — so a stolen session cannot leave a token behind that outlives the reset. Tokens are made only on the settings page, never through the API.Gamend.Accounts.ApiTokens; limitGAMEND_LIMITS_MAX_API_TOKENS_PER_USER(default 10); retention classdead_api_tokens; guide under Authentication.[fixed] Relative links in a folder's
index.mdpointed one level up. A link resolves against the document's folder, which for a page is its slug's parent — but anindex.md's slug already names its folder, so taking the parent there sent[Builds](builds.md)inforge/index.mdto/docs/builds, and every./assets/mesh.mdon a generated reference landing page to a 404.Gamend.Content.Markdown.render_file/2now marks an index file (:index) and its links resolve against its own slug.[fixed] A plugin never loaded in an OTP release. A release runs the code server in embedded mode, where nothing loads on first call and
Code.ensure_loaded/1answers{:error, :embedded}for any module the boot script did not load — and a plugin is never in the boot script. So every plugin failed at boot withplugin=… failed to load module=… {:error, :embedded}in thereleaseimage while working undermix phx.server.Gamend.Hooks.PluginManagernow loads each beam on a plugin's own paths explicitly when the code server is embedded, before the app is loaded and started; interactive mode is unchanged.[added] Accounts that never confirmed their email are deleted after 30 days idle.
GAMEND_RETENTION_UNCONFIRMED_USERS_DAYS(default 30,0keeps forever) sweeps accounts whose only identity is an email never confirmed, idle for that long (coalesce(last_seen_at, inserted_at)), with the exemptions every user sweep has: admins, and anyone holding a purchase or entitlement. A sign-up costs one request, and nothing pruned these, so they were the tier a bot could fill for good. An account that also holds a provider login is kept. Activity decides, not age, becausePOST /api/v1/registersigns in unconfirmed and a player still playing keeps the account. Expiredconfirmtokens are now pruned with the other expired tokens. The admin Users page adds an "Unverified email" filter, also reachable as/admin/users?filter=unverified.[added] A captcha option for
POST /api/v1/register. WithGAMEND_CAPTCHA_ENABLEDon,GAMEND_CAPTCHA_API_REGISTER=true(default off) requires a Turnstile token in the newcaptcha_tokenfield:403 captcha_requiredorcaptcha_invalid,503 captcha_unavailablewhen Cloudflare cannot be reached. Off by default, because a game client with no browser cannot render the widget.[fixed] The browser register and magic-link forms could skip their rate limit. The LiveViews rate-limited by the socket's
peer_data, which the longpoll transport does not carry, and an"unknown"address was let through, so either form was unlimited over longpoll. Behind a reverse proxy the address was the proxy's, putting every visitor in one bucket. The:current_userlive session now signs the address the HTTP request resolved (afterRealIp) into its session,LiveHelpers.client_ip/2reads it there, and"unknown"is a bucket like any other.[added] Content that can carry a manual.
Gamend.Contentreads real frontmatter now (Gamend.Content.Frontmatter: scalars,[a, b]and- alists —title,description,position,image,keywords,slug,label), and a collection registered withnesting: :treeis read at any depth (Gamend.Content.Tree): folders are categories with a_category.md,index.mdis a category's own page, slugs are paths (manual/scenes), anddoc_tree/1,get_doc_category/2,doc_breadcrumbs/2anddoc_toc/2answer what a sidebar, a landing page, a trail and a table of contents need. Rendering (Gamend.Content.Markdown) gives headings ids, renders footnotes,:::tip[Title]directives and> [!NOTE]alerts as one admonition markup, turns amermaidfence into theMermaidDiagramhook's element, rewrites a link to a neighbouring.mdfile into its route (base_path:), and lets a fixed vocabulary of raw HTML —<figure>,<video>,<details>, a classed<div>, an inline<svg>— through the sanitiser.assets: :staticleaves root-absolute image paths forpriv/static. The blog readstitle,slug,date,description,authors(from_authors/<key>.md),image,keywordsandtags, honours<!-- truncate -->, counts reading time, and no longer opens a post with its own frontmatter as the lede. A:pagescollection answers markdown at any unrouted path through the configured-page fallback. Feeds at/blog/rss.xmland/blog/atom.xml.[added] A docs layout for a manual.
use GamendWeb.DocsLive, layout: :sidebarrenders the tree beside every page, a table of contents fromxl, breadcrumbs, a landing page per category, an "Edit this page" link (edit_url:), and previous/next across the tree; the route islive "/docs/*path".:cards, the default, is unchanged.Layouts.app widelets a page with side columns out past the reading width. Blog cards and posts show the cover, the authors and the reading time.[added] Trailing slashes redirect.
GamendWeb.Plugs.TrailingSlash, in the endpoint before the router:GET /docs/intro/is a 301 to/docs/intro, query string kept, so a page has one URL and the inbound links of a site that ended every URL in a slash keep working. The root, non-GET requests and/api/…are left alone;config :gamend_web, :redirect_trailing_slash, falseswitches it off.[added] A page's own social image, and a card grid.
GamendWeb.PageMeta.Providergainedimage/1(withbreadcrumbs/1androbots/1now declared too): the root layout uses it forog:imageandtwitter:imagein place of the theme's banner, and emitsog:url. A presentation section may carry"cards"— icon, title, text, optionalhref— rendered as a responsive grid, for the section whose point is a set. The LiveView socket passes:user_agentinconnect_info, so a page that adapts to the visitor's platform reads the same value on connect as on the dead render.[added] A dark-theme logo. A top-level
logo_darkin the theme JSON is the mark the navbar shows underdata-theme="dark", swapped by the attribute the way the presentation images are; a logo drawn in dark ink had no way to survive the dark page. The admin Config page reads it before deriving_darkfrom the logo's name.[changed] R5 leaves file names alone.
mix gamend.api.lintno longer flags a hyphen in a route whose last segment is a file —llms-full.txtis spelled the way the convention spells it.[fixed]
mix gamend.api.lintread core's router. R9 checked documented/api/v1/…paths againstGamendHost.RouterorGamendWeb.Routerby name, so a host with its own router module had every route it declared reported as undocumented the moment a guide mentioned one.declared_route_paths/0now resolvesconfig :gamend_web, :routerfirst, the way the endpoint andGamendWeb.ApiSpecalready do.[added] "Log in with GitHub".
githubjoins the social sign-in providers, configured like the others:GAMEND_OAUTH_GITHUB_CLIENT_IDandGAMEND_OAUTH_GITHUB_CLIENT_SECRET(a GitHub App's or an OAuth App's pair),GAMEND_OAUTH_GITHUB_ENABLEDto switch it off, callback at/auth/github/callback, and it appears in the sign-in buttons,/auth/:provider,GET /api/v1/auth/providers, the/api/v1/auth/githuband/api/v1/me/providers/githubflows,linked_providers.github(andbool github = 7in the realtimeLinkedProvidersmessage), the admin dashboard, user list and Config page, and agithub_idcolumn onuserswith a partial unique index.Gamend.OAuth.Exchanger.exchange_github_code/5sends the code togithub.com/login/oauth/access_token(a bad code comes back as a 200 witherror, so only a body carryingaccess_tokencounts) and reads/user; the primary email comes from/user/emailswhen the App holds the email permission, carrying GitHub'sverifiedflag so a verified address may attach to an existing account, and a profile whose emails cannot be read signs in with no email, as Steam does. Noscopeis sent: a GitHub App ignores it, its permissions live on the App. Setup guide at/docs/github-oauth.[added] A group selector on the quests page. Quests sharing a
group_keycollapsed to one card each, which is fine for three groups and a wall for fifty (a host with one group per language had 54 cards titled by code). When a viewer's quests fall into groups,GamendWeb.QuestsLivenow offers a selector over them (Gamend.Quests.groups/2) and lists the picked group alone, the selector's other groups off the page. The plain<select>covers every group behind an "All" that is the collapsed cards. A host registers aGamendWeb.QuestGroupSelector(config :gamend_web, :quest_group_selector, Module) to draw its own control, say which groups it covers — the rest keep their collapsed card, so a "visit every country" group is not offered in a language menu — and name the group to open on (default_group/1, the user id or nil); behind a host selector, nothing picked lists none of its groups. InGamend.Quests,list_user_quests/2/count_user_quests/2takedrop_groups:(keys to leave out, collapsed or opened);group:is unchanged. A collapsed entry now carriescollapsed: true, which is what a card reads to know it stands for its group: it readgroup_size > 1, which an opened group's members carry too, so listing them inline drew every one under the group's title. The signed-out catalog now also runs the host'squest_visibility_filter, asvisible_categories/1already did for it.[changed] Usernames are Unicode, and display names hold 255 characters. A handle may be letters and digits of any script (
дмитрий,山田太郎,nicö), still 3-32 characters with non-consecutive._-.Gamend.Accounts.Usernameowns the rules: input is NFKC-normalized and lowercased, so fullwidth, ligature and decomposed spellings land on one stored form the unique index can compare, andget_user_by_username/1normalizes the same way. Against impersonation it applies two rules of UTS #39, the Unicode security standard browsers use for domain names, listed with examples in the authentication guide's Usernames section: scripts mix only as the "Highly Restrictive" profile allows, so a handle keeps to one script or joins Latin with Chinese, Japanese or Korean (王wang,yamada太郎,김민준kimpass; a Cyrillicаinsidepaypalis refused), and combining marks never repeat nor stack past four (three per letter as stored). A host that would rather keep the GitHub and Discord model setsGAMEND_LIMITS_USERNAME_ASCII_ONLY=true: handles are thena-z,0-9and separators, still normalized first, with display names Unicode as before. A plugin with other ideas replaces the character rules wholesale with the newvalidate_username/1hook (:ok,{:error, message}or:default); length, uniqueness and invisible characters stay with core.UsernameGenerator.slug/1still transliterates a Latin name to ASCII (Drágoș->dragos), and now keeps a name in another script instead of falling back to a random word.max_display_namedefaults to 255 (was 80), the column's ownvarchar(255)limit, and the changesets now count codepoints as Postgres does (graphemes let a name with combining marks pass validation and then overflow the column). An Apple name past it is dropped whole, never cut. The website shows a long name truncated with the full one on hover (<.player_name>).[fixed] Sign in with Apple never kept the player's name. Apple sends the name once, on a player's first authorization, and never in the ID token, yet
user_params("apple", …)read it from the token only. The web callback now reads Apple'suserform field (OAuthExchange.apple_web_name/1), andPOST /api/v1/auth/apple/ios/callbackand/me/providers/apple/iostake optionalgiven_name/family_namebesidecode. The name fills a blank display name only (the existing update scrub keeps a set one), dropped rather than cut when pastmax_display_name, so an over-long name cannot fail the sign-in. The Godot SDK sends both from the credential. A player who signed in before this has lost the name for good: Apple sends it again only after they remove the app under Apple ID → Sign in with Apple.[added]
Leaderboards.list_records/2andcount_records/2take a:metafilter,{key, value}, keeping only records whosemetadata[key]matches. Ranks are computed within the filtered set, because "the Spanish board" means first among Spanish, not 57th overall — the opposite of:search, which ranks over the whole board so a found player's real position is what shows. Adapter-specific SQL, the same wayGamend.Notificationsreads a metadata key:->>on Postgres,json_extracton SQLite, with the value bound as a parameter. Uncached, like search, so a caller-supplied value cannot fill the cache with one entry per value anyone picks.[added]
Quests.active_quests_for_event/1, andreport_event/4uses it. Event dispatch scanned every active quest to find the handful that listen, so a host with a large family of quests paid for all of them on every unrelated event — andreport_event/4is the hottest write a player makes. It now reads a list cached per event. Cached per event rather than as one grouped map on purpose: Nebulex copies a value out on read, so a single map of every event's quests would copy the whole catalogue on every lookup, which is the cost this removes. Both keys carryquests_version/0, so a definition change drops them withactive_quests/0.[fixed] The admin Logs page counted the whole buffer and listed a filtered view.
AdminLogBuffer.count_by_level/0took no filters, so the level chips and the "Showing N of M" footer described every buffered entry while the rows beneath them honoured the source, module, query, session and user filters. With the default server-only source that read aserror(7)above a list holding one — the other six were client entries.count_by_level/1now takes the same options aslist/1and applies all of them except:level, which is what a chip beside a live filter has to say ("how many would I see if I picked this");count_by_level/0is unchanged for callers that want the buffer itself. The page keeps both numbers apart: the "Buffer:" header stays unfiltered, the chips and the footer follow the filters, and the footer names the buffer total beside the match count when they differ.[fixed] A TLS alert an iOS client sent after the handshake crashed a connection into the log.
protocol_versionis not in@benign_alertson purpose — this server choosing a version a client will not accept is a real misconfiguration — but a peer that completed the handshake, was served over HTTP/2 and then sends a fatalprotocol_versionis tearing down a live connection over a version it already agreed to, which Apple'sNetworkingExtensiondoes. The atom cannot tell the two apart, so the alert description now does: onlyIn state connection received CLIENT ALERTis dropped, which is post-handshake and peer-sent. Anything during the handshake, and anything this server generates, still reaches the log.[added]
Gamend.Retention.register_class/2. A host application or plugin can register its own pruning class, and it runs on core's sweep with the same batching, failure isolation, telemetry and admin page as core's own.prune_all/0was a fixed map, soCONTRIBUTING's rule that every unbounded table needs a retention class was one a fork could not follow. Registering by name is idempotent, so a boot-time call cannot prune twice, and a registered class cannot shadow one of core's — that would silently stop core pruning that table.[added]
Gamend.Hooks.register_pipeline_hook/1. A host or plugin can declare that its ownbefore_*hook transforms its input, so it is dispatched as a pipeline: each plugin receives the previous one's output and{:error, reason}halts the chain. Core's list of pipeline hooks was fixed, so a host's hook fell through to the fan-out path, where every plugin gets the same arguments, only the first one's result is kept, and the rest run even after the first refused. With one plugin loaded the two are indistinguishable; with two, changes are dropped and side effects happen after a refusal.[fixed] A host app's API routes were served but never documented.
GamendWeb.ApiSpecbuilt the paths fromGamendWeb.Routerwhile the endpoint dispatches through the router named in config, so routes a host added were missing from/api/docs, from the generated SDKs, and from the route existence checks inmix gamend.api.lint. The spec now resolves the router the same wayGamendWeb.Endpoint.dispatch_router/2does, falling back toGamendWeb.Router.[fixed] A host app's own settings were never discovered.
Gamend.Settings.apps/0was core's two apps plus whatever calledadd_app/1, and nothing ever did — so a host that declared settings withGamend.Settings.Providergot no boot validation, no row on the admin Settings page and nothing inmix gamend.settings.env_exampleormix gamend.settings.guide, whileSettings.get/2went on answering with the compiled default. Setting the env var did nothing and said nothing.apps/0now also scans the app named by the:host_static_appconfig, which a host already sets to name itself, so the mix tasks see the host's settings too — they runapp.configwithout starting the application and never reach the boot path.GamendWeb.HostSupervision.init_runtime/1additionally takes:host_appfor a host that wants to name itself explicitly;init_runtime/0still works and isinit_runtime/1with no options.[fixed] The C++ SDK would not configure on Windows. IXWebSocket has no Schannel backend and asks for mbedTLS there, which Windows does not ship, so a first build stopped at
Could NOT find MbedTLSunless TLS was switched off. It now takes OpenSSL when the build has one and builds mbedTLS 3.6.7 alongside the SDK when it does not, and the Windows CI job builds with TLS on instead of off.[fixed]
DELETE /api/v1/friends/{id}answered 500 when the removal failed. It passed the whole{:error, reason}to the error reply, which cannot render it. A request that is already gone now answers 404not_found, and any other failure 400remove_failed.[added] The Godot, Rune (Balaur) and C++ SDKs ship together on the
latestrelease. Each change tomainattachesgodot_addons.zip,balaur_addons.zip(with the version stamped intoversion.rn) andgamend-cpp-sdk.tar.gzto it, under release notes that say which is which and link the guides. The release is now called "Gamend SDKs Nightly". The README names the three SDKs, and the realtime, WebRTC, key-value and architecture guides show the C++ calls beside the Godot and JavaScript ones.[fixed] The second WebRTC DataChannel was refused. The server peer kept one channel per connection while the JS and Godot clients open two by default (
eventsandstate), so whichever opened second was dropped, and when that wasevents, every hook call over WebRTC timed out. It keeps up to four now; a test opens both default channels against it over a real ICE exchange.[breaking] Signing in and linking through a provider are separate endpoints. The provider sign-ins (
POST /api/v1/auth/{provider}/callback,/auth/google/id_token,/auth/apple/ios/callback) always sign in, and answer the sameSessionas email and device login,usernameanddisplay_nameincluded. They answeredOAuthResult, whose six fields were all optional, and linked the provider instead whenever a bearer token rode along: a signed-in game that called "sign in with Google" to switch accounts linked Google to the account it was leaving, and no client could tell from the type which it got. Linking isPOST /api/v1/me/providers/{provider}{code}(a Steam ticket for Steam),/me/providers/google/id_tokenand/me/providers/apple/ios, which require the bearer token and answer the current user; a provider account that belongs to someone else is409 provider_already_linked. The browser flow splits the same way:GET /api/v1/auth/{provider}always starts a sign-in, polled at/auth/session/{id}, which now answers{status, error, message, session},sessionbeing theSessionon the first read after it completes (wasresult, the stored map);POST /api/v1/me/providers/{provider}/authorizestarts a link, polled by its owner atGET /api/v1/me/providers/sessions/{id}(ProviderLinkStatus). A session's status ispending,completedorerror, with the code inerror(conflictwas documented and never sent; failures carrieddetails). Unlinking a provider and linking or unlinking the device answer the current user (were{"ok": true}). A provider sign-in now runs what email login runs after it: theafter_user_logged_inhook and theloginquest event. A Google ID token with no client id configured is503 google_not_configured(was500 server_misconfigured).OAuthResultandOAuthSessionDataare gone from the document. A game that linked by signing in while signed in calls the link flow now:provider_link,apple_native_linkordiscord_native_linkin the Godot SDK,auth.linkin Balaur,Auth::link/Auth::link_steamin C++.[fixed] A finished Steam sign-in session could be redeemed again. The Steam callback accepted any stored session, where the other providers already required one still pending and started for that provider, so whoever started a session could collect the tokens of a later sign-in through it.
[added] A C++ SDK.
cpp_sdk/is a C++17 client for custom engines, Steamworks games and native tools, and the core a future Unreal plugin wraps. Every REST operation is a method ofclient.api(), named as the Godot SDK names it, and every reply reads as a typed model (r.as<models::Lobby>(),r.page<models::Lobby>()), generated from the named schemas.Authsigns in with a device, an email, registration, a Steam ticket or a provider page, links providers, and keeps the session fresh (three quarters into the access token, and once more on a401), withon_session_changed/restoreto keep it between runs.Realtimejoinsuser:<id>, joins any topic, calls server hooks, names every event fromevents.json, reconnects with backoff and a fresh token, and rejoins its topics;RealtimeFormat::Protobufdecodes binary event frames with a reader generated fromproto/gamend_realtime.proto, no protobuf library, and hands a game's own*_pbmetadata and KV data to the decoders it registers.Kvkeeps live key-value rows over reconnects andPresencemerged profiles and online state from the events.WebRtcopens a DataChannel to the server through libdatachannel (GAMEND_WITH_WEBRTC) and calls hooks over it in JSON or protobuf, typed hooks included (call_hook_raw). HTTP, WebSocket and WebRTC are three small interfaces, with libcurl, IXWebSocket and libdatachannel shipped and fakes for tests, so an engine can plug its own; callbacks run inclient.poll()on the game thread. The core builds with-fno-exceptions -fno-rtti, warning-free under-Wall -Wextra -Wpedanticwith Clang and GCC.api.hpp,models.hpp,events.hppand the protobuf table are generated byclients/generate_cpp.shintocpp_sdk/, which is not committed. CI generates it, runs the 93 unit tests on Linux, macOS and Windows, builds a game against the installed package, runs the conformance scenario (sign-in, refresh, socket, lobby events, hooks, KV, a dropped connection, WebRTC) against a booted server in JSON and protobuf, and puts it on thelatestrelease asgamend-cpp-sdk.tar.gz: fetch it withFetchContent, orcmake --installit andfind_package(gamend). The API docs page and the home page link it. Guide: C++ SDK.[changed]
clients/sdkgen/writes the Balaur and C++ SDKs.generate_balaur.pybecame one model (operations, realtime events, names) with an emitter per target; the Balaur output was byte-identical after the move. The generated Balaur calls now check the required fields of a body that is a named schema (client_logs_upload_client_logsneedssessionandentries).[breaking] The Balaur SDK is called by path, one module per tag. Balaur mounts an addon's files as modules, so
addons/gamend/lobbies.rnisgamend::lobbiesin every script with noscript::require. The 259 operations are in 47 modules, each named for its operation without the tag:gamend::lobbies::create_lobby,gamend::matchmaking::join,gamend::admin_kv::upsert_kv; thepushtag ispush_tokens.rn, sincegamend::pushis the engine's own call.events.rnholds onepub modper channel (gamend::events::lobby::MEMBER_JOINED) anddecode; the flat event constants andapi.rnare gone.client.rn,auth.rnandlog_sink.rncall by path,client::unpack_hookdrops its unused first argument, andclient::fetch_cachedcallskv::get_kvwith its three arguments, which it was one short of.[added] Godot SDK:
authenticate_register(email, password, username), which keeps the session it answers like login does, and façade methods for the admin analytics, the filter-word languages, storage usage and the client-log calls, which had generated classes but noGamendApimethod.[fixed] Balaur
auth.sign_innever opened the provider's page. It readurlwhere the server sendsauthorization_url, passed the provider toauthenticate_oauth_requestas a table instead of a string, read the session status outside itsdata, and waited forfailed/cancelledstatuses the server never sends. It now opens the page, stops onerrororconflict, and signs in with the tokens underresult.[fixed] A browser game could not call the API with its run id. CORS allowed
content-typeandauthorizationonly, so the preflight for any request carryingx-gamend-session(every Balaur SDK call, and the Godot client's log uploads) failed and the browser never sent it. The header is allowed now.[added] Email and password registration for game clients.
POST /api/v1/registertakesemail,passwordand an optionalusername, sends the confirmation email as browser sign-up does, and answers201with the same tokens as login. Registration was browser-only, so a game had device login and nothing to sign a player up with an email. It goes through the same path as the browser form: the first account is the admin and confirmed without an email,GAMEND_AUTH_REQUIRE_ACTIVATIONholds, and an email that cannot be sent rolls the account back (503 email_delivery_failed). A taken email or username is409. The auth rate limit applies.[fixed]
DELETE /api/v1/medocuments its body. An account with a password has to sendcurrent_password, and the operation declared no body, so generated clients could not delete such an account. The OpenAPI operation now carries the optionalcurrent_password.[fixed] Chat messages never created a notification.
chat_friend,chat_group,chat_lobbyandchat_partywere missing fromGamend.Notifications.Types, so every chat notification was rejected at write, inside a background task that dropped the error. They are registered now, withquest_completed, which only got through because it was written with atom keys the type check did not read; the check reads both. A chat notification that fails to write is now logged. Tests cover each chat kind and the quest notification.[changed] Social sign-in buttons name the provider. "Log in with Discord", "Register with Google" in place of the same "Log in" five times.
oauth_buttonstakesaction={:login | :register}instead oflabel; the grid is one column belowlg, where the form ismax-w-smand a named label does not fit half of it. Two new strings, translated in all 28 catalogs.[changed] The log in page has one submit button. "Remember me" is a checkbox, on by default, in place of the second "Log in and remember me" button, and a "Forgot password?" link points at the magic link form: a magic link logs the user in and the Account page sets a new password without the old one. Three new strings, translated in all 29 catalogs.
[added] Theme
contact_email. The privacy, data deletion and terms pages give it as a mailto link for access, correction and deletion requests. Without it they still say "support channels", which app-store review and a data-protection request cannot act on.[changed] Lobby responses have names in the OpenAPI document.
Lobby,LobbyPage,LobbyResponse,LobbyStatsResponse,PageMetaandUserBriefreplace the inline schemas, and every lobby error isErrorResponse, so generated clients getLobbyPageinstead ofListLobbies200Response. The inline schema had drifted from the serializer: it lackedstate,state_changed_atand the members'is_activated, which a typed client drops, and typedhost_idas a UUID though a hostless lobby sends"".GamendWeb.ResponseContractnow checks every lobby response in the test suite against its schema, undeclared keys included. Nothing changes on the wire; the generated class names change at the next SDK release. First slice ofdocs/specs/named-api-schemas.md.[changed] User, sign-in and friend responses have names too.
CurrentUser,PublicUser,Session,OAuthResult,Friend,FriendRequest,ProfileUpdate,UploadTicketand their pages replace 26 generated classes such asLogin200ResponseDataandListFriends200ResponseDataInner. The document had drifted here too: every user row lackedis_activated,lobby_idandparty_idwere typed as UUIDs though they are""outside a lobby or party, device login was documented with the OAuth polling payload, and the profile, avatar and upload-ticket answers were documented as empty objects. Two fixes on the wire: a friend request whose user was not loaded now sends that user's whole row (addingprofile_urlandis_activated), and an OAuth sign-up that fails validation answers 400 witherrorsinstead of a 500 from an unencodable changeset.[fixed] 17 authenticated endpoints answered 401 to the JavaScript SDK. A generated client sends the bearer token only where the OpenAPI document declares it, and
get_lobby, every chat endpoint, matchmaking, tournament join/leave/my-match,my_quests,claim_questandget_my_recorddeclared nothing usable: nine nosecurityat all, eight a"bearer"scheme the document does not define. The Godot SDK sends the token everywhere, which hid it. The seven optional-auth endpoints (a group, its members, quests, a tournament, client logs) now declare the token as optional, so a signed-in client sees what it is entitled to instead of the anonymous view.GamendWeb.ApiSecurityTestchecks every route's pipeline against its document entry.[breaking] One response shape for users, sign-in, friends, lobbies, groups, parties, chat, notifications and push. Every answer is now
{"data": ...}, a page{"data": [...], "meta": ...},{"ok": true}, or an error{"error": "code", "message": "..."}— nothing else at the top level. Bare resources moved underdata(a create answers 201 with what it made);{}became{"ok": true}; a profile change answers the whole current user instead of{ok, id, ...}; the OAuth session poll answers{data: {status, message, result}}; unmuting answers{data: {deleted}};get_lobbyputs members and spectator count inside the lobby; party invitation lists are pages;group_nameisgroup_title, in REST, realtime and new notification metadata. Error codes are alwayssnake_case(not_authenticated,invalid_credentials,missing_param...) with prose inmessage;detailsandreasonare gone; a rejected form is always 422validation_failedwitherrors(409 for a uniqueness clash). Unknown routes and the auth pipeline's 401 answer the same shape.docs/specs/api-conventions.md(R15, R16) is the rule;GamendWeb.ApiShapeTestchecks the OpenAPI document against it andGamendWeb.ResponseContractchecks every response the test suite provokes, so an endpoint answering any other way fails CI. The remaining domains follow in later slices.[breaking] Leaderboards and tournaments take the same response shape. Tournament join answers the new entry under
data(was{ok, entry}); the bracket is a page of brackets, each carrying its ownmatchesand theentriesthey name (was one{brackets, entries, matches}object beside the page'smeta);my_matchwith no match waiting answers 404no_current_match(was{"data": null}); standings list placements underplacements(wasentries); records around a user are a page (one complete page holding the window). Errors are codes:not_found,record_not_found,missing_param, and for tournamentsregistration_closedandtournament_full(403),already_registeredandalready_drawn(409),not_registered(404), a hook'srejected(403, its words inmessage),invalid_index(400); a failed changeset is 422validation_failed. Everyalready_*answer is now 409, which movesalready_member,already_admin(groups) andalready_in_lobby(joining a lobby) from 403. Generated clients getLeaderboard,LeaderboardRecord,Tournament,TournamentEntry,TournamentMatch,TournamentBracketand their pages instead ofListLeaderboards200Response-style names.[fixed] The tournament entries page counted every entry whatever the
statefilter, sototal_countandhas_morewere wrong for?state=eliminated.[breaking] Quests, economy and payments take the same response shape. The payments catalog and entitlements are pages (they were bare arrays with no paging); Steam finalize answers the purchase under
data(was{data: {purchase}}); the payment webhooks answer{data: {status}}(was{ok, status}). Claiming a quest that is not completed is 403not_completed(was 409), an unknown questnot_found(wasquest_not_found), and abefore_quest_claimveto 403rejectedwith the hook's reason inmessage(was 422claim_rejectedwith aninspectedreason). Payment refusals keep their codes and take the status of their kind:already_owned,purchase_already_in_progressandreceipt_already_used409,*_not_found404,*_not_configured503 (a webhook provider retries later), a failed changeset 422validation_failed(was 400invalid_data); a reason that is not a code is logged and answeredpayment_failedinstead of itsinspectoutput. An unknown store provider isunknown_provider, as for sign-in (wasunsupported_provider); a malformed user id on quest completionsinvalid_id. Strings that werenullare now""(a purchase'sprovider_product_id, an entitlement'sproduct_idandsource_purchase_id, a tournament match's empty slots). Generated clients getQuest,QuestProgress,QuestClaim,LedgerEntry,WalletBalances,Inventory,Purchase,Entitlement,PaymentCatalogEntryand their pages.[added]
GamendWeb.ApiShapeTestfails on a nullable string other than a date or date-time (R6), checked on the document rather than by grepping source, so a nullableformat: :uuidstring no longer slips past.[breaking] Public user profiles no longer carry
lobby_idandparty_id. They were always""onGET /users/:idand user search (another player's rooms are private), so they told a client nothing; the fields are gone and a test holds them absent.[changed] Every documented endpoint is held to its schema, and the lint holds the undocumented ones.
GamendWeb.ResponseContractno longer takes a list of tags: every operation is checked from its first test.mix gamend.api.lintgains R15: an API controller answers throughGamendWeb.Reply, notjson/2, and documents a JSON response with a named schema module, not an inline one. It found the local upload target (PUT /api/v1/storage/upload), which now answers{"ok": true}(was{ok, key}; the client already holds the key, and S3 answers the same PUT with no body) and a missing tokenmissing_param(wasmissing_token), and the/api/v1404 fallback, which now carries"message": "Not Found"like every other not-found.[changed]
clients/generate_godot.shsheds 46 of its 56perlrewrites and its snake_case class mapping. Every response is a named schema, so the per-model snake_case renames match nothing; each was replayed against the raw generator output and removed only when it changed no file, and the addon it writes is byte-identical.[breaking] The admin API takes the same response shape as the player API. All 94 admin operations answer
{data}, a page,{ok: true}or{error, message?}, and each has a named schema (AdminUser,AdminSession,AdminPushToken,ChatReport,AdminChatMute,ChatFilterWord,AdminLeaderboardRecord,AdminTournament,AdminQuest,AdminQuestProgress,AdminWallet,AdminLedgerEntry,AdminKvEntry,AdminMatchmakingTicket,AdminReadyCheck,StorageObject,RetentionStatus,AnalyticsSummary, ...); where an admin answer has the same shape as the player one it is the same type (Lobby,Group,Notification,ChatMessage,Leaderboard,TournamentMatch,ServerStats). Creates answer 201 (leaderboards, tournaments, quests, KV entries, chat filter words, stored objects); deletes, cancels and resets answer{"ok": true}(was{},{ok, key},{data: {deleted: true}}or{data: {reset: true}}); a grant or spend answers the balance underdata(was{ok, user_id, currency, balance}); resolving a tournament match answers the match (was{ok, winner_entry_id}); the analytics endpoints, the retention status and the quest funnel answer underdata(were bare). Moved out of pages: the filter-word list'slanguagesisGET /admin/chat/filter_words/languages, the storage list'susageisGET /admin/storage/usage. The admin leaderboard is the playerLeaderboard(it gainsis_active,descriptionandicon_url); an admin quest gainsgroup_keyandgroup_title; a label record'suser_idis""(wasnull). Errors are codes with the status of their kind:last_admin,insufficient_funds,insufficient_items,not_drawable,not_running,not_cancelledandnot_completedare 403 refusals;already_resolvedandidempotent_replay409;unknown_language404; a missing fieldmissing_param(was a sentence); an admin push message that fails validation 422validation_failedwith per-fielderrors(was 400invalid_message); a quest or chat-moderation changeset 422validation_failed(was{errors}with noerror, orinvalidwithdetails).[fixed]
DELETE /api/v1/admin/groups/:idanswered 500 for a group that did not exist, and reported abefore_group_deleteveto asnot_found. Now 404, and 403rejectedfor the veto.[fixed] Finishing a tournament in the same second it was drawn answered 500. Admin draw sets
starts_atto now, finish setends_atto now, and "ends_atmust be afterstarts_at" failed on a hard match. Finish now ends it immediately either way; a window the changeset rejects is a 422.[changed]
mix gamend.api.lintR6 exempts only dates. It skipped any line with aformat:, so a nullableformat: :uuidstring passed; the twelve such admin fields are gone with their inline schemas.[breaking] Every player endpoint now takes the same response shape. The last domains (KV, hooks, matchmaking, ready checks, time, health, client logs, stats) moved to
{data}/ pages /{ok: true}/{error, message?}. The health check answers{data: {status, timestamp}}, the clock{data: {server_now}}, and the client-log policy and upload their object underdata(the Godot and Balaur SDKs read either shape). A KV read answers the entry as the realtimekv_updatedevent carries it,{data: {key, user_id, lobby_id, data, metadata}}(was{data: <value>, metadata}). Opening or answering a ready check answers the check underdata(was bare), calling one off{"ok": true}(was{}), and answering with no open check is 404no_open_check(was 409); abefore_ready_check_openveto is 403rejectedwith the reason inmessage(was 422 with aninspectedreason).GET /matchmaking/tickets/mewith no ticket is 404not_queued(was{"data": null}); joining refusesnot_party_leaderandparty_has_blocked_pairwith 403 andparty_too_largewith 400 (all were 409;already_queuedstays 409).GET /hooksis a page, each function naming itsfnascall_hooktakes it (wasname). A hook call's refusals are codes with their detail inmessage(too_many_args,args_too_large,missing_paramfor what wasinvalid_request); an unknown plugin or function is 404 and a timeout 504 (were 400); a hook's own error keeps its atom as the code, anddetailsis gone. Client-log errors areinvalid_batch,session_forbiddenandcollection_disabled(were sentences). Generated clients getKvEntry,HookFunction,MatchmakingTicket,MatchmakingStats,ReadyCheckState(the realtime proto's name),MyReadyChecks,ServerTime,Health,ServerStatsand the per-domain stats it is built from.[fixed]
GET /api/v1/hooksanswered 500 whenever a Gleam, LFE or Erlang plugin was loaded. It listed functions through__info__/1, which only Elixir modules have; the RPC path already used the portablemodule_info/1, and now the listing does too. The bundledexample_gleamplugin was enough to break it.[changed] Chat, notification and push responses have names.
ChatMessage,ChatReadCursor,ChatUnread,ChatMuteRecord,UnmuteResult,Notification,DeletedCount,PushTokenandOkResponse, with their pages, replace the generated classes for 24 endpoints. Muting answers{data: mute}, unmuting{ok, removed}, reporting and deleting a message{ok: true}and marking a conversation read its read cursor; all were documented as something else or nothing. Eight of these endpoints had no test reaching their success response; they do now.[breaking] Request body classes are named after their own endpoint. Generators merged bodies that were identical, so 21 endpoints took another's class — the player's avatar upload an
AdminSetQuestIconRequest,join_lobbyaPartyJoinLobbyRequest,kick_useraKickPartyMemberRequest. Every body is now<Endpoint>Request(SetCurrentUserAvatarRequest,JoinLobbyRequest,KickUserRequest); the other 72 keep the names they had. In the JS SDK the option key follows the class (joinLobby(id, { joinLobbyRequest })).clients/godot_migrate.pyrewrites a game's uses, choosing per call site where one old class now splits in two.[changed] Group and party responses have names.
Group,GroupMember,GroupJoinRequest,GroupInvite(each with a page),Party,PartyInvite,PartyStatsandStatusResponsereplace 17 generated classes such asListMyGroups200ResponseDataInnerandAcceptPartyInvite200Response; party lobby actions document theLobbythey return, and party members are documented as theUserBriefrows they are. The six party-invite endpoints had no test reaching their success response; one flow test covers them now.[breaking] Godot SDK model classes are prefixed
Gamend.GamendLobby,GamendSession,GamendCreateLobbyRequest: GDScriptclass_nameis global, so an unprefixedLobbyorFriendwould clash with a game's own class. Code that names a model class updates by adding the prefix, or to the new name where a response was named:GetLobby200ResponseisGamendLobbyResponse,OauthRequest200ResponseisGamendOAuthAuthorization.GamendApi,GamendClientandGamendResultkeep their names.[fixed]
authenticate_list_auth_providers()in the Godot SDK returned no providers. The generated setter compared the whole array's text against the allowed provider names, never matched, and dropped the value. Enum checks in generated models now apply to strings only. Found by a live run of the regenerated SDK against a dev server.[added] SDK checks and a Godot migration tool.
clients/check_godot.shcompiles every script of the generated addon in a headless Godot, and with a server URL makes live calls that must land in their named classes;clients/check_js.jsdoes the same for the built JS package.clients/godot_migrate.pyrewrites a game's references to renamed SDK classes, pairing old and new classes by operation and property rather than by a hand-kept list; on a copy ofpolyglot-pirates-gameit resolved all 47 references and the game compiled as before. The JSgeneratescript now clears the previous output first, so a renamed model no longer lingers in the built package.generate_godot.shruns a local generator jar instead of Docker whenOPENAPI_GENERATOR_JARis set.[added] Plans for more client SDKs:
docs/specs/client-sdks.md(C++, C#/Unity, TypeScript, Rust, Lua, GML, and one conformance scenario for all of them) anddocs/specs/cpp-sdk.md.[fixed] The admin configuration page never showed the TLS certificate. It read the decoded certificate's signature algorithm where its body was, every field lookup raised, and the rescue turned that into "no certificate". Serial numbers with an odd digit count also paired their hex bytes wrong. Both covered by a test against a generated certificate chain.
[fixed] A malformed id in an admin filter crashed or was ignored. The KV user and lobby filters raised
Ecto.Query.CastError; the push filter silently listed every user's tokens.Gamend.Query.filter_id/3matches nothing for an id that is not one. The admin matchmaking and push pages search by name or id, like economy, inventory and quests.[changed]
Gamend.Accounts,Gamend.Paymentsand the admin configuration page are split by concern. Accounts (3,000 lines) delegates toSearch,Stats,Registration,Identities,Sessions,Profile,PresenceandBroadcasts; Payments (2,275) toAdmin,StripeEventsandStoreEvents, with its payload helpers inPayments.Params; the configuration LiveView (2,940) toConfigDiagnostics,ConfigSectionsandConfigSystemSections. Every public function keeps its name on the original module.mix gen.sdkfollows the delegates, so the plugin SDK stubs keep their docs and specs.[changed] Deduplication with no behaviour change:
GamendWeb.Pagination.total_pages/2(the page count, written inline or as a privateceil_div/2in some forty views),Gamend.Parse.blank_to_nil/1,GamendWeb.AdminLive.Shared.list_opts/2,GamendWeb.ChannelEvents.other_info/2for the finalhandle_info/2of six channels, and paging for the admin logs sessions, storage objects and chat history through the shared helpers.[fixed] Dates read in the reader's language. Calendar dates (
<.timestamp at={%Date{}}>) and the blog's month headings rendered in English everywhere: the month names went through agettextcall that no catalog had entries for. Both now render throughIntlin the browser, like the timestamps already did, pinned to UTC so a date never shows as the day before west of Greenwich.[fixed] Core pages were English on hosts without their own translations.
gamend_web's catalog had not been re-extracted since the blog, changelog and roadmap pages, the error pages, the store's closing times and the stats page's activity cards were added, so a host translating through it — the starter — showed those 33 strings in English in every locale. Extracted, merged and translated in all 29 catalogs; the starter's search palette now takes its page titles from them.[fixed] Admin and player pages crashed on a hand-edited page size, which went through
String.to_integer/1, and "next" paged past the last page into empty tables. Twenty-nine LiveViews now shareGamendWeb.LiveHelpers.prev_page/2,next_page/3andput_page_size/3, which clamp to the known page count and toGamend.Limits.[fixed] A non-numeric score answered 500 from the admin leaderboard record API (
String.to_integer/1again). It answers 400invalid_score, and a record for a deleted leaderboard answers 404. The admin leaderboard page crashed on the same input, and on any failed submit that was not a validation error — an ended leaderboard, an unknown user.[fixed] A row deleted between the check and the write answered 500. The contexts check that a user or leaderboard exists before writing — SQLite cannot name the violated constraint — but the row can go in between.
Gamend.Repo.rescue_foreign_key/2answers that window like the check would have:user_not_foundfor currency and item changes,user_not_foundorleaderboard_not_foundfor scores.[changed]
sender_name,host_name,creator_nameandleader_namefall back to the username. They sent""for a player with no display name, while a party or group invite'ssender_namealready fell back — the same field named the same player two ways. Fields literally nameddisplay_namestill carry the raw column. Web pages no longer label an unknown player "User #<id>".[changed] Unknown channel events get the same
unknown_eventreply and debug line on every channel, throughGamendWeb.ChannelEvents, which also sends the presence and member-updated pushes lobby, group and party channels each built themselves.[changed] Deduplication with no behaviour change:
Gamend.Parse(integer and key-stringifying helpers copied across contexts, controllers and LiveViews),Gamend.Broadcast.best_effort/3,Gamend.Query.filter_user/2(the user search the economy, inventory and quest listings each wrote inline),Gamend.Ledger.list_entries/2,GamendWeb.Serializers.serialize_kv_entry/1andGamendWeb.AdminLive.Shared.put_filters/3.[added]
GAMEND_DEV_BENCH=1runs the dev server without the code reloader and LiveView's debug annotations and expensive runtime checks, which cost more than the change being measured.stress/README.mduses it, with a results directory per run and a fresh database for A/B comparisons.[changed]
gamend_corehas its own test suite. The context tests lived ingamend_weband could lean on web modules without anyone noticing; they now run without the web app, which found a core module (Gamend.Theme.JSONConfig) that crashed whengamend_webwas not loaded.Gamend.DataCase, the fixtures andNoopHooks(nowGamend.TestSupport.NoopHooks) are shared fromapps/gamend_core/test/support, and rootmix testruns both suites.[fixed]
GAMEND_PAYMENTS_ENVIRONMENT=sandboxselected production. Every:atomsetting cast throughString.to_existing_atom/1, which only succeeds when some other compiled module happens to name that atom — so a perfectly valid choice that nothing else mentioned was rejected and silently replaced by the default. Three documented values could not be set at all: paymentssandbox(real money, in a configuration that asked for the sandbox), pushapns_env=sandbox(dev builds talking to the production APNs gateway) and mailsmtp_tls=if_available(STARTTLS quietly off). Settings now declare their choices withvalues:, cast against that list, and the generated settings coverage test proves every documented value round-trips.[fixed] APNs sandbox mode was unreachable a second way. The endpoint config compared the
:apns_envatom against the string"sandbox", which is never true, so every host used the production gateway whatever it configured.[fixed] A stale user id returned 500. Submitting a leaderboard score, granting or spending currency, or granting or consuming an item for a user who does not exist raised
Ecto.ConstraintError. The schemas did declareforeign_key_constraint(:user_id), but SQLite — the default adapter — does not report which constraint an INSERT violated, so Ecto cannot match the declaration and raises instead of returning a changeset; the adapter's own docs say these changeset functions "may not work at all" there. The contexts check withGamend.Accounts.user_exists?/1first and answer{:error, :user_not_found}, which the admin endpoints return as a 404. The constraints stay declared: they are what makes the row impossible, and on Postgres they still report.[changed] One shape for validation errors. A failed changeset now always answers
{"error": "validation_failed", "errors": {field: [message]}}, with messages interpolated and translated. Seventeen of the forty-six hand-rolled call sites had left the raw{msg, opts}tuple in the payload, whichJasoncannot encode — those endpoints answered 500 where their own OpenAPI operation documented a 422, and none of those branches had a test. Twenty-two others shipped the uninterpolated msgid, so a client read"should be at most %{count} character(s)"verbatim. Breaking: endpoints that previously returned"invalid_data", or put the field map undererror/details, now use the shape above.mix gamend.api.lint(R12) rejects a new hand-rolled copy.[changed] The blog page moved into
gamend_web, joining the changelog and roadmap inGamendWeb.ContentPages. It had stayed a shim that looked upGamendWeb.HostBlogLiveby name, so each host wrote the page itself — gamend and the starter carried byte-identical 237-line copies, each with a private reimplementation ofGamend.Content.blog_posts_grouped/0, and they had already drifted apart on date rendering. A host that wants a different page still routes its own module.[fixed] The daily chat-report cap survives a restart. It was enforced only by the in-memory rate limiter, so restarting a node cleared the counter.
Gamend.Chat.Reports.report_message/3now also counts committed rows, which covers plugins calling it directly as well as the HTTP edge.[added] Node-local moderation cache sizes on the admin chat filter and chat mutes pages — the blocklist page flags when this node's in-memory matcher holds fewer words than the database, which is what a missed change broadcast looks like.
[fixed] A context could be asked for a million rows. Seven contexts each had their own
paginate/2, in four behaviours: three applied:page_sizestraight from the caller, two clamped to a hard-coded 1000 that ignored the configurablemax_page_size.GamendWeb.Paginationhad already fixed this at the controller layer, but a plugin calls the context directly. All of them window throughGamend.Querynow, which clamps throughGamend.Limits;mix gamend.api.lint(R13) rejects a new copy. Chat messages and leaderboard listings clamp before their cache key, where an unclamped size also meant unbounded distinct cache entries.[changed] One way to name a user.
Gamend.Accounts.display_name/1joinsdisplay_label/1, which had a single caller while four inline fallbacks were in use. A party invite from a player who had set no display name used to arrive from nobody (display_name || ""); three admin views fell through to the email and then the raw id. R14 rejects a new inline chain.[changed] Deduplication with no behaviour change:
Gamend.Payments.Params(four copies of the JSON-shape helpers),Gamend.Ledger(the idempotent-transaction shape economy and inventory both implement),GamendWeb.ControllerScope,GamendWeb.AdminLive.Shared,Gamend.Codegen, and the two upload helpers intoGamendWeb.Uploads— where the two copies had quietly disagreed on what a missingcontent-typemeans, now an explicit argument.[added]
Gamend.Payments.ProviderandGamendWeb.PageMeta.Providerbehaviours. Both seams were swappable by config but had no declared contract, so a host discovered it by reading core and a misspelled callback failed silently at runtime. Also givesprovider_adapter/1a catch-all: an unknown provider string raisedCaseClauseError, which is a 500 for what is really "no such provider".[removed] Dead code:
GamendWeb.AdminLive.Users.Index(an unrouted duplicate ofAdminLive.Users),GamendWeb.Plugs.Locale(superseded byPlugs.LocalePath, and redirecting the opposite way), and eleven unreferenced functions.ConnectionTracker.count_other_user_channels/1went with the per-user registry key it was the only reader of — every user-channel join had been paying for a write nothing read since cluster-wide presence replaced it.[added] Site search reads what you meant. A name is matched on its first few letters, so "casa in spanish" searches for "casa" rather than for "casa in", in the thirty locales whose readers inflect the language name or write it with a suffix. A word nothing matched is retried as a typo, where two letters swapped count as one mistake rather than two. And a host can now answer queries too numerous to put in the index at all, through an optional
search/2on its search provider — asked once the reader stops typing, with the languages or sections they most likely mean.[added] Site search. A magnifier in the header and Ctrl/⌘+K open a palette that searches the whole site. Out of the box it finds every navigation destination, including the ones a phone buries two taps deep in the hamburger menu; a host puts its own content in it with a
search_providermodule, and turns the feature off withsearch_provider: false. An entry whose href carries{q}is a search rather than a destination, which is how a query the palette cannot answer reaches the page that can.[added]
GamendWeb.Plugs.VisitorId— a stable id for one browser session, signed in or not, for the things that must count something about a visitor who has no account (a free daily allowance, an A/B bucket). Not in the:browserpipeline, so a content host's crawlable pages keep answering without aSet-Cookie;gamend_current_user_routes/2takes:extra_pipelinesto add it to a host's public scope.[fixed] Navbar menus stay open. The layout shell re-derived the theme, the navigation, the breadcrumbs and the unread count on every render, and every one of them counted as changed — so the navbar and footer re-rendered on every diff a LiveView sent, and the browser morphed an open
<details>dropdown shut. A page with a clock in it, like a timed test, closed its own menu once a second. The derived values now only count as changed when an attr they are built from does, which also takes an unread-count query off every patch the site sends.[added]
gamend_coreandgamend_webpublish to Hex. Both packages were held back by pigeon: its kadabra-to-mint rewrite sat unreleased for over a year, Hex refuses a package with a git dependency, and the released 2.0.1 would have dragged httpoison and hackney back in. pigeon 2.1.0 shipped, so the dependency points at Hex and CI publishes both packages alongside the SDK.[changed] Push credential errors stop retrying. pigeon 2.1.0 reports a rejected FCM service account as
:unauthenticatedand Apple's two token-key mismatches as their own responses, instead of folding them into the generic error every dispatcher retried until the attempt budget ran out.[fixed] mint 1.10 — closes two denial-of-service advisories in the HTTP client that push and outbound requests run on.
[fixed] Typed text survives a reconnect. A chat draft or message edit, the login email, the group create/edit forms and the admin live-lobby forms come back after the connection drops; which edit or panel is open now lives in the URL, since a form missing from the re-mounted page cannot be recovered.
[fixed] Admin quest and tournament filters respond again — LiveView only sends change events from inputs inside a form.
[added] Offline notice. Five seconds into a dropped connection every page says so, and clears itself on reconnect. It sets
<html data-connection="offline">and firesgs:connection, so a page can lock input LiveView would silently drop. Replaces the two "Loading..." flashes, which fired together, and the heartbeat goes to 15 s so a dead network is noticed within half a minute.[fixed] An edit on an older chat message survives a reconnect too: the pages of older messages loaded are in the URL (
page), and an edit whose message is still not loaded pages back until it is.[fixed] Opening a chat no longer crashes when it is already read up to its last message — the forward-only read cursor updated no row, which Ecto raised as a stale entry.
August 2026
[added]
mix host.proto.check— checks every registered protobuf schema against the JSON actually stored under it, and reports which values fall back to JSON and why. A schema missing one field is not an error anywhere: it simply never encodes, so the optimisation looks shipped and is inert. Covers KV entry values and user/lobby/group/party metadata, takes a captured payload with--json FILE --message Mod, and exits 1 so it can gate CI. Also lists what is not typed — the KV keys, entities and hooks still going out as JSON.[fixed] Godot binding generation fails loudly. Godot's headless script runner exits 0 whether or not the script succeeded, so
mix host.proto.genreported success while godobuf had written nothing — onereservedfield it could not parse froze a game's Godot bindings for weeks while Elixir and JS kept regenerating fine.reservedis now stripped from godobuf's copy of the proto (it generates no code, and protoc keeps enforcing it), and a run that writes nothing is an error.[fixed] Server scripting works in a release. Plugins were loaded at runtime but a release boots in embedded mode, which refuses to load them — every plugin failed with
{:error, :embedded}while the startup banner still counted them as loaded.[added] Brotli for static assets, alongside the gzip files the digest already wrote.
[added] Hero tour video — a click-to-play walkthrough of the admin panel, quests, store, matchmaking and server hooks;
scripts/screenshots/record_tour.jsre-records it.[added] Image lightbox — clicking a home-page screenshot opens it full-size (ported from the Polyglot Pirates host).
[changed] Home page shows the product — real screenshots (light and dark) of the admin dashboard, quests, groups, login, store, matchmaking, analytics, runtime hooks and economy pages instead of icons; the hero states that Gamend is backend, player website and admin panel in one.
scripts/screenshots/recaptures them.[added] News dropdown in the navigation — blog, changelog, roadmap and guides.
[changed] Home page reflects the current feature set — quests instead of achievements, GDScript/Gleam scripting, and new economy/storage and analytics/observability sections.
[added] Friends admin page
[added] Retention admin page
[added] 16 new guides
[added] Plugins can be written in GDScript
[added] Plugins can be written in Gleam
[added] Client logs
[added] Logs page filters by client session and user, and separates client entries from the server's own tail.
[fixed] Logins survive a busy database — the analytics day-marker and daily counters drop a failed write instead of failing the request they ride on.
[fixed] Repeat quests re-arm right away again, instead of once an hour.
[changed] Matchmaking is near-instant — a join sweeps immediately instead of waiting for the tick.
[added] Performance guide — measured throughput, capacity and database choice.
[added] Socket buffer size is configurable — the largest per-connection memory cost.
[changed] Logins return sooner — the last-seen and activity writes moved off the request path.
[fixed] Concurrent signups no longer queue behind each other on SQLite.
[added] Load-test harness — per-feature benchmarks and a capacity journey, in
stress/.[fixed] Benchmark RPCs measured an error path, not a locked write.
[fixed] Realtime events arrived twice
[fixed] Language flags are cached, so they no longer pop in after the text.
[fixed] Deleting an account deletes its avatar from storage.
[changed] One heading scale across the shipped pages.
[added] Player analytics — D1 / D7 / D30
[fixed] Accessible theme colors
[added] RULES.md — design & accessibility rules.
[added] Grouped quests.
[added] Repeat quest reset type.
July 2026
- [breaking] Renamed to Gamend.
- [added] Captcha on the register and magic-link forms
- [added] Chat moderation — word filter, report queue, mutes
- [breaking] One theme file
- [added] Translation pipeline
- [added] All 30 locales fully translated (machine-translated, pending review).
- [added] Icons everywhere —
icon_urlon notifications, tournaments, groups and leaderboards. - [added] Quest chains are browsable; a chain lists as one quest.
- [breaking] API paths use underscores
- [breaking] One pagination meta shape on every list response.
- [added] API conventions spec +
mix gamend.api.lintin precommit and CI. - [added] Settings: one declared config surface.
- [changed] Guides are markdown files.
/docs/setup - [changed] Times are shown in the reader's timezone.
- [added] Retention for every unbounded table.
- [added] Ready checks
- [added] Push notifications
- [added] Lobby state
- [added] Quests / progression.
- [breaking] Achievements removed — replaced by permanent quests
- [added] Economy.
- [added] Inventory.
- [added] Object storage — with local-disk and S3/R2 backends; presigned avatar uploads.
- [added] Admin Oban Web dashboard at
/admin/oban+ jobs/storage. - [added] Lobby snapshots — opt-in via
LOBBY_SNAPSHOTS_ENABLED. - [added] Matchmaking (ticket queue), admin page and hooks.
- [added] Party matchmaking, matched as one unit.
- [added] Tournaments (bracket system).
- [added] User blacklist, enforced in matchmaking and lobbies.
- [added] Admin runtime page: hooks, env vars, protobuf, channels, events, ER diagram, plugins, jobs.
- [added] Protobuf realtime format (opt-in).
- [changed] Realtime state events send full payloads.
- [removed] JSON delta encoding.
- [removed] Dead modules and client delta code.
- [added] Unique usernames.
- [breaking] UUIDv7 string ids.
- [added] JWT revocation.
- [added] Persistent IP bans.
- [added] Redis rate limiting.
- [added] Data retention pruning.
- [added] New plugin hooks.
- [added] Observability metrics.
- [security] Auth, payments, RPC hardening.
- [perf] Faster broadcasts and queries.
- [fixed] WebRTC RPC replies.
April 2026
- [changed] Root host app restructure.
- [added] Browser theme color, sitemap.xml, robots.txt.
- [added] Native HTTPS
- [added] Account Activation beta mode.
- [added] Translations: Spanish, French, Romanian.
- [added] Roadmap page.
- [added] Security: RealIp, IP bans, OAuth CSRF, rate limiting, WebRTC limits, security headers.
- [added] OPENAPI_ENABLED feature gate.
March 2026
- [changed] Make Leaderboards accept label instead of user_id.
- [added] Initial version of Achievements.
- [added] Initial version of Rate Limiting.
- [changed] Self-hosted Inter font and eliminated all inline scripts.
- [added] Initial version of WebSocket updates.
- [added] Initial version of WebRTC updates.
- [changed] Admin interface with realtime connections view.
Feb 2026
- [added] Initial version of CHANGELOG and Blog.
- [added] Initial version of Groups.
- [added] Initial version of Parties.
- [added] Initial version of Notifications.
- [added] Initial version of Chat.