• [added] A board can hold many rankings, and can be hidden. leaderboard_records.key (default "", migration LeaderboardKeysAndHidden): 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/5 and submit_label_score/5 take key:, as do get_user_record/3, get_label_record/3, list_records/2, count_records/2, list_records_around_user/3 and delete_user_record/3. list_records/2 with key: :all reads every key together; with best_per_user: true each player's best of them once, ranked. :meta is 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 of list_leaderboards/1, list_leaderboard_groups/1, their counts (pass include_hidden: true), the public /leaderboards page 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's theme (dark, light, or system, which forgets it) and what a host allows in config :gamend_core, :client_preferences (key to allowed values). A visitor gets 204 and nothing saved. GamendWeb.Plugs.ColorMode now runs after the scope is fetched and renders a signed-in reader's saved theme over the phx_theme cookie, also as data-theme-saved, which theme-init.js copies 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/3 and demote_member/3 checked "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; the before_group_kick hook 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 raised Ecto.StaleEntryError on the deleted row, and now answers {:error, :not_member}. Gamend.GroupAdminRaceTest races these moves directly; against the old code its admin tests failed every run. Found by Gamend.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/2 leaves 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 by Gamend.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/register took 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 takes email and an optional username, 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 answer 401 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 is 422 and 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/:token is a LiveView (UserLive.Confirmation, :confirm_email) whose button posts to POST /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/3 is gone; register_unconfirmed_user_and_deliver/3, confirm_user_by_code/3, resend_confirmation/3 and get_user_by_confirm_token/1 are new; a :user_notifier module's deliver_confirmation_instructions now takes (user, url, code). SDKs: C++ Auth::register_email(email[, username], done), confirm_registration, resend_confirmation; Godot authenticate_register_email(email, username) (renamed from authenticate_register, so an old (email, password) call fails instead of sending the password as the username), authenticate_confirm_registration, authenticate_resend_confirmation; Balaur client::register_email(node, email, username), auth::confirm_registration, client::resend_confirmation.

  • [removed] Three admin events no page sends. AdminLive.Config's prefill_args and show_docs and AdminLive.Index's set_tab: no template or script pushed them any more. A handle_event clause is part of a public callback, so the compiler never reports one as unused.

  • [changed] Websockets deflate at level 4. HostRuntime gives both Bandit listeners websocket_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 and DynamicIcon'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.LiveSerializer is Phoenix's V2 serializer with text frames encoded by JSON (OTP's :json) instead of Phoenix.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 /live socket uses it: Phoenix.json_library/0 stays Jason, since structs across the app implement only Jason.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/1 makes 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_limit per general_window_ms, 240 a minute by default, the one page loads get) and answers {:error, :rate_limited} past it; ensure_user_conn/1 does 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, after RealIp), recorded at mount as :client_ip on 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/2 returns the page of list_user_quests/2, the count of count_user_quests/2 and the tabs of visible_categories/1 from one read; QuestsLive and GET /api/v1/me/quests use it. Each of the three also reads less: this period's rows and the done prerequisites are one query now, not two. /quests went from 19-21 queries a visit to 10. Same entries, counts and tabs (Gamend.QuestsTest).

  • [changed] Settings reads only the open tab. /users/settings loaded 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's assign_defaults now sets empty values and the settings LiveView reads a tab's data when it opens (load_tab/2, from handle_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.js had moved /x?setlang=en from an unprefixed page in another language over the socket, where LocalePath never 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/1 serves 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's priv/static/flags/*.webp once (digested copies skipped) and points every code that has one at it, the SVG otherwise. A host with no WebPs is unchanged. flag_url/1 follows the same pick.

  • [added] A plain link moves between LiveView pages over the open websocket. Phoenix does this for <.link navigate> into the same live_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.LiveNav builds the router's GET routes once, in the router's order, each with its live_session (nil for a controller), plus the locale prefixes. GamendWeb.Plugs.LiveNavTable serves 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.js then 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-nav or 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 in destroyed, 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.Cache like the user and KV values already did. Economy.balances/1 fetches every currency in one query and balance/2 reads from it; change_balance/4 evicts. Payments.has_entitlement?/2 and entitlement_ever?/2 read 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/2 caches 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 by Gamend.UserReadCacheTest, on a real cache instance.

  • [fixed] The quests page walked every chain once per quest, twice. QuestsLive.chain_positions/1 walked 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.Theme assigns the resolved theme, and LiveView hands that assign back to the root layout and to prepare_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/2 returns the cached theme when the "overlay" is that theme itself; the identity check is cheap because both sides are the same :persistent_term literal. Controller pages that pass theme: (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) and ensure_user_conn/1 (a controller) return the caller's account and, for a signed-out visitor, make one with Accounts.find_or_create_from_device/2 on a device id the server makes up (web: and 24 random bytes), so it is an ordinary device account: the same hooks, the same User.anonymous?/1, the same 90-day anonymous_users_days sweep, and nothing while device_auth_enabled is 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 pushes gamend:anonymous_session with the session token encrypted (Phoenix.Token, five minutes), app.js (anonymous_session.js) posts it to POST /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_scope falls 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?/1 tells 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, through Accounts.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/2 is 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.JSONConfig cached whatever a read put last, and the reload event has GamendWeb.ResponsiveImages read the file in its own process, so a read of the old file that finished after reload/0 put 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 by GamendWeb.HostSupervision.init_runtime/1, logs a query that runs or waits for a connection longer than GAMEND_DB_SLOW_QUERY_MS (1000), and a transaction that holds its connection from begin to commit/rollback longer than GAMEND_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. 0 turns 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 with prefix (matched literally) and that have not been written for days days, in batches, invalidating their caches and listings as delete/2 does. 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), where days is a number or a function read at each sweep (a setting the host declares with Gamend.Settings.Provider, so an operator can change it without a deploy). 0 keeps 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.Preferences holds them in a new private users.preferences map (migration 20261001090000_add_preferences_to_users, Gamend.Accounts.Preferences), kept out of metadata because 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 with config :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/3 sends a server notification through the channels chosen: the in-app row, a push, and an email queued as GamendWeb.Workers.NotificationEmail, which checks the choice again when it sends and carries a one-click List-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 each signup: true group's email, unticked. A notification's metadata["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), and GamendWeb.UserAuth saves it with the page's locale in users.preferences when either changes. Gamend.Accounts.TimeZone answers a user's local/2, local_date/2, local_hour/2 and day_start/2 (UTC when the zone is unknown), with the tz database passed explicitly, so a host needs no global :time_zone_database. Gamend.Accounts.Preferences.locale/1 is the language to write an email in. A user can pick their zone by hand on the Notifications tab (TimeZone.choose/2, from TimeZone.names/0, the IANA zones of tz's zone1970.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/call answered a plugin's crash 400 exception with 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 500 exception now, and Gamend.Hooks logs the raise at error with its stack trace, which the old warning line never had. A missing function clause counts as the caller's function_clause (400) only when the hook's own clauses refused the arguments; one from deeper in the plugin is a crash. The channel's call_hook replies with the same codes (GamendWeb.HookErrors) instead of an inspected tuple: {error: "not_enough_gold", message: "3"} for a tagged refusal, {error: "exception"} for a crash; the WebRTC data channel's hook_error says exception too.

  • [added] One rule for raising and returning errors. "Errors" in CONTRIBUTING.md: an expected failure (client input, a race) returns {:error, reason} and its @spec holds; 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.lint checks the last one as R17: get_*! and Repo lookups with ! in an API controller, a channel, a LiveView handle_event/handle_info/handle_async or an Oban perform. 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/1 and approve_join_request/2 checked, then get_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's kick raised on an unknown target_user_id and now answers 404, as it does for a player not seated in the lobby (422 unexpected_error before); a caller whose lobby was deleted since they were read gets 400 not_in_lobby from every lobby endpoint, where with_lobby/2 answered 404 and with_party/2 400 for the same race. Joining group:<id> for a group deleted mid-join is refused rather than crashing the join. Accounts.delete_user/1 and delete_user_token/1 answer {:error, :not_found} for a row already gone (404 from the admin API), and DELETE /api/v1/admin/sessions/:id answers 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/1 is the tuple twin get_record!/1 lacked, which the admin record API had rescued Ecto.NoResultsError to get.

  • [fixed] A failed lobby delete is no longer a 422. Lobbies.delete_lobby/1 rescued every exception into {:error, exception}, so a database outage reached the player as 422 unexpected_error and was never logged as an error; it raises now (500, logged), and its spec is {:error, :not_found | {:hook_rejected, _}}. POST /api/v1/lobbies/disband answers a hook's veto 403 rejected, 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 of rescue _ -> :ok.

  • [fixed] Local storage lists only the prefix it is asked for, and survives a file vanishing. Gamend.Storage.Local.list/1 and usage/1 walked the whole storage root and filtered by prefix afterwards, so the retention sweep of avatars/ statted every cached PDF; they walk the directory the prefix names (avatars/user-a/ walks that directory, pdf/es walks pdf). A file deleted between the walk and its stat (a concurrent delete, macOS's .DS_Store) is skipped instead of raising, which failed the whole orphaned_avatars retention class.

  • [fixed] One X in a search box. search_input/1 hides the browser's own clear button for type="search" when it draws its own X, so the palette (and every box with close) 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.ConstraintError when the parent was deleted in between; SQLite names no constraint, so foreign_key_constraint/3 could not catch it. Repo.rescue_foreign_key/2 and the new Repo.rescue_stale/2 answer {: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 under Repo.lock_rows/2 (FOR SHARE / FOR UPDATE on 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 400 not_in_lobby (422 before), and kicking from a party disbanded meanwhile 400 not_in_party. membership_concurrency_test.exs runs 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_chat class 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_group in the SDKs), and Groups.delete_group/2 no longer refuses with has_members: it needed an admin who was a member and no members at once, so it never succeeded. Every other member gets a group_deleted notification, a new code in Gamend.Notifications.Types; the admin console and DELETE /api/v1/admin/groups/:id send it too.

September 2026

  • [added] panel/1, eyebrow/1, page_title/1 and search_input/1, and a boxed empty_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/1 draws it now, a step smaller on a phone), and a search box with its magnifier and an optional X (close attributes). The site search palette uses search_input/1, so its magnifier and the host's cannot drift. empty_state/1 takes an :actions slot and compact, its icon is optional, and it draws a dashed box rather than a bare block, on the changelog and blog empty pages too. /ui shows 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/2 with PageHTML.ui_section/1, ui_row/1 and specimen/1; noindex, follow, since it is a reference. Linked from the footer of the default theme and the starter's; a host adds /ui to its own footer. It spells every class out in full, so Tailwind generates them, and uses no component a host may exclude.

  • [changed] Secondary buttons are btn-surface, not a white outline. Every neutral btn-outline in core's templates, the navbar, .header's Back and the admin pages included, is btn-surface: the page's colour behind a dark base-300 hairline, the presentation page's default button look. It is a class a host's stylesheet declares, a @utility that sets only the resting --btn-bg and --btn-border the way daisyUI's btn-outline does, so a button's hover, focus, disabled state and size are btn's own (assets/css/app.css has it). A host that has not declared it shows daisyUI's plain btn. A coloured outline (btn-outline btn-error) is unchanged. The phone's language button in the navbar is btn-ghost (one icon), and the search palette's magnifier shows again: daisyUI's positioned .input had 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 gamend command. Every push to main publishes the server on the server-latest release for macOS on Apple silicon and Linux x86_64 and arm64, in SQLite and -postgres builds (gamend-<os>-<arch>[-postgres].tar.gz), with OpenSSL and libsrtp linked in so nothing else needs installing; rel/install.sh downloads the right one and links gamend into ~/.local/bin. A project is the folder gamend runs in: its .env, theme, markdown, static/, plugins and SQLite database. bin/gamend (a release overlay) runs the server with start, daemon, stop, restart, remote and reload (which re-reads the theme and markdown on the running server), and everything else through GamendWeb.CLI under 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 of host.seed too), demo.seed and plugin.bundle. gamend starter copies a starter project in without overwriting anything: default (priv/starter/default), website (the gamend.org site, published as gamend-website.tar.gz), or any folder, .tar.gz or URL, and writes a .env with 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. The actions/setup-gamend action installs, starts and seeds a server in CI and sets GAMEND_URL, for a game client's end-to-end tests. mix demo.seed is now Gamend.DemoSeed.run/1 behind a thin task, and Gamend.Release gained rollback/1 and dropdb/0. Guide: Download and run.

  • [changed] A release runs from the directory it is started in. bin/gamend_host anchored 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 and priv/storage already resolved against the working directory. All of them resolve against the working directory now, and a release reads that directory's .env as mix phx.server does 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; set GAMEND_DB_SQLITE_PATH to keep the old one. The Docker release image starts from its own root, so nothing moves there, and it now carries the theme, CHANGELOG.md, ROADMAP.md, blog/, priv/docs and 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's priv/static. The endpoint now serves the folders GAMEND_CONTENT_STATIC_DIRS lists first (default static,priv/static, relative to the working directory), for the top-level entries of :host_static_paths and never assets/, with the same cache headers as the built-in files; a folder that is an app's own priv/static is skipped. GamendWeb.ProjectStatic.path_for/1 answers 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/1 is nil for 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 widths variants are cut at runtime. The cutting in mix host.responsive_images moved to GamendWeb.ResponsiveImages, which the task now calls, and a supervised process (in GamendWeb.HostSupervision.children/1) uses it to cut the variants a project's own images are missing, next to the original, at boot and after Gamend.Theme.JSONConfig.reload/0 (which now emits [:gamend, :theme, :reload]). It needs ImageMagick (magick, or convert outside Windows); without it the server logs once at :info and 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 runs GamendWeb.ProjectStatic.reload/0, which resolves the static folders again and rebuilds the ?v= hashes and the theme.css link, 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 of reload/0 never waits on it or sees it fail.

  • [added] A release builds plugins without Mix. The downloadable engine and the release image ship the Elixir compiler but no mix, so the admin Config page's Build bundle was disabled there. Gamend.Hooks.PluginBuilder now falls back to building in-process (Gamend.Hooks.PluginBuilder.InProcess) when mix is not on the PATH: it reads the plugin's mix.exs without running it, transpiles scripts/*.gd into gen/ for a GDScript plugin, checks that every runtime dependency ships with the engine or sits prebuilt in deps/<dep>/ebin (naming the one that does not), compiles into a temporary directory and swaps it in as ebin/ with the .app mix plugin.bundle writes, so a failed build keeps the old bundle. hooks_module is detected (use Gamend.Hooks, or the GDScript script named after the plugin) when mix.exs does 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?/0 is true in a release now, mode/0 says which build runs, build/2 takes mode: :mix | :in_process, and build_all/0 builds every plugin. The engine gained use 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 on gamend_plugin_tools at runtime, so the GDScript transpiler ships in the release; Gamend.GDScript.compile_all/2 takes :root and Gamend.GDScript.source_path/1 is public. The bundled webrtc_lobby_hook dropped its unused bunt dependency, which the engine does not ship. Limits: no Hex dependencies beyond the engine's, no Erlang sources or Gleam, no config/config.exs, and protocols are consolidated. See Building a plugin.

  • [fixed] The error page's links were English in every language. GamendWeb.ErrorHTML looked the :error_page_links labels 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.Retention pruned old lobby snapshots and events in one DELETE each, and kept a flagged run's history with a correlated NOT EXISTS that 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 IN a subquery). A sweep cut short keeps what it deleted.

  • [added] Grant an entitlement without a purchase. Payments.grant_entitlement/3 upserts a user's (user, key) row for a trial, a reward or a support gesture, with :expires_at and :metadata. It never shortens an active grant: a row with no end keeps no end, and a later end wins. Payments.entitlement_ever?/2 answers whether a user has ever held a key (a once-per-account trial), and get_user_entitlement_by_key/2 reads the row whatever its state.

  • [fixed] A menu lit two links for one page. /docs and /docs/reference in 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 image above the body, and a post whose body opens with the same picture (x.webp for an x.png cover) showed it again. GamendWeb.ContentPages.cover/2 leaves 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. /blog lays 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, and GamendWeb.ContentPages.blog_index/1 takes no grid?. 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 :image is its frontmatter image, else the first picture in its body, at the URL the body serves it from (a relative path becomes /content/blog/…, as Gamend.Content.Markdown.image_src/2 resolves it; Markdown.first_image/2 finds it, skipping code blocks). So a post without a cover gets a card picture, a feed image and a link preview. A relative frontmatter image resolves the same way; an absolute one is still used as written. Gamend.Content.register_path/2 takes image_url: {module, function}, called as function(url, use) for each picture a collection serves itself: :page for the rendered HTML (guides too) and a post's cover, :card for the post's new :card_image, which the index shows. Content.image_url/3 applies 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 is fetchpriority="high".

  • [fixed] The reading time was always in English. "N min read" on the blog was an ngettext call no catalogue carried. It is gettext("%{count} min read") now, translated in every locale.

  • [fixed] A blog card with an author link split in two. Each card on /blog is one link, and an author with a url put 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/register answered 201 with 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 answers 201 with the account under data (Registration: user_id, username, display_name, email_confirmed), never tokens. POST /api/v1/login answers 403 email_not_confirmed until 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/2 returns {:error, :email_not_confirmed}, and get_user_by_email_and_password/2 nil. 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's authenticate_register no longer keeps a session, the C++ Auth::register_email takes a plain Callback and answers the account without touching the session, and Balaur's client::register_email is a REST call rather than gamend::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_version bumped). Linking to a confirmed account is unchanged.

  • [fixed] The Hex packages compile as the version they were published as. Each package's mix.exs took its version from the environment of whoever compiled it: GAMEND_CONTENT_APP_VERSION for gamend_core and gamend_web, APP_VERSION for gamend_sdk and gamend_plugin_tools. A host whose Dockerfile exports GAMEND_CONTENT_APP_VERSION=1.0.0 (gamend_starter's did) built the engine as 1.0.0, and the SDK pair, whose @version publishing never stamped, built as 1.0.26 wherever APP_VERSION was unset. Either one failed a host's >= 1.0.1266 requirement with "the dependency does not match the requirement". The publish job now writes the release's version into all four mix.exs files 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.BrotliCompressor no longer fails the digest when brotli is not installed. Its docs promised that a missing binary keeps the gzip, but System.cmd/3 raises :enoent for a command it cannot find, so a host that lists it in :phoenix, :static_compressors had mix phx.digest, and with it mix assets.deploy, crash on any machine without brotli on PATH. It now looks the binary up first and returns :error when it is missing, so the digest writes only the .gz files.

  • [fixed] Slow-request log lines carried players' names and emails. Sign in with Apple posts its callback with a user field 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.RequestTimer now redacts user, any key containing email or phone, and first_name, last_name, full_name (and their unseparated spellings), as it already did credentials. Keys that merely contain a word, such as user_count or name, 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 frontmatter keywords:. 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 answers 304 to a browser whose copy is current (ETag). How long the browser keeps it before asking is GAMEND_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, 0 disables) within GAMEND_AUTH_LOCKOUT_WINDOW_MINUTES (15) lock password sign-in for that address for GAMEND_AUTH_LOCKOUT_MINUTES (15). While locked the password is not checked; POST /api/v1/login answers 429 account_locked with Retry-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 table login_lockouts, pruned by the retention class of the same name; Accounts.authenticate_by_password/2 says why a sign-in failed, and get_user_by_email_and_password/2 goes through it. Admin → Users shows a lock with Unlock.

  • [added] A grace period before a deleted account is gone. With GAMEND_AUTH_DELETION_GRACE_DAYS set (default 0, delete at once), DELETE /api/v1/me and Delete account schedule the deletion that many days out (users.deletion_scheduled_at) and sign the account out everywhere. Until then API sign-ins answer 403 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 class scheduled_deletions deletes it on the day through Accounts.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 asks GamendWeb.Auth.Tokens.refusal/1.

  • [fixed] Abandoned lobbies and seats are released within a minute of their window. GAMEND_RETENTION_ABANDONED_LOBBY_MINUTES and _PARTY_MINUTES say 15 minutes, but the sweep that applies them ran every six hours, so a disconnected player could sit on already_in_lobby for up to 6h15m. The classes that free live state (offline lobby and party seats, abandoned parties, abandoned lobbies) now also run every GAMEND_RETENTION_LIVE_INTERVAL_SECONDS (default 60, 0 leaves them to the full sweep). The full sweep's cadence and batch size are settings too: GAMEND_RETENTION_INTERVAL_HOURS (6) and GAMEND_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/1 answered a link signed for an hour, and that is what uploads saved as profile_url and icon_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 in users, groups, quests, leaderboards and tournaments. /storage/*key serves only avatars/ and icons/, so a host that hands out the URL of any other key from a private bucket must now ask for Storage.url(key, signed: true), or the link is a 404. The prefixes are now a setting, GAMEND_STORAGE_PUBLIC_PREFIXES (default avatars/,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 and GAMEND_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 20 sudo_mode?/1 always 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) and GAMEND_JOBS_PRUNE_AFTER_DAYS (7), applied by Jobs.oban_config/0 over the compiled Oban config. GAMEND_PUSH_QUEUE_CONCURRENCY moved there from HostRuntime.
    • Cache: GAMEND_CACHE_MAX_ENTRIES (1000000) and GAMEND_CACHE_MAX_MEMORY_MB (500) per node, and GAMEND_CACHE_TTL_MS (60000) for every entity cache, read through Gamend.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) and GAMEND_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_URLS and a TURN relay with GAMEND_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.Parsers is built at runtime for it.
    • Game socket: GAMEND_REALTIME_SOCKET_TIMEOUT_MS (300000), how long a silent socket stays open, and GAMEND_REALTIME_SOCKET_MAX_FRAME_BYTES (131072). socket/3 fixes a transport's options when the endpoint compiles, so /socket is now declared with no transport (Phoenix still supervises it) and the endpoint's first plug serves /socket/websocket with options built at runtime. It calls the same Phoenix.Transports.WebSocket plug the macro would; GamendWeb.GameSocketTest upgrades 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) and GAMEND_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.
  • [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, HostRuntime copied GAMEND_CACHE_ENABLED into bypass_mode both ways, and the setting defaults to on, so every test config's bypass_mode: true was overwritten with false. 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.exs should too: Gamend.Tournaments.Ticker, Gamend.Matchmaking.Worker, Gamend.Chat.Moderation.Sync and Gamend.Accounts.StalePresenceSweeper under :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) and config :gamend_web, GamendWeb.IpBanSync, enabled: false (the IP-ban boot load). Each takes enabled: false. The boot log's JWT line now reads the token lifetimes from their settings; it read the Guardian ttl key, which is gone.

  • [changed] Config keys replaced by those settings. The undeclared app-env keys :hooks_call_timeout, :hooks_call_timeout_in_transaction and :slow_hook_threshold_ms under :gamend_core are gone: set call_timeout_ms, call_timeout_in_transaction_ms and slow_threshold_ms on Gamend.Hooks.PluginManager. The signaling keys :signaling_ws_window and :signaling_ice_window are now :signaling_ws_window_ms and :signaling_ice_window_ms. config/host_config.exs no longer sets ice_servers for :webrtc; a host that sets it still overrides the WebRTC settings.

  • [added] Token lifetimes are settings. GAMEND_AUTH_ACCESS_TOKEN_TTL_MINUTES (default 15) and GAMEND_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 (in HostRuntime for prod, and again in config/dev.exs and config/test.exs), the refresh TTL at every call that signed one, and expires_in: 900 in the login, refresh and OAuth answers. GamendWeb.Auth.Guardian now takes its token_ttl from GamendWeb.Auth.Tokens.ttls/0, so every token signed, anywhere, follows the settings, and expires_in is derived from the same value. A value below 1 counts as 1. The ttl key 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 from expires_in and 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/2 stores an IPv6 address under its /64 (2001:db8::1 is listed as 2001: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 before LocalePath.

  • [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. UserNotifier now runs the send in a task limited by the new GAMEND_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/1 and bump_version/1 evict 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/3 takes 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_callbacks and overridable_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 carries managed_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 version 2025-03-31.basil when the configured GAMEND_PAYMENTS_STRIPE_API_VERSION is 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_join hook 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 their before_* 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.AfterCommit holds effects until the commit and drops them on rollback. Every transaction in core opens through it, and Gamend.Lock.serialize/3 uses it too.
    • Gamend.Broadcast.publish/2 and Gamend.Async.run/1 wait for the commit when called inside a transaction, and replace the direct Phoenix.PubSub.broadcast calls in core. A test fails on a bare Repo.transaction or Phoenix.PubSub.broadcast in 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/3 waits 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/settings shows 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/1 finds the account's customer on its newest Stripe purchase (the stored checkout session, or subscription); Payments.create_stripe_billing_portal/2 opens the session. Checkout now keeps one customer per account: a returning buyer's checkout reuses their cus_ 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_module and before plugins. A host that needs one event, after_user_deleted say, no longer has to take over :hooks_module, which made its module the primary for every fan-out hook in place of Gamend.Hooks.Default. A host module exports what it implements and is called only for that; Gamend.Hooks.call/3 still reaches :hooks_module alone.

  • [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 after ForceSSL, 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 is init/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/register and 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 the mailers queue (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, the 503 email_delivery_failed answer is gone: the account is kept and the email retried. A before_user_register plugin that refuses the sign-up now answers 403 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 to before_user_register plugins 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 named gamend_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 the token_version it 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; limit GAMEND_LIMITS_MAX_API_TOKENS_PER_USER (default 10); retention class dead_api_tokens; guide under Authentication.

  • [fixed] Relative links in a folder's index.md pointed one level up. A link resolves against the document's folder, which for a page is its slug's parent — but an index.md's slug already names its folder, so taking the parent there sent [Builds](builds.md) in forge/index.md to /docs/builds, and every ./assets/mesh.md on a generated reference landing page to a 404. Gamend.Content.Markdown.render_file/2 now 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/1 answers {: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 with plugin=… failed to load module=… {:error, :embedded} in the release image while working under mix phx.server. Gamend.Hooks.PluginManager now 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, 0 keeps 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, because POST /api/v1/register signs in unconfirmed and a player still playing keeps the account. Expired confirm tokens 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. With GAMEND_CAPTCHA_ENABLED on, GAMEND_CAPTCHA_API_REGISTER=true (default off) requires a Turnstile token in the new captcha_token field: 403 captcha_required or captcha_invalid, 503 captcha_unavailable when 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_user live session now signs the address the HTTP request resolved (after RealIp) into its session, LiveHelpers.client_ip/2 reads it there, and "unknown" is a bucket like any other.

  • [added] Content that can carry a manual. Gamend.Content reads real frontmatter now (Gamend.Content.Frontmatter: scalars, [a, b] and - a lists — title, description, position, image, keywords, slug, label), and a collection registered with nesting: :tree is read at any depth (Gamend.Content.Tree): folders are categories with a _category.md, index.md is a category's own page, slugs are paths (manual/scenes), and doc_tree/1, get_doc_category/2, doc_breadcrumbs/2 and doc_toc/2 answer 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 a mermaid fence into the MermaidDiagram hook's element, rewrites a link to a neighbouring .md file 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: :static leaves root-absolute image paths for priv/static. The blog reads title, slug, date, description, authors (from _authors/<key>.md), image, keywords and tags, honours <!-- truncate -->, counts reading time, and no longer opens a post with its own frontmatter as the lede. A :pages collection answers markdown at any unrouted path through the configured-page fallback. Feeds at /blog/rss.xml and /blog/atom.xml.

  • [added] A docs layout for a manual. use GamendWeb.DocsLive, layout: :sidebar renders the tree beside every page, a table of contents from xl, breadcrumbs, a landing page per category, an "Edit this page" link (edit_url:), and previous/next across the tree; the route is live "/docs/*path". :cards, the default, is unchanged. Layouts.app wide lets 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, false switches it off.

  • [added] A page's own social image, and a card grid. GamendWeb.PageMeta.Provider gained image/1 (with breadcrumbs/1 and robots/1 now declared too): the root layout uses it for og:image and twitter:image in place of the theme's banner, and emits og:url. A presentation section may carry "cards" — icon, title, text, optional href — rendered as a responsive grid, for the section whose point is a set. The LiveView socket passes :user_agent in connect_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_dark in the theme JSON is the mark the navbar shows under data-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 _dark from the logo's name.

  • [changed] R5 leaves file names alone. mix gamend.api.lint no longer flags a hyphen in a route whose last segment is a file — llms-full.txt is spelled the way the convention spells it.

  • [fixed] mix gamend.api.lint read core's router. R9 checked documented /api/v1/… paths against GamendHost.Router or GamendWeb.Router by 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/0 now resolves config :gamend_web, :router first, the way the endpoint and GamendWeb.ApiSpec already do.

  • [added] "Log in with GitHub". github joins the social sign-in providers, configured like the others: GAMEND_OAUTH_GITHUB_CLIENT_ID and GAMEND_OAUTH_GITHUB_CLIENT_SECRET (a GitHub App's or an OAuth App's pair), GAMEND_OAUTH_GITHUB_ENABLED to 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/github and /api/v1/me/providers/github flows, linked_providers.github (and bool github = 7 in the realtime LinkedProviders message), the admin dashboard, user list and Config page, and a github_id column on users with a partial unique index. Gamend.OAuth.Exchanger.exchange_github_code/5 sends the code to github.com/login/oauth/access_token (a bad code comes back as a 200 with error, so only a body carrying access_token counts) and reads /user; the primary email comes from /user/emails when the App holds the email permission, carrying GitHub's verified flag 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. No scope is 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_key collapsed 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.QuestsLive now 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 a GamendWeb.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. In Gamend.Quests, list_user_quests/2 / count_user_quests/2 take drop_groups: (keys to leave out, collapsed or opened); group: is unchanged. A collapsed entry now carries collapsed: true, which is what a card reads to know it stands for its group: it read group_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's quest_visibility_filter, as visible_categories/1 already 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.Username owns the rules: input is NFKC-normalized and lowercased, so fullwidth, ligature and decomposed spellings land on one stored form the unique index can compare, and get_user_by_username/1 normalizes 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太郎, 김민준kim pass; a Cyrillic а inside paypal is 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 sets GAMEND_LIMITS_USERNAME_ASCII_ONLY=true: handles are then a-z, 0-9 and separators, still normalized first, with display names Unicode as before. A plugin with other ideas replaces the character rules wholesale with the new validate_username/1 hook (:ok, {:error, message} or :default); length, uniqueness and invisible characters stay with core. UsernameGenerator.slug/1 still 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_name defaults to 255 (was 80), the column's own varchar(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's user form field (OAuthExchange.apple_web_name/1), and POST /api/v1/auth/apple/ios/callback and /me/providers/apple/ios take optional given_name/family_name beside code. The name fills a blank display name only (the existing update scrub keeps a set one), dropped rather than cut when past max_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/2 and count_records/2 take a :meta filter, {key, value}, keeping only records whose metadata[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 way Gamend.Notifications reads a metadata key: ->> on Postgres, json_extract on 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, and report_event/4 uses 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 — and report_event/4 is 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 carry quests_version/0, so a definition change drops them with active_quests/0.

  • [fixed] The admin Logs page counted the whole buffer and listed a filtered view. AdminLogBuffer.count_by_level/0 took 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 as error(7) above a list holding one — the other six were client entries. count_by_level/1 now takes the same options as list/1 and 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/0 is 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_version is not in @benign_alerts on 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 fatal protocol_version is tearing down a live connection over a version it already agreed to, which Apple's NetworkingExtension does. The atom cannot tell the two apart, so the alert description now does: only In state connection received CLIENT ALERT is 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/0 was a fixed map, so CONTRIBUTING'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 own before_* 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.ApiSpec built the paths from GamendWeb.Router while 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 in mix gamend.api.lint. The spec now resolves the router the same way GamendWeb.Endpoint.dispatch_router/2 does, falling back to GamendWeb.Router.

  • [fixed] A host app's own settings were never discovered. Gamend.Settings.apps/0 was core's two apps plus whatever called add_app/1, and nothing ever did — so a host that declared settings with Gamend.Settings.Provider got no boot validation, no row on the admin Settings page and nothing in mix gamend.settings.env_example or mix gamend.settings.guide, while Settings.get/2 went on answering with the compiled default. Setting the env var did nothing and said nothing. apps/0 now also scans the app named by the :host_static_app config, which a host already sets to name itself, so the mix tasks see the host's settings too — they run app.config without starting the application and never reach the boot path. GamendWeb.HostSupervision.init_runtime/1 additionally takes :host_app for a host that wants to name itself explicitly; init_runtime/0 still works and is init_runtime/1 with 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 MbedTLS unless 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 404 not_found, and any other failure 400 remove_failed.

  • [added] The Godot, Rune (Balaur) and C++ SDKs ship together on the latest release. Each change to main attaches godot_addons.zip, balaur_addons.zip (with the version stamped into version.rn) and gamend-cpp-sdk.tar.gz to 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 (events and state), so whichever opened second was dropped, and when that was events, 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 same Session as email and device login, username and display_name included. They answered OAuthResult, 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 is POST /api/v1/me/providers/{provider} {code} (a Steam ticket for Steam), /me/providers/google/id_token and /me/providers/apple/ios, which require the bearer token and answer the current user; a provider account that belongs to someone else is 409 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}, session being the Session on the first read after it completes (was result, the stored map); POST /api/v1/me/providers/{provider}/authorize starts a link, polled by its owner at GET /api/v1/me/providers/sessions/{id} (ProviderLinkStatus). A session's status is pending, completed or error, with the code in error (conflict was documented and never sent; failures carried details). 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: the after_user_logged_in hook and the login quest event. A Google ID token with no client id configured is 503 google_not_configured (was 500 server_misconfigured). OAuthResult and OAuthSessionData are gone from the document. A game that linked by signing in while signed in calls the link flow now: provider_link, apple_native_link or discord_native_link in the Godot SDK, auth.link in Balaur, Auth::link / Auth::link_steam in 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 of client.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. Auth signs 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 a 401), with on_session_changed / restore to keep it between runs. Realtime joins user:<id>, joins any topic, calls server hooks, names every event from events.json, reconnects with backoff and a fresh token, and rejoins its topics; RealtimeFormat::Protobuf decodes binary event frames with a reader generated from proto/gamend_realtime.proto, no protobuf library, and hands a game's own *_pb metadata and KV data to the decoders it registers. Kv keeps live key-value rows over reconnects and Presence merged profiles and online state from the events. WebRtc opens 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 in client.poll() on the game thread. The core builds with -fno-exceptions -fno-rtti, warning-free under -Wall -Wextra -Wpedantic with Clang and GCC. api.hpp, models.hpp, events.hpp and the protobuf table are generated by clients/generate_cpp.sh into cpp_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 the latest release as gamend-cpp-sdk.tar.gz: fetch it with FetchContent, or cmake --install it and find_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.py became 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_logs needs session and entries).

  • [breaking] The Balaur SDK is called by path, one module per tag. Balaur mounts an addon's files as modules, so addons/gamend/lobbies.rn is gamend::lobbies in every script with no script::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; the push tag is push_tokens.rn, since gamend::push is the engine's own call. events.rn holds one pub mod per channel (gamend::events::lobby::MEMBER_JOINED) and decode; the flat event constants and api.rn are gone. client.rn, auth.rn and log_sink.rn call by path, client::unpack_hook drops its unused first argument, and client::fetch_cached calls kv::get_kv with 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 no GamendApi method.

  • [fixed] Balaur auth.sign_in never opened the provider's page. It read url where the server sends authorization_url, passed the provider to authenticate_oauth_request as a table instead of a string, read the session status outside its data, and waited for failed/cancelled statuses the server never sends. It now opens the page, stops on error or conflict, and signs in with the tokens under result.

  • [fixed] A browser game could not call the API with its run id. CORS allowed content-type and authorization only, so the preflight for any request carrying x-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/register takes email, password and an optional username, sends the confirmation email as browser sign-up does, and answers 201 with 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_ACTIVATION holds, and an email that cannot be sent rolls the account back (503 email_delivery_failed). A taken email or username is 409. The auth rate limit applies.

  • [fixed] DELETE /api/v1/me documents its body. An account with a password has to send current_password, and the operation declared no body, so generated clients could not delete such an account. The OpenAPI operation now carries the optional current_password.

  • [fixed] Chat messages never created a notification. chat_friend, chat_group, chat_lobby and chat_party were missing from Gamend.Notifications.Types, so every chat notification was rejected at write, inside a background task that dropped the error. They are registered now, with quest_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_buttons takes action={:login | :register} instead of label; the grid is one column below lg, where the form is max-w-sm and 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, PageMeta and UserBrief replace the inline schemas, and every lobby error is ErrorResponse, so generated clients get LobbyPage instead of ListLobbies200Response. The inline schema had drifted from the serializer: it lacked state, state_changed_at and the members' is_activated, which a typed client drops, and typed host_id as a UUID though a hostless lobby sends "". GamendWeb.ResponseContract now 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 of docs/specs/named-api-schemas.md.

  • [changed] User, sign-in and friend responses have names too. CurrentUser, PublicUser, Session, OAuthResult, Friend, FriendRequest, ProfileUpdate, UploadTicket and their pages replace 26 generated classes such as Login200ResponseData and ListFriends200ResponseDataInner. The document had drifted here too: every user row lacked is_activated, lobby_id and party_id were 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 (adding profile_url and is_activated), and an OAuth sign-up that fails validation answers 400 with errors instead 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_quest and get_my_record declared nothing usable: nine no security at 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.ApiSecurityTest checks 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 under data (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_lobby puts members and spectator count inside the lobby; party invitation lists are pages; group_name is group_title, in REST, realtime and new notification metadata. Error codes are always snake_case (not_authenticated, invalid_credentials, missing_param...) with prose in message; details and reason are gone; a rejected form is always 422 validation_failed with errors (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.ApiShapeTest checks the OpenAPI document against it and GamendWeb.ResponseContract checks 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 own matches and the entries they name (was one {brackets, entries, matches} object beside the page's meta); my_match with no match waiting answers 404 no_current_match (was {"data": null}); standings list placements under placements (was entries); 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 tournaments registration_closed and tournament_full (403), already_registered and already_drawn (409), not_registered (404), a hook's rejected (403, its words in message), invalid_index (400); a failed changeset is 422 validation_failed. Every already_* answer is now 409, which moves already_member, already_admin (groups) and already_in_lobby (joining a lobby) from 403. Generated clients get Leaderboard, LeaderboardRecord, Tournament, TournamentEntry, TournamentMatch, TournamentBracket and their pages instead of ListLeaderboards200Response-style names.

  • [fixed] The tournament entries page counted every entry whatever the state filter, so total_count and has_more were 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 403 not_completed (was 409), an unknown quest not_found (was quest_not_found), and a before_quest_claim veto 403 rejected with the hook's reason in message (was 422 claim_rejected with an inspected reason). Payment refusals keep their codes and take the status of their kind: already_owned, purchase_already_in_progress and receipt_already_used 409, *_not_found 404, *_not_configured 503 (a webhook provider retries later), a failed changeset 422 validation_failed (was 400 invalid_data); a reason that is not a code is logged and answered payment_failed instead of its inspect output. An unknown store provider is unknown_provider, as for sign-in (was unsupported_provider); a malformed user id on quest completions invalid_id. Strings that were null are now "" (a purchase's provider_product_id, an entitlement's product_id and source_purchase_id, a tournament match's empty slots). Generated clients get Quest, QuestProgress, QuestClaim, LedgerEntry, WalletBalances, Inventory, Purchase, Entitlement, PaymentCatalogEntry and their pages.

  • [added] GamendWeb.ApiShapeTest fails on a nullable string other than a date or date-time (R6), checked on the document rather than by grepping source, so a nullable format: :uuid string no longer slips past.

  • [breaking] Public user profiles no longer carry lobby_id and party_id. They were always "" on GET /users/:id and 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.ResponseContract no longer takes a list of tags: every operation is checked from its first test. mix gamend.api.lint gains R15: an API controller answers through GamendWeb.Reply, not json/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 token missing_param (was missing_token), and the /api/v1 404 fallback, which now carries "message": "Not Found" like every other not-found.

  • [changed] clients/generate_godot.sh sheds 46 of its 56 perl rewrites 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 under data (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 under data (were bare). Moved out of pages: the filter-word list's languages is GET /admin/chat/filter_words/languages, the storage list's usage is GET /admin/storage/usage. The admin leaderboard is the player Leaderboard (it gains is_active, description and icon_url); an admin quest gains group_key and group_title; a label record's user_id is "" (was null). Errors are codes with the status of their kind: last_admin, insufficient_funds, insufficient_items, not_drawable, not_running, not_cancelled and not_completed are 403 refusals; already_resolved and idempotent_replay 409; unknown_language 404; a missing field missing_param (was a sentence); an admin push message that fails validation 422 validation_failed with per-field errors (was 400 invalid_message); a quest or chat-moderation changeset 422 validation_failed (was {errors} with no error, or invalid with details).

  • [fixed] DELETE /api/v1/admin/groups/:id answered 500 for a group that did not exist, and reported a before_group_delete veto as not_found. Now 404, and 403 rejected for the veto.

  • [fixed] Finishing a tournament in the same second it was drawn answered 500. Admin draw sets starts_at to now, finish set ends_at to now, and "ends_at must be after starts_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.lint R6 exempts only dates. It skipped any line with a format:, so a nullable format: :uuid string 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 under data (the Godot and Balaur SDKs read either shape). A KV read answers the entry as the realtime kv_updated event carries it, {data: {key, user_id, lobby_id, data, metadata}} (was {data: <value>, metadata}). Opening or answering a ready check answers the check under data (was bare), calling one off {"ok": true} (was {}), and answering with no open check is 404 no_open_check (was 409); a before_ready_check_open veto is 403 rejected with the reason in message (was 422 with an inspected reason). GET /matchmaking/tickets/me with no ticket is 404 not_queued (was {"data": null}); joining refuses not_party_leader and party_has_blocked_pair with 403 and party_too_large with 400 (all were 409; already_queued stays 409). GET /hooks is a page, each function naming its fn as call_hook takes it (was name). A hook call's refusals are codes with their detail in message (too_many_args, args_too_large, missing_param for what was invalid_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, and details is gone. Client-log errors are invalid_batch, session_forbidden and collection_disabled (were sentences). Generated clients get KvEntry, HookFunction, MatchmakingTicket, MatchmakingStats, ReadyCheckState (the realtime proto's name), MyReadyChecks, ServerTime, Health, ServerStats and the per-domain stats it is built from.

  • [fixed] GET /api/v1/hooks answered 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 portable module_info/1, and now the listing does too. The bundled example_gleam plugin was enough to break it.

  • [changed] Chat, notification and push responses have names. ChatMessage, ChatReadCursor, ChatUnread, ChatMuteRecord, UnmuteResult, Notification, DeletedCount, PushToken and OkResponse, 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_lobby a PartyJoinLobbyRequest, kick_user a KickPartyMemberRequest. 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.py rewrites 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, PartyStats and StatusResponse replace 17 generated classes such as ListMyGroups200ResponseDataInner and AcceptPartyInvite200Response; party lobby actions document the Lobby they return, and party members are documented as the UserBrief rows 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: GDScript class_name is global, so an unprefixed Lobby or Friend would 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: GetLobby200Response is GamendLobbyResponse, OauthRequest200Response is GamendOAuthAuthorization. GamendApi, GamendClient and GamendResult keep 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.sh compiles 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.js does the same for the built JS package. clients/godot_migrate.py rewrites 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 of polyglot-pirates-game it resolved all 47 references and the game compiled as before. The JS generate script now clears the previous output first, so a renamed model no longer lingers in the built package. generate_godot.sh runs a local generator jar instead of Docker when OPENAPI_GENERATOR_JAR is 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) and docs/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/3 matches 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.Payments and the admin configuration page are split by concern. Accounts (3,000 lines) delegates to Search, Stats, Registration, Identities, Sessions, Profile, Presence and Broadcasts; Payments (2,275) to Admin, StripeEvents and StoreEvents, with its payload helpers in Payments.Params; the configuration LiveView (2,940) to ConfigDiagnostics, ConfigSections and ConfigSystemSections. Every public function keeps its name on the original module. mix gen.sdk follows 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 private ceil_div/2 in some forty views), Gamend.Parse.blank_to_nil/1, GamendWeb.AdminLive.Shared.list_opts/2, GamendWeb.ChannelEvents.other_info/2 for the final handle_info/2 of 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 a gettext call that no catalog had entries for. Both now render through Intl in 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 share GamendWeb.LiveHelpers.prev_page/2, next_page/3 and put_page_size/3, which clamp to the known page count and to Gamend.Limits.

  • [fixed] A non-numeric score answered 500 from the admin leaderboard record API (String.to_integer/1 again). It answers 400 invalid_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/2 answers that window like the check would have: user_not_found for currency and item changes, user_not_found or leaderboard_not_found for scores.

  • [changed] sender_name, host_name, creator_name and leader_name fall back to the username. They sent "" for a player with no display name, while a party or group invite's sender_name already fell back — the same field named the same player two ways. Fields literally named display_name still carry the raw column. Web pages no longer label an unknown player "User #<id>".

  • [changed] Unknown channel events get the same unknown_event reply and debug line on every channel, through GamendWeb.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/1 and GamendWeb.AdminLive.Shared.put_filters/3.

  • [added] GAMEND_DEV_BENCH=1 runs 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.md uses it, with a results directory per run and a fresh database for A/B comparisons.

  • [changed] gamend_core has its own test suite. The context tests lived in gamend_web and 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 when gamend_web was not loaded. Gamend.DataCase, the fixtures and NoopHooks (now Gamend.TestSupport.NoopHooks) are shared from apps/gamend_core/test/support, and root mix test runs both suites.

  • [fixed] GAMEND_PAYMENTS_ENVIRONMENT=sandbox selected production. Every :atom setting cast through String.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: payments sandbox (real money, in a configuration that asked for the sandbox), push apns_env=sandbox (dev builds talking to the production APNs gateway) and mail smtp_tls=if_available (STARTTLS quietly off). Settings now declare their choices with values:, 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_env atom 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 declare foreign_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 with Gamend.Accounts.user_exists?/1 first 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, which Jason cannot 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 under error/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 in GamendWeb.ContentPages. It had stayed a shim that looked up GamendWeb.HostBlogLive by name, so each host wrote the page itself — gamend and the starter carried byte-identical 237-line copies, each with a private reimplementation of Gamend.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/3 now 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_size straight from the caller, two clamped to a hard-coded 1000 that ignored the configurable max_page_size. GamendWeb.Pagination had already fixed this at the controller layer, but a plugin calls the context directly. All of them window through Gamend.Query now, which clamps through Gamend.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/1 joins display_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 into GamendWeb.Uploads — where the two copies had quietly disagreed on what a missing content-type means, now an explicit argument.

  • [added] Gamend.Payments.Provider and GamendWeb.PageMeta.Provider behaviours. 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 gives provider_adapter/1 a catch-all: an unknown provider string raised CaseClauseError, which is a 500 for what is really "no such provider".

  • [removed] Dead code: GamendWeb.AdminLive.Users.Index (an unrouted duplicate of AdminLive.Users), GamendWeb.Plugs.Locale (superseded by Plugs.LocalePath, and redirecting the opposite way), and eleven unreferenced functions. ConnectionTracker.count_other_user_channels/1 went 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/2 on 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_provider module, and turns the feature off with search_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 :browser pipeline, so a content host's crawlable pages keep answering without a Set-Cookie; gamend_current_user_routes/2 takes :extra_pipelines to 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_core and gamend_web publish 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 :unauthenticated and 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 fires gs: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.gen reported success while godobuf had written nothing — one reserved field it could not parse froze a game's Godot bindings for weeks while Elixir and JS kept regenerating fine. reserved is 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.js re-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_url on 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.lint in 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.