A Discord bot that tracks League of Legends players and posts a rich match card to your server whenever a tracked player finishes a game.
The card is a modern HTML/CSS layout — champion splash, KDA, per-role performance grade, KP / damage share / vision breakdown, the lane matchup, team objectives, and either the ranked block (rank, LP delta, win-rate, recent-form streak) or a champion-mastery block for unranked queues.
Rendering is delegated to Cloudflare so the bot itself stays lean (256 MB, no browser, no font stack):
tentrackule (bot, 256 MB) Cloudflare Browser Run
┌──────────────────────────┐ POST /screenshot ┌──────────────────────────────┐
│ • poll Riot for new match │ { html } ─────▶ │ Managed headless Chromium │
│ • build card HTML (Rust) │ │ • pre-installed CJK fonts │
│ • inline assets & fonts │ ◀──── JPEG bytes │ • crops #card-root │
│ as data URIs (cache) │ │ • billed per browser second │
└──────────────────────────┘ └──────────────────────────────┘
│ JPEG ─▶ Discord attachment
- Card HTML is assembled in Rust (
src/discord/scorecard.rs) from a fully resolved view-model — no template engine, every dynamic field HTML-escaped. - Assets (splash, spells, runes, items, opponent icon) are fetched once through
a disk+memory cache (
ImageCache) and inlined asdata:URIs, like the Latin web fonts, so the render needs no network. CJK names use the fonts pre-installed in the browser. - Rendering POSTs the HTML to Cloudflare Browser Run,
which waits for
loadanddocument.fonts.ready, then screenshots the#card-rootelement atdeviceScaleFactor: 2. Rate limits honourRetry-After. - Fallback: if the renderer is unset, out of quota or unreachable, the match is still announced as a compact text embed, so a render outage never blocks tracking.
Data comes from Riot match-v5, league-v4 and champion-mastery-v4, plus
Data Dragon for asset/version lookups. The performance grade is computed locally
(src/discord/grade.rs); the recent-form streak is read from a local SQLite table
(zero extra API calls).
With ANTHROPIC_API_KEY set, every Summoner's Rift card gets an 🧠 Analyse it!
button. A click posts a coach-style breakdown of the tracked player's game under the
card (in French): a third-person recap for the server, whether the result came down
to the player or the team, then coaching for the player (a read of both drafts and
the matchup, strengths, turning points, mistakes with a concrete fix, a build & runes
verdict, one focus for the next game). The prompt asks for causes and
counterfactuals, not a restatement of the stats the card already shows. The button is then
disabled. The analysis is posted folded (recap and verdict only, so it doesn't bury
the cards around it); its 🔽 Voir le coaching / 🔼 Replier button unfolds or
folds it for the whole channel, read back from SQLite with no new Claude call.
- Same shape every game. Three fixed parts (
src/analysis/): a digest built in Rust from match-v5 and the match timeline (lane diffs every 5 min, every allied lane's gold diff at 10/15 min, gold swings, each death with its zone, fight context, unspent gold and the enemy objectives that followed, where the player stood when objectives fell, objective trades, level 6/11/16 and item timings against the lane opponent, skill order); every player's classes, runes, spells, damage split (physical/magic/true), healing, CC and final build; a versioned system prompt (prompt.md) followed by the patch's item catalog from Data Dragon (stats and effects of every completed Rift item, so build advice matches the live shop); and a structured-output JSON schema the reply must follow, rendered into a fixed embed. All numbers are computed locally; the only identity sent is the analysed player's game name (for the third-person summary), never a tag or puuid. - Patch meta from OP.GG. Before the Claude call, the bot queries OP.GG's public
MCP server (
https://mcp-api.op.gg/mcp, no key;src/analysis/opgg.rs) for the Emerald+ reference on the player's champion and role (core build, boots, 4th/5th item options, runes, skill max order, each with games and win rate; best and worst matchups), OP.GG's read of the exact lane matchup, and every champion's tier in its role (tier list cached 6 h). It is a fixed set of calls made from Rust, not tools Claude drives, and only champion names and roles are sent. If OP.GG fails, the analysis runs without that section. - Claude Opus 5.5 at high effort, over raw HTTP (no official Rust SDK), with
server-side refusal fallbacks enabled. The instructions and item catalog (~20k
tokens) are prompt-cached, shared by every analysis within 5 minutes; the digest
adds ~7k, and the output
5k tokens with thinking, i.e. **$0.15–0.25 per analysis** and about a minute of wait (the exact cost is logged per call). - Paid once per game. Analyses are cached in SQLite per (match, player, prompt
version), so later clicks and other guilds are served for free, and
simultaneous clicks share one call. Token counts are stored with each row
(
SELECT SUM(input_tokens), SUM(output_tokens) FROM match_analyses). BumpPROMPT_VERSIONafter changing the prompt to regenerate on the next click.
| Command | Description |
|---|---|
/track <game_name> <tag_line> <region> |
Start tracking a player |
/untrack … |
Stop tracking a player |
/list |
List players tracked in this server |
/config channel <#channel> |
Set the alert channel (needs Manage Server) |
/dev_test_alert … |
Render a sample card for any queue/outcome (dev, bot owner only) |
/dev_analyze <game_name> <tag_line> <region> [match_id] [fresh] |
Run the AI analysis on any game (dev, bot owner only) |
Set via environment (a .env file is loaded in dev — see .env.example):
| Variable | Required | Default | Notes |
|---|---|---|---|
DISCORD_TOKEN |
✅ | — | Bot token |
RIOT_API_KEY |
✅ | — | Riot API key |
DATABASE_URL |
sqlite:tentrackule.db |
SQLite connection string | |
POLLING_INTERVAL_SECS |
60 |
Base poll tick / fastest cadence; idle players are polled less often (activity-adaptive) | |
RIOT_RATE_LIMIT_PER_SECOND |
20 |
Client-side rate limit (fast window) | |
RIOT_RATE_LIMIT_PER_TWO_MIN |
100 |
Client-side rate limit (2-minute window) | |
DDRAGON_VERSION |
(auto) | Data Dragon patch. Unset → resolved from versions.json at boot and refreshed every 24h; set to pin a patch |
|
CLOUDFLARE_ACCOUNT_ID |
(unset) | Cloudflare account running Browser Run; unset → text-embed fallback | |
CLOUDFLARE_API_TOKEN |
(unset) | API token with the Browser Run - Edit permission (secret) |
|
ANTHROPIC_API_KEY |
(unset) | Claude API key for the post-game analysis button; unset → no button (secret) | |
RUST_LOG / LOG_FORMAT |
info / — |
Tracing filter; LOG_FORMAT=json for prod |
A Nix flake provides the toolchain (nix develop / direnv), or use a local Rust
toolchain (edition 2024).
cargo run # run the bot (needs DISCORD_TOKEN + RIOT_API_KEY)
cargo test # unit tests
cargo clippy --all-targetsThe card builder is unit-testable without any infrastructure. Tests write sample
cards to target/scorecard-samples/*.html (open them in a browser, or screenshot
#card-root, to review visually). The HTML is also locked by golden snapshots under
src/discord/snapshots/:
UPDATE_SNAPSHOTS=1 cargo test # regenerate goldens after an intentional markup changeTo render real cards on Cloudflare (Latin, CJK and Cyrillic names), add the two
CLOUDFLARE_* variables to .env, then review the JPEGs written to
target/scorecard-samples/:
cargo test renders_real_cards_on_cloudflare -- --ignored --nocaptureTo check the analysis on a real game, pick a player and run the free digest check
(Riot only), then the full report (one paid Claude call, needs ANTHROPIC_API_KEY).
Outputs land in target/analysis-samples/:
export ANALYSIS_TEST_RIOT_ID="Name#TAG" ANALYSIS_TEST_PLATFORM=EUW1
cargo test real_match_digest -- --ignored --nocapture
cargo test real_match_analysis -- --ignored --nocapturetentrackule— the bot on Fly.io (fly.toml, 256 MB, persistent volume for the DB).- Cloudflare Browser Run — the managed renderer, with nothing to deploy. Setup,
limits and failure modes are in
docs/renderer-deploy.md.
docs/html-scorecard-roadmap.md— design & implementation roadmap for the HTML match card.docs/renderer-deploy.md— renderer setup guide.docs/optimization-roadmap.md— resource, API and infra-cost optimization roadmap.