Building a TFT Overlay: Smart Composition Recommendations in Real-Time
Published: August 11, 2025
The Problem: Static Meta vs Dynamic Gameplay
Most TFT tools like MetaTFT.com show you what's theoretically strong this patch - tier lists, optimal compositions, and item guides. But TFT success comes from adapting to your specific situation: the champions you've been offered, the items you've collected, and what your opponents are contesting.
So I built TacticalFlow: a real-time overlay that analyzes your current board state and recommends what YOU should play right now. Instead of forcing the S-tier composition every game, it evaluates your actual resources and suggests the best path forward.
The result? A tool that understands TFT's fundamental truth - flexibility beats forcing.
From Memory Reading to Overwolf APIs
My previous TFT tools used memory reading with Python and the pyMeow library - a complex approach requiring pattern scanning, offset calculations, and constant maintenance as the game updates. Here's what that looked like:
# Memory reading with pattern scanning - cumbersome and fragile
def rip_relative(pattern):
sig_location, = pyMeow.aob_scan_module(handle, PROCESS_NAME, pattern, relative=False, single=True, algorithm=0)
displacement = pyMeow.r_int(handle, sig_location + 0x3)
return sig_location + displacement + 0x7 - base_address
# Find minion list through memory scanning
OFFSET_MINION_LIST = rip_relative("48 8B 0D ?? ?? ?? ?? E8 ?? ?? ?? ?? E8 ?? ?? ?? ?? E8 ?? ?? ?? ?? 48 8B C8")
# Manual shop data extraction
def get_shop_units():
SLOT_SIZE = 0x68
SHOP_OFFSET = 0x30
units = []
for slot_number in range(5):
slot_name = pyMeow.r_string(handle, pyMeow.pointer_chain_64(handle, shop_ptr + SHOP_OFFSET, [SLOT_SIZE * slot_number, 0x8, 0x0]), 256)
units.append(slot_name)
return units
def infer_level_from_base_stats(base_attack_damage, raw_name):
static_base_attack_damage = static_champions[raw_name].attack_damage
if base_attack_damage >= 2.0 * static_base_attack_damage:
return 3
elif base_attack_damage >= 1.4 * static_base_attack_damage:
return 2
elif base_attack_damage >= 1.0 * static_base_attack_damage:
return 1
else:
return 0
This approach was cumbersome and fragile. Every game patch could break the memory offsets. You can see the complexity in my previous memory reading work here:
Overwolf overlays eliminate all this complexity. No reverse engineering, no offset hunting, no anti-cheat concerns. Just clean API access to game state through legitimate channels.

