import { useCallback, useEffect, useRef, useState } from 'react' import { Accessibility } from '@repo/domain/src/enums' import { GAME_VERSION } from '@repo/domain/src/presence-db' import { NotificationType } from '../../../notify/src/notification-types' import { authFailure, authUnreachable } from '../auth-messages' import { DISCORD_INVITE, DOWNLOAD_URL, LICENSE_URL, QUEST_DOWNLOAD_URL, SOURCE_REPO, } from '../links' import type { ReactNode } from 'react' /** * The SPA calls the SAME endpoints the game does — `auth` for tokens and the password * change, `accounts` for the profile, `api` for the photo feed, `notify` for the admin * broadcasts — rather than proxying each one through `www`, exactly as rec.net's own * site did. Those workers answer CORS for it (see their `withDefaultCors()`), and the * access token lives here in the browser. * * `www` serves only two things of its own (see www.app.ts): the config below, and * signup, which is Turnstile-gated and so cannot leave the server. */ /** Where each worker lives. From `/api/config`, never baked into this build. */ interface Hosts { auth: string accounts: string api: string img: string notify: string rooms: string cdn: string storage: string } /** * Site config from `www`. `signupEnabled` is false when the operator has no Turnstile * keypair configured — web signup runs behind that bot check, so without it the endpoint * is closed and the UI must not offer the form. */ interface SiteConfig { signupEnabled: boolean turnstileSiteKey: string | null /** * Whether the Discord-verified benefits claim is configured. False when the operator * has no Discord app/guild/role set, in which case the claim page and its links stay * hidden — the endpoint would refuse anyway. */ benefitsEnabled: boolean /** * The Discord consent URL to send the player to, assembled by `www` (scopes and the * redirect URI are its business, and must match what the claim will accept). Null when * benefits are off. The `state` nonce is appended here — see `startDiscordAuth`. */ discordAuthorizeUrl: string | null } /** The private self DTO from `accounts` (`GET /account/me`). */ interface SelfAccount { accountId: number username: string displayName: string email: string | null /** * Username changes left on the account — each change spends one, and an account * starts with one. Absent on an older self DTO, which reads as "unknown": the form * stays usable and lets the server be the one to refuse. */ availableUsernameChanges?: number } /** * One subroom, as `rooms` re-attaches them to every room read. A room is a container; * the subrooms are the actual places players load into, each with its own accessibility * and its own save history. */ interface SubRoom { SubRoomId: number Name: string /** Set INDEPENDENTLY of the room's — a public room can hold a private subroom. */ Accessibility: number IsSandbox: boolean MaxPlayers: number /** The subroom's scene-data key, served back by `cdn` under `room/`. */ DataBlob?: string /** * A save posted without `AutoPublish` waits here. Cleared when that save is * published, so a non-null value means "edited since players last saw a change". */ StagedSubRoomDataSaveId: number | null /** * What players actually load. Null until the first publish — and a subroom without * one silently loads nothing, which is worth surfacing to an owner who can't tell * that apart from a broken room. */ CurrentSave: { SubRoomDataSaveId: number CreatedAt: string Description: string /** * The scene-data key for the published save — what the client downloads to load * the place, and the file worth keeping a copy of. `subRoomDataBlob()` resolves * this one first, ahead of the subroom's own. */ DataBlob: string } | null } /** * One room from `rooms` (`GET /rooms/ownedby/me`), narrowed to what these pages draw. * The worker serves the stored room blob verbatim — dozens of fields the game needs and * the website doesn't — so only the ones read here are declared. */ interface OwnedRoom { RoomId: number Name: string Description: string /** A key on the `img` worker; a room with no image of its own gets the fallback. */ ImageName: string /** The `Accessibility` ordinal, NOT the enum name — see ACCESSIBILITY_LABEL. */ Accessibility: number CreatedAt: string MaxPlayers: number /** False blocks `POST /rooms/{id}/clone` — nobody can take a copy of the room. */ CloningAllowed: boolean SupportsScreens: boolean SupportsWalkVR: boolean SupportsTeleportVR: boolean SupportsQuest2: boolean SupportsMobile: boolean SupportsJuniors: boolean /** `Type` 0 is a tag the owner set, 2 one the server derived. */ Tags: Array<{ Tag: string; Type: number }> SubRooms: SubRoom[] /** Always present: the worker folds the live counters in on every read. */ Stats: { CheerCount: number FavoriteCount: number VisitorCount: number VisitCount: number } } /** * What an `Accessibility` ordinal is called on screen — rooms and subrooms both carry * one. The two dev values are reachable (the game sets them), so they're named rather * than left to fall through to the unknown case in `accessibilityLabel`. */ const ACCESSIBILITY_LABEL: Record = { [Accessibility.Private]: 'Private', [Accessibility.Public]: 'Public', [Accessibility.Unlisted]: 'Unlisted', [Accessibility.Dev_only]: 'Dev only', [Accessibility.Dev_Unlisted]: 'Dev unlisted', } /** * RecNet (4) is the web platform, stamped as the token's `platform` claim on sign-in. * NOT passed on signup: create_account treats an asserted platform as one to verify * against Steam and rejects RecNet — the web signup is the (platform-less) password * account path. */ const WEB_PLATFORM = '4' /** * The session's access token, in localStorage so a reload stays signed in. * * Readable by page JS, which the httpOnly cookie this replaced was not — that is the * tradeoff that comes with the browser calling the workers itself, and it's the same * posture the game client has. Nothing third-party runs on this origin except the * Turnstile widget, which is Cloudflare's own. */ const TOKEN_KEY = 'rf_token' let token: string | null = localStorage.getItem(TOKEN_KEY) function setToken(next: string | null) { token = next if (next === null) localStorage.removeItem(TOKEN_KEY) else localStorage.setItem(TOKEN_KEY, next) } /** * Filled in once `/api/config` lands, before any worker call is made — a module value * rather than a prop threaded through every form, since the components that call a * worker only render after the config resolves. */ let hosts: Hosts | null = null /** The hostnames, once known. Throws rather than guessing a domain. */ function where(): Hosts { if (hosts === null) throw new Error('Still starting up — please reload the page.') return hosts } /** * Roles that unlock the admin controls. Mirrors the notify worker's `ADMIN_ROLES` gate — * this only decides whether to SHOW them; notify verifies the token on every call. */ const ADMIN_ROLES = new Set(['developer', 'moderator']) /** * Whether the session token carries an admin role. Decodes the `role` claim WITHOUT * verifying it — a page holds no signing key, and faking one here only reveals buttons * whose endpoints reject the same token. A malformed token reads as "not admin". */ function isAdmin(): boolean { const payload = token?.split('.')[1] if (!payload) return false try { const b64 = payload.replace(/-/g, '+').replace(/_/g, '/') const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=') const claims = JSON.parse(atob(padded)) as { role?: unknown } return Array.isArray(claims.role) && claims.role.some((r) => ADMIN_ROLES.has(r as string)) } catch { return false } } /** * An OAuth machine code (`invalid_grant`, `server_error`) rather than a sentence — a * lower_snake_case word with no spaces. A worker that speaks OAuth puts one of these in * `error`, where the readable reason is in `error_description`. */ const isErrorCode = (s: string) => /^[a-z][a-z\d]*(_[a-z\d]+)+$/.test(s) /** * The message worth showing for a refusal. `error` wins, since that's where a worker * puts a sentence it wrote for the player — but NOT when it's a bare OAuth code, which * tells nobody anything. Some refusals carry no body at all (accounts answers a * malformed email with an empty 400), hence the last-resort line. */ function errorMessage(data: Record, status: number): string { const error = typeof data.error === 'string' ? data.error : '' const description = typeof data.error_description === 'string' ? data.error_description : '' return ( (error && !(isErrorCode(error) && description) && error) || description || error || `Request failed (${status})` ) } interface CallOptions { method?: 'GET' | 'POST' | 'PUT' /** Form fields — auth and accounts read their input with Hono's `parseBody()`. */ form?: Record /** A JSON body — what notify's internal endpoints take instead. */ json?: unknown /** * A multipart body — what `storage`'s `/upload` takes, since it carries a file. Passed * to `fetch` as-is: the browser writes the `content-type` itself, because only it * knows the boundary it generated. */ multipart?: FormData /** Send the session token. */ authed?: boolean /** * What to say when the worker refuses with a 400 and NO body. Several accounts routes * do exactly that (email, display name, bio), so without this the player reads * "Request failed (400)" — the status, not the reason. */ refusal?: string } /** Call a worker. Returns the parsed body, or throws with something worth showing. */ async function call>(url: string, opts: CallOptions = {}): Promise { const headers: Record = {} if (opts.authed && token) headers.authorization = `Bearer ${token}` let body: string | FormData | undefined if (opts.form) { headers['content-type'] = 'application/x-www-form-urlencoded' body = new URLSearchParams(opts.form).toString() } else if (opts.json !== undefined) { headers['content-type'] = 'application/json' body = JSON.stringify(opts.json) } else if (opts.multipart) { // Deliberately no content-type: setting one would omit the boundary. body = opts.multipart } const res = await fetch(url, { method: opts.method ?? (body === undefined ? 'GET' : 'POST'), headers, body, }) const data = (await res.json().catch(() => ({}))) as Record if (!res.ok) { // Expired or revoked. Cleared here so no caller has to remember to. if (res.status === 401 && opts.authed) { setToken(null) throw new Error('Your session has expired. Please sign in again.') } // Only when the body really is empty — a worker that did send a reason keeps it. if (opts.refusal !== undefined && res.status === 400 && Object.keys(data).length === 0) { throw new Error(opts.refusal) } throw new Error(errorMessage(data, res.status)) } return data as T } /** The signed-in account, straight from `accounts`. */ const fetchMe = (): Promise => call(`${where().accounts}/account/me`, { authed: true }) /** * The caller's own rooms, from the `rooms` worker — the same list the game's "My Rooms" * loads. `ownedby/me` rather than `createdby/me`: the dorm is auto-provisioned, not a * room the player made, and it's the one room they can't do anything with from here. * * The worker deliberately does NOT filter on accessibility for this list, so a room that * has never been published shows up — which is the point, since that's the one its owner * is most likely to be looking for. * * Sorted newest-first here rather than upstream: the query has no ORDER BY (D1 hands * back insertion order, which is not a promise), and the room someone just made is the * one they came to see. */ async function fetchMyRooms(): Promise { const rooms = await call(`${where().rooms}/rooms/ownedby/me`, { authed: true }) // A bare array is the contract; anything else is treated as "no rooms" rather than // thrown, since `.sort` on a non-array would surface as an unreadable TypeError. if (!Array.isArray(rooms)) return [] // ISO-8601 timestamps, so lexical order IS chronological order. return [...rooms].sort((a, b) => (a.CreatedAt < b.CreatedAt ? 1 : -1)) } /** * The `UploadFileType` a room's scene data is posted under. `storage` maps this to the * `room/` subfolder of the CDN bucket — the one prefix `cdn`'s `GET /room/:dataBlob` * reads back, and so the only one a `DataBlob` key can point into. */ const FILE_TYPE_ROOM_SAVE = '1' /** * The game build this server targets, as `YYYY-MM-DD` — read from the same `GAME_VERSION` * the auth token and presence carry rather than written out again here, so upgrading the * client moves this line with it instead of leaving a stale date on the upload form. * * It's shown because a scene blob is only loadable by the build that wrote it (or older * ones that understand it): a save taken out of a room built on a later version can fail * outright, and nothing between here and the game says why. */ const CLIENT_BUILD_DATE = `${GAME_VERSION.slice(0, 4)}-${GAME_VERSION.slice(4, 6)}-${GAME_VERSION.slice(6, 8)}` /** * Upload a scene blob to `storage` and return the key it was stored under — the * `/` name every `DataBlob` field holds. * * This is the same two-step the game does: the bytes go to `storage` first, and only its * generated name is handed to `rooms`. Nothing about the file is inspected here — a room * blob is an opaque Unity payload, and the server doesn't parse it either, so the only * honest validation available is whether the game can load it afterwards. */ async function uploadRoomBlob(file: File): Promise { const form = new FormData() form.set('FileType', FILE_TYPE_ROOM_SAVE) form.set('File', file) const { filename } = await call<{ filename?: string }>(`${where().storage}/upload`, { method: 'POST', multipart: form, authed: true, }) if (!filename) throw new Error('The storage worker accepted the file but returned no name.') return filename } /** * The blob's SHA-256, base64 — the encoding this API's hash fields use (an invention's * `BlobHash` comes back the same way). `rooms` only echoes it back on the save, but a * save whose hash doesn't describe its blob is worse than one carrying none. */ async function blobHash(file: File): Promise { const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', await file.arrayBuffer())) let binary = '' for (const byte of digest) binary += String.fromCharCode(byte) return btoa(binary) } /** * Record a room save against one subroom, pointing it at an already-uploaded blob. * * `AutoPublish` decides whether players see it now or whether it waits on the room's * publish step, exactly as it does for the game — the site doesn't get its own rule. * The envelope answers HTTP 200 either way and puts the refusal in `error`, so success * has to be read from the body rather than the status. `value.room` is the updated room, * which the page re-renders from rather than re-fetching the whole list. */ async function saveSubRoomBlob( roomId: number, subRoomId: number, input: { filename: string; hash: string; description: string; autoPublish: boolean } ): Promise { const res = await call<{ success?: boolean error?: string | null value?: { room?: OwnedRoom } | null }>(`${where().rooms}/rooms/${roomId}/subrooms/${subRoomId}/data`, { method: 'POST', authed: true, json: { SubRoomData: { Filename: input.filename, Hash: input.hash }, Description: input.description, AutoPublish: input.autoPublish, }, }) if (res.success !== true) { throw new Error(res.error || 'The rooms worker refused the save.') } const room = res.value?.room if (!room) throw new Error('The save was recorded but the room came back empty.') return room } /** * Sign in with auth's password grant, posted directly the way the game posts it. The * account is resolved by `username` (case-insensitive) — web players sign in with their * username, not the numeric account id. * * A refusal is translated through the table shared with the worker (see * `auth-messages.ts`): auth's `error` is always a machine code, and the reason in * `error_description` is written for an operator, not a player. */ async function signIn(username: string, password: string): Promise { const res = await fetch(`${where().auth}/connect/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'password', username, platform: WEB_PLATFORM, password, }).toString(), }).catch(() => null) if (res === null) throw new Error(authUnreachable('login')) const data = (await res.json().catch(() => ({}))) as Record if (!res.ok) { const code = typeof data.error === 'string' ? data.error : '' const description = typeof data.error_description === 'string' ? data.error_description : '' throw new Error(authFailure('login', res.status, code, description).message) } if (typeof data.access_token !== 'string') throw new Error(authUnreachable('login')) setToken(data.access_token) } /** * Create an account — the one flow that goes through `www`, because it's gated by * Turnstile and that check needs a secret key a page can't hold. www hands back auth's * token response unchanged, so the session is established just as sign-in establishes it. */ async function signUp(password: string, turnstileToken: string): Promise { const data = await call<{ access_token?: string }>('/api/signup', { json: { password, turnstileToken }, }) if (typeof data.access_token !== 'string') throw new Error(authUnreachable('signup')) setToken(data.access_token) } /** * Change the username. * * `accounts` answers this one in its own envelope — `{ success, error, value }` at HTTP * 200 even when it refused (taken name, no changes left) — so a 200 is not enough to * call it done. The sentences it writes are already player-facing, so they're shown as-is. * * On success the SELF account is re-read rather than using the envelope's `value`: that * is the PUBLIC DTO, and it carries no `availableUsernameChanges` — the very field this * form needs to know whether another change is left. */ async function changeUsername(username: string): Promise { const result = await call<{ error?: unknown }>(`${where().accounts}/account/me/username`, { method: 'PUT', form: { username }, authed: true, }) const refusal = typeof result.error === 'string' ? result.error : '' if (refusal !== '') throw new Error(refusal) return fetchMe() } /** * Set the account's email. * * The address is NOT checked here first. `accounts` validates it with `isemail`, which * can't come along into the browser (it reaches for node's `util`, which vite stubs with * a throwing Proxy in dev) — and a second, looser copy of the rule would only disagree * with the real one. The server decides; this just names the refusal it answers with. */ const saveEmail = (email: string): Promise => call(`${where().accounts}/account/me/email`, { form: { email }, authed: true, refusal: 'That email address looks wrong.', }) /** Change the account's password. Lives on `auth`, not `accounts`. */ const changePassword = (oldPassword: string, newPassword: string): Promise => call(`${where().auth}/account/me/changepassword`, { form: { oldPassword, newPassword }, authed: true, }) /** Where this account's benefits stand: `www` reads them off the account row. */ interface BenefitsStatus { /** Whether the account already has Rec Room Plus. */ hasPlus: boolean /** Whether a Discord identity is already tied to it. Which one is deliberately not served. */ linked: boolean } /** * The two ends of the benefits claim. Both live on `www` rather than on one of the game * workers, because the claim needs the Discord client secret — see www.app.ts. */ const fetchBenefitsStatus = (): Promise => call('/api/benefits/status', { authed: true }) /** Redeem the code Discord sent us back with. The access token never reaches this page. */ const claimBenefits = (code: string): Promise<{ discordUsername?: string }> => call<{ discordUsername?: string }>('/api/benefits/claim', { json: { code }, authed: true }) /** * The per-attempt CSRF nonce for the Discord round-trip, in sessionStorage. * * OAuth's `state` only means anything if the same page that minted it is the one that * checks it, so it can't come from the server. sessionStorage rather than localStorage: * it belongs to this tab and this attempt, and it should not outlive the tab that started * the flow. */ const OAUTH_STATE_KEY = 'rf_discord_state' /** * Send the browser to Discord's consent screen. * * A real navigation, not a client-side route — Discord is another origin. The `state` is * minted here and stashed for the return leg; `www` built everything else about the URL * (see `/api/config`), so this only ever appends the one parameter it owns. */ function startDiscordAuth(authorizeUrl: string) { const state = crypto.randomUUID() sessionStorage.setItem(OAUTH_STATE_KEY, state) const url = new URL(authorizeUrl) url.searchParams.set('state', state) window.location.assign(url.toString()) } /** * Admin-only broadcasts. The token goes to `notify`, which enforces the admin-role gate * — so a session without the role is rejected there (403) even though the UI shows no * button. The maintenance frame carries `Msg: { StartsInMinutes }`, matching the game * client's ServerMaintenance handler. */ const broadcastMaintenance = (startsInMinutes: number): Promise<{ delivered?: number }> => call<{ delivered?: number }>(`${where().notify}/internal/broadcast`, { json: { notificationType: NotificationType.ServerMaintenance, data: { StartsInMinutes: startsInMinutes }, }, authed: true, }) const coachMessageAll = (messageContent: string): Promise<{ sent?: number }> => call<{ sent?: number }>(`${where().notify}/internal/coach-message-all`, { json: { messageContent }, authed: true, }) /** * The same coach message to ONE player. `notify` queues it when they're offline, so * `queued` (rather than a 0 delivery) is what "they weren't online" looks like here — * it still arrives on their next connect, unlike the broadcast. */ const coachMessage = ( playerId: number, messageContent: string ): Promise<{ delivered?: number; queued?: boolean }> => call<{ delivered?: number; queued?: boolean }>(`${where().notify}/internal/coach-message`, { json: { playerId, messageContent }, authed: true, }) /** * Resolve an `@username` to the account id the workers address a player by. * * `accounts` serves no exact-name lookup, so this goes through the PREFIX search and * keeps only an exact (case-insensitive) hit: a prefix match is a different player, and * sending a message to whoever happened to sort first would be worse than refusing. The * exact name always sorts first among its own prefixes, so it's inside the search limit * whenever it exists. */ async function accountIdForUsername(input: string): Promise { const name = input.trim().replace(/^@/, '') if (name === '') throw new Error('Enter a username to send to.') const matches = await call>( `${where().accounts}/account/search?name=${encodeURIComponent(name)}` ) const found = Array.isArray(matches) ? matches.find((m) => m.username?.toLowerCase() === name.toLowerCase()) : undefined if (typeof found?.accountId !== 'number') throw new Error(`There's no player called @${name}.`) return found.accountId } /** Minimal history-based router: current pathname + a navigate() that pushes state. */ function useRouter() { const [path, setPath] = useState(() => window.location.pathname) useEffect(() => { const onPop = () => setPath(window.location.pathname) window.addEventListener('popstate', onPop) return () => window.removeEventListener('popstate', onPop) }, []) const navigate = useCallback((to: string) => { if (to !== window.location.pathname) { window.history.pushState(null, '', to) window.scrollTo(0, 0) } setPath(to) }, []) return { path, navigate } } type Navigate = (to: string) => void /** An in-app link that routes client-side instead of doing a full page load. */ function Link({ to, navigate, className, children, }: { to: string navigate: Navigate className?: string children: ReactNode }) { return ( { e.preventDefault() navigate(to) }} > {children} ) } /** * The benefits claim itself: where the player stands, and the button that starts (or * re-runs) the Discord round-trip. * * This is BOTH ends of the OAuth round-trip: it sends the player to Discord, and it is * what renders when Discord sends them back. Which half is running is decided by whether * the URL carries a `code`. * * What it never holds is a Discord access token. It forwards the one-time `code` to * `www`, which does the exchange with the client secret and answers with a verdict; that * is the whole reason this one feature has a server side at all. * * Rendered in TWO places, which is why it is a component rather than a page. Its home is * the "Claim benefits" tab in the account dashboard, where someone would go looking for * it. But it also has to render on `/claim`, because that path is Discord's registered * redirect URI — the browser comes back to it with a `?code=`, and it is the only URL a * cold load can land on mid-flow. One component means the two can't drift. * * The effect keys off whether the URL carries a code, so the same code covers both: on * the dashboard there is none, and it just reports status. */ function BenefitsPanel({ account, config }: { account: SelfAccount; config: SiteConfig }) { const [status, setStatus] = useState(undefined) const [error, setError] = useState('') const [done, setDone] = useState('') const [pending, setPending] = useState(false) // Shown after a successful claim only. Plus rides on the game's token as `rn.plus`, // stamped at login, so the copy of it the player is holding still says they have none — // and tokens last a day and are never refreshed. Without this line the claim looks like // it silently did nothing, which is the single most likely support question here. const [relogin, setRelogin] = useState(false) // StrictMode runs effects twice in dev, and a Discord code is single-use: the second // run would redeem a spent code and report a failure over a claim that just worked. const redeemed = useRef(false) useEffect(() => { const params = new URLSearchParams(window.location.search) const code = params.get('code') const state = params.get('state') const expected = sessionStorage.getItem(OAUTH_STATE_KEY) if (code === null) { // Nothing came back from Discord — either the dashboard tab, or `/claim` opened // directly. Just show where they stand. Discord also returns with // `?error=access_denied` when someone cancels: no code, nothing to say, and the // button is right there to try again. void fetchBenefitsStatus() .then(setStatus) .catch(() => setStatus(undefined)) return } // The return leg. Strip the query first, whatever happens next: the code is spent by // the request below, so a reload must not carry it (and a code has no business // sitting in the address bar, or in whatever the player pastes it into). replaceState // rather than a route change, so Back doesn't walk into a used code either. window.history.replaceState(null, '', '/claim') if (redeemed.current) return redeemed.current = true sessionStorage.removeItem(OAUTH_STATE_KEY) // The nonce this tab minted must be the one that came back. A mismatch means the // round-trip wasn't started here, which is exactly what `state` exists to catch. if (state === null || expected === null || state !== expected) { setError('That Discord sign-in did not match this browser. Please start again.') return } setPending(true) claimBenefits(code) .then((result) => { setStatus({ hasPlus: true, linked: true }) setDone( result.discordUsername ? `Verified as ${result.discordUsername} — Rec Room Plus is now on your account.` : 'Verified — Rec Room Plus is now on your account.' ) setRelogin(true) }) .catch((err: unknown) => setError(err instanceof Error ? err.message : String(err))) .finally(() => setPending(false)) }, []) // Read into a local so the narrowing survives into the click handlers below. const authorizeUrl = config.discordAuthorizeUrl if (!config.benefitsEnabled || authorizeUrl === null) { return (

Claim benefits

Benefit claims aren’t available on this server right now.

) } const claimed = status?.hasPlus === true return (

Rec Room Plus

Members of our Discord with a supporter role get Rec Room Plus on their account. Verify with Discord and we’ll check your roles — we only ever read your username and which roles you hold in our server.

Claiming as @{account.username} (#{account.accountId}). A Discord account can claim on one RecFlare account only.

{error &&

{error}

} {done &&

{done}

} {relogin && (

Restart Rec Room and sign in again to pick it up — your game reads Rec Room Plus from the session it signed in with, so it won’t show until then.

)} {pending ? (

Checking your Discord roles…

) : claimed ? ( // Already claimed. The button stays, because a player whose roles changed (or who // re-linked) can safely run it again — the claim is idempotent on their own // account — but it no longer reads as the thing to do. <> {!done && ( <>

Rec Room Plus is active on this account.

If the game doesn’t show it, sign out and back in — Rec Room Plus is read from the session your game signed in with.

)} ) : ( )}
) } /** * `/claim` — the page Discord redirects back to. * * Not linked from anywhere any more: the claim lives in the account dashboard's "Claim * benefits" tab. This route still has to exist and still has to work on a cold load, * because it is the app's registered `redirect_uri` — the browser arrives here from * Discord carrying the `?code=`, with whatever session it has. * * Signing in comes FIRST, and not only because the grant needs an account to land on: the * bearer token is what tells `www` whose row to write, so a claim without one has no * subject. Hence the sign-in card rather than a redirect — someone who arrives here from a * link should be told what this is before being bounced to a login form. */ function ClaimPage({ account, config, navigate, }: { account: SelfAccount | null | undefined config: SiteConfig | undefined navigate: Navigate }) { if (account === undefined || config === undefined) { return (

Loading…

) } if (account === null) { return (

Claim your benefits

Sign in first

Benefits are granted to a RecFlare account, so we need to know which one is yours before you verify with Discord. If you were part-way through a claim, start it again from your account page once you’re signed in.

Sign in
) } return (

Claim your benefits

) } /** * The room id in `/rooms/`, or null for any other path. Numeric rather than the * room's name: a name is renameable (`PUT /rooms/{id}/name`), so a link someone * bookmarked would rot the moment they renamed the room. */ function roomIdFromPath(path: string): number | null { const match = /^\/rooms\/(\d+)$/.exec(path) return match ? Number.parseInt(match[1], 10) : null } export function App() { // undefined = still checking the session; null = signed out. const [account, setAccount] = useState(undefined) // undefined until the config lands. Signup is treated as closed until told otherwise, // so a slow (or failed) config fetch can't flash a form the server would refuse. const [config, setConfig] = useState(undefined) const { path, navigate } = useRouter() const roomId = roomIdFromPath(path) useEffect(() => { // Config first, and everything else after it: it carries the hostnames every other // call needs. A config that doesn't land leaves the page signed out with signup // closed rather than guessing where the workers are. call('/api/config') .then(async ({ hosts: resolved, ...site }) => { hosts = resolved setConfig(site) if (token === null) return setAccount(null) // A stored token that `accounts` rejects is stale — `call` has already dropped // it, so this just falls back to signed-out rather than surfacing an error. await fetchMe() .then(setAccount) .catch(() => setAccount(null)) }) .catch(() => { setConfig({ signupEnabled: false, turnstileSiteKey: null, benefitsEnabled: false, discordAuthorizeUrl: null, }) setAccount(null) }) }, []) // Nothing to tell a server: the access token is a stateless JWT, so dropping it here // IS the sign-out. (The refresh token auth issues alongside it is never stored, so a // closed session leaves nothing behind to redeem.) const logout = useCallback(() => { setToken(null) setAccount(null) navigate('/') }, [navigate]) return ( <> {path === '/login' || path === '/signup' ? ( // One page, two doors. `/signup` exists so the homepage's create-account link // lands on that tab instead of dropping people on sign-in to find it — and so // the URL is linkable. Unknown paths fall back to index.html (see the assets // config in wrangler.jsonc), so a cold load of /signup reaches the SPA. ) : path === '/account' ? ( ) : path === '/claim' ? ( // Its own page rather than a dashboard tab: this path is Discord's registered // redirect URI, so it has to be one stable URL a cold load can land on. ) : roomId !== null ? ( ) : ( )} ) } /** Footer: where to go next, plus the affiliation disclaimer. */ function SiteFooter() { return (
MIT licensed {' '} — made by fans, not affiliated with Rec Room Inc.
) } /** Top nav: brand → home, plus a sign-in / my-account link for the session. */ function NavBar({ account, path, navigate, onLogout, }: { account: SelfAccount | null | undefined path: string navigate: Navigate onLogout: () => void }) { return (
RecFlare
) } /** * How many photos the hero asks the feed for. Explicit rather than left to the api's * default, since the count is a design decision here: the stage rotates one photo every * six seconds, so ten is a minute of it — long enough that a repeat visitor sees fresh * photos, short enough that the arrows stay walkable and the payload stays small. */ const SLIDESHOW_TAKE = 10 /** A recent public image plus who took it and where. */ interface Slide { url: string username: string roomName: string | null } /** * Loads the public photo feed once. `slides === null` means still in flight. * * Waits for the config, since the feed is served by the `api` worker — the same public * endpoint the game reads it from — and its hostname arrives with the config. Each entry * names an image; the browsable URL for it is on the `img` worker. */ function useSlideshow(config: SiteConfig | undefined) { const [slides, setSlides] = useState(null) const [error, setError] = useState('') useEffect(() => { if (config === undefined) return type Feed = { Images?: Array<{ ImageName: string; Username: string; RoomName: string | null }> } // Wrapped in an async call rather than started directly, because `where()` THROWS // when the config didn't land — synchronously, which straight out of an effect // would take the page down instead of leaving an empty stage behind the fold. void (async () => { const h = where() const d = await call(`${h.api}/api/images/v1/slideshow?take=${SLIDESHOW_TAKE}`) setSlides( (d.Images ?? []).map((i) => ({ url: `${h.img}/${i.ImageName}`, username: i.Username, roomName: i.RoomName, })) ) })().catch((e) => setError(e instanceof Error ? e.message : String(e))) }, [config]) return { slides, error } } /** * Public homepage. The stage leads: photos players actually took, with the way in * on top of them. Everything about how the thing is built sits below, for whoever * scrolls looking for it. */ function HomePage({ account, config, navigate, }: { account: SelfAccount | null | undefined config: SiteConfig | undefined navigate: Navigate }) { const feed = useSlideshow(config) // The signup offer only makes sense to a signed-out visitor when the server would // actually take one. `account === undefined` is still-checking, so it shows nothing // rather than offering an account to someone who already has one. const offerSignup = account === null && config?.signupEnabled === true return (
) } /** * The hero: the headline and the way in on the left, a rotating in-game photo on the * right. The photo is proof, never the payload — when the feed is slow or down the * frame holds its space and the left half reads the same, so "Play now!" is reachable * either way. */ function Stage({ slides, offerSignup, navigate, }: { slides: Slide[] | null offerSignup: boolean navigate: Navigate }) { const [idx, setIdx] = useState(0) const count = slides?.length ?? 0 // A timeout keyed on the current slide rather than one long-lived interval: steering // by hand re-arms it, so a photo you just picked gets its full six seconds. useEffect(() => { if (count < 2) return const t = setTimeout(() => setIdx((i) => (i + 1) % count), 6000) return () => clearTimeout(t) }, [count, idx]) const slide = slides && slides.length > 0 ? slides[idx] : null const step = (by: number) => setIdx((i) => (i + by + count) % count) return (
{/* Deliberately doesn't name the game: this is a fan project, so the trademark stays out of the headline and appears lower down, in plain nominative use next to the disclaimer. */}

Play today!

The servers you remember, rebuilt and running — free, open source, and up right now.

{/* A line rather than a fourth button: the download is the point of this page, and launching the game makes an account by itself — signing up here is the way in for someone who wants one first. Hidden entirely when signup is closed, matching /login, which hides its create-account tab the same way. */} {offerSignup && (

New here?{' '} Create an account

)}
{slide && ( {`Photo )}
{/* Always mounted, so the frame doesn't shift down when the feed lands. */}
{slide && ( Photo by @{slide.username} {slide.roomName && ` in ${slide.roomName}`} )} {/* Arrows and a count, not a dot per photo: a dot each is wide enough to shove the headline's half of the split off the page, and it would have to be rebuilt the moment SLIDESHOW_TAKE grows. */} {count > 1 && ( {idx + 1} / {count} )}
) } /** The slideshow's back/forward mark. Decorative — the buttons carry the label. */ function Chevron({ next }: { next?: boolean }) { return ( ) } /** What RecFlare is, under the fold, for whoever wants it. */ function About({ slides, error }: { slides: Slide[] | null; error: string }) { // The feed answering is proof the server replied, so the indicator can't claim // the server is up when it isn't. const state = slides !== null ? 'online' : error ? 'down' : 'checking' return (

A cloud architected server for the 2023/2025 game clients

A free fan project, made by players who missed it. Aiming to be{' '} feature-complete and infinitely scalable —{' '} architected for the cloud, no gatekeeping, no basement server.

{state === 'online' ? 'Servers are up' : state === 'down' ? "Can't reach the servers" : 'Checking…'}

{/* Only when it's actually up: when it isn't, people want the status, not the joke. */} {state === 'online' &&

The cloud never goes down, right?

}
) } /** * The sign-in page — sign in, plus create-account when the server says signup is open * (it needs a Turnstile keypair; see SiteConfig). Redirects to the account page once a * session exists, however it was obtained. */ function LoginPage({ account, config, initialTab, navigate, onAuthed, }: { account: SelfAccount | null | undefined config: SiteConfig | undefined initialTab: 'signup' | 'login' navigate: Navigate onAuthed: (a: SelfAccount) => void }) { // The tab IS the route (`/login` vs `/signup`) rather than local state, so the two can // never disagree — switching tabs pushes history, and back goes back to the other one. const tab = initialTab useEffect(() => { if (account) navigate('/account') }, [account, navigate]) const authed = (a: SelfAccount) => { onAuthed(a) navigate('/account') } const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null return (
{siteKey && (
)} {siteKey && tab === 'signup' ? ( <>

Create account

A username is assigned for you — you'll see it on your account page. Choose a password, and the two together sign you in here and in the game.

) : ( <>

Sign in

Use your username and password. Launching the game also creates an account, linked to your Steam ID — set a password on it and it signs in here too.

{/* The tabs above already offer this; the line under the button is where someone who just found out they have no account is actually looking. Gated on the same key, so it can't point at a door that isn't there. */} {siteKey && (

Don't have an account?{' '} Create one

)} )}
) } /** The signed-in account page. Redirects to sign-in when there's no session. */ function AccountPage({ account, config, navigate, onChange, }: { account: SelfAccount | null | undefined config: SiteConfig | undefined navigate: Navigate onChange: (a: SelfAccount) => void }) { useEffect(() => { if (account === null) navigate('/login') }, [account, navigate]) if (!account) { return (

{account === undefined ? 'Loading…' : 'Redirecting…'}

) } return (

My account

) } /** * One room's own page — what it is, how it's set up, and the subrooms inside it. * * The room is found in the caller's OWN list rather than read from the public * `GET /rooms?id=`, which is unfiltered by design (the game looks any room up that way). * Going through `ownedby/me` is what makes this the owner's page: a room that isn't * yours simply isn't in the list, so there's no second ownership rule here to drift out * of step with the one the mutating endpoints enforce. */ function RoomPage({ account, roomId, navigate, }: { account: SelfAccount | null | undefined roomId: number navigate: Navigate }) { const [rooms, setRooms] = useState(null) const [error, setError] = useState('') const accountId = account?.accountId useEffect(() => { if (account === null) navigate('/login') }, [account, navigate]) useEffect(() => { // Waits for the session: the list is auth-gated, and `account === undefined` only // means the stored token hasn't been checked yet. if (accountId === undefined) return void fetchMyRooms() .then(setRooms) .catch((e) => setError(e instanceof Error ? e.message : String(e))) }, [accountId]) if (!account) { return (

{account === undefined ? 'Loading…' : 'Redirecting…'}

) } const room = rooms?.find((r) => r.RoomId === roomId) return (

← My rooms

{error ? (

{error}

) : rooms === null ? (

Loading…

) : room === undefined ? ( // Covers both "no such room" and "someone else's" — deliberately the same // sentence, since telling a stranger which of the two it is answers a question // they have no business asking.

That isn't one of your rooms.

) : ( setRooms((current) => (current ?? []).map((r) => (r.RoomId === updated.RoomId ? updated : r)) ) } /> )}
) } /** The platforms a room says it supports, named the way the game names them. */ function platformList(room: OwnedRoom): string[] { const on: string[] = [] if (room.SupportsScreens) on.push('Screens') if (room.SupportsWalkVR) on.push('VR (walk)') if (room.SupportsTeleportVR) on.push('VR (teleport)') if (room.SupportsQuest2) on.push('Quest 2') if (room.SupportsMobile) on.push('Mobile') if (room.SupportsJuniors) on.push('Juniors') return on } /** * A room's settings and its subrooms. Its own fields are read-only — rooms are edited in * game — with one exception: a subroom's scene data can be replaced from here, which is * the one thing the game gives an owner no way to do (it can only save what it just * built, never restore a file they kept). */ function RoomDetail({ room, imgHost, cdnHost, onRoomChange, }: { room: OwnedRoom imgHost: string cdnHost: string onRoomChange: (room: OwnedRoom) => void }) { const created = new Date(room.CreatedAt) const platforms = platformList(room) const subRooms = room.SubRooms ?? [] return ( <>
{/* 512 rather than the list's 256: this one is displayed large. Both are sizes the img worker allows, so each is a cached variant. */}

^{room.Name}

{room.Description ? (

{room.Description}

) : (

No description set.

)}

{room.Stats.VisitCount.toLocaleString()} visit {room.Stats.VisitCount === 1 ? '' : 's'} · {room.Stats.FavoriteCount.toLocaleString()}{' '} favourite {room.Stats.FavoriteCount === 1 ? '' : 's'} · {room.Stats.CheerCount.toLocaleString()}{' '} cheer{room.Stats.CheerCount === 1 ? '' : 's'}

Settings

Room id
{room.RoomId}
Visibility
{accessibilityLabel(room.Accessibility)}
Max players
{room.MaxPlayers}
Cloning
{room.CloningAllowed ? 'Anyone may clone this room' : 'Nobody may clone this room'}
Plays on
{platforms.length > 0 ? platforms.join(', ') : 'Nothing — no platform is enabled'}
Tags
{room.Tags?.length ? room.Tags.map((t) => t.Tag).join(', ') : 'None'}
Created
{Number.isNaN(created.getTime()) ? room.CreatedAt : created.toLocaleDateString()}

Subrooms

The places inside the room players actually load into. Each keeps its own accessibility and its own saves, so a public room can still hold a subroom nobody else can reach.

{subRooms.length === 0 ? (

This room has no subrooms.

) : (
    {subRooms.map((sub) => ( ))}
)}
) } /** One subroom: what it is, and — the part an owner can't see anywhere else — its save. */ function SubRoomRow({ sub, roomId, roomName, cdnHost, onRoomChange, }: { sub: SubRoom roomId: number roomName: string cdnHost: string onRoomChange: (room: OwnedRoom) => void }) { const save = sub.CurrentSave ?? null const saved = save ? new Date(save.CreatedAt) : null // Cleared when that save is published (see publishSubRoomSave), so a value here always // means work the owner saved but players still can't see. const staged = sub.StagedSubRoomDataSaveId !== null && sub.StagedSubRoomDataSaveId !== undefined const name = sub.Name || `Subroom ${sub.SubRoomId}` return (
  • {name} {sub.IsSandbox && Sandbox}

    #{sub.SubRoomId} · up to {sub.MaxPlayers} players

    {save === null ? ( // A subroom with no published save loads an empty scene without erroring, which // from the inside looks exactly like a broken room. Say so plainly. Never published — players load an empty scene. ) : ( <> Published save #{save.SubRoomDataSaveId} {saved && !Number.isNaN(saved.getTime()) && `, saved ${saved.toLocaleString()}`} {save.Description && ` — “${save.Description}”`} )} {staged && ( · a newer save is staged, waiting to be published. )}

    {/* The published save's blob first: that's the copy of the room worth keeping, and the one the client resolves ahead of the subroom's own key. */} {save?.DataBlob && ( )} {sub.DataBlob && ( )}
  • ) } /** * Replace one subroom's scene data with a file from disk. * * The two steps are the game's own: the bytes go to `storage` under the RoomSave type, * and the key it hands back is posted to the subroom's `…/data` route as * `SubRoomData.Filename`. So this is a room save like any other — it lands in the * subroom's history beside the ones the game wrote, and both endpoints are already gated * on the room's creator (or a co-owner), which is why there is no ownership check here: * the page only lists rooms that came back from `ownedby/me` in the first place. * * Publishing is offered rather than assumed. A save normally only STAGES — players keep * loading the last published version until the owner publishes — and quietly making an * uploaded file live would be a bigger step than the game's own save takes. Left on by * default all the same: someone uploading a blob here is restoring a room, and a restore * nobody can see isn't one. */ function BlobUpload({ roomId, subRoomId, onRoomChange, }: { roomId: number subRoomId: number onRoomChange: (room: OwnedRoom) => void }) { const [file, setFile] = useState(null) const [description, setDescription] = useState('') const [publish, setPublish] = useState(true) // The file input is uncontrolled — React can't set its value — so clearing the picked // file after a save takes a handle on the element itself. const input = useRef(null) const { pending, error, done, run } = useAction() return (
    { e.preventDefault() if (!file) return void run(async () => { const [filename, hash] = await Promise.all([uploadRoomBlob(file), blobHash(file)]) onRoomChange( await saveSubRoomBlob(roomId, subRoomId, { filename, hash, description: description.trim(), autoPublish: publish, }) ) setFile(null) setDescription('') if (input.current) input.current.value = '' return publish ? 'Uploaded and published — players load this scene now.' : 'Uploaded and staged. Publish it in game to make it live.' }) }} > {/* Said out loud, on the control itself: this is the newest thing on the site and the only one that overwrites what players load. Someone about to hand us a file they can't get back should read that before the file picker, not after. */}

    Replace scene data Beta

    New and lightly tested. Nothing here checks the file — the server stores whatever it is and the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so scene data from a room built on anything newer may not load at all. Download the save above and keep it before replacing it.

    {error &&

    {error}

    } {done &&

    {done}

    }
    ) } /** * A download filename built from player-supplied names, with everything that isn't a * word character, dot or dash flattened to a dash — a subroom can be called anything, * and that string is about to become a path on someone's disk. */ const safeFilename = (...parts: string[]): string => `${parts.join('-').replace(/[^\w.-]+/g, '-')}.bin` /** * One scene-data blob: the key, and a link that downloads it from `cdn`. * * `href` is the real CDN URL, so open-in-new-tab and right-click → Save As work like any * other link. The click is intercepted only to give the file a NAME: blobs are stored * under a date-foldered UUID, so three downloads otherwise land as three * indistinguishable extensionless files. The `download` attribute can't do that on its * own — browsers ignore it cross-origin, and `cdn` is always a different origin from the * website — hence fetching the bytes and saving them through an object URL. */ function BlobDownload({ label, blobKey, filename, cdnHost, }: { label: string blobKey: string filename: string cdnHost: string }) { // Room build data is served under `room/` — the same prefix the storage worker // uploads it to, and the one the game downloads it from. const url = `${cdnHost}/room/${blobKey}` const [pending, setPending] = useState(false) const [error, setError] = useState('') const download = async () => { setPending(true) setError('') try { const res = await fetch(url) // The blob key is stored on the subroom, so a miss here means the object is gone // from the bucket — worth saying, rather than saving a file of the 404 body. if (!res.ok) throw new Error(`the CDN answered ${res.status}`) const href = URL.createObjectURL(await res.blob()) const link = document.createElement('a') link.href = href link.download = filename link.click() // The click is dispatched synchronously but the save reads the URL after this // frame, so the revoke waits a tick rather than pulling it out from under. setTimeout(() => URL.revokeObjectURL(href), 0) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setPending(false) } } return (
    {label} { e.preventDefault() void download() }} > {blobKey} {/* Only rendered when it has something to say — an empty span would still take a gap from the flex row, leaving the key trailed by a stray space. */} {pending ? ( Downloading… ) : error ? ( Couldn’t download — {error}. ) : null}
    ) } /** How a room or subroom's `Accessibility` reads on screen. */ const accessibilityLabel = (accessibility: number): string => // Unknown ordinals shouldn't happen, but this label is the only thing telling an owner // whether a room is visible — so show the raw value rather than nothing at all. ACCESSIBILITY_LABEL[accessibility] ?? `Accessibility ${accessibility}` /** * The visibility pill. Public gets the same green "healthy" reading as the server * status; every other value stays neutral, since Private is a choice, not a fault. */ function VisibilityBadge({ accessibility }: { accessibility: number }) { return ( {accessibilityLabel(accessibility)} ) } /** Small hook wrapping a submit handler with pending/error/success state. */ function useAction() { const [pending, setPending] = useState(false) const [error, setError] = useState('') const [done, setDone] = useState('') const run = useCallback(async (fn: () => Promise) => { setPending(true) setError('') setDone('') try { setDone(await fn()) } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { setPending(false) } }, []) return { pending, error, done, run } } /** * Turnstile's browser API, as much of it as the signup widget uses. Loaded from * Cloudflare at runtime (see loadTurnstile) rather than bundled, so it isn't in * node_modules and has no types of its own. */ interface TurnstileApi { render: ( el: HTMLElement, opts: { sitekey: string action?: string callback?: (token: string) => void 'expired-callback'?: () => void } ) => string | undefined reset: (widgetId?: string) => void remove: (widgetId?: string) => void } declare global { interface Window { turnstile?: TurnstileApi } } /** * Load Turnstile's script, once per page, resolving when `window.turnstile` is ready. * `render=explicit` stops it scanning the document for widgets: this is a SPA, so the * container mounts and unmounts with the form and we render into it ourselves. * * The promise is cached at module scope, so switching tabs back and forth reuses the * loaded script instead of appending another tag. A rejection is cached too — the retry * is a page reload, which is what the error message asks for. */ let turnstileScript: Promise | null = null function loadTurnstile(): Promise { turnstileScript ??= new Promise((resolve, reject) => { const el = document.createElement('script') el.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit' el.async = true el.defer = true el.onload = () => resolve() el.onerror = () => reject(new Error('load failed')) document.head.appendChild(el) }) return turnstileScript } /** * Mount a Turnstile widget and hand back the token it produces. No token means no * submit: the BFF refuses a signup without one, so the form gates its button on it * rather than letting the request fail. * * `reset` re-arms the widget for another attempt — a token is single-use, so a rejected * signup can't be retried with the same one. */ function useTurnstile(siteKey: string) { const container = useRef(null) const widgetId = useRef(undefined) const [token, setToken] = useState('') const [error, setError] = useState('') useEffect(() => { let live = true loadTurnstile() .then(() => { // StrictMode mounts twice, and the cleanup below removes the first widget; bail // if this effect is the stale one so we don't render into a detached container. if (!live || !container.current || !window.turnstile) return widgetId.current = window.turnstile.render(container.current, { sitekey: siteKey, // Marker Cloudflare uses to segment Turnstile integrations; carries no user data. action: 'turnstile-spin-v1', callback: (t) => setToken(t), // Tokens expire after a few minutes; drop ours so the button locks again and // Turnstile can hand us a fresh one. 'expired-callback': () => setToken(''), }) }) .catch(() => { if (live) setError("Couldn't load the bot check — reload the page to try again.") }) return () => { live = false if (widgetId.current) window.turnstile?.remove(widgetId.current) widgetId.current = undefined } }, [siteKey]) const reset = useCallback(() => { setToken('') if (widgetId.current) window.turnstile?.reset(widgetId.current) }, []) return { container, token, error, reset } } /** * Create an account from the website: a password, plus a Turnstile token proving a human * filled the form. The username comes back auto-assigned from `auth` (players don't pick * one), and the session is live on success — so this lands on the account page, where the * username is shown. */ function SignupForm({ siteKey, onAuthed, }: { siteKey: string onAuthed: (a: SelfAccount) => void }) { const [password, setPassword] = useState('') const [email, setEmail] = useState('') const { container, token: widgetToken, error: widgetError, reset } = useTurnstile(siteKey) const { pending, error, run } = useAction() return (
    { e.preventDefault() void run(async () => { const wanted = email.trim() try { await signUp(password, widgetToken) } catch (err) { // The widget token is spent either way, so re-arm before they retry. Only // a failed signup gets here — past this point the account exists, and a // retry would spend another slot against auth's per-IP cap. reset() throw err } // Saved with the new session's own token: `create_account` takes no email, // `accounts` owns the field. Deliberately not fatal — the account exists and // the session is live, and the same field is one call away on the account // page. if (wanted !== '') await saveEmail(wanted).catch(() => {}) // The session is already stored, so a failure here isn't one they can act on // by retrying: a reload finds them signed in. const me = await fetchMe().catch(() => { throw new Error( 'Your account was created, but loading it failed. Reload the page — you are already signed in.' ) }) onAuthed(me) return '' }) }} > {/* Optional, and the button doesn't wait on it — but it's the only contact detail an account has, so the hint says plainly what it's for rather than leaving it to be guessed. `type="email"` gets the right keyboard on mobile and a free format check; the worker re-checks it before the account is created. */}
    {widgetError &&

    {widgetError}

    } {error &&

    {error}

    } ) } function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) { const [username, setUsername] = useState('') const [password, setPassword] = useState('') const { pending, error, run } = useAction() return (
    { e.preventDefault() void run(async () => { await signIn(username, password) onAuthed(await fetchMe()) return '' }) }} > {error &&

    {error}

    }
    ) } function Dashboard({ account, config, navigate, onChange, }: { account: SelfAccount config: SiteConfig | undefined navigate: Navigate onChange: (a: SelfAccount) => void }) { // The dashboard sections, shown one at a time via the left tab rail. Admin-only // sections are appended when the session carries an admin role. const sections = [ // First, so a player who just signed in lands on what they made rather than on a // settings form they opened the page to avoid. { id: 'rooms', label: 'My rooms', render: () => }, { id: 'username', label: 'Username', render: () => , }, { id: 'email', label: 'Email', render: () => , }, { id: 'password', label: 'Password', render: () => }, // Only when the operator has Discord configured — otherwise the panel has nothing to // offer and the tab is a promise the server can't keep. The claim also still lives at // /claim, because that URL is Discord's registered redirect and has to keep working. ...(config?.benefitsEnabled ? [ { id: 'benefits', label: 'Claim benefits', render: () => , }, ] : []), ...(isAdmin() ? [ { id: 'maintenance', label: 'Server maintenance', render: () => }, { id: 'coach', label: 'Coach message', render: () => }, ] : []), ] const [active, setActive] = useState(sections[0].id) const current = sections.find((s) => s.id === active) ?? sections[0] return ( <>
    Signed in as
    {account.displayName || account.username}
    @{account.username} · #{account.accountId} · {account.email ?? 'no email set'}
    {current.render()}
    ) } /** * The rooms the signed-in player owns. * * Read-only on purpose: rooms are made and edited in game, and there is nothing here a * player could change that the game doesn't already own. What the web is better at is * the overview — everything you've made in one place, including the rooms you never * published, which are invisible everywhere else. */ function MyRooms({ navigate }: { navigate: Navigate }) { const [rooms, setRooms] = useState(null) const [error, setError] = useState('') useEffect(() => { void fetchMyRooms() .then(setRooms) .catch((e) => setError(e instanceof Error ? e.message : String(e))) }, []) return (

    My rooms

    Every room you've made, newest first — unpublished ones included. Your dorm isn't here: it was made for you rather than by you.

    {error ? (

    {error}

    ) : rooms === null ? (

    Loading…

    ) : rooms.length === 0 ? (

    You haven't made a room yet. Rooms are created in game — clone one you like, or start from a blank one in the Rec Center.

    ) : ( // `where()` THROWS when the config never landed, and a throw in render takes the // page down (see useSlideshow). It can't here: this branch is only reached once // the fetch above resolved, and that fetch went through `where()` itself.
      {rooms.map((room) => ( ))}
    )}
    ) } /** * One room in the list: its thumbnail, what it's called in game (`^Name`), and how it's * doing. The whole row links to the room's own page. * * The thumbnail is asked for at 256px wide — one of the img worker's four allowed sizes, * so it's a cached variant rather than the full-size upload. A room with no image of its * own still answers 200 there (the worker serves its fallback), so there's no broken * frame to handle. */ function RoomCard({ room, imgHost, navigate, }: { room: OwnedRoom imgHost: string navigate: Navigate }) { const created = new Date(room.CreatedAt) return (
  • {/* A real `` (see Link), not a click handler on the row: it has to be reachable by keyboard, and openable in a new tab like any other link. */}
    {/* The caret is how the game writes a room name, so it reads as the thing you type to get there rather than as a title someone wrote. */} ^{room.Name}
    {room.Description &&

    {room.Description}

    }

    {room.Stats.VisitCount.toLocaleString()} visit {room.Stats.VisitCount === 1 ? '' : 's'} · {room.Stats.FavoriteCount.toLocaleString()}{' '} favourite {room.Stats.FavoriteCount === 1 ? '' : 's'} · {room.Stats.CheerCount.toLocaleString()}{' '} cheer{room.Stats.CheerCount === 1 ? '' : 's'} {!Number.isNaN(created.getTime()) && ` · made ${created.toLocaleDateString()}`}

  • ) } /** * Admin-only: send a coach/system message, either to one player by `@username` or to * everyone online. * * The two go to different endpoints because they behave differently, not just in reach: * the broadcast is online-only (nothing holds a message with no addressee), while a named * recipient's message is queued by the hub and delivered whenever they next connect. The * recipient box therefore says which of those the operator is about to do. */ function CoachMessageForm() { const [recipient, setRecipient] = useState('') const [message, setMessage] = useState('') const { pending, error, done, run } = useAction() // The `@` is how the name is written, not part of it — accepted either way, shown back // with it, and sent without it. const handle = recipient.trim().replace(/^@/, '') const toOne = handle !== '' return (

    Coach message

    Send a message from the Coach to one player, or leave the recipient blank to send it to every connected player. A broadcast reaches only who is online right now; a message to one player waits for them if they aren't.

    { e.preventDefault() void run(async () => { const content = message.trim() if (!toOne) { const { sent } = await coachMessageAll(content) setMessage('') return `Sent to ${sent ?? 0} online player${sent === 1 ? '' : 's'}.` } // Resolved before sending: the workers address players by id, and a name that // matches nobody should be a refusal rather than a message into the void. const { queued } = await coachMessage(await accountIdForUsername(handle), content) setMessage('') return queued === true ? `@${handle} is offline — it will arrive when they next connect.` : `Sent to @${handle}.` }) }} >