Release history
Changelog
Every new feature, fix and speed-up on s&box Explorer, newest first.
- Last 30 days
- 90
- Features shipped
- 233
- Fixes
- 218
- Days with changes
- 66
508 changes
October 2026
7
-
Feature
9:49 AM
One default profile layout for every user
Details
Drop per-user layout editing (profile-layout API, drag handles, reset); keep bio, social links and featured badges as widgets. Two-column band grid, balanced columns, hide empty Packages, cap Achievements to 6 games with masonry cards and skip full lists for games over 200 achievements.
2fe1896 -
Fix
8:36 AM
Save the Steam presence toggle where the profile reads it
Details
The presence settings endpoint wrote user_presence_settings, which nothing reads, while the page and profile read user_settings.steam_presence_enabled through SteamIntegrationManager. The toggle appeared to save but reverted on reload. Both routes now use SteamIntegrationManager, and the save is read back before reporting success.
99f048e -
Feature
8:20 AM
Rebuild the Settings page as a sidebar workspace
Details
Replace the tabbed three-column form with a sticky section nav and full-width content. Themes get CSS-rendered previews from palettes the themes endpoint now reads from each theme file, with search and dark/light filters. Language gets a searchable grid. MCP gets stats, a copyable endpoint and compact key management. New strings live in locales/en/settings.json.
Fixes found while auditing:
- delete_account now verifies the typed account name server-side
- account deletion also removes claimed_orgs, org_claim_requests and mcp_api_keys
- saving a theme merged nothing and reset other settings (including Steam presence) to defaults; it now merges with stored settings
- mcp-keys-api rotated the CSRF token on every call, breaking a second create/revoke; it now accepts the X-CSRF-Token header
- MCP key and rate limits are constants shared by the page and server
- setTheme accepts reload:false to apply without a page reload
763aeb8 -
Fix
7:56 AM
Stop the settings page rotating the CSRF token after the header prints it
Details
mcp.php called generateCsrfToken(), which issues a new token on every call. The header had already emitted the previous one in the csrf-token meta tag, so every settings save from that page (theme included) sent a stale token and got a 403. getCsrfToken() reuses the session token, so the meta tag and the MCP forms agree.
515288d -
Feature
7:47 AM
Redesign header and footer, rebuild themes around tokens, add eleven game themes
Details
Footer: three flat tiers (link groups, stats strip shown only when the status API is OK, legal bar). Social buttons and stat tiles are flat fills, and the language and support buttons no longer inherit hard theme shadows. Project and Community headings are translated in all locales.
Header: rebuilt from nav-config.php with a Discover dropdown, search trigger, user menu and mobile toggle, driven by navigation.js. Header loop variables are prefixed nav so they no longer clobber page variables such as the $link closure in /changelog. Header menus close and return focus when a button inside them is activated.
PWA install: the floating install button is replaced by an Install app entry in the Discover menu's Tools group. It stays hidden until beforeinstallprompt fires, prompts only on click, hides after use and on appinstalled, and never shows in standalone mode. On iOS it toggles an Add to Home Screen hint. The update toast styles now inject with the toast and use theme tokens, and the slideIn keyframes used by two other toasts move to base/animations.css.
Themes: all 32 existing themes set tokens on html.<name>-theme instead of overriding components. Game Boy, Rust and High Contrast are rebuilt, the retro themes lose broken effects, AMOLED is flat colour, and the Game Boy sub-stylesheets are folded into one file. Eleven game-inspired themes are added (Fallout 76, Starfield, Blueprint, Control, No Man's Sky, Aperture Science, Red Dead Frontier, Mirror's Edge, Subnautica, Windows XP, Black Mesa), bringing the total to 43. Blueprint uses opaque surface tokens, a quiet canvas-only drafting grid, corner ticks and a solid tooltip.
Package page: the header kept three columns until 760px, so wide action buttons squeezed the title to one word per line near 768px. It now stacks the actions below 1000px.
Cleanup: changelog commit hashes use 7 characters, the admin .user-avatar rule is scoped to .user-profile-header, Global Stats credit links use --link for light-theme contrast, and obsolete footer and header rules are removed from the shared sheets.
Known and left alone: 311 declarations use the invalid rgb(var(--color-*-rgb) / x%) form site wide.
a83efd5 -
Feature
3:32 AM
Restructure Global Stats into Overview, Activity and Games views
Details
The page was ~6,000px of stacked bands, four near-identical bar charts and a full-width hero plus card grid. It is now three views sharing one shell, with each view loading only its own data.
Views
- Overview: live and last-24-hours stat rows, the live player chart, one daily chart with a metric selector (players, new, returning, returning share) and a compact most-played / rising leaderboard.
- Activity: growth stats with period deltas, longer online history, the daily chart with extra metrics (editor users, hours, peak), the retention curve (folded in from a standalone view that held a single chart) and the every-day table.
- Games: sortable, searchable, tag-filterable table with Show more, and the sbox.watch rising list.
Navigation
- Tabs are real links (?view=), enhanced to swap the panel from a ?partial=1 fragment with pushState, popstate and arrow/Home/End key support. The fragment is sent with X-Robots-Tag: noindex. Without script the links and a <noscript> rule still give the full game list.
- Each view has its own canonical, og:url and description. meta-tags.php takes an optional $pageMeta['canonical'] (a path with a single leading slash); pages that do not set it keep the existing strip-the-query behaviour.
- ?view=retention 301s to Activity's #retention section. Activity and Games are in the sitemap generator.
Data
- New players and sessions "today" do not exist upstream (history ends yesterday), so the page shows 24-hour totals and yesterday's new and returning players with the date. Returning share is returning / players per day.
- Rising is fetched once at the largest size and sliced for Overview so the views share one cache entry. The cached game list drops summary and orgTitle, which only the removed hero read.
Charts
- PlayerHistoryChart removes its resize listener once its canvas leaves the DOM, which tab switching would otherwise leak.
- The daily chart tooltip sits above the hovered bar, sizes to its content so it no longer wraps at the right edge, is frosted with a backdrop blur at the owner's request (a deliberate exception to the page's no-blur rule), and works on touch: a tap shows it and it lingers after the finger lifts.
- Stat values use the --fs-heading and --fs-figure tokens.
Tests
- Vitest covers filtering, sorting, the games table, the daily chart and touch. test_global_stats_grid and the new test_global_stats_meta cover the server side: view resolution, daily series, canonicals and descriptions.
Removes the unused platform-history view and the old page stylesheet.
748ddd4 -
Feature
2:26 AM
Use unused Facepunch and sbox.watch fields, load largest thumbnails
Details
Global Stats: platform reach row (unique players 24h/7d/30d, sessions, playtime) from stats/platform, and returning-player and editor-user charts, KPI and table columns from stats/platform/history.
Package page: Recommended Players and Play Modes in config details from Version.Extra, and the source stat on achievement cards.
Org page: all-time totals card from sbox.watch analytics/organizations.
thumbSources() now always returns the largest variant (Square 512, Wide 910) and no longer takes a target width, so org cards and other square thumbnails are no longer blurry.
7d69f14
2
-
Feature
12:49 PM
Api-list-diff --quiet so cron only mails on change
Details
cPanel cron mails any output, so staying silent when nothing changed turns the weekly run into a change alert.
5fe8e96 -
Feature
12:47 PM
Add api/list snapshot diff tool
Details
Fetches the Facepunch api/list and reports added, removed and parameter-changed endpoints against a saved baseline. Whitelisted in .gitignore so the deploy ships it for the server cron.
a866144
3
-
Fix
2:57 PM
Show the install button on desktop too, and use 4px corners
Details
beforeinstallprompt was deferred with preventDefault() but only mobile got a button, so desktop never called prompt() and Chrome logged the banner warning on every page. The button was also a 50px pill.
06f7605 -
Fix
2:53 PM
Serve service-worker.js with no-cache
Details
The immutable year-long rule for .js also covered the service worker, so Cloudflare kept the old script and its old CSP header. A service worker's fetches are governed by the CSP on its own response, so the widened connect-src never reached it. The docroot .htaccess is not deployed; the same block was added there by hand.
089546a -
Fix
2:47 PM
Restore review modal, guard null steam id on profile, widen CSP connect-src
Details
openReviewModal/closeReviewModal were lost in the admin shell refactor, so Approve/Reject threw. The profile favorites count fataled when the profile had no valid steam id. connect-src now covers the hosts the service worker fetches (cdnjs, fonts.googleapis, steam header CDNs, files.facepunch).
93018e3
59
-
Feature
10:30 PM
Add Rising today, daily gains and server links from sbox.watch
Details
Global Stats gets a Rising today band: the ten games gaining the most favourites and upvotes in 24 hours, from the dashboard endpoint sorted by change24h. sbox.watch redirects that endpoint unless it is requested as sort then pageSize with no kind, so the request is built in exactly that order. Cached for 15 minutes like the other digests.
Game pages show Favourites today and Upvotes today in the Player activity card, and each live server links to its sbox.watch page. Only canonical https://sbox.watch/servers/... addresses are kept; anything else (other hosts, javascript:, look-alike domains, trailing newlines) is dropped.
Unit tests cover the rising normaliser (bad idents, negatives, clamping, maps excluded), momentum parsing and the server link filter.
56bc7af -
Feature
9:57 PM
Rebuild the changelog page as a readable release history
Details
The page was a raw commit log: ten commits a page (splitting days), a commits-per-hour joke, a stats wall with a one-person contributor list, a Last updated line that never rendered, and a 2,262 line stylesheet inside the global bundle.
It now has a hero with a Latest card (the newest day's best changes), a four tile summary, a Highlights default view (features, fixes, performance, security) with Features, Fixes, Performance, Maintenance and Everything filters, search, clickable month columns that show quiet months as zero, and pages of eight days so a day is never split. Commit bodies become paragraphs and lists behind a native Details toggle; no JavaScript is needed. Filtered and paged URLs are noindex; legacy ?type= links still work.
Filtering, grouping, search, input validation and body parsing live in ChangelogFeed with unit tests. The stylesheet is a small per-page file and no longer part of the bundle. Radii are 4px at most, including the credits page role label.
The generator now maps the PatrickJnr git identity to Patrick Jr., so the stats show one author instead of two.
c5f4b33 -
Fix
9:37 PM
Actually delete inactive session records after 30 days
Details
The privacy policy promises session data is deleted after 30 days of inactivity, but cleanupOldSessions() was never called. The scheduled refresh now runs it once a day around 03:00, and it matches on last_activity (falling back to login_time) so people who are still active are not removed. Covered by an integration test that seeds stale, long-login-but-active and recent rows. Also writes the audience key colour opacity as a percentage to satisfy stylelint.
af53781 -
Feature
9:37 PM
Rebuild the credits page and correct stale About and Legal text
Details
Credits is now About, Built by, Data & services, Technology, Thanks and an ownership statement, driven by backend/includes/credits_data.php. It no longer claims the project is open source, MIT licensed or open to contributions (the repository is private). Removed Redis, Fira Code, the build and lint tools and other things production does not use; added sbox.watch (with its attribution), sboxskins.gg, Ko-fi, Cloudflare, the front-end libraries and fonts that are actually loaded, and the profanity word lists with their licences (one is CC BY 4.0).
About: dropped the Redis, 'sub-200ms', '10-50x' and '10 specialized classes' claims and replaced the load-time tile with the language count. Legal: third-party list now covers advertising, Cloudflare, Ko-fi, sbox.watch and sboxskins.gg; the source code is described as private; log retention is 30 days (what the rotation script does, not 90); the IP wording says addresses are stored in full and only masked when displayed, as production has no SALT set. Last-updated date and version history brought up to date.
Translations: stale strings were removed from the 29 other locales so the corrected English shows instead, and the unused credits strings (including the old open-source ones) were deleted from all 30.
908a2af -
Feature
8:52 PM
Add sbox.watch analytics, organization stats and small-game chart bounds
Details
- Player activity card on game pages: rank, average and peak against the previous period, hour-of-day strip, update impact, session length, new and active players, 30 day approval trend and new vs returning players per day. Only a compact digest of the 200 KB analytics report is cached (15 min), and views tolerate digests cached before the new keys existed.
- Activity card on organization pages (peak 24h, members, last game update).
- Hourly averages keep two decimals so small games are not flattened; the chart uses decimal ticks under 4 players and draws sbox.watch history for those games as exact average plus hourly blocks up to the rounded-up peak, with the tooltip saying 'peak at least N'.
67bfac8 -
Fix
8:52 PM
Stop logging expected review 404s and guard missing SecondsPlayed on profiles
Details
Package reviews return 404 for packages nobody has reviewed, so HttpService no longer logs them as errors. The Recently Played card skips entries with no Package and hides the hours line when SecondsPlayed is absent, which was raising undefined-key warnings on signed-in profiles.
28ec260 -
Feature
7:56 PM
Restore long-range player history and add live servers from sbox.watch
Details
Integrates two operations of the public sbox.watch API (no key, no published licence or rate limit), server side through a new SboxWatch client.
activity-history gives platform and per-game history from before Explorer's collector existed, back to September 2025, which refills the Global Stats chart and the game Player History widget and adds 1y and All ranges to Global Stats. Our own data stays authoritative: the history API only uses sbox.watch when its series reaches meaningfully further back than ours (more than 10 percent of the range, at least an hour, or when we have nothing), one source per response, and says which in source and attribution. 90d maps to their 1y trimmed to 90 days. As the collector accumulates, sbox.watch is used for fewer ranges on its own.
packages/{game|map}/{ident} gives liveServers, which Explorer has no equivalent of, shown as a Live servers card on game and map pages. Playtime, users and sessions are not repeated; Facepunch's UsageStats already supply them. Dashboard, search, organisations, analytics and the server directory overlap existing data or would need new pages and are left alone; docs/SBOX_WATCH.md records why.
Caching is CacheManager::getOrSet per key (lock plus early expiry) with a seven day last-good copy as the stale fallback: history 5 min (24h), 10 min (7d), 30 min (30d), 6 h (1y, all); packages 5 min; a 404 is cached for the same TTL. Resilience: 3 s timeout, no retries, no redirects, 1.5 MB cap, a circuit breaker (3 failures pauses upstream for 2 min) and a budget of 30 upstream requests a minute site-wide, so arbitrary valid idents sent to the history API cannot be used to hammer them. The client builds sbox.watch's canonical URL (the default range=24h is omitted and range precedes game); anything else gets a 301 that the client deliberately does not follow.
Data is validated before use: idents (anchored with \z), allow-listed ranges and kinds, a fixed host, numeric points capped at 2000, server names stripped of control characters and clipped, escaped at output; addresses and upstream URLs are never stored or shown.
'Data provided by sbox.watch' links to sbox.watch under the Global Stats chart and the history widget when the series came from them (with a note when partialCoverage means older totals may omit games), in the Live servers card, and on the Credits page.
Tests: test_sbox_watch.php covers the normalisers and the source rule; test_sbox_watch_cache.php runs the client against a mock upstream that also enforces the canonical URL (hit, miss, 404, stale fallback, breaker, timeout, redirect, size cap, budget, and five concurrent processes causing one refresh); two chart tests cover the source being passed through. Checked locally against the live API and with sbox.watch unreachable.
c247d50 -
Fix
7:36 PM
Let Google see noindex instead of blocking the pages it indexes anyway
Details
Search Console reported pages indexed though blocked by robots.txt. They were URLs that public pages link to without nofollow, which robots.txt blocked, so Google indexed the bare URL and could never read a noindex: /auth/login.php and its ?return_to= variants (the sign-in link, on every page), /mcp/ (the MCP landing page) and /backend/api/docs.php?format=openapi (the API docs download).
robots.txt no longer blocks /auth/, and allows /mcp/ (exact) and /backend/api/docs.php, which are more specific than the existing Disallows. /auth/* and docs.php now send X-Robots-Tag: noindex, nofollow from sendNoindexHeader() in bootstrap, so the redirect and the JSON drop out of the index. Every sign-in link and the OpenAPI download get rel=nofollow. /mcp/ is a real page, so it stays indexable, gets a meta description and goes into the sitemap.
test_robots.php parses the real robots.txt with a most-specific-rule matcher (crawlable and blocked paths), checks the noindex paths, and that no sign-in anchor lacks nofollow.
da0bf73 -
Fix
7:33 PM
End the Steam launch link with a slash
Details
The link is now steam://run/590830//-rungame org.package/, matching the one sbox.game's own Play button uses, instead of ending at the package id.
af796dd -
Feature
7:25 PM
Play button that launches the game through Steam
Details
Game package pages get a Play button next to View on sbox.game. It links to steam://run/590830//-rungame org.package, the same launch sbox.gg uses, and lets the browser hand the steam:// URL to Steam, with no dialog or custom launcher. It is drawn only for packages of type game and only when the identifier is valid, and it is hidden on touch-only devices (hover: none and pointer: coarse), where Steam cannot launch it.
steamLaunchUrl() in helpers.php builds the link and validates through PackageValidator, which is also what the API uses. That validator's $ anchor accepted a trailing newline, so 'a.b\n' passed; it now anchors with \z. The s&box app id lives in one SBOX_STEAM_APP_ID constant, and the three places that carried 590830 as a literal (auth callback, steam_functions twice, the inventory URL) use it. The English label is common.play; other locales fall back to English.
test_steam_launch.php checks the exact URI for pcjr.emojislots and facepunch.sandbox, more valid ids, 22 malformed or hostile ones, the view wiring, the touch-device rule, and that the app id literal stays in constants.php.
38a5e14 -
Fix
7:11 PM
Stop the header and news meta reading array keys that are not there
Details
Production's config/config.php defines its own getCurrentUser(), which wins over the one in auth-helpers.php and returns is_stinky, not is_blocked. header.php read $user['is_blocked'] unguarded, so every signed-in page view logged an undefined-key warning (error logging became visible when production stopped using error_reporting(0)), and the Blocked badge never showed for blocked users. It now shows for either key.
meta-tags.php read $newsPost['title'] unguarded for the image alt text and keywords on a post with no title. Both now default to an empty string.
test_meta_news.php renders the news branch with and without a title and checks the header guard.
9e947ff -
Feature
6:46 PM
Index plus per-section sitemaps for games, orgs, news and jams
Details
sitemap.xml is now a sitemap index over sitemap-pages, -games, -orgs, -news and -jams. Games list every public game as /org/ident/ with its Updated time as lastmod (2007 of them), orgs are the ones that have a listed game (1174) and take their newest game's time, news is the platform posts and jams the current jam idents. Pages gain /forum and /ugc/charts/game|map, which were routable but missing.
Each section is built independently. If an upstream fetch fails the earlier file for that section is kept and still listed, and a section with no data and no earlier file is left out of the index, so an API outage at deploy time no longer ships a sitemap with holes. Files are written atomically, split at 50000 URLs into numbered files, and stale chunks are removed. Idents are validated, private games skipped, and the type list keeps its existing filter.
--verify checks every static page and a sample of each dynamic section against a running server. --out is now a directory. Generated sitemap-*.xml files are gitignored like sitemap.xml.
test_sitemap.php runs the generator against a mock API: sections, filtering, lastmod, and the outage fallback.
3a68fc8 -
Feature
6:33 PM
Add sbox_docs refresh, and fix clearCache missing the cached pages
Details
sbox_docs gains action=refresh with target all|docs|api. It clears the cached doc pages and index, re-reads the index, and re-downloads the API schema when asked. It needs an admin per-user key or the master key, is limited to one run per five minutes across all callers, and writes a refresh_docs audit entry (admin_id 'master' for the master key).
DocsService::clearCache built its keys from the full link path (/dev/doc/x.md) while fetch() caches under the slug (x.md), so the existing admin clear_doc_cache never removed a cached page, only the index. It now strips the prefix. McpAuth::isMaster() records that the request used the master key, since that key has no Steam identity and mcpIsAdmin() is false for it.
test_docs_refresh.php covers the key fix and the cooldown.
9c9bcd5 -
Fix
6:11 PM
Default the retention and history card headings when the caller sets none
Details
retention-curve.php and platform-history.php read $retentionHeading / $historyHeading in the ternary's true branch, so a caller that never sets one (the package page's retention section) got an undefined variable, an empty tag name and a broken heading. They now default to h2. test_heading_default.php renders the partial with no heading, with h3 and with an invalid one.
b4f9aa2 -
Fix
5:40 PM
Name the package tabs on phones, where the label is hidden for inactive tabs
39220ce -
Fix
5:38 PM
Finish moving inline handlers to delegated actions, label the settings tabs, and tune the CSP report path
Details
- remaining JS-generated onclick/onerror handlers (reviews retry, PWA update banner, claimed-orgs retry, search retry, version compare, achievements expand and icon fallback) use data-action or listeners; tests for the review and achievement renderers cover hostile names and titles, fallbacks and error states
- settings tabs get ids (the panels' aria-labelledby pointed at nothing) and accessible names, which the phone layout hid with the visible text
- CSP: the service worker's fetch() of fonts and styles was missing from connect-src, found from the collector's own reports; reports use a separate 30/min bucket so they cannot use up the API allowance; the Report-Only header is sampled at 10% (CSP_REPORT_SAMPLE_PERCENT)
- README: the schema is not auto-created; run create_consolidated_schema.php on an empty database first
1caa6dc -
Fix
5:29 PM
Remove a duplicate obscureIpAddress() and add an authenticated smoke test
Details
The PHPStan commit's claim that obscureIpAddress() did not exist was wrong: it is defined in config/session_tracking.php, which analysis was not scanning. The duplicate added to helpers.php was a fatal redeclaration for every signed-in user, found by the post-remediation sweep. tests/test_authenticated_smoke.php drives signed-in pages and the recently-viewed API against a running site and fails on any 5xx or PHP error; it fails with the duplicate present.
85270ba -
Fix
5:16 PM
Consolidate recently viewed on RecentlyViewedManager
Details
- one store: the recently_viewed table behind RecentlyViewedManager, keyed by $_SESSION['steam_id']; package pages, recommendations and the profile page already used it
- the manager now creates its table on PostgreSQL (the only migration that did was SQLite-only), validates package ids, and takes an injectable clock
- /user-experience/recently-viewed POST wrote user_recently_viewed, which nothing read; it now tracks through the manager. DELETE had no route, so the profile page's Clear history button got a 404; it exists now
- user-experience-api.php no longer calls track() with a null user for anonymous POST/DELETE
- account deletion also removes recently_viewed rows
- remove backend/api/recently-viewed.php: wrong session key and a column that does not exist, no caller
- tests/_support.php: integration tests fail with a reason instead of silently passing; tests/test_recently_viewed.php
2ebdfa3 -
Security
5:13 PM
Nonce every inline script, add a Report-Only CSP with a report collector, and start migrating inline handlers
Details
- cspNonce() on all 51 inline <script> tags; backend/includes/csp.php sends a strict Report-Only policy (no script 'unsafe-inline') for HTML pages and /backend/api/csp-report.php logs violations
- actions.js: delegated data-action clicks and data-fallback-src image fallbacks replace inline handlers; migrated mcp-tools (52), image onerror (24), the async font preloads and the 404 back button
- tests/test_csp.php fails if a template gains an inline script without a nonce
fea57c3 -
Security
5:07 PM
Reject example secrets everywhere, deny HTTP access to internal directories, remove the web installer
Details
- isPlaceholderSecret() is the one check for MCP, cron and Ko-fi keys; the README's sample MCP key was 20 characters and would have been accepted
- the root .htaccess is not deployed (cPanel owns it), so include-only and operator directories (tools, backend/includes, mcp/lib, ...) get their own deny files; tools/ holds 33 scripts with no web guard and was reachable
- remove tools/setup.php: unauthenticated, locked only by a file's absence, wrote .env with unsanitised input, and its include paths no longer resolve
91bd0ed -
Performance
5:01 PM
Add claimed_orgs.is_public only when it is missing, not on every request
1e30659 -
Fix
4:59 PM
Answer array-valued query parameters with 400 instead of a TypeError 500
Details
Nine legacy JSON endpoints (package-info, similar-packages, search-api, steam-friends, organization-details, package-changelists, package-achievements, package-manifest, announcement) passed ?x[]= straight into string functions. Bootstrap and the legacy rate-limit include now refuse them up front.
1248420 -
Fix
4:59 PM
Read text query parameters through queryString() so array-valued parameters stop causing 500s
Details
?tags[]=x on /t/game, ?type[]=x on /award-winners, ?packages[]=x on /compare and similar reached trim()/explode() as arrays and ended in a TypeError. queryString() treats anything that is not a string as absent.
439f80a -
Fix
4:58 PM
Let config.php set the admin and blocked lists, and log PHP errors in production
Details
- constants.php defined ADMIN_STEAM_IDS/BLOCKED_STEAM_IDS before config.php loaded, so the lists a deployment set in config.php were rejected as redefinitions and the built-in defaults were used. The defaults now apply after config.php.
- production ran error_reporting(0), which also stopped errors reaching the log; it now logs everything except notices and deprecations and still displays nothing
- say so in the log when config.example.php is being used in place of config.php
e67a0fb -
Fix
4:56 PM
Refuse placeholder and short MCP API keys; stop serving frontend/index.php with display_errors on
edbc8d9 -
Fix
4:56 PM
Cap saved profile blobs, validate recently-viewed ids, repair recently-viewed session lookup
Details
- profile widgets and layout are limited to 64 KB of JSON (413 beyond that)
- /user-experience/recently-viewed accepted any string and grew without bound; it takes org.package ids and keeps the latest 50
- backend/api/recently-viewed.php read $_SESSION['user'], which is never set, so it always answered 401
da4d33b -
Fix
4:55 PM
Validate profile event types, store only known fields, bound the table
Details
POST /profile-analytics accepted any type (an array raised an uncaught TypeError) and stored the whole request body. It now accepts the three event types the client sends, keeps validated fields only, prunes events older than 90 days, and notifications get a (steam_id, created_at) index for the list query.
7953bf0 -
Fix
4:54 PM
Only cache API responses the server allows to be cached
Details
The service worker stored every API GET and answered from it for up to five minutes, ignoring Cache-Control. /stats/live and the chart refresh returned stale numbers, and signed-in responses stayed in the browser after sign-out. no-store and private responses are no longer written, and the cache version is bumped so existing entries are purged.
8c3ec78 -
Fix
4:54 PM
Command palette accessibility and typing, contrast, and indexing of personal pages
Details
- command palette: '/' no longer swallows typing in inputs and textareas, the dialog/combobox/listbox roles and aria-activedescendant are set, Tab stays inside, focus returns to the opener, searches are debounced and a slower earlier response can no longer replace a newer one, recents store a slim record
- remove the 'g h' style shortcuts that could never match
- award-winners filter and release-notes button failed WCAG AA contrast
- noindex search, favorites, settings and notifications; robots.txt keeps crawlers off /backend/ and /mcp/
0a05404 -
Fix
4:48 PM
Bound client-supplied limits and rate-limit the public legacy endpoints
Details
- take/limit on player-extended, code-search, reputation history, announcements, notifications, package-types, similar-packages and leaderboard are clamped
- package-types built a cache key from the raw take, so every value made a new entry and a new upstream request
- 27 legacy endpoints that proxy Facepunch or read the database had no rate limit; they now use the existing legacy limiter
e3075f2 -
Fix
4:47 PM
Escape package, achievement, review and version-diff text rendered with innerHTML
Details
- reviewer name/avatar/steam id in package reviews went into markup and attributes raw
- achievement and package titles/descriptions on the profile dashboard were unescaped
- version comparison interpolated field names and values, and the version string into an onclick
- clear the three remaining ESLint errors
6183415 -
Fix
4:45 PM
Verify TLS on Facepunch calls, block cross-site writes, pin the canonical host, lock down init-user-reputation
Details
- four callers forced CURLOPT_SSL_VERIFYPEER off over HttpService's verified default
- bootstrap rejects POST/PUT/PATCH/DELETE that Sec-Fetch-Site or Origin mark as cross-site
- requestHost() allow-lists the Host header used for canonical, og:url and OpenAPI URLs
- init-user-reputation needed no login and accepted any string as a steam id
- escape REQUEST_URI in the UGC charts refresh link, encode the vanity name in the login redirect
- stop returning exception messages from the public themes and docs endpoints
99e63c1 -
Fix
4:24 PM
Semantics, a working phone layout, and live numbers that stop when the tab does
Details
Structure and accessibility
- Card headings were h2 inside h2 sections, and the range selector sat inside the chart's <h2> (a <div> in a heading, so its name read "Total players over time 24h 7d 30d"). Bands are h2, cards h3, and the selector is a button group with aria-pressed, matching the per-game widget.
- The headline numbers are a <dl>. They had aria-label on bare <span>s, which overrides the text for some screen readers so the number was never read, and aria-live on two values that changed every thirty seconds.
- The search box is type=search with a label; tag chips are type=button; card icons are hidden from assistive tech and the counts say "playing" and "favourites". "Day by day" KPI labels pass contrast (axe: 1 violation -> 0).
- The page set no description of its own; it now has one (via $pageMeta).
- Bands and jump links exist only for sections that have content, so a Facepunch outage no longer leaves headings over nothing, and the filter bar is not shown with no games to filter.
Layout
- Tag chips were crushed to ~30px on a phone ("ltipla", "hoote"). They scroll, with a fade, and carry a count.
- A phone showed one 16:9 poster per game, a ten-thousand-pixel page. Cards are rows there (7,600px); a desktop row is four cards and a page is 24, which divides evenly into 2, 3 and 4 columns.
- The sticky filter bar stuck at top:0, under the 60px nav. It sits below it and follows the nav as it hides on scroll; on phones it is not sticky.
- On a phone the 90-day card drew 30 columns under a heading naming 90 days. The date range now follows what is drawn.
- Cards were opacity:0 until script added .visible, so the section was blank without JS. They are visible by default; a <noscript> rule shows the ones past the first page.
- A footnote says why the chart (public games) and "Playing now" (the platform total) differ by a few percent.
Behaviour (frontend/js/global-stats.js, extracted from an inline script)
- Polling used to run in hidden tabs forever, fetched /stats (200 games summed) for three numbers, ignored a legitimate 0, and never refreshed the chart or the 24h peak. It now polls /stats/live only while the tab is visible, backs off on failure, says "reconnecting" after three misses, and refreshes the chart without a spinner.
- Search and tag filters show a result count, announce it, keep the filter in the URL (?tag=&q=) so it survives a reload or a shared link, include the #1 hero in the filter, and move focus when "Show more" runs out. Muted autoplay video is paused for prefers-reduced-motion.
Tests: 23 JS tests for the filter, count text, back-off and the DOM behaviour (hidden tab, outage, bad payload); the grid test now pins no-JS visibility.
4df8747 -
Feature
4:21 PM
Let a page set its own title, description and keywords
Details
Pages with no package, profile or post to look up all shared the homepage's description in search results and link previews. A page can now set $pageMeta = ['title' => ..., 'description' => ..., 'keywords' => ...] before including meta-tags.php.
50fcedf -
Feature
4:21 PM
Add /stats/live for the platform counts the page polls
Details
Global Stats polled /stats every thirty seconds for three numbers. That endpoint also fetches and sums two hundred games, and reports a smaller "estimated" total when stats/platform fails, which the page then wrote over its own. /stats/live returns just the platform's players, editor users and 24-hour peak, cached for thirty seconds, and answers 503 rather than an estimate when the platform call fails.
57deea9 -
Feature
4:21 PM
Round axes, calendar ticks, touch tooltips, silent refresh
Details
The y-axis split the data's own maximum into five, so a 556-player peak labelled its gridlines 111, 222, 334, 445. It now steps on 1-2-5 (0, 200, 400, 600), whole numbers only.
The x-axis placed evenly spaced labels from the first sample to the last, so the 24h chart read "04:06 PM ... 04:06 PM" at its two ends. Ticks now sit on calendar boundaries in the viewer's time zone (6 PM, 8 PM, midnight labelled with the day), stepping through hours, days and months to the largest that fits the width.
The tooltip listened for mouse events only, so a phone had no tooltip at all. It now uses pointer events; a touch keeps its tooltip for a few seconds after the finger lifts, since the pointer leaves the instant it does.
load(null, { silent: true }) is the periodic refresh: no spinner, no draw-in animation, and a failed refresh leaves the chart already on screen alone.
Both consumers (the per-game widget and /global-stats) get all of this.
cb57ee1 -
Fix
4:09 PM
Cache a slim game list, never cache a failed scan, drop duplicate KPIs
Details
The page cached the raw package/find responses - up to eight pages of five hundred full packages - to render about fifty cards, and a scan where every request failed was stored as "no games" for a minute, blanking the page for everyone. The scan, ranking and tag counting now live in backend/includes/global_stats.php: only the fields the page draws are cached, an empty scan is not cached and falls back to the last good one, and ties in the ranking resolve by votes then name so the order is stable between loads.
The catalogue totals came from a hand-rolled fetch with TLS verification switched off and a cache file written beside the code; they now go through fetchCachedEndpoint like every other Facepunch call.
The headline strip repeated the "Players per day" and "New players" figures (and their week-on-week deltas) that the Growth card shows a screen later, and "Games with players" / "Games tracked" were two spellings of one number. The strip is now live figures only, and "Games live" reads "of N games" using the size the API reports. "24h peak" said "today"; it is the rolling 24 hours.
Cards were gold, silver, bronze from #2, but #1 is the hero, so the podium was shifted a place; it now runs hero, silver, bronze.
557f31f -
Fix
3:53 PM
Span the x-axis over the selected range
Details
The axis was fitted to the first and last returned point, so with only a few hours of history every range drew the same chart. 24h/7d/30d/90d/1y now plot over the full window ending now, with the data where it falls; all still fits the data.
5e5edcb -
Fix
3:43 PM
Use whole-number y-axis ticks on small ranges
Details
The axis always drew six ticks across maxVal and rounded each label, so a game peaking at 1 player showed 1 1 1 0 0 0. maxVal is now a whole number and the tick count is capped at it, so a peak of 1 draws 0 and 1.
1c0a5af -
Fix
3:40 PM
Load the chart script through jsAsset so a stale edge copy cannot be served
Details
header.php loaded player-history-chart.js with no version query. Cloudflare caches static files as immutable, so after the chart rewrite the edge kept serving the old file, which has no PlayerHistoryChart.create, and Global Stats threw on load. jsAsset adds the content hash like the other scripts.
bebb8d1 -
Fix
3:33 PM
Close the chart fill along the baseline
Details
The fill path began with a moveTo from the baseline, then trace() issued its own moveTo, so closePath drew a diagonal from the last baseline point to the first data point. It was invisible when a series started at 0 and showed as a wedge under the line on Global Stats, where the total never reaches 0.
31ffd54 -
Fix
3:14 PM
Make the admin actions and docs match what the system does
Details
- the admin page called a bare 'php' for the collector and aggregator; use getCliPhpBinary() like the rest of the project, and force 'collect now'
- 'Prune Old Raw Data' deleted raw rows without rolling them up first, silently losing up to a week of hourly history; it now aggregates, then prunes
- ensure the tables exist so the page loads before the first collection
- document that only games with players are stored and that gaps are not zeros, and how the two schedules interact
- list the two tools in the README
992e7ec -
Fix
3:14 PM
One shared chart, honest states, no stale-response race
Details
- player-history-chart.js is now a shared core used by both the per-game widget and the global stats page; global-stats.php carried its own ~150-line copy of the same drawing and fetching code
- the latest range click wins: a slow earlier response can no longer overwrite a newer one while the range buttons show the newer one
- x is placed by timestamp, not by index, and the line breaks where the collector was not running instead of drawing a slope across the outage
- a failed request shows an error with a retry instead of 'no history yet'
- aggregated ranges show avg and peak in the tooltip and a low/peak band
- axis labels follow the span shown (no more 'Sep 29, Sep 29, Sep 29')
- the canvas is role=img with a summary label; status regions are announced
- drop the dead 'Reset Zoom' button, which was hidden by the script
1dd0d87 -
Fix
3:02 PM
One API, honest gaps, no double counting
Details
- delete backend/api/player-history-api.php: a second, broken copy of the controller (no dispatcher, undefined sendResponse, clashing class name) that /api/player-history was rewritten to
- move the queries into PlayerHistory and make the controller thin
- a game missing from a run that happened is a zero; no run means no point, so a collector outage is a gap instead of a fake drop to zero (this replaces the PHP zero-fill and its strtotime($ts.' UTC') parsing)
- timestamps are emitted by the database as ISO-8601 UTC
- the global total takes the highest sample per game per minute so overlapping collectors cannot double it
- '1y' now actually covers a year (it was identical to 'all')
- idents are lower-cased to match the collector, and a game with no data gets an empty series rather than a flat zero line
- summary is one query and averages over the runs that happened
37892b2 -
Feature
3:01 PM
Add the collector, aggregator and schema that were never in the repo
Details
The admin page and scheduled-refresh both spawn tools/collect-player-history.php and aggregate-player-history.php, but /tools/* is gitignored and neither file (nor any table definition) was ever committed, so nothing in the repo could create or fill the data the API and charts read.
- PlayerHistory owns the schema (with unique indexes), the sweep, the writes and the roll-up
- a run shares one minute, so a game missing from a run that happened is zero and a missing run is a gap
- an incomplete sweep records nothing instead of dropping every game on a failed page to zero; one collector at a time; skipped inside the minimum interval so cron and scheduled-refresh cannot double-sample
- hourly/daily averages divide by the runs that happened; pruning is aligned to whole hours/days so a half-pruned hour is never rolled up wrongly
e71f447 -
Fix
2:35 PM
Fill slots with no samples as zero players
Details
The collector only stores a row when a game has players, and the chart spaces points evenly by index, not by time. A game with a handful of scattered single-player samples therefore drew as a steady line at 1 for the whole range. The history endpoint now returns one point per slot (10 minutes for 24h and 7d, hourly for 30d and 90d, daily for 1y and all) with 0 where nothing was recorded.
e4adc4e -
Fix
2:28 PM
Answer the refresh-cache request with JSON and limit it to the owner or an admin
Details
The refresh button fetches /u/{name}/?refresh=1&ajax=1 and expects JSON, but player profiles cleared the caches and then rendered the whole page, so the button always reported "Failed to refresh cache". The player branch now returns JSON once the package cache is rebuilt.
The same request also let any visitor, logged in or not, clear a profile's vanity, package and presence caches. That now needs the profile's owner or an admin.
588b150 -
Fix
2:18 PM
Stop the forum lookup flood, make desktop nav keyboard-reachable, fix contrast and heading order
Details
- negative-cache missing package forums for an hour and stop logging their 404s: the production error log was 99% forum/{package}/threads 404s, one upstream request per package view
- navigation.js set tabindex=-1 on every nav link at load, even on desktop, so Home/News/Charts/Forums/Discover could not be reached with Tab
- lift the muted rank/count text on charts from ~3:1 to 5:1 contrast
- give the homepage trending and tag cloud headings a valid level
34ac8a8 -
Fix
2:06 PM
Harden icon font fallback, add skip link and form labels, honest outage states
Details
- hide Material Symbols ligature text until the icon font has loaded, so a blocked or slow font no longer shows raw icon names over labels
- add a skip-to-content link and focusable scrollable code blocks
- label code-search and skins filters, group the leaderboard podium
- show an em dash instead of misleading zeros on global stats when live data is unavailable
- return 503 with Retry-After and a clear message when the forum index is unreachable instead of a 404 Not found
f4ebdf7 -
Fix
1:50 PM
Drop user leaderboard endpoint that upstream answers with an empty body
Details
package/leaderboard/1/.../u/{userid}/{scope}/ returns 200 with no content for every request, real or not, and 401 for friends, so the route could only ever 500. Jam entry stays; docs record why the leaderboard route is not wired.
cb9c805 -
Feature
1:41 PM
Expose user leaderboard scopes and jam entry lookups
Details
Add package/leaderboard/{v}/{ident}/{board}/u/{userid}/{scope} and jam/{ident}/entry/{package} behind validated path builders, with REST routes and docs. The authenticated Facepunch endpoints stay unwired.
ae1cec6 -
Security
12:58 PM
Review frontend XSS sinks
Details
Escape quotes in every shared escapeHtml helper (they were used for attribute values), escape profile widget and package data before it reaches innerHTML, and only emit http(s) or site-relative widget links on profile pages.
ea7ca6b -
Security
12:56 PM
Harden MCP auth/SSRF and remove committed credentials
Details
Refuse the placeholder MCP master key, stop following redirects blindly in the KB fetcher (revalidate each hop, pin the resolved IP, reject userinfo/backslash URLs), and read the Steam API key from configuration instead of a hardcoded constant. Drop a seeded Discord webhook URL from the schema file.
8fbcbdb -
Security
12:54 PM
Harden Ko-fi and Discord webhook handling
Details
Verify the Ko-fi token before logging, refuse the placeholder token, keep the token out of logs and validate field types. Restrict outbound webhook targets to Discord endpoints and require CSRF on webhook.php.
6e472ae -
Security
12:53 PM
Drop dead shell-out from the changelog page
Details
The git log fallback was guarded by a .gits directory that never exists, so it never ran; the page renders from the generated changelog JSON.
70bf047 -
Security
12:52 PM
Add CSRF and tighten CORS
Details
Require the X-CSRF-Token header on profile-layout and settings writes (the API client already sends it), and only emit credentialed CORS headers for the configured trusted origin.
a712eec -
Security
12:51 PM
Harden environment and session configuration
Details
Select the environment from APP_ENV only, never from REMOTE_ADDR or the Host header, and stop trusting X-Forwarded-For and localhost peers for TLS, rate-limit and admin decisions.
9c26c45 -
Security
12:51 PM
Remove sensitive database backup artifacts
Details
Untrack the archived SQLite backups (user data) and ignore *.backup/*.bak. The files remain in history on origin/master.
eca5aee -
Security
12:51 PM
Harden Steam OpenID authentication
Details
Validate op_endpoint, return_to, claimed_id/identity against the signed field list and configuration, and reject replayed or stale response nonces via an atomic nonce store before establishing a session.
56bca24
September 2026
1
-
Feature
10:08 AM
Exact verdict counts and a verdict filter
Details
The summary was worked out from the first 50 reviews. It now reads the exact Count for score 1, 2 and 3 from package/reviews, and the list can be filtered by verdict server-side with an optional score parameter.
score is validated to 1-3 and dropped otherwise. The count requests use take=2 because upstream rejects take=1 and omits Count.
04e57e8
3
-
Fix
1:41 AM
Mark staff with the logo in the badge instead of a red square
Details
The Facepunch logo PNG is fully opaque - every pixel including the corners is #EC1C24 - so using it as a faint backdrop painted a red square across the author column and underneath the badges, whatever the size or opacity. The logo now sits inside the Facepunch badge, where it is meant to be solid, and the staff tint carries the rest of the signal.
Post one no longer carries a Started this badge: the post number already says it, and dropping the second badge row takes the card from 216px to 169px.
e435c95 -
Fix
1:31 AM
Drop the broken score glyph and pull the staff watermark inside the card
Details
The score prefix was written as a star but stored mis-encoded, so it rendered as stray characters in front of every score. The Facepunch watermark was an 8.5rem layer offset -1.6rem past the corner, which the card had to clip and which sat underneath the badges and the score. It is now a 3.25rem mark inset inside the column.
c5af5ff -
Fix
12:42 AM
Link the right profiles, and render what Trix was hiding
Details
Every author link was built from the display name - /u/xraybeesx - which 404s, because a name is not an address. The API hands us the address in Author.Url, either a vanity (/u/weregonlose) or the steam id for the many people who never set one, and forumAuthorUrl() now uses it: vanity when there is one, steam id otherwise, and nothing at all when the id is not a steam id, which is how the Facepunch "Bot" account stopped pointing at /u/1. A private profile has no page here - player/{id}/overview 404s upstream for those - so those names link out to sbox.game rather than to a dead end on this site. 56 author links across the forum now resolve; before this, any author without a vanity url was a broken link.
Quotes were being deleted. Facepunch stores them as a Trix attachment whose visible content is an empty <span>, with the author, the text and the quoted post id in a JSON attribute - and the sanitiser strips attributes, so a reply quoting someone rendered with no quote at all, just the response. The sanitiser now expands that attachment into a real blockquote before the allowlist runs, links the attribution to the quoted post (an in-page anchor when it is on the same page, sbox.game when it is not), and keeps escaping everything it reads out of the JSON, so markup in a quoted post is text.
Link attachments got the same treatment: a YouTube url becomes a click-to- play facade - the thumbnail plus a play button, no iframe until someone asks for one, so a thread full of videos costs a few images rather than a few players and nothing is requested from Google on page load. Other urls stay as plain links. Only the four class names this code writes survive the sanitiser, so none of this lets an author style their own post.
The thread page itself is rebuilt around the two-column shape a forum wants: a bar carrying the date and the post number, an author column with avatar, name, badges and score, and the body beside it. Posts by Facepunch staff - read from organization/facepunch, 38 members, cached six hours - carry a Facepunch badge, a blue frame and the logo as a watermark; when you are signed in, your own posts are framed in green with a "You" badge, the way sbox.game marks them. The thread header now reads "Started by X · 15 days ago · 14 posts · 815 views", which meant caching those fields alongside the title when a listing is fetched.
Also: forum.css had the same token drift the jam sheet had - var(--border-color) and var(--accent-color) are not tokens here, so every rule naming them fell back to a hard-coded colour. All of it names real tokens now. The badge modifier rules had landed after the base rule at equal specificity and were losing to it, so the staff and "You" badges were rendering identical to a plain one; measured in the browser, fixed, re-measured.
Verified: 18 routes clean, 0 broken author links, every piece of forum text at or above 4.5:1, no horizontal overflow, 57 sanitiser checks covering the quote, embed and profile-link paths.
254f27e
8
-
Fix
11:47 PM
Escape every meta tag exactly once
Details
Every branch in meta-tags.php pre-escaped its dynamic parts with htmlspecialchars and then concatenated raw literals onto them - the " - s&box Explorer" suffix, the "s&box, sbox, packages" keyword list. The output then disagreed with itself about what that left: og:title, og:url, description and keywords echoed the value raw, so a bare & reached the attribute, while twitter:title, twitter:description and both image:alt tags ran htmlspecialchars over it again and double-escaped whatever the branch had already escaped. A package titled "Tom & Jerry" shipped one tag saying "Tom & Jerry" and another saying "Tom &amp; Jerry".
Rather than chase the escaping through ten branches, the values are normalised once after they are all built: html_entity_decode then htmlspecialchars, which lands on the canonical single-escaped form no matter how the branch assembled it, and is idempotent, so a value that was already correct passes through unchanged. Re-encoding means nothing can come out less escaped than it went in. The four duplicate htmlspecialchars calls at the output are gone.
og:url picks up the fix too - a query string's & was reaching the attribute bare on every paginated and sorted page on the site.
Checked across 23 routes: no bare &, no &amp;, no PHP notices, and og:title now matches twitter:title on all of them.
2671346 -
Feature
11:31 PM
Share the winning game, and square up the labels
Details
A jam page shared on Discord or Twitter used the site's default og-image, so Game Jam 1 and Tech Jam were indistinguishable in a link preview. meta-tags.php already honours an image for a news post; the jam branch now does the same, and game-jams.php hands it the wide art of the jam's first winner with an alt of "My Summer Cottage: Tech Demo - Game Jam 1 winner". A jam with nothing awarded yet, like the running one, keeps the site image.
The hub cards said "Prizes" where the feature card and every detail page said "Prize pool". They all say "Prize pool" now.
The category badge and prize chip on a winner card sit over whatever artwork the entry shipped. At 65% black the white text could fall under 4.5:1 on a bright thumbnail - Terrain Pro's is a rainbow - so the scrims are 82% now, which holds the text at roughly 13:1 whatever is behind it.
Measured the rest rather than assuming: every piece of text these pages draw clears WCAG AA against its composited background, the muted 11px labels included, at 5.2:1 and up.
2ea3f69 -
Feature
11:07 PM
Give the jam pages a podium, and the hub real cards
Details
The four jam detail pages all rendered their winners as one flat row list, so Game Jam 1's £10,000 first place looked exactly like its £1,000 fifth, and Tech Jam's ten equal categories looked like a ranking they are not. jamIsRanked() tells the two apart - a jam is ranked only when every awarded category is titled "1st", "2nd" and so on - and the winners now render through views/partials/jam-winner-card.php: a podium of three for a ranked jam with the runners-up smaller beneath it, or an even grid of category cards for a jam like Tech Jam. Each card carries the entry's wide art, its category and prize, its org and its player and favourite counts, so a finished jam's page finally answers "who won, and was it any good" without a click. The grids use auto-fill rather than auto-fit, or a two-entry runners-up row would stretch each card wider than the first place above it.
The hub listed its four jams as text rows that said less than the detail pages did. They are cards now - status, theme, dates, prize pool, entries, how many categories were won, and for a ranked jam the game that won it. The running jam keeps its feature card and drops out of the list below, which is headed "Other jams" while one is live. The hub header was a hand-rolled .jam-hub-head; it is the site's .page-hero now, like every other page and like the jam pages it links to.
The detail pages never showed when a jam ran. The facts bar leads with "Ran 16 Feb - 17 Mar 2024" (or "Runs", live), and the hero icon follows the state instead of showing an hourglass over a jam that finished two years ago.
Removed: the dimmed winner-art backdrop behind the hero. At 12% opacity under the announcement banner it read as a rendering fault rather than a flourish, and the winner cards already carry that art at full strength. On the live jam, the prizes-and-votes grid was two equal columns, which left a 665px hole under the short prizes card; the prizes column is capped at 22rem now and the vote tally takes the rest.
Also: the newer half of the stylesheet had drifted off the design system - var(--border-color) is not a token anywhere in this codebase, so every rule naming it was silently falling back to a hard-coded rgb(); text was dimmed with opacity instead of --text-muted; font sizes were rem values between the --fs- steps. All of it now names tokens. A winner card with no package URL renders as a div rather than an <a href="#">, and .jam-winner__jam meets the 24px touch target it missed at 18px.
Verified at 1400, 820 and 390: no horizontal overflow, no heading-level skips, no console errors, every image has alt text. Load-more still pages 24 at a time and the countdown still ticks.
305143b -
Feature
10:41 PM
Rebuild the jam hub and cut the live jam page from 954KB to 202KB
Details
Game Jam III rendered all 179 entries at once: 480 package cards, 589 images, 954KB and three and a half seconds. The community vote card added another 80KB by rendering all 118 entries with votes so that a details element could hide most of them.
Entries now render 24 at a time. The Show more link carries ?show= and works on its own, and a small script fetches the same URL, swaps the grid in and updates the button and the address bar so the page never reloads. Sorting by most played, most favourited, newest or A to Z is a link rather than a script, so it survives with JavaScript off and each order has its own URL. The vote card now lists forty entries at most and says how many more are on sbox.game.
The hub was a live jam card and a four-row list, which said nothing about the jams as a whole. It now opens with what the four jams add up to - £135,000 in prizes, 478 entries, 20 categories won - and a gallery of the twenty winners across every jam, each with its thumbnail, category and prize, newest jam first. Winners come from Categories[] .Winner rather than the entry lists, which is the only source that has all of them.
Jam pages link to the jam before and after them, so the four are browsable without going back to the hub each time.
New styles follow the 4px radius the rest of the site uses and the card bodies are padded, both measured rather than eyeballed: the winners gallery, entry controls and jam navigation all sit 17px off the card edge at 1400px.
15943a4 -
Performance
10:32 PM
Serve forum pages from cache and stop every expiry costing a second
Details
A thread listing took 937ms on a cache miss and the entries lasted five minutes, so roughly every fifth minute somebody paid a full second for a page that had not changed. Profiling first ruled out the obvious suspect: sanitising fourteen posts costs 1ms, so the HTML pass is not worth caching and is left alone.
The forum fetchers now go through CacheManager::getOrSet rather than a plain read-then-fetch. That was already in the codebase and does two things this needs: it takes a lock so only one request regenerates while everyone else is handed the existing entry immediately, and its XFetch beta regenerates probabilistically before expiry so the changeover is spread out instead of landing on one unlucky visitor.
Lifetimes now follow how mutable the data actually is. The forum list itself changes about never, so an hour. Page one of a listing keeps its five minutes because new threads land there; later pages get half an hour because they only change when threads shuffle off page one. A thread page that is not the last page cannot gain posts, so it is held for six hours after it is read.
Forum activity keeps its own short mutex so a cold batch of ten upstream calls is not started by several visitors at once; the rest are handed the stale copy. It is best-effort rather than a real lock, since the worst case is two batches running and the cost is one wasted fetch.
Every fetcher keeps the last good answer for a week and falls back to it when Facepunch fails, which the tests cover along with the refetch on expiry.
Warm page loads are now 30-40ms across the forum index, listings, deep pages and threads.
bd13b1b -
Feature
10:25 PM
Rebuild the forum browsing, cross-link it, and restructure Global Stats
Details
Forums. The index listed thread and post counts and nothing else, so a forum with 489 threads looked the same as one with 29. Each forum now shows its newest thread with who replied last, the page opens with the six most recent threads across every forum, and the header states the 2,635 threads and 12,417 posts the whole board holds.
Thread lists gained sort controls covering the four orders the API takes - latest reply, newest, most replies, most viewed - along with the author's avatar, when the thread started, and a last-reply column that links straight to the final page rather than the first.
Thread pages gained numbered posts with per-post anchors, a highlight and a badge on whoever opened the thread, an edited marker where the edit came more than a minute after the post, a back link to the parent forum, and a windowed pager at the top and bottom instead of bare previous and next links.
The forums are now reachable from the rest of the site: a top-level nav entry, the five newest threads on the homepage, and a card on player profiles showing threads that person started or replied to. There is no endpoint for a user's posting history, so that card reads the active thread listings and says so rather than implying it is complete.
One batch of ten calls feeds all three of those, cached for fifteen minutes. Only /forum itself is allowed to pay for a cold fetch; the homepage and profiles render from cache or skip their block, because Facepunch serialises concurrent thread listings and fourteen of them took fifteen seconds. The index and the homepage previously warmed two different cache keys, so the homepage block could never have populated.
Global Stats. Eight equal-weight metric cards opened the page with no sense of which mattered, and four unrelated sections followed with no headings between them. The page now leads with five headline numbers - players now, 24h peak, in the editor, players per day and new players, the last two carrying their week-on-week change - with catalogue size demoted to a single quiet strip. Everything below is grouped under Right now, Growth, Retention and Top games, with jump links in the header. Every id and class the page's scripts bind to is unchanged; the metric card styles they replaced are deleted.
Padding and radii. The new cards inherited no body padding, so icons and text sat flush against the card border, and the new sheets had invented pill shapes and 7 to 14px corners. Radii now come from --radius-sm like the rest of the site, which caps at 4px; the only exception is the 50% on avatars, matching the site's existing use.
b1d75e6 -
Feature
10:06 PM
Pool seven cohorts and draw the platform baseline behind each package
Details
A single day's cohort was too small to read: facepunch.sandbox's was 39 players, which put a 0.0% on both the day-7 and day-30 tiles and left gaps in the bars that looked like missing data rather than churn.
Retention now pools the seven daily cohorts ending 35 days back, which is the oldest window where every cohort still has a full day-30 point. Pooling is weighted properly - day N is the sum of returning players over the sum of cohort sizes, not an average of ratios - so sandbox reads 305 players instead of 39 and the curve stops flickering between zero and one player.
Each package curve now draws the platform curve behind it as a dashed line and each tile says how far above or below average it sits, which is the only way a number like 14.8% means anything. The chart gained a percentage axis, a flat marker for days nobody came back, and day labels every five days instead of thirty crowded ones.
The seven upstream calls go out in one parallel batch and cache for six hours, including the empty answers, so a package with no cohort pays the cost once.
Stat names are humanised from their raw idents, a prefix every stat in a package shares is dropped, and the card lists the four largest with the rest behind a disclosure. The old 'N more stats' link pointed at the package page, which shows the viewer their own stats - on someone else's profile it led nowhere useful.
332e6bf -
Feature
9:51 PM
Wire the five unused Facepunch endpoints into pages
Details
The api/list sweep turned up five public endpoints nothing on the site read. Four now render; the fifth is confirmed dead upstream and is not worth another attempt.
Forums. forum, forum/{slug}/threads and forum/thread/{id}/posts are fully public and had no presence here at all. /forum lists the 18 real forums grouped by category, a forum lists its threads, and a thread renders its posts with each author linked to their existing profile page. The index also carries 4,722 auto-created per-package forums (Parent "p"); those are filtered out of the index because almost all are empty, and surface instead as a Discussion tab on the package page, which only appears when that package actually has threads.
Post bodies are Trix HTML written by other people, so they go through a new sanitiseForumHtml(): a DOMDocument pass with a tag allowlist, an attribute allowlist of href/src only, http(s)-or-relative URL checks, and rel/target forced on links. Everything else, including the data-trix-attachment payloads and any class or event handler, is dropped. The posts endpoint returns no thread title and no forum, so listings cache each thread's title and forum as they are rendered and the thread page reads that back, falling back to an excerpt of the first post when nobody has browsed the listing yet.
Per-user package stats. package/stats/2/{ident}/u/{steamid} is public and returns a full per-stat breakdown. It fills a new In-Game Stats card on player profiles for that profile's three most-played packages, and a "Your stats in this game" table on the package page for the signed-in viewer. Values outside a readable range are shown as an em dash with a count of how many were suppressed: facepunch.sandbox reports damage_taken as 6.8e38, which would otherwise wreck the column.
Review by steam id. package/reviews/{ident}/{steamid} gives one user's review directly, so the package page can show the viewer their own review without paging the whole list.
Retention. stats/platform/retention and package/{ident}/retention share one partial. Two upstream quirks drive the shape of it: a day with no returning players omits Users and Retention entirely rather than sending zeroes, so bare {"Day":3} rows are read as 0% instead of being skipped, and the curve stops at day 30, so the default cohort is 35 days back to keep a day-30 milestone. Cohorts under 20 players are not drawn at all; facepunch.walker's cohort is one person and produced a flat 0% curve that looked like a bug.
Not wired: package/leaderboard/{v}/{ident}/{stat}/u/{id}/global and /country answer 200 with an empty body even where the user is rank 1 of 1, /friends is 401 and version 2 does not exist. player/{id}/feed is 404 for every id tried. The existing package/{ident}/leaderboard/{stat} call already returns what those promised.
formatRelativeDate() takes a prefix argument now; it hardcoded "Published", which reads wrong on a forum reply and on a stat timestamp. Existing callers are unaffected.
9b28316
9
-
Fix
10:55 AM
Version theme stylesheet URLs so edge caches release them
Details
.htaccess stamps every .css with `public, max-age=31536000, immutable`, by extension and without checking for a version query. head.php's inline preloader already appends ?v=<bundle filemtime>, but loadThemeCSS and preloadAllThemeCSS built bare `/frontend/css/ee/NAME.css` URLs. Those are therefore pinned in browser and Cloudflare caches for a year with no revalidation.
The theme repair deployed correctly and is right at origin - a cache-busted fetch returns the new NAME-theme selectors, and the five removed themes 404 - but the edge kept serving copies from before the deploy, including 200s for files that no longer exist. Every future theme change would have been swallowed the same way.
Both builders now route through themeHref(), which reads the asset version off the data-asset-version attribute the inline script in head.php already carries, so the URL changes whenever the bundle does and no purge is required. Versioned URLs are what makes the immutable header correct, so the header stays as is.
fafdf79 -
Fix
10:36 AM
Repair the 14 themes that never rendered, then curate the rest
Details
Fourteen of the thirty-seven themes targeted `body.theme-NAME` while all four class-derivation sites (themes.php, UtilityController, SettingsValidator, generate-critical-css) produce `NAME-theme`. Nothing server-side ever supplied the prefix form, so picking any of them applied a class matching zero selectors and silently did nothing. theme-template.css taught the broken convention, so anything copied from it was born dead. That is why the retro/console set looked unfinished - it was never rendering. Renaming their selectors also fixes the critical CSS, which had been emitting rules for 14 classes that did not exist.
Removed five themes that are perceptually redundant, measured in OKLab rather than judged by eye: brutalist (distance 0.0 from amoled-dark - identical ground and accent), moonlight (identical accent to palenight), catppuccin-frappe and catppuccin-macchiato (keeping mocha, the darkest, and latte, the only light theme), and midnight-blue (invented rather than an upstream palette, and crowded by kanagawa and tokyo-night).
Raised muted-text contrast to at least 4.5:1 on 21 themes by moving each colour toward that theme's own body text, so palettes stay intact. Game Boy is the one exception and is left failing: an authentic four-shade DMG LCD spans too little luminance to reach 4.5, and the first attempt reached it only by making muted identical to body text, which destroyed the text hierarchy. It now uses the fourth authentic shade.
Stripped dead duplicated rule blocks from 20 files, including a second differing *:focus block in sunset and brutalist where the earlier one was already dead under the cascade. Fixed rust.css's corrupted .beta-badge selector and win95's mojibaked checkbox glyph, whose font-size also referenced --fs-title on a 13px mark.
Deleted DARK_THEMES and the dark-mode class: the class was added and removed by the theme manager and read by nothing, and the list named a phantom theme-gta6 while omitting some twenty real dark themes. Fixed isCurrentTheme, whose startsWith('theme-') test marked Default as selected while a theme was active. Added pruneUnknownTheme so a stored theme that no longer exists is cleared instead of requesting a 404 stylesheet on every page load.
All 32 surviving themes now carry a display name and a distinct icon; the picker previously showed one generic palette icon for every theme, under names like "Ps1" and "Win95".
tests/test_themes.php asserts every theme targets the class the API derives and that the unreachable form cannot come back.
9e4cc5c -
Fix
9:52 AM
Three more entry points, found by sweeping the rest
Details
Probing every read-only endpoint under /api/ turned up three more failures beyond the four already fixed, and the same root cause in a new disguise.
/api/search answered 500 with an empty body. It does require bootstrap, but on line 11, after SearchAnalytics and SearchIndex on lines 9 and 10 - and SearchIndex pulls in cache_manager, which calls CacheManager::init() and dies on DB_HOST before bootstrap has run. Requiring bootstrap is not enough; it has to come first. The body was empty because the guard around those requires catches Exception, and an undefined constant throws Error, so nothing answered at all. It now catches Throwable.
/api/package-comparison and /api/cache-management had no bootstrap. Both reach the database transitively through cache_manager rather than naming it, which is why the first version of this test did not see them. cache-management is the worse of the two: it checks $_SESSION['is_admin'] and a CSRF token before doing anything, but it fatalled before reaching either check, and bootstrap is what starts the session those checks read.
analytics-api used a float as an array offset when picking a median ($durations[floor($count / 2)]). intdiv says what was meant and clears the only two errors PHPStan reported for backend/api.
The test now checks the rule that actually holds: an entry point that requires anything reaching config - cache_manager, api_functions, PostgreSQLManager, SearchIndex and friends - must require config/bootstrap.php, and it must be the first require. That rule flags all three of these and nothing else.
Confirmed working and not touched: storage returns 400 for missing params, settings and featured-badges return 401, docs returns its listing, package-leaderboard, similar-packages, package-info, package-types, organization-details, code-search, player-extended and package-changelists all answer correctly.
27fa6e6 -
Fix
9:44 AM
Four legacy endpoints died on every request
Details
.htaccess maps /api/<name> straight at backend/api/<name>.php, so those files are entry points, but four of them were written as though something had already set the process up.
/api/themes and /api/profanity-words called apache_setenv() unguarded. That function only exists under mod_php, and this host runs PHP as FastCGI, so both died with "Call to undefined function apache_setenv()" before sending a byte. backend/api/settings.php already had the function_exists guard; the other two now match it.
/api/health and /api/player-history died with "Undefined constant DB_HOST" in PostgreSQLManager, because neither loaded config/bootstrap.php and so no config had ever been read. similar-packages.php, which works, requires bootstrap first; these now do the same. A health check that fatals when called is worse than no health check, since an uptime monitor sees HTTP 200 with a stack trace in the body.
Reproduced locally before and after: the CLI has no apache_setenv either, so running the files directly shows the same fatal, and now shows JSON. health.php reports status healthy with a live database check.
tests/test_legacy_api_entrypoints.php holds the rule rather than the fix: apache_setenv only behind function_exists, and every database-using entry point in backend/api loads bootstrap.
Not changed: /api/leaderboard, /api/counters and /api/activity-stream answer 503 "Redis not available" by design - RedisHelper::isAvailable() returns false whenever CACHE_DRIVER is not redis, and production deliberately runs the SQLite cache. Their /api.php/ equivalents work.
f096548 -
Feature
8:58 AM
Carry the whole jam record onto package pages, cards and orgs
Details
The jam list already holds the theme, prize pool, entry count, deadline and full timeline, and jam/{ident}/voting holds the live tally. All of it was being thrown away after the one bit the badge needed, so the package page now shows the rest.
packageJamStanding carries the jam's facts and its current and next timeline step, worked out from dates rather than the Phase number, which has gaps. jamPackageRanks reads the package's position out of the tally by reusing jamTally, so competition ranking - two entries tied on twelve votes are both second and the next is fourth - is the same rule the jam page already uses.
The detail page gains a rail card - placing, theme, phase, what happens next and when, entries, prize pool - and its chip now reads "Game Jam III · 1st in Best Game" while voting is open. Package cards and org cards get a badge too.
The voting call is the only new upstream request, so it is made only when a package is actually in a live jam with community voting, and only by the detail page: fetchPackageJams takes withRanks, and the grids leave it off. The jam list and entry index are memoised per request, so a page of forty cards costs one lookup rather than forty. Measured: 0.004s warm for the detail page, 0.001s for forty cards. The jam page suppresses the badge on its own grid, where every card is an entry in the jam being viewed.
Live check: limestudio.roundbound reads 1st of 48 in Best Game on 40 votes, phase Building, next Nominations, theme One More Round, pool £50,000.
44bc01c -
Performance
8:52 AM
Stop loading a 300KB png for a 72px icon
Details
The package icon beside the title renders at 72 square but was served the full-size Thumb: 268KB for roundbound, 378KB for micromayhem and sandbox. Every package publishes Square webp variants at 32 through 512, and the 128px one is 2-5KB - a 99% saving on an image that carries fetchpriority="high" and so competes with the hero for bandwidth on first paint.
This is the same conversion the browse grids and homepage got in e7738c3; the detail page's own icon was missed. thumbSources falls back to Thumb, so packages cached before the change still render.
370b617 -
Fix
8:51 AM
Stop repeating the jam win Facepunch already flairs, link it instead
Details
Every one of the twenty category winners across the four jams already carries a contest-winner flair reading "Won Best Least in Tech Jam, January 2025", so the badge added in the previous commit was a second copy of it on every winner page. The flair says it better than a chip can, but it goes nowhere.
The flair is now the link: jamForFlair matches a flair's tooltip to a jam by name, longest title first so "Game Jam III" is never read as "Game Jam II", and the badge becomes an anchor to that jam's page. A jam already named by a flair is dropped from the chip row, so nothing is said twice.
The winner chip is kept for the case the flair does not cover it. All twenty winners are flaired today, but the entry search is missing seven of them, so the two sources still disagree and the chip is what shows a winner the flair has not caught up with.
Checked against the live API: gbonn.surreal renders one badge linking to Tech Jam, apetavern.micromayhem one linking to Game Jam II, limestudio.roundbound its unlinked "new" flair plus a Game Jam III entry chip.
0e8436a -
Feature
8:46 AM
Show a package's game jam entry and any category it won
Details
The package payload carries nothing about jams, so membership has to come from the jam endpoints. Two sources are needed, not one: the entry search (package/find/2?q=jam:X) is incomplete - Game Jam III reports 76 entries but returns 53, and seven of Tech Jam's ten category winners are absent from it entirely, because winners were not always tagged as entries. Category winners are therefore read from the jam list itself, which is already fetched and costs nothing extra, and unioned with the search results. A package that won a category it was never listed in still gets its badge.
packageJamStanding is pure so tests/test_jams.php can pin the rules without the network; the ident->jams index is built once and cached for an hour, since entries only move while a jam is live.
Verified against the live API: limestudio.roundbound resolves to a Game Jam III entry, gbonn.surreal to a Tech Jam "Best Least" winner, and a package in no jam to nothing.
58d8d96 -
Performance
8:29 AM
Stop shipping a 5MB icon font and full-size PNGs where WebP exists
Details
Two findings from measuring what the browser actually downloads.
The Material Symbols request left every variable axis open (opsz,wght,FILL,[email protected],100..700,0..1,-50..200), so Google served the complete variable font: 5,248KB. base.css renders every icon at a single point in that space - FILL 1, wght 400, GRAD 0, opsz 24 - so the axes are now pinned to exactly that: 447KB, a 4.8MB saving on a first visit. Weight ranges are not worth it; any range costs 1,208KB, so the two rules asking for wght 500 and the profile tag icons asking for 600 are set to 400, which at icon sizes is not a visible difference. material-symbols.css also imported Material Symbols Outlined, a second 3,887KB face that nothing could use: base.css overrides the outlined class to the Rounded family with !important. That import is gone, and head.php and the stylesheet now request one identical URL so it is fetched once.
Facepunch publishes resized WebP variants of every thumbnail - Square 32-512, Wide 160x90-910x512, Tall - in the same responses the site already reads, and the site was using the full-size PNG. thumbSources() picks the smallest variant that covers the space and falls back to the old field, so packages cached before this change still render. Measured, same pages: /t/model/ 1.10MB -> 0.60MB, /hidden-gems/ 11.23MB -> 0.67MB. The homepage reads its lists from a 30-60 minute cache, so it changes over as that expires; fetchCarouselPackages was verified directly and now resolves to a 320x180.webp.
The screenshot rail on package pages loaded the full-size stage image for each thumbnail. Screenshots carry their own Thumb, usually the same file but not always - one package had a 5.6MB PNG whose thumb is 114KB - so the rail uses it where it differs.
Org logos are left alone: the API exposes only Thumb for an organisation, the CDN ignores resize parameters, and some orgs upload 600KB PNGs shown at 24px. That one needs an image proxy or a word with Facepunch.
Three files show a large diff because git normalises their CRLF line endings to LF on staging under core.autocrlf=input; `git diff -w` shows the real change is 8, 3 and 7 lines.
e7738c3
Generated from the project's commit history whenever the site is deployed. Times are server time.