2K Build Lab

Reverse-engineering a shipped game’s rating engine into a product people pay for.

A solved build: every attribute the search committed to, the body that makes it legal, and the cap-breaker plan that pays for it.
What a correct answer looks like Every attribute the search committed to, the body that makes it legal, and the exact cap-breaker plan. Read the plan closely — five cap breakers buy +5 three-point, while one buys +5 driving dunk. That non-linearity is the whole problem.

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.

Search space
9,485 bodies × 21 attributes, 25 to 99, × every legal cap-breaker allocation
Engine
Rust, compiled to WASM — 15x native and 11.5x wasm over the JavaScript it replaced
Parity standard
Byte-identical — 930,405 cells validated against the game, zero mismatches
Billing rule
Only on success — a run that finds nothing costs the player nothing

A build is a purchase you cannot undo

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.

2K Build Lab landing page
LandingThe pitch is decision support, not a database lookup. The hero card shows the shape of a real answer: body geometry, target ratings, and the cap breakers needed to reach them.

What the product does

The 2K Build Lab builder with all attribute sliders at baseline
The builderTwenty-one attributes across six categories, each showing a live ceiling. Anything left at its baseline of 25 counts as unset, so the optimizer can spend those points wherever they buy the most. Category token and badge-slot counts recompute as targets move.

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.

Builder with five target ratings set
Declaring targetsYou name only the ratings that matter to your playstyle. Everything else stays flexible, and that flexibility is what the optimizer converts into a cheaper build.
Cap breaker budget set to twelve
Cap breakersCap breakers apply after the natural attribute budget is spent, so they change which builds are reachable, not merely what they cost. Telling the optimizer how many you hold reshapes the search rather than adjusting a number at the end.

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.

Conflict detection showing two targets that cannot coexist
Conflict detection95 three-point and 95 driving dunk do not fit on one legal body. The panel names both resolutions and the knock-on cost of each. The build title, "2-Way 3PT Slasher", comes from a forward engine I rebuilt out of the game's own bitmask-to-string-table lookup.
Build readiness meter
Build readinessA five-state recommendation machine drives the copy, icon and colour from one shared map, so desktop and mobile cannot drift apart. It advises and never blocks. A heuristic that hard-blocked genuinely buildable configurations is a documented regression class in this codebase.
An optimized build result with its cap breaker plan
The resultEvery attribute the search committed to, the body that makes it legal, and the exact cap-breaker plan. The card exports straight from the DOM as a PNG.

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
Build by badges mode showing badge tier ladders
Build by badgesThe inverse workflow. Pick badges at the tiers you want and the attributes get derived. Every threshold and height gate here came out of the game's binary tuning data, verified cell by cell.
The builder on a phone viewport
MobileThe desktop rail becomes a bottom sheet. The PNG export forces desktop width while it captures, a detail with its own regression test, because media queries key off the viewport and not the element.

Why this cannot be a formula

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 invariant

The objective, stated precisely

The 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.

Three generations, each validated against the last

No generation replaced its predecessor until it reproduced that predecessor's winners exactly.

01 · Beam search. Brute force, tuned by measurement

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.

02 · Analytic. Decoding the objective turned search into guidance

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.

03 · Rust. One core crate, three compilation targets

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.

Search machinery worth naming

  • The corner rule. The verified score over the body grid turns out to be a plateau, so one deep solve per feasible height, at the lowest-weight and highest-wingspan corner, ties a full ranked scan. Body search dropped from about 300 solves to between one and four.
  • The slack ladder. Solving at budget plus slack and filtering back recovers builds whose path crosses momentarily over-budget intermediates. My naive always-on version regressed the low-budget case badly, 21 false no-builds out of 21, because the validation corpus skewed 77 of 106 toward high budgets. A threshold ladder and a deliberately budget-diverse corpus fixed it. The lesson went straight into the docs: a corpus skewed to one regime hides regressions in the other.
  • Reachability floors. Per-solve minimum-natural floors derived by biased-random sampling plus binary search on a deterministic seeded RNG. Byte-identical winners, 17 to 31 percent off expensive solves. The precomputed version is impossible, and I proved that too. Floors are inherently per-build.
  • Three-valued feasibility. A first-witness-stops search with a deterministic fuel cap measured in work units rather than wall time, so the verdict is the same on any hardware. Unknown never gets reported as infeasible.

Reading the rating engine out of a 12.7 KB WASM binary

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.

Extracting a new game title before any tool supported it

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:

  • The attribute cap formula in closed form, 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.
  • 58 badges' tier requirements at 106 of 106 cells, plus badge token costs, 30 takeover slots, badge effect curves, stamina, and the WNBA and GoatBuilds league variants.
  • 4,604 build names mined from a 137,271-string UTF-16 localization table using a learned grammar and vocabulary gate, scoring 99.93 percent recall against the known 2K26 name set.
  • The legal body grid, proved identical to 2K26's. The same 9,485 bodies.

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.

The badge-slot investigation, wrong turns included

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.

Measured, not assumed

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.

Table of measured performance wins across engine, infrastructure and search
Measured performance workRoughly 70 percent off solve latency across eight independently A/B tested changes, plus the infrastructure and payload work. The A/B harness compares winning builds, not just timings.

The kill list

Ideas I built or measured to death, written down so nobody re-attempts them:

Linear cap-breaker cost model Killed at 43%

43 percent accuracy against a 74 percent always-predict-the-same baseline. The classifier really is the function.

Cap-breaker pre-screen Killed, unsound

It would have wrongly discarded 23 percent of keepable states.

Archetype memoization Killed at a 16.7% ceiling

Even a perfect cache key tops out at a 16.7 percent hit rate. I measured that before building it.

Flip-aware gate Reverted, zero gain

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.

Deep search on by default Restricted, 0 of 113

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.

Honesty as an engineering requirement

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.

Sign-in gate showing an example report labelled as not the user's build
The sign-in gateThe sample report carries the label "EXAMPLE REPORT, NOT YOUR BUILD" in the interface itself. Its numbers are a hand-picked archetype built from real attribute and badge identifiers, deliberately not a fabricated personalised result.
Pricing page
PricingCredits get reserved when a job is queued and debited only when a viable build comes back. No-builds, conflicts and errors cost nothing, enforced in the ledger rather than promised in the copy.

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.

What it is built from

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.

  • React
  • Vite
  • Rust
  • WebAssembly
  • rayon
  • Node 22
  • Cloud Run
  • Cloud Tasks
  • Supabase
  • Postgres
  • Stripe
  • Deno
  • Playwright
  • wabt

Practices that made the rest possible

  • Kill switches on everything. Around fifteen environment flags gate individual optimizations, so a risky change reverts without a redeploy. Engine defaults live in code, so a lost environment variable cannot quietly revert production.
  • Tests that encode specific historical bugs. Source-pattern scans that grep for required helpers, a test that parses the edge function to catch a hand-copied pricing table drifting, and a CSS test enforcing that every mobile rule touching the capture region has a desktop override.
  • Living decision docs. Nineteen context documents with a written bar for inclusion, retractions kept inline, and a standing rule that a confidently stale note is worse than no note.
  • Determinism wherever money is involved. Seeded RNG, work-unit budgets instead of wall-clock timeouts, and idempotency keys on every charge path.

No affiliation with NBA 2K, 2K Sports or Take-Two Interactive.