Documentation
Everything you need to know about FaB Stats
About
FaB Stats is a community-built match tracker and analytics platform for Flesh and Blood. It was created by azoni as a passion project to give FaB players a way to track their competitive journey, analyze their performance, and connect with the community.
The project is independent and community-driven — not affiliated with Legend Story Studios.
Tech & Architecture
Tech Stack
FaB Stats is built with modern web technologies, optimized for speed and static deployment.
Technical Architecture
A deeper look at the engineering decisions, trade-offs, and patterns behind FaB Stats.
Static Export with Client-Side Data
FaB Stats uses Next.js with output: 'export' in production, generating a fully static site of pre-rendered HTML shells. All user data is fetched at runtime by the browser via the Firebase JS SDK — there is no server-side rendering of user content.
Why static? — Zero server cost, globally CDN-cached pages, instant TTFB, and no cold start latency. The entire site is served from Netlify's edge network.
Offline-capable caching — Firestore is initialized with persistentLocalCache using a multi-tab IndexedDB manager. Data survives page reloads, works across browser tabs without conflicts, and provides near-instant subsequent page loads from cache.
Dynamic routing trick — Since static export can't produce truly dynamic routes, the build generates a single player/_.html shell. Netlify's routing rules rewrite every /player/* URL to this shell with a 200 status. The client reads the username from the URL path and fetches the player's data from Firestore at runtime.
Trade-off — Crawlers only see an empty HTML shell, which is why the edge function system (below) exists to inject real meta tags at the CDN edge.
OG Image Generation Pipeline
Social sharing (Twitter, Discord, Slack) requires per-player Open Graph images. FaB Stats generates these dynamically with a three-layer pipeline:
Layer 1 — Clean URL proxy — Netlify rewrites /og/player/username.png to a serverless function, so crawlers and CDNs see a clean, cacheable image URL.
Layer 2 — PNG generation (Lambda) — A Node.js serverless function uses satori (JSX → SVG) and resvg-js (SVG → PNG) to render a 1200×630 player card. It queries Firestore via the REST API directly (avoiding bundling the full Firebase SDK) and bundles WOFF font files from disk. Images are cached at the CDN edge for 1 hour with 24-hour stale-while-revalidate.
Layer 3 — Edge meta tag injection (Deno) — Netlify edge functions intercept HTML responses at the CDN edge. For player pages, the edge function fetches the static HTML shell, queries Firestore REST for the player's stats, and rewrites the <title>, og:title, og:image, and description tags in-place before returning the response to the crawler.
Domain constraint — All OG image URLs must use www.fabstats.net because the bare domain 301-redirects to www, and Twitter's crawler does not follow redirects when fetching card images.
Firestore Data Model
The database is structured around a few core collections with denormalized aggregates for read performance:
| Collection | Purpose |
|---|---|
| users/{uid}/matches/* | Per-user match subcollection — each doc is a match record |
| users/{uid}/profile/main | Singleton profile document with display name, settings, visibility |
| usernames/{username} | Username reservation — maps username to userId, enables prefix search |
| leaderboard/{uid} | Denormalized aggregate stats, rebuilt on every match save |
| feedEvents/{id} | Global activity feed — imports, placements, achievements, FaBdoku |
| friendships/{id} | Bidirectional friend pairs with participants array |
| kudos/{id} | Individual kudos records between giver and recipient |
| kudosCounts/{uid} | Aggregated kudos received per player |
Atomic username reservation — Profile creation uses a Firestore transaction to claim the username document and create the profile simultaneously, preventing race conditions where two users register the same username.
Prefix search — Player search uses range queries on the usernames collection (e.g. "biggs" → range ["biggs", "biggt")). A parallel reversed-word query handles "First Last" vs "Last First" matching, with results deduped by Set.
Denormalized leaderboard — Rather than querying every user's matches to build rankings, each user's aggregate stats (~30 fields including hero breakdowns, weekly/monthly windows, and playoff finishes) are precomputed and stored in a flat leaderboard collection. This trades storage for read speed — the leaderboard page fetches one collection instead of thousands.
Import Pipeline
Getting match data from GEM into FaB Stats involves parsing noisy, semi-structured text:
GEM paste parser — Parses raw copied text from the GEM website. The text contains navigation links, copyright notices, rating changes, and partial metadata intermixed with match rows. A regex noise filter discards ~20 categories of irrelevant lines before structured extraction begins.
Event boundary detection — Date lines serve as event boundary markers. The parser collects context lines (format, venue, rated status, event type) between date markers to associate metadata with match groups.
Playoff detection — Round names are pattern-matched (Top 8, Semifinals, Finals, Round P#, Quarter) to automatically detect playoff sections and infer Best Finish results.
Fingerprint deduplication — Before writing, all existing matches are loaded and fingerprinted (date + opponent + notes + result). Only truly new matches are written, using Firestore batch writes in 500-document chunks.
Extension vs paste — The Chrome extension produces structured JSON with GEM event IDs for reliable grouping. The paste parser uses heuristic date/name matching. Both converge on the same internal format before write.
Leaderboard & Ranking System
34 ranking tabs — Each tab is defined with a filter predicate and sort comparator. Rankings are computed client-side by filtering and sorting the full leaderboard snapshot. This avoids 34 separate Firestore queries.
Bulk-import pollution guard — If more than 80% of a player's matches fall within the current weekly window and they have 30+ total matches, the weekly stats are zeroed out. This prevents bulk imports of old matches with incorrect dates from dominating the weekly leaderboard.
Client-side caching — The leaderboard snapshot is cached in-memory for 15 minutes. Real-time subscriptions are not used here — the data is fetched once per session to minimize Firestore reads.
Ranked borders — A player's "best rank" across all 34 tabs determines their avatar glow tier (Grandmaster through Bronze for top 5). Event prestige tiers are separate — players with top-8 finishes at high-tier events receive card border colors reflecting their best event tier.
Share Image System
DOM-to-PNG capture — Share cards are rendered as real React DOM elements off-screen, then captured as PNG blobs using html-to-image at 2× pixel density (1.5× on mobile to stay within share sheet limits).
Platform-adaptive sharing — The share function implements a waterfall: mobile native share sheet (navigator.share with file) → desktop clipboard image (ClipboardItem) → text URL fallback. Each strategy returns a typed result so the UI can show accurate feedback.
CORS retry — If a profile card embeds an external avatar image that triggers a CORS error during capture, the function automatically retries with a filter that strips all images from the capture tree.
Achievement Detection
Pure function evaluation — Each achievement is a static definition with a check(ctx) predicate and optional progress(ctx) function. Evaluation is a single filter over the definitions array against a pre-computed stats context — no database queries during evaluation.
Tiered groups — 130+ achievements are organized into groups with up to 11 tiers. Only the highest earned tier in each group is typically displayed, keeping profiles clean while rewarding long-term progression.
New achievement detection — When a player imports matches, achievements are evaluated twice (before and after) and the difference is used to trigger feed events and notifications for newly unlocked badges.
Chrome Extension Architecture
Manifest V3 content script — The extension injects a single content script into GEM pages. No background worker, no popup, no remote API calls. It's entirely self-contained.
DOM scraping — On click, the extension expands all collapsed event sections, waits for AJAX-loaded tables, then walks the DOM upward from each match table to find the nearest heading and extract event metadata (date, format, venue, event type, hero from decklists).
Multi-page state — GEM paginates match history. The extension serializes its accumulated state to sessionStorage, navigates to the next page, and the re-injected script detects saved state and resumes automatically.
Data transfer — Scraped JSON is Base64 URL-encoded and appended as a hash fragment to the import page URL. For larger exports (>1MB), users download a JSON file and upload manually.
Optimistic UI Updates
Interactive features like kudos, reactions, and friend requests use optimistic UI — the interface updates immediately on click while the Firestore write happens in the background. If the write fails, the UI state is already consistent because the next page load will fetch the true state. This makes interactions feel instant despite the round-trip to Firestore.
Self-Healing Data
Several features use a self-healing pattern: when reading data, if the canonical source (a user's private subcollection) has data but the public denormalized copy is missing, the read function automatically backfills the public collection. This recovers from silent write failures (e.g. Firestore rule misconfigurations) without requiring manual migration scripts.
Getting Started
FaB Stats imports your match history from the official GEM (Game Event Manager) system. There are three ways to get your data in:
Home Workspace
The Home section is the main logged-in workspace. It keeps the high-level profile and stat summary on the Overview tab, then lets you switch into Matches, Events, Opponents, Trends, and Tournament Stats from the tab row at the top of the page.
The direct URLs still work for bookmarks and sharing, but the sidebar stays lighter by keeping these tools grouped under Home.
Chrome Extension
The FaB Stats Chrome Extension adds a one-click export button to your GEM match history page. It automatically detects heroes played in each event using the card data on the page.
1. Install from the Chrome Web Store
2. Go to your GEM match history at gem.fabtcg.com
3. Click the "Export to FaB Stats" button that appears
4. Your matches are copied to clipboard — paste them on the Import page
Each import tracks which extension version was used and whether data came from the extension, copy-paste, or CSV upload.
Win Rate & Record
Win rate is calculated as wins / total matches. All match results count toward the total — wins, losses, draws, and byes are all included in the denominator. This means byes and draws will slightly lower your win rate percentage.
Example: 80 wins, 15 losses, 5 byes = 80/100 = 80.0% win rate, not 80/95.
Streaks
Streaks track consecutive wins or losses. Only wins extend a win streak and only losses extend a loss streak.
Draws break streaks — A draw resets both your win streak and loss streak to zero.
Byes are ignored — Byes don't count as a win and don't break your streak. They're skipped entirely in streak calculations.
Matches are sorted chronologically to determine streak order. Your current streak shows the most recent consecutive result.
Best Finish
Your Best Finish is determined by your top playoff result across all competitive events. Playoff rounds are detected automatically from GEM round data using pattern matching on round names (Top 8, Semifinals, Finals, Round P#, Quarter, etc.).
Finishes are ranked in this order:
How finish type is determined:
Champion — No losses in playoff rounds
Finalist — Played in the finals but lost
Top 4 — Played in semifinals but not finals
Top 8 — Entered playoffs but eliminated before semis
When you have multiple finishes of the same type, the more prestigious event wins. Event prestige tiers:
| Event Type | Prestige |
|---|---|
| Worlds | 10 |
| Pro Tour | 9 |
| The Calling | 8 |
| Nationals | 7 |
| Battle Hardened | 6 |
| Road to Nationals | 5 |
| ProQuest | 4 |
| Championship | 3 |
| Skirmish | 2 |
| On Demand | 1 |
Armory, Pre-Release, and unrecognized event types are excluded from Best Finish calculations.
Event Detection
Events are grouped from your match data by event name, date, and venue. Matches from the same event are combined into a single event entry on your Events page.
Multi-format events — Major tournaments like Nationals, Pro Tour, and Worlds combine all formats (e.g. CC + Draft) into a single event, rather than splitting them.
Event type classification — Event types are refined from the event name using pattern matching. Common abbreviations are recognized (BH for Battle Hardened, PQ for ProQuest, RTN for Road to Nationals). If the event name contains a known type, it overrides the default GEM classification.
Rated events — Some events are flagged as "rated" based on GEM data. Rated match stats are tracked separately and have their own leaderboard tab with a minimum of 5 rated matches to appear.
Hero Mastery
Play matches with a hero to progress through 8 mastery tiers. Each tier unlocks at a specific match count threshold with that hero.
| Tier | Matches Required |
|---|---|
| Novice | 1+ |
| Apprentice | 5+ |
| Skilled | 15+ |
| Expert | 30+ |
| Master | 50+ |
| Grandmaster | 75+ |
| Legend | 100+ |
| Mythic | 150+ |
Achievements
Earn badges by hitting milestones, building streaks, mastering heroes, and exploring the game. There are 30 achievements across 5 categories:
Each achievement has a rarity level:
Hero Data Shield
The shield badge next to your name shows how complete your hero data is across all matches. When you import matches, each match can have a hero attached. The more matches with hero data, the higher your shield tier. The badge appears everywhere your name shows: profile, leaderboard, activity feed, and share cards.
Starting February 24, 2026, new imports require hero selection. You can always choose "Unknown" if you don't remember, but you must make the choice explicitly.
Below 35% completion no badge is shown. Import more matches with heroes or edit existing matches to increase your completion percentage.
Supported Formats
Formats are detected automatically from your GEM event data. FaB Stats tracks:
Leaderboard
The leaderboard ranks all public players across multiple categories. Players need a minimum number of matches to appear on most tabs.
Ranked Borders
The top 5 players on each tab receive a ranked border on their card:
If you rank top 5 on multiple tabs, your profile shows the border from your highest rank.
Where Borders Appear
Leaderboard cards — Each leaderboard entry shows the ranked border for that tab's top 5.
Homepage profile card — Your profile card on the homepage displays the border from your highest overall rank, along with your match count, win rate, events, and top hero.
Shareable profile card — When you capture your profile as an image, the card includes your ranked border, tier ring around your avatar, trophy case, and armory garden.
Home and player cards — Ranked borders carry through the Home overview, player profiles, and share cards so top leaderboard placements stay visible without needing a separate spotlight module.
Weekly & Monthly Stats
Weekly and monthly leaderboard tabs track recent activity:
Weekly — Rolling 7-day window. Shows matches played and wins from the last 7 days.
Monthly — Rolling 30-day window. Shows matches, wins, and win rate from the last 30 days.
Bulk imports with incorrect dates are automatically detected and excluded from weekly/monthly stats to prevent inflated numbers. If the majority of a player's matches appear to land in the current period, the stats are reset to zero.
Versus
Compare your stats head-to-head against any other player. The Versus page locks you in as Player 1 and lets you pick an opponent — or choose from common opponents you've both faced.
Power Level
Each player gets a composite score from 0–99 based on their overall stats. The formula combines seven weighted components, each measuring a different aspect of competitive strength:
| Component | Max Points |
|---|---|
| Win RateScales with match count — full weight at 20+ matches | 30 |
| Match VolumeLog scale, caps at 500 matches | 15 |
| Event SuccessEvent wins (10), top 8 finishes (6), events played (4) | 20 |
| StreaksLongest win streak (7), current win streak (3) | 10 |
| Hero MasteryUnique heroes played (5), top hero depth (5) | 10 |
| Rated PerformanceRated win rate — requires 5+ rated matches | 10 |
| EarningsLog scale — rewards any prize money earned | 5 |
Total possible: 100 points, displayed as 0–99. A new player with few matches will have a low volume and streak score, while a veteran with many events and hero variety scores higher across the board.
| Tier | Power Level |
|---|---|
| Grandmaster | 80–99 |
| Diamond | 65–79 |
| Gold | 50–64 |
| Silver | 35–49 |
| Bronze | 0–34 |
Dominance Score
The dominance score determines the overall winner. Each stat category has a weight, and for every weighted stat, both players' raw values are compared proportionally:
points = (your value / combined total) × 10 × weight
The head-to-head record carries the most weight (×4) when available, reflecting the importance of the direct matchup. Categories with missing data (e.g. no rated matches) are excluded rather than penalized.
Common Opponents
Shows every opponent both players have faced, sorted by total combined games. For each shared opponent, win/loss records are compared side-by-side.
Edge detection — An "edge" is when one player has a winning record (≥50%) against an opponent while the other has a losing record (<50%). The Opponent Network summary counts how many edges each player has, giving a transitive comparison even when no direct head-to-head exists.
Verdict
The verdict uses personality-driven language based on how wide the dominance gap is:
H2H record — If both players have faced each other, their direct head-to-head record is shown in a dedicated arena section with a visual win-rate bar.
Hero roster — Side-by-side comparison of each player's top 5 heroes with match counts and win rates.
Share card — Generate a shareable image of the showdown with power level tier icons, key stats, opponent network edges, and the verdict.
Nemesis & Best Friend
Your profile highlights two special opponents:
The opponent you struggle against the most. Calculated as the opponent with the lowest win rate against you, with a minimum of 3 matches. "Unknown" opponents are excluded.
The opponent you've played the most. Calculated as the opponent with the highest total match count against you, regardless of win rate.
The Nemesis leaderboard tab ranks all public players by who has the worst win rate against any single opponent (min 3 matches).
Activity Feed
The Activity Feed on the homepage shows what the community is up to — match imports, achievement unlocks, and tournament placements.
Type filters — Filter by All, Imports, Achievements, or Placements. Your filter preference is saved between visits.
Community vs Friends — Toggle between seeing all public activity or just activity from your friends and favorited players.
Discovery — The global search bar is the fastest way to jump to players and teams, while Home keeps the personal activity and match management tools close to your own stats.
Friends
Add other players as friends to see their activity in your feed and quickly access their profiles.
Friend requests — Send a request from any player's profile. They'll see it in their notifications and can accept or decline.
Favorites — Star any player from the Opponents page to add them as a favorite. Favorites appear in the Friends activity filter alongside accepted friends.
Friends-only profiles — Players can set their profile visibility to "Friends" in Settings so only accepted friends can view their stats. Non-friends will see a locked profile message.
Kudos
Give kudos to other players to recognize their gameplay and sportsmanship. Kudos appear on player profiles and are tracked on the Leaderboard.
Types — There are four kudos types: Props (general), Good Sport (sportsmanship), Skilled (gameplay), and Helpful (community). Each appears as a button on player profiles.
Limits — You can give up to 10 kudos per day across all types. Each player can receive at most one of each type from you. After revoking a non-Props kudos, there's a 7-day cooldown before you can re-give the same type to that player.
Admin endorsement — When the site admin gives kudos to a player, those boxes display a subtle glowing border to highlight the endorsement.
Leaderboard — Kudos received and given are tracked on the Rankings page with separate leaderboards for each type and a total category.
FaBdoku
FaBdoku is a daily puzzle game where you fill a 3×3 grid with Flesh and Blood heroes. Each hero must satisfy both its row and column constraints (class, talent, cost, set, etc.).
Scoring — Each correct cell earns 1 point (max 9/9). Your score and number of games played appear as a badge on your profile card.
Streaks — Win streaks are tracked across consecutive days. Your current streak and best streak are shown in your FaBdoku stats.
Uniqueness — After completing the puzzle, you can see how common each of your picks was compared to other players. Lower percentages mean more unique choices.
Discord Bot
The FaB Stats Discord bot lets you look up player stats, leaderboards, hero data, and more directly from any Discord server. Add to your server · Join the Discord
Player Lookup
/stats <username> — Overall record, win rate, power level, streaks, top hero, and events played.
/hero <username> <hero> — Stats with a specific hero: W/L/D, win rate, and matchup breakdown.
/recent <username> — Last 10 matches with results, heroes, and opponents.
/event <username> [name] — Most recent event (or search by name): round-by-round results, format, and record.
/opponents <username> — Top 10 most-played opponents with W/L records.
Comparison
/compare <player1> <player2> — Side-by-side comparison of two players' stats.
/h2h <opponent> — Your head-to-head record vs an opponent (requires /link).
/matchup <your_hero> <vs_hero> — Your personal record in a specific hero matchup (requires /link).
Community
/leaderboard [category] [sort] — Community leaderboard with 34+ ranking categories. Supports autocomplete search.
/meta — Current season's Top 8 hero breakdown across the community.
/herolist [sort] — Community hero tier list sorted by matches, win rate, or players.
/community-matchup <hero1> <hero2> [format] — Community win rates for a hero matchup across all FaB Stats players. Supports autocomplete.
Weekly Armory Recaps
/armory-subscribe — Opt in (or out) of weekly armory stat recaps for your server. Requires /link first.
/armory-channel — Set the current channel as the destination for weekly armory recap posts (requires Manage Channels).
Every Sunday at 6 PM, the bot automatically posts a recap showing each subscriber's armory win-loss record and heroes for the week. Players who didn't play that week are silently omitted. Results are pulled from your FaB Stats match data — just import your matches as usual and the bot picks them up automatically.
Account
/link <username> — Link your Discord account to your FaB Stats profile. Required for /h2h, /matchup, and /armory-subscribe.
/unlink — Remove the link between your Discord and FaB Stats accounts.
/manage-link @user [action] [username] — View, set, or remove a player's Discord–FaB Stats link. Requires Manage Server permission or bot owner.
Utility
/help — List all available commands.
/invite — Get the bot invite link to add it to another server.
/botstats — Bot info: server count, uptime, and ping.
Showcase & Pinned
Customize your profile with two configurable sections: Pinned (top) and Showcase (below). Each section has a budget of 12 points — most cards cost 1 point (half-width), achievements cost 2 (full-width).
Card types — Featured Match, Hero Spotlight, Best Finish, Rivalry, Event Recap, Achievements, Stat Highlight, Format Mastery, Event Types, Streaks, Recent Form, and Rankings.
Rankings card — Shows your current leaderboard positions (top 8). If you drop off a leaderboard, that position automatically disappears from the card.
Editing — Click Edit to rearrange, add, or remove cards. Changes save automatically. Each card type can be configured — pick a specific match, hero, event, or stat to highlight.
On This Day
The On This Day widget shows matches you played on today's date in previous years. See your record, opponents, heroes, and events from past years at a glance.
Placement detection — If you made a top-cut at an event, On This Day automatically detects your placement (Champion, Finalist, Top 4, or Top 8) and shows a badge on the card. The badge appears in the collapsed header too.
Round ordering — Rounds are sorted in natural order: swiss rounds first (R1, R2, …), then playoffs (QF, SF, F). Playoff rounds are highlighted in purple so they stand out.
Shareable — Capture your On This Day memories as an image to share on social media. The share card includes your record and match details for each year.
Community Meta
The Meta page aggregates data across all public players to show which heroes are most played and have the highest win rates. Filter by format and event type to see how the meta shifts across different competitive tiers. Data updates as players import matches.
Tournament Stats
Tournament Stats focuses on your own competitive events rather than a featured tournament feed. It groups rated and high-tier events, tracks records by round, summarizes top cuts and finals, and can be filtered by format, event type, and hero from the Home workspace.
Teams
Create or join a team to track combined stats, showcase tournament results, and represent your squad across the site. Teams require 25+ logged matches to create.
Team Page — Each team gets a public page at fabstats.net/team/yourteam showing aggregate stats (matches, win rate, events, top 8s, best streak), recent accomplishments with member photos, trophy case, hero breakdown, armory garden, and a full roster with individual stats.
Customization — Upload a team icon and background image, choose an accent color (9 presets or custom), set a description, and pick between open join or invite-only.
Roles — Owner, Admin, and Member. Owners can transfer ownership, manage settings, and disband. Admins can invite, kick, and edit settings. Members appear on the roster and contribute to team stats.
Team Badge — Your team badge appears next to your name on the leaderboard, your profile, and in the activity feed.
Share Card — Generate a themed team card image showing stats, roster, tournament results, and top heroes. Share via clipboard, native share sheet, or download as PNG.
Filters — Filter all team stats by format, event type, hero, and tier. Stats, placements, and trophy case all update with the active filters.
Privacy — Teams can be set to public or private. Private teams are hidden from browse and search but accessible via direct link.
Privacy
Your profile is private by default. You can make it public from the Settings page to appear on the Leaderboard and let others find you via Search.
Public profiles — Your stats, hero breakdown, and events are visible to anyone. Opponent names are hidden on public profiles to protect their privacy. If an opponent has opted in via "Show name on profiles" in their settings, their name will be visible.
Friends-only — A middle ground between public and private. Only accepted friends can see your stats. Non-friends see a locked profile message.
Private profiles — Only you can see your data. You won't appear on the Leaderboard or in search results.
Hide from Guests — An optional toggle in Settings that hides your profile, search results, and leaderboard entry from visitors who aren't logged in. Disabled by default.