I built this because I wanted full control over how TFT match data is collected, normalized, queried, and visualized.
Public tools are good at broad dashboards. They are not good at letting me ask very specific questions over a local dataset, re-run the pipeline when the meta shifts, and change the query model without waiting on someone else's product decisions.
This repo is split into three parts:
tft-miner: Riot API ingestion and relational data processingtft-backend: Laravel API over the mined datasettft-frontend: React UI for profile views, leaderboard, explorer, and planner work
I keep the pipeline split into three background processes because they solve different problems and they fail in different ways.
crawler.py
- Calls Riot endpoints for ranked players and match history.
- Pulls raw match IDs and writes them into
matches_queue. - Treats collection as an ingestion problem, not a stats problem.
miner.py
- Consumes queued match IDs.
- Fetches full match payloads.
- Normalizes the response into relational tables such as
matches,match_participants,participant_traits, andparticipant_units. - Does the heavy lifting once so the API does not have to rebuild aggregates on every request.
update_ranks.py
- Syncs current LP and challenger entry data into
player_league_entries. - Keeps rank data fresh independently of the slower match-ingestion cycle.
I do not run this work inside normal web requests for a reason. Riot calls are rate-limited, match payloads are large, and the transformation logic is not cheap. If I tried to fetch, normalize, and aggregate that data on-demand during HTTP requests, I would get slower APIs, harder failure recovery, and no clean way to reprocess historical data after schema or balance changes.
This split gives me three useful properties:
- ingestion can be retried independently
- mining can be tuned for throughput and idempotency
- rank sync can run on its own cadence without blocking analytics work
I chose @dnd-kit/core instead of standard HTML5 drag-and-drop because I needed predictable pointer-level control inside a custom board layout.
The HTML5 API is fine for file drops and simple list reordering. It is not a good fit when the drag surface is a stylized game board with custom overlays, mobile support concerns, and drop logic that needs to stay under my control. @dnd-kit/core gives me that control directly through sensors, overlays, and collision strategies.
The hard part was not the drag token itself. The hard part was the mismatch between the geometry I render and the geometry the browser actually uses for hit testing. The board is presented as interlocking CSS hexagons, but the DOM still reasons about rectangles. That means I had to tune board spacing, row offsets, and collision behavior so drag intent feels correct even though the visible board is hexagonal and the pointer math underneath is not.
On state management, I took the pragmatic route. The current planner keeps a single canonical board state at the page boundary and passes only the minimum data needed into board and pool components. That avoids spraying transient drag state through the whole tree. If the planner grows into persisted comps, route-level sharing, or collaborative state, Zustand is the next extraction I would make. Right now I do not need a global store just to feel architecturally pure.
One recent bottleneck was the Explorer.
The UI started feeling laggy, and the backend responses were drifting into the kind of latency that makes a filter-heavy tool annoying to use. The underlying issue was not one bug. It was a stack of smaller problems.
On the frontend, I had let too much behavior accumulate inside large React components. I broke those views down so render boundaries were clearer and easier to reason about. I also fixed a silent debounce problem where effect timing and request state could interact badly enough to cause redundant fetches and feedback loops. The current explorer flow is explicit about request timing and abort behavior instead of hoping React timing will save it.
On the backend, I stopped pretending generic indexes were enough for compound analytics filters. Explorer queries are built around combinations of participant, unit, trait, and item predicates, so I added the indexes the workload actually needed. That includes composite B-Tree indexes such as:
participant_units(character_id, item_1)participant_units(character_id, item_2)participant_units(character_id, item_3)participant_units(participant_id, character_id)participant_traits(name, tier_current, participant_id)
That changed the query profile materially. Requests that were taking seconds under filter-heavy explorer workloads dropped into millisecond territory once the planner, filter semantics, and index design were aligned with the actual access patterns.
- I use one root
.envfile for the entire stack. - I do not keep separate
.envfiles inside backend, frontend, or miner. - Docker Compose injects the required values into each service.
- Docker Desktop with Compose enabled
- A valid
RIOT_API_KEYin the root.env
The repo already contains the single root .env. Start by reviewing it and replacing the Riot key if needed, then build the stack.
docker compose up -d --build
docker compose exec backend composer install
docker compose exec frontend npm install
docker compose exec backend php artisan migrateWhat this does:
- builds the MySQL, Laravel, React, and Python images
- starts MySQL and imports
init.sql.gzon first boot - starts Laravel on
http://localhost:8000 - starts Vite on
http://localhost:3000 - applies database migrations against the MySQL container
If the images are already built, this is enough:
docker compose up -dThat starts:
- MySQL on
localhost:3307 - Laravel on
localhost:8000 - the frontend Vite dev server on
localhost:3000
If I stop the stack for the day:
docker compose downIf I need a clean rebuild after dependency or Dockerfile changes:
docker compose up -d --build --force-recreateThe default miner service runs update_ranks.py. I keep crawler and miner worker as explicit one-shot jobs so I can trigger them manually when I want to refresh data.
Run the crawler:
docker compose --profile jobs run --rm crawlerRun the miner:
docker compose --profile jobs run --rm miner-workerRun the rank updater:
docker compose run --rm minerIf I want to run the full pipeline in sequence:
docker compose --profile jobs run --rm crawler
docker compose --profile jobs run --rm miner-worker
docker compose run --rm minerOpen a shell in the backend container:
docker compose exec backend shOpen a shell in the frontend container:
docker compose exec frontend shOpen a shell in the Python image:
docker compose run --rm miner shTail logs:
docker compose logs -f backend
docker compose logs -f frontend
docker compose logs -f dbTilt runs the same Compose services, but it syncs code changes into the running containers instead of rebuilding images. The plain Compose setup above still works as before.
Tilt is a single binary. The official install script puts tilt.exe in $HOME\bin:
iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/tilt-dev/tilt/master/scripts/install.ps1'))Or download tilt.<version>.windows.x86_64.zip from the releases page and extract tilt.exe into $HOME\bin. Either way, add that folder to the user PATH once and open a new terminal:
[Environment]::SetEnvironmentVariable('Path', [Environment]::GetEnvironmentVariable('Path', 'User') + ";$HOME\bin", 'User')
tilt versionFrom the repo root:
tilt upThe Tilt UI is at http://localhost:10350 (press space in the terminal to open it). tilt up starts db, backend and frontend. Nothing else runs until I click it. tilt ci does the same startup, waits until the three services are healthy, exits, and leaves them running.
Tilt uses the same Compose project (tft-analytics-stack), containers and db_data volume as plain Compose, and fills in values from the root .env the same way. It loads only docker-compose.yml, not docker-compose.override.yml, so the frontend bind mounts are replaced by Tilt's sync.
start.sh still runs php artisan migrate --force whenever the backend container starts, under Tilt too: on the first tilt up, after a backend image rebuild, and when switching between Tilt and plain Compose recreates the container.
| Resource | Group | What it does |
|---|---|---|
db |
infra | MySQL on localhost:3307 |
backend |
app | Laravel on http://localhost:8000 |
frontend |
app | Vite on http://localhost:3000 |
miner |
jobs | Runs update_ranks.py once |
crawler |
jobs | Runs crawler.py once |
miner-worker |
jobs | Runs miner.py, which loops until stopped |
migrate |
tools | php artisan migrate --force in the backend container |
backend-tests |
tools | phpunit on the host |
larastan |
tools | phpstan (Larastan) on the host |
frontend-tests |
tools | vitest on the host |
frontend-lint |
tools | ESLint on the host |
Jobs and tools never run on tilt up or on file changes. The trigger button in the UI runs them, or from a terminal: tilt trigger backend-tests. Triggering a job removes its old container, rebuilds the image from the current tft-miner code (cached layers keep that quick) and starts it. The jobs call the Riot API with RIOT_API_KEY and write to the database. To stop miner-worker:
docker compose --profile jobs stop miner-workerTriggering backend or frontend forces a full image rebuild and a fresh container, which is the reset button if a live update ever leaves a container in a bad state.
| Change | What happens |
|---|---|
Backend code (app/, routes/, config/, ...) |
Synced into /var/www. The next request uses it within about 2-3 seconds, because opcache rechecks files every 2 seconds. |
tft-backend/composer.json or composer.lock |
Synced, then composer install runs in the container |
tft-backend/Dockerfile, docker/ or .dockerignore |
Full image rebuild. The container is recreated if the image changed, which reruns start.sh. |
Frontend src/, public/, index.html and config files |
Synced into /app. Vite HMR picks them up, or Vite restarts itself for vite.config.ts. |
tft-frontend/package.json or package-lock.json |
Synced, then npm ci runs in the container and Vite restarts |
tft-frontend/Dockerfile or .dockerignore |
Full image rebuild |
tft-miner/ |
Nothing until I trigger a job |
docker-compose.yml, .env or Tiltfile |
Tilt reloads its config and redeploys services whose config changed |
Everything in a service's .dockerignore (host vendor and node_modules, logs, framework caches, build output) is never synced or copied into images.
Ctrl+Cin thetilt upterminal stops Tilt and leaves the containers running.docker compose stopthen stops the containers. Containers anddb_datastay.tilt downremoves the containers and the network. Named volumes, includingdb_data, are kept. Never add--delete-volumes: it deletesdb_data.
Tilt always runs Compose with --remove-orphans, which deletes any container in this Compose project that docker-compose.yml does not define. The Tiltfile refuses to start while such a container exists and names it.
Both use the same containers, and Tilt's builds also update the image tags plain Compose uses. Switching recreates the frontend (bind mounts versus sync), and the backend if its image changed. Backend code that Tilt synced only lives in the running container, so after switching back to plain Compose I rebuild the backend when its code changed:
docker compose up -d --build backendGET /api/leaderboardGET /api/player/{region}/{gameName}/{tagline}POST /api/explorerGET /api/matchesGET /api/units/stats
- Riot API keys expire. If the pipeline suddenly starts failing, I check the key first.
- Explorer output is only as useful as the current local sample size.
- The MySQL import from
init.sql.gzonly happens on the first database initialization. If I need to re-import from scratch, I remove the volume and boot again.
docker compose down -v
docker compose up -d --build