# Vash Esports
[](https://discord.gg/n3mZgWk)
Automated osu! matches and tournaments. Players queue or sign up, and Vash hosts the osu! lobby over IRC: it invites players, runs the roll, bans, picks, and tiebreakers, starts maps, reads the results, updates ratings, and closes the lobby. Referees can step in at any point, but nothing waits on them.
## Features
- **Live match dashboard.** A second-screen page with a live scoreboard, the map pool board (bans, picks, winners), turn prompts that pop up with a countdown, live scores while a map plays, a result reveal after every map, the lobby chat, and a timeline. Alt-tabbed players get a chime, a blinking tab, and an optional browser notification when it is their turn.
- **Configurable match procedure.** Best of any length, bans per team (also split into rounds, alternate or snake order), pick-or-ban choice for the roll winner, tiebreaker selection (ban down, random, re-roll, opening roll), freemod score multipliers, join windows, action timeouts with auto-roll / random pick / forfeited or random ban, no-show concede or cancel, early-disconnect restarts, and ties for even series.
- **Referee controls.** Pause and resume (the timer keeps its remaining time), force start, abort and replay or return the map to the pool, undo a pick, skip or extend a timer, act for a team, correct a map winner (ratings follow), forfeit, and cancel.
- **Spectating.** Anyone can watch any match live, signed in or not. The **Live** page shows every match being played, updating in place, with what is coming up and recent results.
- **Predictions and pick'ems.** Spectators call the winner (10 points) and the exact score (5 more) until the first map starts, and see the crowd split and the rating odds. Tournaments get a pick'em page per bracket with a leaderboard, and the site leaderboard has a Predictors tab.
- **OpenSkill ratings.** Every rating has an uncertainty. New players settle within a few matches, and in team matches each player moves by how much the system knows about them. Corrections undo a match's rating changes exactly.
- **Stream overlays and an auto-director.** Broadcast channels follow one match, a tournament, or whatever is most exciting, and render a customizable 1920×1080 overlay made of drag-and-drop components. A Windows agent adds live scores from the osu! tourney client through tosu and drives OBS. A container streams a channel to Twitch, YouTube, Facebook, X, Instagram and TikTok at once (or relays OBS), records every broadcast to storage, and plays it live and afterwards on the site with chapters per match. See [streamer/README.md](streamer/README.md).
- **Quick queue** for 1v1 to 4v4, matched by rating, with a pool picked by rating.
- **Tournaments** with registration, qualifiers, groups, single and double elimination, scheduling, and full branding: banner, icon, accent color, the osu! forum post (BBCode), prizes, links, and a player showcase.
- **Test lab** in dev and staging: play or watch full matches against simulated players, in a simulated lobby or a real osu! lobby over IRC with 0, 1 or 2 real sides (bots play inside Vash and never appear in osu!).
- **Several osu! accounts** host lobbies together; capacity is four lobbies per normal account.
- **Discord** result posts and stream announcements.
## How a match runs
```
web actions ─┐
IRC events ──┼─▶ match actor ─▶ reduce(state, action) ─▶ commit to Postgres ─▶ publish snapshot (websocket)
deadlines ───┘ (one per live match, pure, one transaction, └▶ lobby commands
actions run in order) no I/O revision-checked (!mp map, chat, …)
```
- `src/lib/server/osu/procedure.ts` is the whole match procedure as a pure reducer: state + action → new state + lobby effects. Timers are deadlines in the state, so a restart resumes the same decision. It is exhaustively unit tested.
- `src/lib/server/match/runtime.ts` keeps one actor per live match. It turns BanchoBot lines, web requests, and deadlines into actions, commits, publishes, and runs the effects. When IRC misses the finish line, it recovers the result from the osu! API.
- `src/lib/server/osu/irc.ts` is real Bancho IRC, one connection per account, on a small client over Bun's TCP sockets (`irc-client.ts`: login, pings, rate limits, reconnect and rejoin). `src/lib/server/osu/simulator.ts` implements the same lobby interface in-process, with BanchoBot's exact wording, so the same parser and runtime are exercised in tests and dev. `fake-bancho.ts` puts an IRC server in front of the simulator, so tests also play whole matches over real sockets, with split packets and dropped connections.
- `src/lib/server/match/store.ts` loads a match in a handful of queries and commits each change in one transaction, with a revision check.
- `src/lib/match-view.ts` is the snapshot the match page receives on load and on every change. The page never refetches.
- `src/lib/server/broadcast.ts` runs broadcast channels: the director (`src/lib/broadcast/director.ts`) picks each channel's match, and overlays get one payload per change on the `channel:` topic. Streamer agents connect to `/ws/agent`.
The server leans on Bun instead of packages: `Bun.SQL` under Drizzle, `Bun.S3Client` for R2, Bun TCP sockets for IRC, and `bun build --compile` for the streamer agent. One websocket endpoint (`/ws`) carries every live topic: `match:`, `live`, and `channel:`.
Long-lived state (IRC connections, the simulator, actors, timers) lives in `src/lib/server/singleton.ts` and `src/lib/server/services.ts`. A Vite reload replaces services and resumes match actors. **Restart `bun run dev` after changing the IRC client or provider:** those connection objects survive HMR and keep their original methods until a process restart.
For a streaming PC that connects to the existing site, follow the [Windows + WSL setup](streamer/WSL.md).
## Getting started
Prerequisites: [Bun](https://bun.sh) 1.3+, PostgreSQL 16+.
```sh
git clone https://git.stanrunge.dev/stan/esports.git
cd esports
cp .env.example .env # fill in DB_URL, BETTER_AUTH_SECRET, OSU_CLIENT_ID/SECRET at least
bun install
bun run db:migrate # apply scripts/migrations in order
bun run db:seed # dev only: seeded players, teams, pools, a branded tournament
bun run dev # http://127.0.0.1:3102
```
On an empty database, create the base tables first with `bun run db:push`, then run the migrations.
### Testing without osu!
With `BANCHO_MODE=simulator` (the default outside production):
1. Open the dashboard. The **Simulation lab** card starts a 1v1 against a bot, or a bot-vs-bot match from 1v1 to 4v4.
2. On the match page, **Sim lab** controls map speed, network chaos (split lines, delayed scores, a dropped finish line), which teams the bots play for, and each player: join, leave or disconnect, ready, `!roll`, next score, or type anything in the lobby (`!pick NM1`, `!ban HD2`, `!choose ban`).
3. **Play as** copies a 10-minute link that signs a private window in as a seeded player, so you can play both sides yourself. From the terminal: `bun scripts/dev-login.ts dev-player-03`.
Automated tests run the reducer, the parser, the simulator, whole matches through the runtime with fake timers, and whole matches over IRC against the fake Bancho:
```sh
bun run test:unit --run
```
## Scripts
| Command | Description |
| ------------------------ | ------------------------------------------------------- |
| `bun run dev` | Dev server with HMR on 127.0.0.1:3102 |
| `bun run build` | Production build (`SENTRY_AUTH_TOKEN=` to skip uploads) |
| `bun run start` | Production server with the match websocket |
| `bun run check` | Type-check with svelte-check |
| `bun run test:unit` | Unit tests (Vitest) |
| `bun run test:streamer` | Streamer agent tests (bun test) |
| `bun run streamer` | Run the streamer agent from source |
| `bun run streamer:build` | Build `dist/vash-streamer(.exe)` for Linux and Windows |
| `bun run db:migrate` | Apply pending SQL migrations |
| `bun run db:migrate:dev` | Back up vash_dev, then migrate it |
| `bun run db:seed` | Seed the dev database |
| `bun run db:studio` | Drizzle Studio |
## Database changes
Schema lives in `src/lib/server/db/schema.ts`. Every change also gets an idempotent SQL file in `scripts/migrations/` (`0005-…sql`, and so on). `bun scripts/migrate.ts` applies the pending files in order, in one transaction, and records them in `schema_migration`; `--status` lists them. The staging and production deploys take a verified `pg_dump` and then run it. Migrations only add: they never drop or rewrite existing users, teams, pools, or match history.
## Environments
| | dev | staging | prod |
| --------------- | ---------------------------- | ------------------------------------------------- | --------------------------- |
| URL | esports-dev.vash.software | esports-staging.vash.software | esports.vash.software |
| Runs as | `bun run dev` in tmux, :3102 | Docker, /opt/esports-staging, :3101 | Docker, /opt/esports, :3100 |
| Database | vash_dev | vash_staging | vash |
| Lobbies | simulator | simulator (set `BANCHO_MODE=irc` to use real IRC) | osu! IRC |
| Title and badge | `(DEV)` | `(STAGING)` | none |
The environment comes from `APP_ENV`, or from `ORIGIN` when that is unset. Dev and staging sit behind Cloudflare Access and are marked noindex.
## Deployment
Work on `staging` and push. Gitea runs the unit tests, builds `git.stanrunge.dev/stan/esports:`, backs up the staging database, migrates it, and starts the image at `/opt/esports-staging`. After checking staging, run the **Deploy production** action: it backs up and migrates the production database and starts the same image at `/opt/esports`.
Deploys refuse to switch images while `/api/health` reports live matches, unless `FORCE_DEPLOY=1`. Before a deploy, use **Drain before restart** on /admin: no new lobbies open, the queue pauses, and live matches play to the end. After a restart, the runtime rejoins the lobbies it was hosting and continues; a lobby it cannot find pauses its match for a referee.
Environment files are `/etc/esports-staging/staging.env` and `/etc/esports/prod.env`.
## Discord
The bot ("Vash Esports", CesarBot) only posts: results to `DISCORD_MATCH_CHANNEL_ID` with no pings, and stream announcements to `DISCORD_STREAM_CHANNEL_ID`. /admin shows whether it is connected, whether it can post in each channel, an **Add bot to server** link, and a **Send test message** button. Several environments can share the token; give dev and staging a private channel.
## Contributing
Conventional commits are enforced by git hooks (`feat:`, `fix:`, `chore:`, `refactor:`, `docs:`). Pre-commit runs Prettier and ESLint on staged files; pre-push runs the type check and unit tests.
## License
All rights reserved. This source code is publicly available for reference and educational purposes. See the project maintainers for contribution or usage inquiries.