Architecture: Event-Driven State Management
The key insight that makes Overwolf overlays performant is their event-driven architecture. Instead of constantly polling game state (expensive and inefficient), the system only updates when the game state actually changes.
How Event-Driven Updates Work
The background script registers with Overwolf for TFT (game ID 5426) and listens for specific state changes:
// Event-driven state management
overwolf.games.events.onInfoUpdates2.addListener(this.handleInfoUpdates.bind(this));
handleInfoUpdates(info) {
// Update current game state
this.currentGameInfo = { ...this.currentGameInfo, ...info.info };
// Trigger recommendation recalculation if meaningful updates
const hasImportantUpdate = info.info.board || info.info.bench ||
info.info.store || info.info.player;
if (hasImportantUpdate && this.gameRunning) {
setTimeout(() => this.calculateRecommendations(), 200);
}
}
// Set required features for TFT (game ID 5426)
const requiredFeatures = [
'live_client_data',
'match_info',
'game_info',
'board',
'bench',
'store',
'player',
'round'
];
The system intelligently responds to different types of events. Match start/end events control the overlay lifecycle, while state changes (board, bench, shop) trigger recommendation recalculation:
handleGameEvents(events) {
for (const event of events.events) {
switch (event.name) {
case 'match_start':
this.gameRunning = true;
this.openInGameWindow();
break;
case 'match_end':
this.gameRunning = false;
this.closeInGameWindow();
break;
case 'round_start':
case 'round_end':
// Good time to recalculate recommendations
this.calculateRecommendations();
break;
case 'board_change':
case 'bench_change':
case 'shop_change':
// Immediate updates on meaningful state changes
setTimeout(() => this.calculateRecommendations(), 100);
break;
}
}
}
Why this matters: The overlay only burns CPU cycles when your game state actually changes. No wasted computation checking for updates that haven't happened. This keeps the system lightweight and responsive during gameplay.
State Processing and Debugging
The background script maintains a comprehensive view of the game state and provides detailed logging for debugging recommendation accuracy:
async calculateRecommendations() {
const boardChamps = this.currentGameInfo.board ? Object.keys(this.currentGameInfo.board) : [];
const benchChamps = this.currentGameInfo.bench ? Object.keys(this.currentGameInfo.bench) : [];
// Extract shop data from store.shop_pieces
let storeChamps = [];
if (this.currentGameInfo.store?.shop_pieces) {
const shopPieces = typeof this.currentGameInfo.store.shop_pieces === 'string'
? JSON.parse(this.currentGameInfo.store.shop_pieces)
: this.currentGameInfo.store.shop_pieces;
storeChamps = Object.keys(shopPieces);
}
console.log('[Background] Current game state:', {
hasBoard: !!this.currentGameInfo.board,
hasBench: !!this.currentGameInfo.bench,
hasStore: !!this.currentGameInfo.store,
hasPlayer: !!this.currentGameInfo.player,
hasRound: !!this.currentGameInfo.round,
boardChampions: boardChamps.length > 0 ? boardChamps : 'No board data',
benchChampions: benchChamps.length > 0 ? benchChamps : 'No bench data',
storeChampions: storeChamps.length > 0 ? storeChamps : 'No store data'
});
const recommendations = engine.getRecommendations(this.currentGameInfo);
// Log top recommendation with tier and completion percentage
if (recommendations.length > 0) {
console.log('[Background] Top recommendation:', {
name: recommendations[0].name,
tier: recommendations[0].tier,
score: recommendations[0].score,
completion: recommendations[0].completion_percentage,
keyChampions: recommendations[0].key_champions?.slice(0, 3).map(c => c.name + (c.owned ? ' ✅' : ' ❌'))
});
}
}
This shows how the system processes incoming game events and transforms them into actionable recommendation data. The logging reveals exactly what information Overwolf provides and how the algorithm interprets it.
The Recommendation Engine: Understanding TFT Strategy
The challenge wasn't just accessing game data - it was encoding TFT strategy into an algorithm. How do you teach a computer to recognize good vs bad situations?
The Scoring Formula
Each composition gets evaluated on five factors:
final_score = base_tier + champion_ownership + item_synergy + trait_coverage + feasibility
Base tier comes from TFTFlow's rankings (S/A/B tier). But the other factors matter more - they determine if you can actually execute the composition.
Champion scoring reflects TFT's harsh reality: carries are everything. Missing your main carry usually means 8th place. Missing a support? You'll probably be fine.
// Separate carry champions (weight >= 0.8) from support champions
const carryChampions = composition.key_champions.filter(champion =>
(champion.weight || 1.0) >= 0.8
);
// Carry scoring: cost × star_level × weight × 3
for (const carry of carryChampions) {
const ownedChampion = gameState.ownedChampions.get(carry.apiName) ||
gameState.benchChampions.get(carry.apiName);
if (ownedChampion) {
const championCost = carry.cost || 3;
const starLevel = ownedChampion.stars;
const roleWeight = carry.weight || 1.0;
// Balanced scoring: 2★ cheap carry = 1★ expensive carry
const carryScore = championCost * starLevel * roleWeight * 3;
score += carryScore;
}
}
// Support scoring: heavily reduced multiplier
for (const support of supportChampions) {
const costMultiplier = support.cost || 2;
const starMultiplier = Math.pow(ownedChampion.stars, 1.5);
const roleWeight = support.weight || 0.5;
const supportScore = costMultiplier * starMultiplier * roleWeight * 0.2;
score += supportScore;
}
The Component Sharing Problem
Here's where most TFT tools fail. Consider this scenario:
- Ashe wants: 2× Guinsoo's Rageblade (bow + rod each) + 1× Runaan's Hurricane (bow + cloak)
- You have: 1 bow, 2 rods, 1 cloak
A naive algorithm says "you can build all these items!" because bow+rod=Guinsoo's, bow+cloak=Runaan's. But you can't - there's only one bow. The algorithm needs to understand resource contention:
calculateItemCompletionWithSharing(itemApiName, availableComponents, usedComponents) {
const recipe = itemCraftingEngine.getRecipe(itemApiName);
const requiredCounts = new Map();
// Count required components for this item
for (const component of recipe.recipe) {
const apiName = itemCraftingEngine.getItemApiName(component);
requiredCounts.set(apiName, (requiredCounts.get(apiName) || 0) + 1);
}
let totalNeeded = 0;
let totalAvailable = 0;
// Calculate completion considering already used components
for (const [component, needed] of requiredCounts.entries()) {
const available = availableComponents.get(component) || 0;
const alreadyUsed = usedComponents.get(component) || 0;
const remainingAvailable = Math.max(0, available - alreadyUsed);
const canUse = Math.min(remainingAvailable, needed);
totalNeeded += needed;
totalAvailable += canUse;
}
return totalNeeded > 0 ? totalAvailable / totalNeeded : 0.0;
}
Shop Data Processing and Parsing
The overlay parses live shop data from Overwolf's store events, extracting champion information for each of the 5 shop slots:
sendShopDataToInGame() {
if (!this.currentGameInfo.store?.shop_pieces) return;
const shopPieces = typeof this.currentGameInfo.store.shop_pieces === 'string'
? JSON.parse(this.currentGameInfo.store.shop_pieces)
: this.currentGameInfo.store.shop_pieces;
let shopUnits = [];
for (let i = 1; i <= 5; i++) {
const slotKey = `slot_${i}`;
const slotData = shopPieces[slotKey];
if (!slotData || slotData.name === 'Sold') {
shopUnits.push(null);
continue;
}
const championKey = slotData.name; // e.g., "TFT15_Katarina"
const championData = this.championData[championKey];
const displayName = championKey.replace(/^TFT\\d+_/, '');
shopUnits.push({
name: displayName,
fullKey: championKey,
cost: championData?.cost,
tier: championData?.tier,
slotIndex: i - 1
});
}
this.sendToInGame('shop_updated', shopUnits);
}
Data Pipeline: Offline-First Architecture
Why Offline-First?
TFTFlow.com has comprehensive composition data, but it's a lot to scrape and only updates weekly. More importantly, my Overwolf dev overlay (without submitting to their store) cannot make external HTTP requests during runtime. The solution: I built a scraper that pulls all the information necessary that I run periodically (once a week).
// Data fetching strategy
scripts/fetch-data.js:
1. Scrape champion data from TFTFlow's JavaScript files
2. Download tier list HTML and extract 39 composition links
3. Fetch individual composition pages with 500ms rate limiting
4. Store everything in src/data/raw/ for offline parsing
Runtime behavior:
- Zero external requests during gameplay
- All analysis happens locally
- Data updates only when patches release
This offline-first approach solves multiple problems: eliminates CORS issues, removes network dependencies during gameplay, avoids rate limiting concerns, and is required since dev overlays can't make external requests.

