A clean, modern web frontend for bancho.py, built with React + TypeScript. It's an SPA driven entirely by bancho.py's v2 api — no database access, no server-side rendering.
- Registration & sign in — website account creation (backed by bancho.py's v2 accounts/sessions apis) with optional captcha (reCAPTCHA, hCaptcha or Turnstile); important since bancho.py disables in-game registration by default
- Home — server stats (online/registered players) & "how to connect" guide
- Leaderboards — per-mode rankings (vanilla/relax/autopilot) with performance/score/accuracy/playcount sorts, country filtering, avatars, clan tags & pagination
- Player profiles — per-mode stats with global/country ranks, level bar, grade counts, userpage, live "now playing" status, best/recent scores and most played maps with beatmap thumbnails
- Beatmap pages — map info with cover art, difficulty stats, and per-mode leaderboards with grades, mods & FC markers
- Clans — clan listing & clan pages with member roles
- Player search — debounced live search in the navbar
- Settings — avatar upload for signed-in players (requires bancho.py
with
PUT /v2/players/{id}/avatar)
| Layer | Choice |
|---|---|
| Build | Vite 5 |
| UI | React 18 + TypeScript (strict) |
| Styling | Tailwind CSS 3 |
| Routing | React Router 6 |
| Data | TanStack Query 5 (caching, retries, refetch) |
You'll need Node.js 18+ and a bancho.py instance to talk to.
npm install
npm run dev # http://localhost:5173bancho.py routes its api by Host header (api.{DOMAIN}), so the dev server
proxies /api/* to your local instance with the Host header rewritten.
Configure via .env (see .env.example):
BANCHO_API_TARGET=http://127.0.0.1:10000 # where bancho.py listens
BANCHO_DOMAIN=cmyui.xyz # builds the api.{DOMAIN} Host headerOther scripts:
npm run build # typecheck + production build into dist/
npm run typecheck # typecheck only
npm run preview # serve the production build locally
npm run storybook # component workshop at http://localhost:6006
npm run build-storybook # static storybook buildThe brand theme (near-black neutral palette + crimson accent) lives in
tailwind.config.js as Tailwind color tokens (canvas, surface,
surface-2/3, line, muted, accent, accent-2). Shared primitives
in src/components/ui/ keep spacing & shape consistent:
Card— the standard surface container (defaultpx-5 py-4padding, or edge-to-edge for tables & media)PageHeader— page title + description blockPillTabs— the segmented pill-tab control (mode switchers, sorts, tabs)
Run Storybook to iterate on components and the theme in isolation —
the Design/Theme story documents the palette, type scale, spacing
conventions and radii.
All VITE_* variables are baked in at build time (see .env.example):
| Variable | Purpose | Default |
|---|---|---|
VITE_APP_NAME |
Server name shown in navbar & titles | bancho.py |
VITE_BANCHO_DOMAIN |
Domain in "how to connect" instructions | cmyui.xyz |
VITE_API_BASE_URL |
Base url for api requests | /api |
VITE_AVATARS_BASE_URL |
Base url for avatars (a.{DOMAIN}) |
https://a.cmyui.xyz |
VITE_DISCORD_INVITE |
Discord invite shown in the footer (keep in sync with bancho.py's DISCORD_INVITE) |
empty = hidden |
Build the static bundle and serve dist/ from any web server. The SPA needs:
- History fallback — unknown paths must serve
index.html - An api origin — keep the default
/apiand proxy it to bancho.py (recommended): sessions ride an http-only,SameSite=Laxcookie, which Just Works when everything stays same-origin. (Splitting the api onto another origin requires CORS withAccess-Control-Allow-Credentialsand an explicitAccess-Control-Allow-Origin— avoid unless you need it.)
# http context: rate limits for the authentication endpoints.
# registration is limited strictly to prevent automated account
# creation; logins are limited to slow down credential stuffing.
limit_req_zone $binary_remote_addr zone=bancho_registrations:10m rate=1r/m;
limit_req_zone $binary_remote_addr zone=bancho_logins:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=bancho_avatar_uploads:10m rate=10r/m;
server {
listen 443 ssl;
server_name osu.example.com;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH:@SECLEVEL=1";
# bancho.py resolves client ips from these headers (used for
# geolocation during registration & for rate limiting fairness)
proxy_set_header Host api.example.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# ---- security headers ----
# csp: allowlist of what pages may load/execute; limits the blast
# radius of xss. keep exactly ONE Content-Security-Policy line —
# the one matching your captcha provider — and replace
# a.example.com with your avatar host. roll out with
# Content-Security-Policy-Report-Only first if you've customized
# the frontend.
# no captcha:
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://a.example.com https://assets.ppy.sh https://b.ppy.sh https://flagcdn.com; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
# cloudflare turnstile:
#add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://a.example.com https://assets.ppy.sh https://b.ppy.sh https://flagcdn.com; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
# google recaptcha:
#add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; frame-src https://www.google.com/recaptcha/; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://a.example.com https://assets.ppy.sh https://b.ppy.sh https://flagcdn.com; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
# hcaptcha:
#add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://hcaptcha.com https://*.hcaptcha.com; frame-src https://hcaptcha.com https://*.hcaptcha.com; style-src 'self' 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com; img-src 'self' data: https://a.example.com https://assets.ppy.sh https://b.ppy.sh https://flagcdn.com; connect-src 'self' https://hcaptcha.com https://*.hcaptcha.com; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
root /srv/bancho-web/dist;
try_files $uri /index.html;
}
location = /api/v2/accounts {
limit_req zone=bancho_registrations burst=3 nodelay;
limit_req_status 429;
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:10000;
}
location = /api/v2/sessions {
limit_req zone=bancho_logins burst=5 nodelay;
limit_req_status 429;
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:10000;
}
# avatar uploads are up to 2MB (nginx's default body limit is 1MB)
location ~ ^/api/v2/players/\d+/avatar$ {
limit_req zone=bancho_avatar_uploads burst=5 nodelay;
limit_req_status 429;
client_max_body_size 4m;
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:10000;
}
location /api/ {
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:10000;
}
}src/
├── lib/
│ ├── api/ # typed api client (http envelope, types, endpoints)
│ ├── gamemodes.ts # mode ids <-> vanilla/relax/autopilot helpers
│ ├── mods.ts # mod bitmask -> acronyms
│ ├── grades.ts # grade display (XH/X/SH/S/A/...)
│ ├── rankedStatus.ts
│ ├── format.ts # numbers, accuracy, playtime, relative time
│ └── assets.ts # avatar/cover/flag url builders
├── components/ # shared components (Navbar, ModeSwitcher, badges, ...)
└── pages/ # one component per route
To consume a new api endpoint: add its response shape to lib/api/types.ts,
a fetcher to lib/api/client.ts, then use it with useQuery in a component.
- Avatars are served by your bancho.py deployment (
a.{DOMAIN}), with a built-in placeholder fallback. - Beatmap covers/thumbnails load from osu!'s public cdn
(
assets.ppy.sh/b.ppy.sh) by set id. - Country flags load from flagcdn.com.