Reverse-engineering a shipped game’s rating engine into a product people pay for.
A combinatorial optimizer for NBA 2K character builds, reverse-engineered from the game's own WebAssembly rating engine and running today as a paying product across two game titles.
I built and operate all of it: the product, the search engine, the infrastructure, the billing, and the support inbox. Live at 2kbuildlab.com.
NBA 2K players spend real money and weeks of grinding on a character build, then find out it misses a rating threshold they cared about. There is no refund and no respec. You start over.
The in-game builder gives you sliders and no foresight. Raise three-point shooting and something else quietly loses headroom, because the interactions run through attribute dependencies, body geometry, and a post-cap currency called cap breakers whose price shifts depending on the rest of your build. Players find the conflict after they have already committed.
2K Build Lab turns that around. You declare the ratings you actually care about, and the optimizer searches the legal build space for the cheapest configuration that satisfies all of them, leaving you the maximum number of points and cap breakers free for everything else.
Every slider release re-runs a constraint-closure pass in a Web Worker, inside a three-second budget on weak hardware. It recalculates each attribute's reachable ceiling given every other target you have already set. That is why the ceilings move while you type, before anything reaches a server.
The most useful thing the tool does is refuse to waste your money. Two targets that cannot coexist on any legal body get caught in the browser, before a credit is spent. It does not just refuse, either. It shows both ways out and what each one costs you elsewhere.
Read that cap-breaker plan closely. Five cap breakers buy +5 three-point. One cap breaker buys +5 driving dunk. That non-linearity is the whole problem, and it is why there is no price list to look up and no shortcut around searching.
Cap breaker plan
The search space is roughly 9,485 legal body geometries of position, height, weight and wingspan, multiplied by 21 attributes each running 25 to 99, multiplied by every legal cap-breaker allocation over that vector. Trillions of legal builds, and no closed form.
The cost function does not separate, either. A 15-row archetype classifier decides cap-breaker pricing by reading the entire attribute vector, so raising one attribute can change the price of every other one. I built and measured several attempts to precompute or predict that price. All of them died. The classifier is the objective, and something has to evaluate it.
Position never affects caps or cap breakers. They key on body geometry alone. That finding removed an entire dimension from the search, and it took a controlled sweep to establish rather than an assumption.
Load-bearing invariantThe winning build minimises naturalRawOverall + 0.25 x usedCapBreakers. Lower committed overall is better, because it means fewer points locked into the floor and more left free. That reads backwards to anyone who assumes a build optimizer maximises overall rating, and it briefly shipped inverted. It is now a guarded invariant with identical formulas in all three engines.
No generation replaced its predecessor until it reproduced that predecessor's winners exactly.
A width-600, seven-round beam over roughly a million variants per solve, with dominance pruning, per-round dedup and a worker pool. Sweeps found a hard feasibility cliff. Width 550 returns no build, width 575 finds the winner. They also showed the winner usually surfaces in round six. The tuning carried real weight, and I proved that rather than assuming it.
Reverse-engineering the rating model showed that overall rating and cap-breaker pricing are one system. Overall is a per-height interpolation over the maximum of 15 category weighted averages, and the argmax category is the cap-breaker pricing row. That collapsed brute force into 15 fixed-row greedy descent trajectories, a width-128 mini-beam to rescue the coordinated reallocations greedy structurally cannot reach, and an exact verify gate at every feasible state so the model only guides and never approximates the answer.
It came out 25 to 40 times faster with equal-or-better winners on every fixture. It also recovered 30 of 43 historical production "no build" results as genuinely buildable, which is revenue the old engine had been turning away.
I ported the analytic engine leaf-first: cap-breaker pricing, then data baking, closure, rating, the search phases, and orchestration last. Each stage had to pass a parity gate against JavaScript before anything downstream could depend on it. A single zero-dependency core now compiles to a native addon, server WASM and browser WASM.
That bought 15x native and 11.5x wasm over JavaScript, with rayon adding another 4.45x bit-identically through an ordered collect. Heavy solves that took 17 to 20 seconds now return in under a second. I picked Rust over C++ for one reason: IEEE-strict float semantics by default, which is what makes byte-exact parity reachable without compiler-flag footguns.
The game's web builder ships an AssemblyScript-compiled WebAssembly module that computes caps and cap-breaker pricing. Disassembled with wabt, its cap-breaker function breaks down into a caps computation, a 15-row by 21-attribute archetype classifier of about 315 float multiply-accumulates, and a cumulative five-tier boost loop.
I pulled the weight matrix of 6,300 floats and the nonlinearity table of 2,100 floats straight out of live WASM linear memory at fixed byte offsets, along with internal caps from a scratch buffer. Shadow-diffing against the original then surfaced three details no amount of reading disassembly would have settled. The engine uses banker's rounding rather than truncation. The boost clamp has a specific form. And its internal caps disagree with its own public API on some bodies.
The JavaScript replica I built from that validated on 930,405 tier-cells with zero mismatches, runs about 18 times faster than calling the original, and ships on by default behind a kill switch. It is worth 21 to 38 percent of solve time.
I decoded the rating model itself to machine precision: 6,300 of 6,300 weight cells, 2,100 of 2,100 curve cells, maximum drift between f32 and f64 of 4.3e-5, across nine curve shapes and 300 height-specific weight vectors. That work also caught the source file's own annotations being wrong about which curves were exponential.
Build names turned out to be a bitmask, a binary search and a string table. The data file was encrypted, so I captured the decoded table off the running site with conditional breakpoints, then rebuilt it into a forward engine that takes attributes plus position and returns the exact build name, plus a reverse dataset mapping any name to its minimum caps per position.
NBA 2K27 had no web builder to read from, only the game's own tuning file, which I sourced through Steam's cloud-sync cache. It stores a packed big-endian bitstream. Booleans take one bit, enums take ceil(log2(cardinality)) bits, and alignment padding is simply absent. The official type-layout offsets do not describe the stream at all.
Whole-file sequential models drifted about 5 percent, so the working method became signature anchoring with relative offsets. Find a known byte pattern, then read outward from it. That produced:
min(99, round(25 + 74 · hMul · wMul · wsMul)), verified 147 out of 147. Two near-miss formulas got falsified first, one of them killed by proving its rounding-offset rescue interval was empty.The trap worth remembering: badges and takeovers reference attributes through two different enums, one with 22 values in 5-bit fields and one with 156 values in 8-bit fields. Read either with the other's width and you get plausible garbage.
Badge slot counts resisted everything. I read constants out of the binary and validated them with permutation self-checks, fitted models, and ran one-variable sweeps against the live builder. I published two conclusions and then publicly retracted both. Eventually I identified a transcription-error signature in my early readings, and found that the documentation's own token-potential vector was wrong.
The algorithm finally came out of a competitor's minified client bundle, where computeBadgeSlots revealed the missing input carrying 60 percent of the weight: earnable badge counts. Then a photograph of the actual in-game screen outranked all of it and killed the competitor's uncapped fill pass. It shipped with provenance notes attached, because how I got there matters for how much to trust it.
Baking that data into the Rust core and pushing the JavaScript engine to 169 of 169 bit-exact parity surfaced three real float bugs that had been invisible until then: a clamped raw overall, an f64-versus-f32 weight read, and an association order that flipped argmax ties.
Speed that changes the answer is not speed. Every optimization below had to return the identical winning build before it counted, and every one ships behind an environment-variable kill switch.
Ideas I built or measured to death, written down so nobody re-attempts them:
43 percent accuracy against a 74 percent always-predict-the-same baseline. The classifier really is the function.
It would have wrongly discarded 23 percent of keepable states.
Even a perfect cache key tops out at a 16.7 percent hit rate. I measured that before building it.
I built it and it worked exactly as proven, skipping 69 percent of cap-breaker solves. It bought zero wall time. Reverted anyway, which stung.
Running every slack mode and keeping the cheapest winner improved 0 of 113 corpus cases. It ships opt-in and pro-only rather than charging everyone double the compute.
I also root-caused two of my own wrong conclusions instead of quietly dropping them. A corpus study concluding certain no-builds were truly infeasible fell apart when the harness turned out to search a different body domain than production. A "height is a dead signal" finding traced back to an artifact of synthetic test data. Feasibility telemetry now carries a contract version and engine revision, because verdicts from different engines are not comparable.
Three questions had been tangled together in the interface. Can you run this, should you run this, and is a claim about your build trustworthy. Separating them produced a rule the whole system now follows. Only a constructive witness, meaning a real attribute vector plus a tier plan, may call a build feasible. Only a sound proof may call one impossible. Heuristics may warn and may never block.
This audience spots manipulation instantly, so the funnel skips the usual levers. No fake scarcity. No fabricated progress denominator on the referral page. Credit reset dates appear only when they are real.
I audited the three highest-intent pages against a 40-principle UX framework and then retracted several of my own recommendations once I checked them against the real mathematics. "N attributes are blocking you" is wrong, because readiness is a threshold and not a count. I could not make the Target Load meter more motivating without making it dishonest, so I left it alone.
React 18 and Vite on the front end, with CSS Modules on a per-file component convention, a Web Worker running the feasibility engine, browser WebAssembly for the cap engine, and DOM-to-PNG result capture. A Rust core crate compiles to a native addon, server WASM and browser WASM, parallelised with rayon.
Three Cloud Run services handle compute: a public enqueue endpoint, a private worker, and a preflight service at min-instances zero so free feasibility checks never queue behind paid solves. Cloud Tasks sits between them, Secret Manager holds credentials, and GitHub Actions deploys on push. Supabase Postgres stores everything behind row-level security, with SECURITY DEFINER RPCs, advisory locks and Deno edge functions for checkout, webhooks, referrals and job dispatch. Stripe handles subscriptions, one-time packs and coupons, on a two-phase reserve-then-debit flow with idempotency keys, where refunds read the ledger rather than the price table.
No affiliation with NBA 2K, 2K Sports or Take-Two Interactive.