What I Learned
Legitimacy > Cleverness
My old memory reading approach was technically impressive but fundamentally fragile. Every patch could break it. Riot's anti-cheat made it risky. The complexity was enormous.
Overwolf's legitimate APIs eliminated all of that. No reverse engineering, no offset hunting, no ban risk. The difference between spending days on infrastructure versus hours on actual functionality.
Understanding Strategy Through Code
Building TacticalFlow forced me to deeply understand TFT mechanics. Why are carries worth 3x supports in scoring? What makes component sharing so crucial? How do feasibility calculations actually work?
You can't automate what you don't understand. The algorithm became a mirror for my own strategic knowledge.
The Paradox of Automation
Here's the irony: I rarely use TacticalFlow in actual games. When I do, it feels like a crutch that makes me worse at TFT. I second-guess my intuition, rely on external suggestions instead of reading the game state myself.
But building it taught me more about TFT than hundreds of games could. To create an algorithm that makes good recommendations, I had to deeply understand:
- Why carry champions matter exponentially more than supports
- How component scarcity creates strategic bottlenecks
- When flexibility beats forcing optimal compositions
- How economic timing affects feasibility calculations
The real value wasn't the tool - it was understanding the game well enough to automate it.
Lessons Beyond TFT
This project reinforced something important about learning: sometimes the best way to master a domain is to try automating it. The process forces you to make your intuitions explicit, to understand the edge cases, to formalize what "good" actually means.
TacticalFlow sits unused in my development folder. But the strategic insights from building it improved my gameplay more than any guide or tier list ever could.
This project was built as a technical exercise to explore real-time game analysis and Overwolf development. The focus was on understanding TFT strategy deeply enough to encode it algorithmically, rather than creating a commercial product.