
How Block Blast harvests its players for ad money
Rules downloaded from their server decide your pieces, your score and your ads. One waits for an expensive ad, then asks for a hand you can’t place.
以下正文同步自 Hacker News,版权归原站所有,已转换为易读排版。
Rules downloaded from their server decide your pieces, your score and your ads. One waits for an expensive ad, then asks for a hand you can’t place.
I posted a video about this game that got over 15 million views, so I wanted to do the full technical write-up: the code, and how you can reproduce it yourself.
Some context on why it matters: between January and May 2026, Block Blast made an estimated $127 million from ads. Candy Crush Saga made $81.1 million over the same months (Sensor Tower estimates).
We started with the Android app, version 10.8.2. We decompiled the Java side with jadx and the native Cocos library with Ghidra, and found almost nothing interesting in the package you download from the store. Block Blast is built from small modules the code calls traits, and each trait has variants called arms. Running it on a clean Android 14 emulator with no account and watching its cache and logcat showed how it works: when the app launches, the server sends a config that says which arm of which trait you get, and the app downloads them as encrypted JavaScript. The config my install got names about 700 traits. My install selected 254 of them, and 101 were downloaded after install.
So two people playing side by side can be running different games, and the studio can change yours without an app update. Most of those 254 are ordinary: tutorials, animations, sounds. A handful are about when you lose. One of them is about ads.
It’s called RewardHighValueAdHarvestTrait. By finding where the game unlocks these files in its own native library, we decoded it into 15,616 bytes of plain, minified JavaScript. Its constructor sets the parameters:
t.HIGH_PRICE_MULTIPLIER = 3.5; t.MEDIUM_PRICE_MULTIPLIER = 1.5; t.MIN_HISTORY_AD_WATCH_COUNT = 3; t.RECENT_AD_NO_ESCAPE_COUNT = 3; t.TARGET_AD_LEVEL = "high";
It waits for three things to line up: you finished your last three rewarded ads, the ad loaded right now is worth at least 3.5 times your last one, and you’re 1,000 points past your high score. All three get checked in one function. Here it is with the minified null checks rewritten as optional chaining; the logic is the same:
shouldTriggerDeath() { if (!this.canTrigger()) return false; // finished your last 3 rewarded ads const d = this.storageData; return d.currentGameNum === hs.classGameInfo?.gameNum // same game && !d.currentGameHasTriggeredDeath // hasn't fired yet && !(hs.classScoreInfo?.score < this.currentScoreThreshold) // past high score + 1,000 && !!this.isTargetAdReady(); // loaded ad ≥ 3.5× the last one }
Here’s how it checks each one.
- You finished your last three rewarded ads. Every time a rewarded ad starts, the rule pushes false onto a list and keeps the latest three (lines 174–183). If the ad SDK reports that the reward was earned, it flips that entry to true (191–199). The check needs all three to be exactly true:
t.prototype.canTrigger = function() { var e = this.storageData; if (e.totalRewardShowCount < this.MIN_HISTORY_AD_WATCH_COUNT) return !1; var t = e.recentRewardResults.slice(0, this.RECENT_AD_NO_ESCAPE_COUNT); return !(t.length < this.RECENT_AD_NO_ESCAPE_COUNT) && t.every(function(e) { return !0 === e; }); };
That flag comes from AppLovin MAX’s onUserRewarded callback in the Java layer, so an ad you close early stays false.
- The ad loaded right now is worth at least 3.5 times your last one. The rule reads the value of the best rewarded ad currently loaded with NativeAd.callNativeGetMaxEcpmNumber("2") (line 304). In the Java layer, that call goes through each ready rewarded ad slot, takes the revenue AppLovin MAX reports for it, multiplies it by 1,000, and returns the highest. The value of your last rewarded ad gets the same ×1,000 (line 310), so the comparison is like for like:
t.prototype.classifyLoadedRewardLevel = function(e) { var t = this.storageData.lastRewardShowEcpm; return !Number.isFinite(t) || t <= 0 ? "none" : e >= t * this.HIGH_PRICE_MULTIPLIER ? "high" : e >= t * this.MEDIUM_PRICE_MULTIPLIER ? "medium" : "none"; };
- You’re past your old high score. At the start of each game the rule saves your high score (line 145). The bar is that high score plus scoreDeltaStep (lines 107–110). Each player gets one arm: mine, 332243014, uses 1,000, and the app’s bundled config has others at 500 and 750. If you revive after the rule fires, the bar moves up another step, so it can fire again in the same game.
Then it asks for the lethal puzzle. Before dealing your next set, the game runs a list of “bottom offer” rules in order. When this one qualifies, it replaces the list with a single generator:
t.prototype.onBottomOfferAlgo = function() { if (this.shouldTriggerDeath()) { hs.algorithmBottomSequenceInfo.setAlgorithmBottomList([ this.deathAlgorithm ]); this.storageData.currentGameHasTriggeredDeath = !0; this.saveStorageData(); } };
deathAlgorithm returns hs.OFFER_TYPE.ZHI_SI_TI (line 102). The game sends that request to its puzzle engine and deals the three piece IDs it gets back (main script lines 45062–45121 and 15074–15123). If the engine fails to find a set, the rule resets and tries again on the next deal.
To watch it decide, we ran the downloaded file itself, unmodified, with test ad and score values. It requests the lethal puzzle exactly when all three conditions hold: 28 cases, including two ads instead of three, one ad closed early, a score 1 point short, and an ad worth just under 3.5×.
Then we ran it live, in a test copy of the iPhone app running in Apple’s simulator, and read the rule’s inputs while we played. It had three good rewarded ads on record. The score was past the line. A rewarded ad from Google AdMob was loaded and ready. The rule priced it at 72.39, against 37.41 for the last one, which is 1.94 times. It needs 3.5. So on iPhone the rule is live, reading real ad prices, and waiting for an ad worth 3.5 times your last one.
The name maps through three tables in the main script:
23878 e[e.ZHI_SI_TI = 9] = "ZHI_SI_TI"; 16092 … r[n.OFFER_TYPE.ZHI_SI_TI] = i.AlgoClassTypeConst.algo_must_death, … 258441 e[e.algo_must_death = 24001] = "algo_must_death";
Algorithm 24001 exists twice: in JavaScript, and in the game’s native C++ library, where the function is called func_death_blcok (their spelling). They do the same search. Here’s the JavaScript core:
for (var d = 0; d < n.length; d++) if (41 != d) { var m = n[d]; r.canPut(m) ? o.push(m) : i.push(m); // o: fits now, i: doesn't fit now } … iC(r, o[p], o[f], i[], h); // try every order of two pieces that fit iC(r, o[p], i[], o[f], h); // and one that doesn't, clearing lines iC(r, o[f], o[p], i[], h); // after each placement iC(r, o[f], i[], o[p], h); if (0 == h.length) { // no order worked: take this set u = [ o[p], o[f], i[_] ]; … a.names = [ "致死题", "致死题", "致死题" ]; // "lethal puzzle" ×3
It looks for two pieces that fit now and one that doesn’t, and keeps the set only if no order and no set of positions places all three, counting every line you’d clear along the way. Two of the three fit when they’re dealt, so the hand doesn’t look impossible until you try it.
Here’s what that looks like, with the game’s own code running in your browser. Step through it:
Press “New random board” for a fresh mid-game board, and the game’s code picks a new hand for it. On the test board in the evidence bundle, the game’s generator accepts a vertical domino, a horizontal domino and a 3×3 square as a lethal hand: we tried all 171 placements, and none of them finish it. On an empty board, those same three pieces fit 67,680 ways in each order.
The lethal puzzle is a generator, and any rule can ask for it. The ad rule is one caller. While we were testing it live on iPhone, a second caller fired on camera: PersonalMarginBreakTrait. Once you’re 10% past your high score, it rolls on every deal, and 20% of the time it asks for the same lethal puzzle:
t.prototype.onAlgorithmStrategyPriority = function() { this.hasReplacedThisGame = !1; if (this.state.isFirstGame) ; else if (this.isReviveRound()) ; else if (this.state.isBreakHighScore && this.isCurrentScoreOverRecordPercent()) if (100 * Math.random() >= this.prob) ; else { var e = this.replaceAlgoId; // 9, the lethal puzzle … if (this.callNewAlgorithm(e)) ; else {
Our record was 1,304, so its line was 1,434. At 3,356 points it rolled, and the game dealt a five-long bar, a five-tall bar and a zigzag. The game’s own record of that deal names this rule as the caller, shows algorithm 9 both requested and used, and labels all three pieces 致死题, lethal puzzle. There was no way to place them. Two separate exhaustive searches agree: zero. The rule, the live settings and the deal are in the live iPhone bundle.
The rules so far are hand-written. The game also downloads trained models and runs them on your phone, and one of them decides where in a level the lethal puzzle shows up.
It’s a 500-tree XGBoost classifier for the game’s level mode, which the code calls Chapter. Its file is named predict_travel_difficulty_dead, and its 52 inputs are mostly about how you’ve been doing: your current level, how many times you’ve tried it, how many games you’ve finished this week, and how long you’ve had the app. The game feeds it 12 options, four values of a setting called sw_process times three difficulty levels, and keeps whichever one the model scores highest:
if (0 == e) for (var i = 1; i <= 4; i++) for (var l = 1; l <= 3; l++) { var c = this.getInputFeature(this._jsonModel.learner.feature_names, i, l); if ((u = this.xgb_clf_predict(c, …)) > n) { // keep the highest-scoring option n = u; a = i; o = l; } } … storage.setItem("DieModel_last_predict_info", { swProcess: a, travelLvDifficulty: o, pre: n });
Another rule turns that pick into a stretch of the level: the first 30%, 30 to 80%, or the last 20%. When you reach that stretch, it offers you an extra revive if you usually take them (more than 75% of the time). Then, from the fourth deal after that, it asks for the lethal puzzle:
var t = hs.chapterConfigInfo.getChapterProgress(), i = e.getLastPredictInfo(this.traitName), r = i.swProcess, …; … 1 == r ? s = [ 0, .3 ] : 2 == r ? s = [ .3, .8 ] : 3 == r ? s = [ .8, 1 ] : 4 == r && (s = [ 1, -1 ]); return t < s[0] ? a.First : t <= s[1] ? a.Second : a.Third; // before, inside or after that stretch … if ((n = this.saveData).needZs && n.zsRoundNum > 3) { hs.algorithmStrategyInfo.setAlgorithmPriorityList([ hs.OFFER_TYPE.ZHI_SI_TI ]); // the lethal puzzle hs.algorithmStrategyInfo.setAlgorithmFailList([ hs.OFFER_TYPE.SHANG_ZENG_3 ]); }
Other rules read the same pick the opposite way and make a level easier when it lands on the fourth option.
A second model puts a price on you. The game downloads xgboost_regression_model111.json from its server and runs it on your phone.
It’s an XGBoost regression model: 59 decision trees, up to 8 levels deep, 27 inputs. Most of it is about money: 18 of the inputs are about the ads the game has loaded for you, today and yesterday. The rest are where you are, your phone, when you installed, and how your first game went. The trees lean hardest on your first score, the price of your latest ad, your RAM, your phone model and your country.
The code calls its answer finalEcpm. eCPM is the ad industry’s name for what a thousand ads are worth, and it’s the same unit the game stores its ad prices in. So the model is estimating what you’re worth in ads.
GPT-6 Astra ported the game’s input code and tree walk, and the port reproduces the game’s own outputs to the last decimal. We ran it on a made-up new player in the US with a first score of 1,000, once on an iPhone 15 installed from the App Store and once on Android, then changed one input at a time:
Moving the same player from the US to Brazil cuts their value to about a third, and India cuts it further. Your first game matters too, but not in a straight line: on iPhone, a first score of 500 makes you worth more than 2,000 or 10,000.
The
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。