How Block Blast harvests its players for ad money
ayoships.com 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.
!Image 1**Ayo Omolewa** October 15, 2026
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).
Most of Block Blast isn’t in the app you install
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.
CTAgloReviveTrait CTReviveTimeOutSkipSplashTrait ClassNewDeathOptimizeTrait DeadlyToHardTrait DecreaseReviveUIShowByLimitHighScoreTrait DifferentStrategyDieTrait EndlessDeathBoardTrait FastHandPunishTrait GLBFDieReplayTrait IsCacheReviveNumTrait IsClassADReviveNoInerestingTrait IsOpenInAdvanceDeathByLowEfficiencyGiveReviveTrait IsOpenRandomToRandomNoDieTrait LevelReviveCondiTrait LevelReviveCountTrait LevelReviveTrait MABXiBaiTrait MultiClearBoostThenKillTrait RepateCPlusPlusRandomNoDeathTrait ReviveBuffTrait ReviveCleanBoardTrait ReviveCountTrait ReviveLimitTrait RewardHighValueAdHarvestTrait AdLevelTrait AdPlusMinusStrategyTrait AddClearTipsExTrait AddMoreGameTrait AdjustPercentChTrait AlgoFillSortEdgeTrait AlgoSDK_modifyBlockPosTrait AlgoSDK_useAlgoSoAloneTrait AlgoSameBlockShangZengTrait AlgoSymmetryBlockTrait BComboScoreChangeTrait BCommentDisplayTrait BPuzzleTimeTrait BReplaceBinaryTrait BackSpaceClickLimitTrait BaseplateConditionDiffTrait BaseplateLaneTrait BaseplateTrait BlockLimitClassicsTrait BlockLimitLevelTrait BlockPoolRoundAdjustV2Trait BlockPoolStrategyExperimentTrait ButtonClickAddShakeTrait CTAdvertisementMultipleCallTrait CTAdvertisementNoHideTrait CTAgloNameSpecialTrait CTAgloNameTrait CTAgloStrategyTrait CTBAlgorithmGaohuibaoARGTrait CTChapterScoreComboTrait CTChapterSkinBonesTrait CTChapterWinOverTrait CTClassSkinBonesTrait CTComboDotTrait CTCommentskinWithFeedBackTrait CTDotClickGameEndInfoTrait CTDotClickUsrDataGameEndTrait CTDotDeadReportToUsrDataGameEndTrait CTDotFirstClickDataGameEndTrait CTEffectAccTraitTrait CTGBMClearTrait CTGBMEmptySurfaceGameStartDotTrait CTGameComboScoreTrait CTGameEndStatusDotTrait CTLowDeviceEffectTrait CTMergeBlocksAgloNameTrait CTMergeBlocksSkinBonesTrait CTSkinBonesTrait ChapterAlgoStrategyTrait ChapterCollectScoreFixedTrait ChapterWinShowRainbowTrait ClassGuideEndNoAwaitNextFrameTrait ClassicsRandomReplaceTrait CleanSceneRandomSkinTrait CleanSceneUseSequenceSkinTrait ClearBoardHeartRippleAniTrait ClearBoardPlusBlockPosTrait ClearBoardPlusTrait ComboAddRemoveNumTrait ComboModeCtrlTrait ComboRainbowTrait ComboScoreSplitTrait ComboTimeTrait Combo_tips_lv_blockTrait ComboflyScoreTrait ConsecutiveLossRecoveryTrait ContinueSameMoreRoundLimitTrait ControlHighestScoreRhythmTrait CrossClearHardTrait CtrlAlgoLimitTimeTrait DailyPerformanceReviewTrait DailyWinStreakStateTrait DifficultChangeColorTrait EightHoursTravelTrait ExceedAimScoreTrait ExpendBoardScoreTrait FastNearTkxcClassTrait FastNearTkxcTrait FeatclearscreenbombTrait FeatclearscreenbombfixTrait FillCleanAlgoTrait FillExcludeTrait FirstGameScoreTrait FirstHardMoreClearAlgoTrait FiveRoundStatisticsTrait GLBFMiddleTipsViewTrait GLBFProgressTrait GLGameAdStrategyOptimizeTrait GLResultView6Trait GLShowAdsByGameTimeTrait GameInfoDisplayTrait GameLobbyNewEntryCtrlTrait GameLobbyTrait GameLobbyUnlockByBreakHighScoreTrait GameOverShowMiniGameIconTrait GateWinStreakStateTrait GpEmptyBoardRatioGuardTrait HighProgressShowBannerTrait HighScoreBreakthroughBoostTrait HighScoreLimitByUserAbilityTrait HighValueInterstitialReliefTrait HighWeightSessionQuestionReplacerTrait HoleFillPriorityTrait HotAreaAdaptScreenBottomStateTrait HotAreaClickUtilTrait InitBoardDelayTrait InitBoardSnakeProStyleProTrait InitialBoardDisableTrait IsOpenBottomCoverChangeTrait IsOpenChangeSkinTrait IsOpenClassEncourageEffectByScoreTrait IsOpenComboContinuousTrait IsOpenComboInterruptionStrategyReplaceBlockTrait IsOpenExtraReportTrait IsOpenGLHallMoreGamesPopupTrait IsOpenGlLoadAllGameBackTrait IsOpenLevelOverTipsChangeTrait IsOpenMoreCleanChangeTrait IsOpenMoreCleanTrait IsOpenNoVideoNoRevivieTrait IsOpenOperaPosTrait IsOpenRainbowPutBlocksEffectTrait IsOpenRightPutTrait IsOpenShang1ToShang3Trait IsOpenVibrateTrait IsOpenZhiJueToKunNanTrait IsOpenZiMuShangZengTrait IsPuzzleTimeTrait IsShowAchievementTrait IsStatisticalDataCollectTrait IsTravelMoreDiamondAwardTrait IsTurnAroundSurfaceTrait IsWarmhandsTrait JewelNewOutBlockTrait LDEnableNoSyncHighScoreRollingTrait LWMDualConditionHardPuzzleTrait LaneSchemeTrait LevelConfigTrait LevelExpAlgorithmTrait LevelLoopTrait LevelNoThemeTrait LevelRandomReplaceTrait LevelRemoteThemeTrait LevelWayHelpConfigTrait LevelWayHelpTrait LimitHighComboTrait LimitNoviceBlockSizeTrait LineDynamicSendTrait LoadingTieringTrait LocalThemeSaveTrait LoginTimeFirstDifficultyRatioTrait LongThinkClearAssistTrait LowFeedbackPleasurableOfferTrait LowHardSetFps30Trait LowWeightFillBlockSortAdjustAlgoTrait LowWeightFillBlockSortTrait MaturityDevelopmentV2Trait MergeBlocksIsPuzzleTimeTrait MergeBlocksLaneSchemeTrait MoreClearStopComboTrait MoreTimeToOpreateStateTrait MultiElementCollectTrait NewAbilityModelTrait NewBottomOfferTrait NewPlayerContinuePlayIncentiveTrait NewSeasonNotifyTrait NewStructTrait NewUserLevelTrait New_player_direct_guide_to_thirdTrait NewbieScoreRegulationTrait NoBannerAdNewUserByDaysTrait OpenChangeContinueHardTrait OpenComboModelTrait OpenLevelModelTrait OriginSkinShowGreyDefultSetBtnTrait OverHighScoreClearBoardTrait ParallelClearHardTrait Param230012Trait PixelBitControllerTrait PlaceBackAdaptTrait PlaceHabitAdaptPathTrait PlaytimeNoInterstitial1Trait PostGameToServerTrait PreClearScreenAnimOptimizeTrait ProblemIntervalTimeRandomBetaTrait PromotionMoreGamesInBlockCtrlTrait PutBlockOptimizeTrait Puzzle100Trait Puzzle100WeightConditionTrait PuzzleAfterTimingTrait PuzzleBeforeTimingTrait PuzzleConditionDiffTrait PuzzleFail100Trait PuzzleSuccess100Trait PuzzleTiming100Trait PuzzleTimingMaxPriorityTrait RatioAdjustTrait RatioDownTrait ReduceTravelSettlementTrait ReplaceBoardTrait ReplaceClearnScreenAniTrait RightPutHintCtrTrait RobotModelEventDataTrait SequencePredictDynamicDifficultyTrait ServerControl_board_init_v2Trait SetAddDefultSkinBtnTrait SettleFireworkTrait SetupNewTrait ShangZengAddRandomTrait Shangzeng3ReplaceZimushangzengTrait ShowSSSWhenSkinChangeTrait ShowTravellClearAnimaTrait SimpleMultiSolutionTuning2Trait SkinChangeAblePushTrait SkinPreEliminateGoodPerformanceTrait SkinRandomTrait SkinThree24NewColorsTrait SkinThreeOptimizationsTrait SkinThreeStageTrait SlowPlayerAnimTrait SmallgameLocalResTrait SolidColorTrait TerminateRefreshScoreTrait TheMoreEliminateTheMoreGoodEffectTrait TravelEntryClockShadowFixTrait TravelEntryClockShadowTrait TravelHappyOverTrait TravelNoSkinTrait TravelResultOptimizingTrait TravelScoreCutoffByProgressTrait
All 254 traits assigned to my Android test install. The ones about losing, revives and ads are highlighted.
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.
The rule that waits for an expensive ad
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:
RewardHighValueAdHarvestTrait.js · lines 63–67
``` 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 · lines 262–267 (reformatted)
``` 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.
**1. 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`:
canTrigger · lines 268–275
``` 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`.
**2. 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:
classifyLoadedRewardLevel · lines 290–293
``` 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"; }; ```
**3. 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:
onBottomOfferAlgo · lines 245–251
``` 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 lethal puzzle
The name maps through three tables in the main script:
main/index.45fec.js (original line numbers)
``` 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:
rC · main/index.45fec.js lines 318602–318678 (excerpt)
``` 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:
Step 1 of 5
A normal hand
A board from the middle of a game and three random pieces. They go down one at a time, and any full row or column clears.
Placing the pieces…
Every step comes from running the game’s own generator code in your browser.
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 ad rule isn’t the only one that asks for it
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:
PersonalMarginBreakTrait · onAlgorithmStrategyPriority, lines 153–159 · live config: prob 20, recordPercent 10, replaceAlgoId 9
``` 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 deal at 3,356 points, through to the Revive button. Recorded in a test copy of the iPhone app in Apple’s simulator, after we reset its saved high score so we could reach the line quickly.
The model that picks where you lose
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:
ClientDieModelLoadTrait · doPredictFeature, lines 1286–1312 (excerpt)
``` 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:
IsOpenDeathChangeReviveCountTrait · lines 224–229 and 181–184
``` 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.
The model that prices you
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:
| Change only this | Ad value, iPhone 15 | Ad value, Android | | --- | --- | --- | | Nothing (US, first score 1,000) | 25.48 | 48.96 | | Live in Brazil instead | 8.08 | 17.90 | | Live in India instead | 5.59 | 14.83 | | First score of 500 | 25.79 | 53.57 | | First score of 2,000 | 21.12 | 52.64 | | First score of 10,000 | 20.34 | 58.26 |
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 game uses prices like this to decide what kind of ads you get, and how many. The clearest case asks its server for two prices for you, one for full-screen ads and one for rewarded ads, and compares them:
IsOpenInterAndRewardAdvDecideReviveTrait · checkEcpm, mainTraits/index.88dc4.js lines 38529–38534
``` t.prototype.checkEcpm = function() { var e, t, o, r = this.saveData.interEcpm, i = this.saveData.rewardEcpm; // your full-screen and rewarded prices … return (…isReverse) ? r <= i * (…multiple) : i >= r * (…multiple); }; ```
In the versions the app ships, if your rewarded ads are worth at least twice your full-screen ads (1.5 or 2.5 times in other versions), you get an extra chance to revive, and a revive plays a rewarded ad. If full-screen ads pay more for you, you don’t.
Other rules work the same way. `FullScreenAdAddByEcpmTrait` adds an extra full-screen ad when your price is high. `IsOpenEcpmTooLowNoInterTrait` skips full-screen ads when you’re worth almost nothing. A few more open an early or a third revive when your rewarded ads are worth enough.
Try the on-phone model yourself:
25.48 Your ad value per 1,000 ads (eCPM)
finalEcpm 25.47779944720001 Loading the model
Platform
iPhone Android
First game score
200 500 1K 2K 10K
Recent ad prices
None yet Cheap Expensive
Country
Phone
Installed from
Vibration
On Off
All 27 inputs Sorted by how many of the model’s splits use each one.
1. Score in your first Classic game**1000**`endless_first_score`687 splits 2. Price of your latest ad**−999**`ad_revenue_recent`630 splits 3. Phone memory in GB**6**`total_ram`615 splits 4. iPhone or iPad model**74**`device_model`482 splits 5. Country**2**`country`448 splits 6. Highest ad price**−999**`ad_revenue_max`419 splits 7. Price of your latest rewarded ad**−999**`ad_revenue_recent_reward`382 splits 8. Lowest rewarded ad price**−999**`ad_revenue_min_reward`334 splits 9. Average ad price**−999**`ad_revenue_avg`328 splits 10. Highest rewarded ad price**−999**`ad_revenue_max_reward`276 splits 11. Days since install**0**`anzhuang_interval`267 splits 12. Lowest ad price**−999**`ad_revenue_min`263 splits 13. Ad campaign you installed from**15**`af_media_source`253 splits 14. Average rewarded ad price**−999**`ad_revenue_avg_reward`243 splits 15. Highest ad price, as of yesterday**−999**`ad_revenue_max_yesterday_bf`161 splits 16. Lowest rewarded price, as of yesterday**−999**`ad_revenue_min_reward_yesterday_bf`157 splits 17. Hours from install to first open**0**`active_install_interval`150 splits 18. Lowest ad price, as of yesterday**−999**`ad_revenue_min_yesterday_bf`146 splits 19. Latest rewarded price, as of yesterday**−999**`ad_revenue_recent_reward_yesterday_bf`137 splits 20. Latest ad price, as of yesterday**−999**`ad_revenue_recent_yesterday_bf`127 splits 21. Rewarded prices seen, as of yesterday**−999**`load_cnt_reward_yesterday_bf`123 splits 22. Average ad price, as of yesterday**−999**`ad_revenue_avg_yesterday_bf`111 splits 23. Ad prices seen, as of yesterday**−999**`load_cnt_yesterday_bf`108 splits 24. Highest rewarded price, as of yesterday**−999**`ad_revenue_max_reward_yesterday_bf`105 splits 25. Average rewarded price, as of yesterday**−999**`ad_revenue_avg_reward_yesterday_bf`90 splits 26. Music on**1**`bgm_switch`18 splits 27. Vibration on**1**`vibrate_switch`15 splits
Here’s the part of the game that walks the trees. It’s a plain loop: start at the root, go left or right on each input, add up the leaf weights.
EcpmFeatureBaseInfo · runModel, main/index.45fec.js lines 63141–63175 (excerpt)
``` for (var m = parseFloat(a + ""), u = 0; u < t.length; u++) { // m starts at the base score; t is the 59 trees for (var p = t[u], f = 0, _ = 0; _ < o; ) { _ += 1; var h = p.left_children[f], g = p.right_children[f], y = p.split_conditions[f], C = p.default_left[f], v = s[e[p.split_indices[f]]], T = -1; (null === v && 1 === C || v < y) && (T = h); (null === v && 1 !== C || v >= y) && (T = g); if (-1 === T) { m += parseFloat(p.base_weights[f].toString()); // reached a leaf: add its weight break; } f = T; } if (_ >= o) throw new Error("[EcpmFeature] 树的遍历超过最大深度,可能存在死循环。"); // "Tree traversal exceeded max depth, possible infinite loop." } ```
A model that guesses you’ll play less tomorrow
There’s also a 1,000-tree LightGBM model with 93 inputs that predicts whether you’ll be less active tomorrow. It leans hardest on how long you’ve had the app, your high score, your RAM and your average score. If it scores you at 0.69 or above, a companion rule swaps in a pink revive button, and the revive skips the ad:
NewNextDayLowActivityTrait line 1010 · NextDayLowActivityFirstReviveFreeTrait lines 112–117
``` return this.state.predictionScore >= this._lowActivityThreshold; // 0.69 … if (hs.tp.isClassAdvertisement_RewardProxyShieldPlayAdvertisement(e) || …) { var r = cc.isValid(this._pinkReviveNode); if (!this.isTrigger || !r) return; e.returnValue = !0; e.args[0] = !0; // shield the ad: the revive goes through without one this.saveNextDayLowActivityReviveTime(Date.now()); } ```
It works once a day.
Models on their server
Some rules send your data out and act on the score that comes back, from server models with names like churn, LTV, revive-click and xDeepFM. Their weights stay on the server, so all we can show is what the phone sends. One of those requests, to `/v1/ecpm_prediction`, sends 40 fields, including your carrier, storage, the hour and stats on your recent ad prices.
Other rules that shape your game
The ad rule isn’t the only one that changes your pieces or your score. We caught one working live on iPhone. `MaturityDevelopmentV2Trait` gives each session a “maturity” score made of three parts: habit (10.98), skill (14.66) and your ad history (19.27). Ours added up to 44.91, under its line of 65, so it swapped some of the hard puzzles it would have dealt for easy fills. Another, `FailStreakStrategyTrait`, stepped in after two games below our record and asked for easier pieces.
These came up while tracing the ad rule on Android, and each has the same kind of evidence in my research files.
| Rule | What the code does | | --- | --- |
| `MultiClearBoostThenKillTrait` | Sends your user ID to two server endpoints and gets back two labels. The code names both tiers as “not sensitive to being forced to die” (怼死不敏感). For those tiers, it waits for a big clear, then requests the lethal puzzle a few rounds later once the game has lasted about as long as your usual game. |
| `MABXiBaiTrait` | A six-arm experiment on how soon the lethal puzzle can be requested: never, or after 4, 6, 10, about 17 or 30 minutes. For each arm it reports whether you started a new game within 15 minutes, and what share of revive offers you accepted. | | `FastHandPunishTrait` | Asks for hard pieces when your last six sets averaged under 4.2 seconds each and you’re past 93% of your high score. | | `AdPlusMinusStrategyTrait` | After one kind of extra revive offer, adds a random bonus of up to 10% of your high score to your current score. |
Questions people asked
Is it the same on iPhone?
Yes. We took apart Block Blast for iPhone too, version 7.4.9, and a second GPT-6 Astra run checked those findings from scratch. It downloads the same rule from its own update server, and the file matches the Android one except for one line. Everything else is identical: three ads, 3.5×, the score bar, and the same lethal-puzzle generator.
The iPhone app also lists a second version of the rule, `RewardHighValueAdHarvestV2Trait`, and in that one the ad-history check can be switched off:
RewardHighValueAdHarvestV2Trait.js (iOS) · lines 323–331
``` t.prototype.canTrigger = function() { var e = this.storageData, t = this.viewCountThreshold; if (e.totalRewardShowCount < t) return !1; if (!this.requireRecentNoEscape) return !0; var r = e.recentRewardResults.slice(0, t); return !(r.length < t) && r.every(function(e) { return !0 === e; }); }; ```
The app ships 12 versions of it. Half turn that check off, and they need anywhere from 1 to 3 ads, so in some of them a single rewarded ad you closed early is enough. The price bar depends on how long you’ve had the app: 3.2× the first ad loaded that session in your first week, 2.7× through day 30, and 3× your last ad after that.
What happens if I play without wifi?
This rule is saved on your phone after it downloads, so it still runs offline. But it only fires when a rewarded ad is loaded, and ads load over the internet, so once nothing is loaded it can’t fire. Other rules keep going. On my Android install, the one that asks for hard pieces when you’re playing fast near your record runs entirely on your phone. The one that asks the server which players “don’t mind being forced to die” can’t get an answer offline, so it stays off for that session.
Does an ad blocker do anything?
It depends on the blocker. Browser ad blockers, on iPhone or Android, only work inside the browser, so they don’t touch the game. A blocker that covers your whole phone, like Private DNS on Android or a DNS or VPN-based blocker on either, can stop rewarded ads from loading, and then this rule can’t fire, for the same reason as playing offline. The rules that don’t use ads keep running: the fast-play one, the experiment that times when the lethal puzzle can be requested, and, since you’re still online, the one that asks the server about you.
Is this why I always lose right after beating my high score?
That’s exactly where these rules are aimed. We caught `PersonalMarginBreakTrait` doing it live: once you’re 10% past your record, every deal has a 20% chance of being the lethal puzzle. The ad rule waits for 1,000 points past your record (500 or 750 in other versions), and after a revive it moves the bar up and can fire again.
Can I avoid it?
Not really. On Android, the original rule needs your last three rewarded ads to pay out, so closing one early does turn it off until you’ve had three good ones in a row again. But on iPhone, the V2 versions can skip that check completely and fire after a single ad, even one you closed. And the other rules that ask for the lethal puzzle, like the timing experiment and the one that waits for a big clear, don’t look at your ads at all. The one thing that stops this rule everywhere is not having a rewarded ad loaded, which is what happens offline or with a phone-wide ad blocker.
Isn’t this just adaptive difficulty?
Adaptive difficulty changes the pieces based on how you’re playing. This rule doesn’t look at how well you’re placing pieces. Its inputs are your recent ad results, the price of the ad sitting in memory, and your score relative to your record. And the generator it asks for isn’t a harder set of pieces. It’s a set chosen because no way of placing it works.
Is this even allowed? Doesn’t Apple review apps?
Apple reviews the app you download from the store, and this rule isn’t in it. The app downloads it from the game’s server after you install, and the server decides which players get which version. Apple does let apps download JavaScript updates like this, which is how tools like Expo Updates work, as long as the update doesn’t change what the app is. Whether changing your pieces based on ad prices fits that is up to Apple.
Why does it run on the phone instead of their servers?
The server does decide who gets the rule and which version. But the decision itself needs things only your phone knows in that moment: your board, the next set it’s about to deal, and the ad that’s loaded right now. So that part runs on the phone, which is also why it could be taken apart.
How I know this is real
- **The apps.** Android: the 10.8.2 XAPK from APKPure, downloaded October 11, 2026. All its split APKs pass signature verification, and the version matches the public listing on APKMirror. iOS: the 7.4.9 IPA from a public mirror. 4,670 of its game resources, including the main script and the catalog, check out against Apple’s code signature.
- **The rules came from their servers.** The Android app’s cache says it downloaded the rule from `bband1.afafb.com`, the same host it fetches all its traits from. We fetched that URL separately, and the file matched the app’s cached copy byte for byte. The iOS app’s signed catalog names both iOS rules, and the app fetches them from `bbios1.afafb.com`. As of October 15 all three URLs still serve the same files, so you can check this yourself (commands below).
- **It was assigned and loaded.** On a fresh Android 14 emulator with no account, at 23:01:15 UTC on October 11, the app had the config ready, arm 332243014 selected for this rule, the module loaded with its storage initialized, and the bottom-offer pipeline it plugs into switched on.
- **The decoding is exact.** The traits are encrypted with XXTEA and compressed with Brotli. The decoded rule’s hash is below. For the native library, my offline decode matched a memory dump of the running app across 25,317,680 bytes with zero mismatches.
- **Nothing in the Android game was changed.** The only change was a rendering workaround so the game would draw on the emulator. No scores, ads, flags or config values were edited.
Check it yourself
There are three evidence bundles: Android (113 KB, SHA-256 `bd55151e6a1527f628459c2fffa115d5b14a765a3fb5a3f729387fbac6f707c8`) and iOS (2.7 MB, SHA-256 `02bdb0e0e9b02d77844efbcf4b82f48e17f2b688fc48cbf84f83e75ae289df7d`), plus a third for the live iPhone catch (270 KB, SHA-256 `5f36863daabd2b500c8062d005f41e6c5e7dcea5a238c89e558e19a928dcc57f`). They have the decoded rules, the encrypted originals, excerpts from the rest of each app with their original line numbers, the test scripts and the provenance records. You need Node.js and Python 3.
Terminal · Android bundle
```
the rule, run unmodified with fake inputs: 28 checks, prints "passed": true
node proof/verify_reward_harvest.js
brute-force search of the test board: 0 solutions in every order
python3 proof/verify_unsolvable.py
the game's own generator on that board: picks pieces [2, 3, 13]
node proof/verify_packaged_algorithm.js ```
Terminal · iOS bundle
```
both iOS rules, the generator, two solvers and the code-signature check
python3 proof/run_all.py ```
Terminal · the encrypted rules on their servers match the bundles
``` curl -s https://bband1.afafb.com/update/block_android_online/remoteRes/0/remote/RewardHighValueAdHarvestTrait/index.700f5.js | shasum -a 256
dec91aa4001f651a74bf101d512587b9a364cb71ea2a81a9cc9dbbcd2444b477
curl -s https://bbios1.afafb.com/update/block_ios_online/remoteRes/0/remote/RewardHighValueAdHarvestTrait/index.ff35e.js | shasum -a 256
61ac70045995f44b3c6bb22b2d062a386593ce6a632fbcec3863a51110850cc2
curl -s https://bbios1.afafb.com/update/block_ios_online/remoteRes/0/remote/RewardHighValueAdHarvestV2Trait/index.677f6.js | shasum -a 256
e38953430980f4b6a10b29337ec9a41634bb732ee84dd5997bfe78507b26ee1e
```
| Android base APK | `a520fcc1d1ba1ff0c643e30b059c87a01fde650ec46be5c85fde5d815ec82447` | | --- | | Android XAPK | `91988cf6e92cd0d346b869df0a8c6740f3a5fd8b722e21779e045426c7553015` | | Android rule, decoded | `f94a5d54d7f6ab0af097f408bda09b091e7767b17749f8ba828b41b638b330ad` | | Android main script | `8dce6532cafc25d982f2af3659647e0e0017c3902cbc31f872d375804dc3678c` | | iOS IPA | `7fc9b47e10dc8303f662e1c0ad88b93e7119e2846972eddb1a18bd205b54056a` | | iOS rule, decoded | `179bf50ae3c0c4a9bd5457281e4876faae728778f6181dbb0d3799b42ec70d61` |
The key and the steps for getting past the apps’ protection aren’t in the bundles. If you’re a journalist or researcher and want to verify the decoding step, email me.