
MTB Fantasy
A fantasy league platform for downhill mountain bike racing
The Brief
I'm a downhill mountain bike fan and I wanted a fantasy league that matched how I thought the game should work. The existing fantasy MTB options out there had mechanics I found frustrating — awkward team-building rules, unclear scoring, or clunky interfaces that made the whole experience feel like an afterthought. So I decided to build my own from scratch, with the game mechanics and UX I actually wanted as a player.
My Role
Sole developer and product owner. I designed the game mechanics, built the full-stack application, manage the infrastructure, and handle the ongoing data pipeline that imports real-world race results to drive the scoring.
Technical Approach
MTB Fantasy is a full-stack application with a React frontend built with Vite and an Express backend, both written in TypeScript. The server bundles two logical services under a single process: a game mechanics service (team building, scoring, leaderboards) and a rider data service (rider profiles, race metadata, and results). This keeps deployment simple while maintaining clean separation of concerns internally.
The game mechanics were the core design challenge. I built a budget-based team builder where players pick a roster of six starters and a bench rider within a salary cap, with enforced gender slot constraints (four men, two women) reflecting the real-world race categories. The UI needed to make these constraints immediately understandable — I implemented live budget progress bars with colour states, explicit slot indicators, and inline explanations when a rider can't be added (over budget, wrong gender balance, roster full). On mobile, where over half the users browse, the team builder uses a single-column layout with a sticky action bar and collapsible rider search.
For the data pipeline, I built admin tools that import rider data from UCI sources and allow race results to be seeded from official UCI results pages. When results are posted, the scoring engine automatically calculates points based on finishing positions and updates the leaderboard. This means the platform can run a full season with minimal manual intervention once races are configured.
The database is PostgreSQL accessed through Drizzle ORM, with an idempotent schema bootstrap that creates tables at startup — this keeps both Docker and fresh local environments working without manual migration steps. Authentication uses OIDC with session management, and the whole application is containerised for production with a GitHub Actions CI/CD pipeline that builds, tests, and deploys via SSH to the production server.
I also integrated Google Analytics (GA4) with custom event tracking for key user actions — team creation, joker card usage, friend requests — to understand how players actually use the platform.
Key Features
- Budget-constrained team builder — roster assembly with live budget tracking, gender slot enforcement, and contextual validation that explains why a rider can't be added
- Automated scoring pipeline — UCI race results import with automatic point calculation and leaderboard recalculation
- Mobile-first responsive design — single-column layout on mobile with sticky headers, bottom action bars, and touch-optimised controls for over 50% handheld users
- Rider data service — dedicated service handling rider CRUD, race metadata, and results, fed by UCI data import tools
- Season management — race schedule with countdown timers, team lock deadlines, and bench auto-substitution rules for DNS riders
- CI/CD pipeline — GitHub Actions building, testing, and deploying via SSH with Docker containerisation
Tech Stack
React · Vite · Express · TypeScript · PostgreSQL · Drizzle ORM · Tailwind CSS · React Query · Wouter · OIDC · Docker · GitHub Actions CI/CD · GA4