From 0f726b35dac321f646dc24757ab0aeb4b24cb05e Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 25 Aug 2026 19:33:11 -0400 Subject: [PATCH] [www] maybe add a fun little globe of players --- apps/auth/src/auth.app.ts | 16 +- apps/match/src/match.app.ts | 13 + apps/www/package.json | 1 + apps/www/src/client/App.tsx | 404 +++++++++++++++++++++- apps/www/src/client/styles.css | 126 +++++++ apps/www/src/privacy.ts | 4 +- apps/www/src/test/integration/api.test.ts | 69 +++- apps/www/src/www.app.ts | 23 +- apps/www/wrangler.jsonc | 13 +- packages/domain/src/presence-db.ts | 122 +++++++ pnpm-lock.yaml | 8 + 11 files changed, 787 insertions(+), 12 deletions(-) diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index e479c19..8c73fdf 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -13,6 +13,7 @@ import { getPasswordHash, getRoomById, hashPassword, + presenceGeoFromCf, RoomInstanceType, setLastLoginTime, setLoginContext, @@ -62,7 +63,7 @@ import { consumeRefreshToken, issueRefreshToken } from './refresh-db' import { verifySteamTicket } from './steam-ticket' import type { Context } from 'hono' -import type { Account } from '@repo/domain' +import type { Account, PresenceGeo } from '@repo/domain' import type { App } from './context' import type { PlatformLink } from './platform-db' @@ -154,7 +155,8 @@ const ORIENTATION_INSTANCE_ID = -2 async function placeNewPlayerInOrientation( env: App['Bindings'], accountId: number, - deviceClass: number + deviceClass: number, + geo: PresenceGeo | null ): Promise { // getRoomById hydrates the room's SubRooms from the subroom table (they no longer // live in the room blob), so the Orientation scene resolves the same way match does. @@ -195,6 +197,9 @@ async function placeNewPlayerInOrientation( vrMovementMode: 1, platform: 0, appVersion: GAME_VERSION, + // The first pin a new player gets — the sign-in that made the account is the only + // request we've seen from them, and match's heartbeat refreshes it from there. + geo: geo ?? undefined, }) } @@ -893,7 +898,12 @@ const app = new Hono() await setPasswordHash(c.env.DB, account.accountId, await hashPassword(password)) } // Place the new player in Orientation (they don't explicitly matchmake into it). - await placeNewPlayerInOrientation(c.env, account.accountId, deviceClass) + await placeNewPlayerInOrientation( + c.env, + account.accountId, + deviceClass, + presenceGeoFromCf(c.req.raw.cf) + ) } else if (grantType === 'refresh_token') { const presented = typeof body.refresh_token === 'string' ? body.refresh_token : '' const refreshed = presented ? await consumeRefreshToken(c.env.DB, presented) : null diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index a63e1e0..e0fe756 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -32,6 +32,7 @@ import { MatchmakingErrorCode, MessageType, MOST_ACTIVE_CLUBHOUSE_LIMIT, + presenceGeoFromCf, recordRoomVisit, refreshInstanceFullness, RoomInstanceType, @@ -523,6 +524,10 @@ async function enterRoom(c: Context, id: number, roomInstance: RoomInstance // Carry the session lock recorded at login forward, so matchmake doesn't wipe it // and the heartbeat can keep verifying against it. loginLock: prev?.loginLock, + // Where this matchmake came from, coarsened at the edge (see presenceGeoFromCf). + // Falls back to the row's last known cell when the request carried no geolocation, + // so a player only leaves the globe when they leave the server. + geo: presenceGeoFromCf(c.req.raw.cf) ?? prev?.geo, }) // Count the visit. Every matchmake route funnels through here with the instance the @@ -1285,6 +1290,7 @@ const app = new Hono() const presence = await getPresence(c.env.DB, id) if (presence) { presence.loginLock = loginLock + presence.geo = presenceGeoFromCf(c.req.raw.cf) ?? presence.geo await setPresence(c.env.DB, presence) } else { // No live presence yet — seed a lobby row (roomInstance null) holding the @@ -1299,6 +1305,7 @@ const app = new Hono() platform: account?.platform ?? 0, appVersion: (await callerVersion(c)) ?? GAME_VERSION, loginLock, + geo: presenceGeoFromCf(c.req.raw.cf) ?? undefined, }) } } @@ -1574,6 +1581,11 @@ const app = new Hono() const versionChanged = version !== null && presence.appVersion !== version if (versionChanged) presence.appVersion = version + // The heartbeat is the only call a parked player keeps making, so it's what + // keeps their location current — someone who moves house or switches to mobile + // data re-pins on the next refresh instead of at their next matchmake. + presence.geo = presenceGeoFromCf(c.req.raw.cf) ?? presence.geo + // Otherwise the heartbeat's only side effect is refreshing the TTL, and only // once it's within PRESENCE_REFRESH_THRESHOLD (s) of lapsing — a still player is // refreshed periodically rather than re-written on every beat. `expiresAt` is @@ -1611,6 +1623,7 @@ const app = new Hono() const presence = await getPresence(c.env.DB, id) if (presence && !Number.isNaN(sv)) { presence.statusVisibility = sv + presence.geo = presenceGeoFromCf(c.req.raw.cf) ?? presence.geo await setPresence(c.env.DB, presence) } } diff --git a/apps/www/package.json b/apps/www/package.json index 6648e6e..f1206ee 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -19,6 +19,7 @@ "@repo/domain": "workspace:*", "@repo/hono-helpers": "workspace:*", "@scalar/api-reference": "1.63.0", + "cobe": "2.0.1", "hono": "4.12.27", "react": "19.2.7", "react-dom": "19.2.7", diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index eddf11b..5ce049e 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -1,3 +1,4 @@ +import createGlobe from 'cobe' import { useCallback, useEffect, useRef, useState } from 'react' import { Accessibility } from '@repo/domain/src/enums' @@ -13,6 +14,7 @@ import { SOURCE_REPO, } from '../links' +import type { COBEOptions, Globe, Marker } from 'cobe' import type { ReactNode } from 'react' /** @@ -777,6 +779,7 @@ function HomePage({
+
) @@ -943,6 +946,399 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) { ) } +/* ---- Who's playing, and where ------------------------------------------- */ + +/** One pin from `/server-status/locations`: a grid cell and how many players are in it. */ +interface Pin { + lat: number + lon: number + /** ISO 3166-1 alpha-2, or `XX` when the edge couldn't name a country. */ + country: string + players: number +} + +/** + * The whole answer from `/server-status/locations`. `players` is everyone online and + * `located` only those with a pin — a player the edge couldn't place is counted in the + * first and missing from the second, so the section can say so rather than quietly + * showing a smaller number than the rest of the page. + */ +interface WorldPresence { + players: number + located: number + pins: Pin[] +} + +/** How often the globe re-asks who's online. */ +const GLOBE_POLL_MS = 30_000 + +/** + * Poll `www` for where the online players are. `presence === null` means the first + * answer hasn't landed yet. + * + * Same-origin, so unlike the photo feed this doesn't wait on the config — `www` serves + * it itself. Polling stops while the tab is hidden and asks again on the way back, so a + * page left open in a background tab overnight isn't a few thousand requests. A failed + * poll keeps the last good answer on screen: a globe that empties out because one + * request timed out reads as "everyone left", which is worse than being 30s stale. + */ +function useWorldPresence(): { presence: WorldPresence | null; error: string } { + const [presence, setPresence] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + let live = true + let timer: ReturnType | undefined + + // Function declarations, not consts: `schedule` names `poll` and `poll` names + // `schedule`, and hoisting is what lets them be written in reading order. + function schedule() { + clearTimeout(timer) + if (!live || document.hidden) return + timer = setTimeout(poll, GLOBE_POLL_MS) + } + + function poll() { + call('/server-status/locations') + .then((next) => { + if (!live) return + setPresence(next) + setError('') + }) + .catch((e: unknown) => { + if (live) setError(e instanceof Error ? e.message : String(e)) + }) + .finally(schedule) + } + + // Coming back to a tab that was away: answer now, rather than after a timer that + // was deliberately never armed while it was hidden. + const onVisibility = () => { + if (!document.hidden) poll() + } + document.addEventListener('visibilitychange', onVisibility) + poll() + + return () => { + live = false + clearTimeout(timer) + document.removeEventListener('visibilitychange', onVisibility) + } + }, []) + + return { presence, error } +} + +/** Radians the globe turns per frame when nobody is steering it. */ +const GLOBE_SPIN_PER_FRAME = 0.0028 +/** Radians per pixel of drag — cobe's own demo figure, and it feels right. */ +const GLOBE_DRAG_PER_PX = 1 / 200 +/** + * Where the spin starts. Arbitrary — the globe turns continuously, so this only decides + * which face the first second shows; nudge it if that first face keeps landing on ocean. + */ +const GLOBE_START_PHI = 4.1 + +/** A pin's dot size, from the smallest that reads to one that still isn't a blob. */ +const PIN_MIN_SIZE = 0.028 +const PIN_MAX_SIZE = 0.075 + +/** + * Pins → cobe markers. Sized by head-count against the busiest cell so a crowd reads as + * one, on a square root because area is what the eye compares: scaling the radius + * linearly makes four players look sixteen times the size of one. + */ +function pinMarkers(pins: Pin[]): Marker[] { + const busiest = pins.reduce((n, pin) => Math.max(n, pin.players), 1) + return pins.map((pin) => ({ + location: [pin.lat, pin.lon], + size: PIN_MIN_SIZE + (PIN_MAX_SIZE - PIN_MIN_SIZE) * Math.sqrt(pin.players / busiest), + })) +} + +/** cobe wants colours as 0–1 RGB triples, so the palette is repeated here in its terms. */ +const GLOBE_THEME = { + // Warm dark: the surface the screenshots are lit against (--surface-hi / --line). + dark: { + dark: 1, + baseColor: [0.21, 0.17, 0.13], + glowColor: [0.31, 0.24, 0.17], + markerColor: [1, 0.44, 0.004], // --accent #FE7101 + mapBrightness: 5.4, + }, + light: { + dark: 0, + baseColor: [0.86, 0.84, 0.81], + glowColor: [1, 0.99, 0.97], + markerColor: [0.88, 0.37, 0], // --accent #E05F00 + mapBrightness: 2.2, + }, +} as const + +/** + * The globe itself: a dotted earth with a pin per populated cell, spinning slowly and + * draggable. + * + * Drawn by `cobe`, a ~13KB WebGL globe that takes markers as plain lat/lon and does the + * projection — no three.js, no map tiles and no network of its own, which is what makes + * it affordable on a page whose point is the hero photo above it. + * + * Purely the picture: every number it shows lives in the list beside it too, so a + * browser with no WebGL (or a reader who isn't looking at pixels) loses nothing. That's + * also why the canvas is aria-hidden rather than labelled. + */ +function PlayerGlobe({ pins }: { pins: Pin[] }) { + const canvas = useRef(null) + const box = useRef(null) + const [failed, setFailed] = useState(false) + const [theme, setTheme] = useState<'dark' | 'light'>(() => + typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: light)').matches + ? 'light' + : 'dark' + ) + + // New markers are handed to the running globe rather than rebuilding it, so a poll + // doesn't restart the spin. The flag is what keeps the buffer upload to the frames + // where something actually changed instead of all sixty a second. + const markers = useRef(pinMarkers(pins)) + const markersChanged = useRef(true) + useEffect(() => { + markers.current = pinMarkers(pins) + markersChanged.current = true + }, [pins]) + + // How far the pointer has dragged the globe, in radians. A ref, not state: it changes + // on every pointermove and the animation loop is the only thing that reads it, so + // re-rendering React for it would be sixty wasted renders a second. + const nudge = useRef(0) + const dragFrom = useRef(null) + + // The site follows the system theme with no toggle of its own (see styles.css), so + // this listens for the same switch the CSS does and rebuilds the globe in the other + // palette — cobe takes its colours at creation. + useEffect(() => { + if (typeof matchMedia !== 'function') return + const query = matchMedia('(prefers-color-scheme: light)') + const onChange = () => setTheme(query.matches ? 'light' : 'dark') + query.addEventListener('change', onChange) + return () => query.removeEventListener('change', onChange) + }, []) + + useEffect(() => { + const surface = canvas.current + const frame = box.current + if (!surface || !frame) return + + let globe: Globe | null = null + let request = 0 + let phi = GLOBE_START_PHI + let size = 0 + let sizeChanged = false + + // The auto-spin is decoration, and a globe that never stops moving is exactly what + // this setting is for. The pins (and the drag) still work. + const still = + typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches + + function draw() { + if (!globe) return + // Only the parts that changed: cobe reallocates the drawing buffer whenever it's + // handed a width, which would clear the canvas on every single frame. + const next: Partial = {} + if (sizeChanged) { + next.width = size + next.height = size + sizeChanged = false + } + if (markersChanged.current) { + next.markers = markers.current + markersChanged.current = false + } + // A hand on the globe stops the drift, and it picks back up from wherever it was + // let go rather than snapping to where it would have got to. + if (!still && dragFrom.current === null) phi += GLOBE_SPIN_PER_FRAME + next.phi = phi + nudge.current + globe.update(next) + request = requestAnimationFrame(draw) + } + + function begin() { + // Nothing to draw into yet — the observer calls back again once there is. + if (globe || size === 0) return + try { + globe = createGlobe(surface!, { + devicePixelRatio: Math.min(devicePixelRatio || 1, 2), + width: size, + height: size, + phi, + // Tilted a little north: most of the pins are, and a globe seen dead-on from + // the equator reads as a flat circle. + theta: 0.22, + diffuse: 1.2, + mapSamples: 14000, + markers: markers.current, + ...GLOBE_THEME[theme], + // The palette is readonly (`as const`), which the option type isn't. + baseColor: [...GLOBE_THEME[theme].baseColor], + glowColor: [...GLOBE_THEME[theme].glowColor], + markerColor: [...GLOBE_THEME[theme].markerColor], + }) + } catch { + // No WebGL, or a context the browser refused to give. The list beside this + // carries every number the globe was going to show, so drop the canvas and + // leave the section otherwise intact. + setFailed(true) + return + } + markersChanged.current = false + request = requestAnimationFrame(draw) + } + + // Square, and sized from the layout rather than from a constant, so the globe fills + // its column at every breakpoint instead of being letterboxed on one of them. + const observer = new ResizeObserver(() => { + const width = Math.round(frame.clientWidth) + if (width === 0 || width === size) return + size = width + sizeChanged = true + begin() + }) + observer.observe(frame) + + return () => { + observer.disconnect() + cancelAnimationFrame(request) + globe?.destroy() + } + }, [theme]) + + if (failed) return null + + return ( +
+
+ ) +} + +/** Country codes to names, once — building an Intl formatter per row is not free. */ +const countryNames = + typeof Intl.DisplayNames === 'function' ? new Intl.DisplayNames(['en'], { type: 'region' }) : null + +/** A country code as something to read. `XX` is the edge declining to name one. */ +function countryName(code: string): string { + if (code === 'XX') return 'Somewhere else' + return countryNames?.of(code) ?? code +} + +/** Players per country, busiest first — the pins in a cell-by-cell list's stead. */ +function byCountry(pins: Pin[]): Array<{ country: string; players: number }> { + const totals = new Map() + for (const pin of pins) totals.set(pin.country, (totals.get(pin.country) ?? 0) + pin.players) + return [...totals] + .map(([country, players]) => ({ country, players })) + .sort( + (a, b) => + b.players - a.players || countryName(a.country).localeCompare(countryName(b.country)) + ) +} + +/** How many countries to name before the rest become "and n more". */ +const COUNTRY_ROWS = 6 + +/** + * "People are playing this right now, from all over" — the claim the rest of the page + * makes in words, shown instead. + * + * The globe is the illustration and the list is the content: everything the pins say is + * written out beside them, which is what lets the canvas be decorative (and lets the + * whole thing degrade to a list where WebGL isn't available). + */ +function PlayersWorldwide() { + const { presence, error } = useWorldPresence() + const pins = presence?.pins ?? [] + const countries = byCountry(pins) + + return ( +
+
+

+ Somebody is playing right now +

+ {presence === null ? ( +

+ {error ? "Can't reach the servers to ask who's online." : 'Counting who’s on…'} +

+ ) : presence.located === 0 ? ( +

+ {presence.players > 0 + ? `${presence.players.toLocaleString()} online — nobody placed on the map yet.` + : 'Nobody is online this second. The servers are up; be the first one on.'} +

+ ) : ( + <> +

+ {presence.located.toLocaleString()}{' '} + {presence.located === 1 ? 'player' : 'players'} in {countries.length}{' '} + {countries.length === 1 ? 'country' : 'countries'}, right now. +

+
    + {countries.slice(0, COUNTRY_ROWS).map((row) => ( +
  • + {countryName(row.country)} + {row.players.toLocaleString()} +
  • + ))} + {countries.length > COUNTRY_ROWS && ( +
  • + and {countries.length - COUNTRY_ROWS} more +
  • + )} +
+ {/* The head-count and the map can disagree — say which, rather than + letting the smaller number look like the answer. */} + {presence.players > presence.located && ( +

+ {presence.players - presence.located} more online from somewhere we couldn't + place. +

+ )} + + )} + {/* Not a disclaimer in the footer: people see a map of themselves and want to + know how precise it is, so it says so where they're looking. */} +

+ Pins are rounded to about 55km before anyone stores them, and nobody's address is + kept — see the privacy policy. +

+
+ +
+ ) +} + /** * 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 @@ -1382,10 +1778,10 @@ function BlobUpload({ 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. + 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.