Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 6 min read

JavaScript vs. Quadrillions: Why We Had to Ban the 'Number' Type in Our Gaming Architecture

A few weeks ago, while building a reference database for an idle simulation game, our team hit a bizarre bug report: "Why does my mutated pet with 14.2 Quadrillion speed display the exact same comparison stats as my pe

A few weeks ago, while building a reference database for an idle simulation game, our team hit a bizarre bug report:

"Why does my mutated pet with 14.2 Quadrillion speed display the exact same comparison stats as my pet with 14.28 Quadrillion? Your tier list is ranking them identically."

We opened DevTools, inspected our Next.js static payload, and ran a quick check in the console:

14200000000000000 === 14200000000000001
// => false (phew!)

14285714285714285 === 14285714285714286
// => true (wait... WHAT?)

Welcome to the murky underworld of Roblox idle games and modern tycoon mathematics, where the game engine happily spits out values in Quintillions ($10^{18}$) and Sextillions ($10^{21}$), and JavaScript's venerable IEEE 754 64-bit float quietly waves the white flag.

Most technical writeups about modern frontend engineering focus on shaving 20 milliseconds off TTFB or tweaking React Suspense boundaries. But when you are dealing with astronomical game mechanics, the hardest architectural problem isn't performance.

It's numerical epistemology: how do you store, serialize, sort, and calculate numbers that exceed JavaScript's hardware capabilities without corrupting data or crashing JSON.stringify?

Here is why we had to completely ban the native number primitive for game stats in our production site, Ride A Pet Wiki, and the dual-track architecture we engineered instead.

1. The Trap: Number.MAX_SAFE_INTEGER Silent Drift

JavaScript's Number is an IEEE 754 double-precision float. It reserves 53 bits for the mantissa (significand), which gives us:

$$\text{Number.MAX_SAFE_INTEGER} = 2^{53} - 1 = 9,007,199,254,740,991 \approx 9.007 \times 10^{15}$$

Anything larger than ~9 Quadrillion loses precision. It doesn't throw an exception. It doesn't issue a warning. It just quietly rounds to the nearest representable float:

const max = Number.MAX_SAFE_INTEGER; // 9007199254740991

console.log(max + 1); // 9007199254740992
console.log(max + 2); // 9007199254740992  <-- Silent identity corruption!

In idle tycoon games like Ride A Pet, late-game mechanics routinely push pet velocities and income multipliers into $10^{16}$ (Qa), $10^{18}$ (Ql), and beyond.

If you store pet stats as vanilla JavaScript numbers in your JSON or database, you enter a nightmare of non-deterministic bugs:

  • Two distinct top-tier pets evaluate to petA.speed === petB.speed.
  • Tier list sort algorithms produce arbitrary, unstable arrangements across SSR hydration passes.
  • Mutation multipliers compound rounding errors, rendering stat comparisons completely untrustworthy.

2. Why Not Just Use BigInt? (The JSON Trap)

Every engineer's immediate knee-jerk reaction is: "Just use BigInt(n)!"

We thought so too. Until we tried running our static generation build:

// pages/api/pets.ts or getStaticProps
const pet = {
  name: "Celestial Snail",
  baseSpeed: 14285714285714285n,
};

JSON.stringify(pet);
// πŸ’₯ TypeError: Do not know how to serialize a BigInt

By design specification, the JSON standard has no syntax for BigInt. JSON.stringify() explicitly refuses to handle it to avoid silent data loss in downstream parsers (like Python or Go services that parse integers as 64-bit signed ints).

The Dirty Hack People Try:

// DON'T DO THIS IN PRODUCTION
BigInt.prototype.toJSON = function () {
  return this.toString();
};

Monkey-patching BigInt.prototype is tempting, but it pollutes the global runtime. If any third-party npm package in your dependency graph also touches BigInt serialization, you create terrifying cross-package side effects.

Furthermore, game numbers aren't pure integersβ€”they often contain fractional multipliers (e.g., +1.35x Speed Mutation applied to 4.8Qa). BigInt truncates fractions entirely:

4800000000000000n * 135n / 100n; // Loses fine-grained precision on fractional bases

3. The Solution: Dual-Track Numeric Representation

To solve this cleanly across Next.js SSG, client-side React components, and dynamic sorting, we established an immutable rule across our codebase:

All game statistics must be stored and transmitted as canonical strings. Numbers are only derived projections.

Here is the TypeScript structure we designed for our dataset:

export interface CorroboratedStat {
  /** The authentic raw text string straight from game data/UI (e.g., "14.2Qa", "500B") */
  readonly raw: string;

  /** Normalized floating-point log-scale representation exclusively for sorting */
  readonly sortValue: number;

  /** Source credibility tier (S1 to S5) */
  readonly tier: 'S1' | 'S2' | 'S3' | 'S4' | 'S5';

  /** Verification timestamp */
  readonly verifiedDate: string;
}

The Radix Suffix Engine

Roblox games use standard engineering shorthand: K, M, B, T, Qa, Ql, Sx, Sp, Oc, No, Dc.

Instead of converting 14.2Qa into 14200000000000000 (which breaks), we parse it into a Logarithmic Sort Projection:

const SUFFIX_EXPONENTS: Record<string, number> = {
  k: 3,
  m: 6,
  b: 9,
  t: 12,
  qa: 15,
  ql: 18,
  sx: 21,
  sp: 24,
  oc: 27,
  no: 30,
  dc: 33,
};

export function computeSortValue(input: string): number {
  const match = input.trim().toLowerCase().match(/^([\d.]+)\s*([a-z]*)$/);
  if (!match) return 0;

  const [, numStr, suffix] = match;
  const mantissa = parseFloat(numStr);
  if (isNaN(mantissa)) return 0;

  const exponent = suffix ? (SUFFIX_EXPONENTS[suffix] ?? 0) : 0;

  // We map the astronomical magnitude to a logarithmic space:
  // log10(mantissa * 10^exponent) = log10(mantissa) + exponent
  // This easily fits inside Number.MAX_SAFE_INTEGER while preserving exact relative ordering!
  return mantissa > 0 ? Math.log10(mantissa) + exponent : 0;
}

Notice what happened:

  1. The display and data pipeline remain 100% loss-free: "14.2Qa" travels through Next.js SSG, hydration, and React state as a pure string.
  2. Sorting is $O(1)$ and immune to precision clipping: By projecting into logarithmic space (Math.log10(mantissa) + exponent), a value of $14.2 \times 10^{15}$ becomes 1.1522 + 15 = 16.1522. A value of $10^{33}$ becomes 33.0.
  3. JavaScript's 53-bit float can comfortably sort numbers with exponents up to $10^{308}$ without breaking a sweat!

You can see this live in action on our Ride A Pet Interactive Calculator & Comparison Tool, where users can mutate and compare pets with multi-quadrillion stats in real-time.

4. The Second Challenge: Circular Scraping & The "Epistemology" of Game Wikis

Fixing the math was only half the battle. Once we had a mathematically sound engine, we ran into an even deeper modern web crisis: Circular Scraper Poisoning.

If you search for game stats on Google today, you will find 10 different wiki sites that all show the exact same numbers. But if you decompile the actual game client or inspect memory states, you discover all 10 sites are completely wrong.

Why?

  1. Site A uses an LLM to guess a formula or publishes an uncorroborated leak.
  2. Scraper Bots B, C, and D ingest Site A's data.
  3. Site E sees four sites agreeing and assumes it's verified consensus.
  4. An AI search engine crawls Site E and summarizes the fake stat as ground truth.

The Evidence-Grading Protocol (S1 to S5)

To protect the integrity of Ride A Pet Wiki, we baked an epistemological firewall directly into our data pipeline:

Tier Definition Rule
S1 Official API / Client Binary Extraction First-party ground truth
S2 Official Developer Patch Notes / Discord Announcements Primary source statement
S3 Corroborated ($\ge 2$ independent empirical tests) Verified community consensus
S4 Single-source empirical report Marked as provisional
S5 Unverified / Speculation Strictly forbidden from being published as a value

If our research team discovers that a mutation (e.g., "Radioactive" or "Void") has an unknown multiplier, our calculator refuses to show an interpolated guess.

Instead, our UI explicitly displays a "Data Under Corroboration" badge with an open call for player telemetry. In an era of AI hallucination, an honest missing value is infinitely more valuable than a confident wrong answer.

5. Architectural Lessons for Web Engineers

Building tools for gaming communities is one of the best ways to test your engineering fundamentals against edge cases you rarely see in typical SaaS CRUD apps.

Here are the core takeaways from this journey:

  1. Beware the IEEE 754 Horizon: Never assume numbers in domain spaces (gaming, crypto, astrophysics, telemetry) fit inside Number.MAX_SAFE_INTEGER. If precision matters, treat strings as primary and numbers as derived views.
  2. Never Monkey-Patch Primitives: BigInt.prototype.toJSON might solve your build error today, but it turns your serialization layer into a ticking bomb.
  3. Logarithmic Projection for Sorting: When sorting values with massive dynamic ranges ($1$ to $10^{30}$), don't store raw floats. Project them into $(\log_{10}(\text{mantissa}) + \text{exponent})$ to maintain flawless sort ordering.
  4. Data Honesty > UI Completeness: When building reference databases, resist the urge to fill empty cells with algorithmic guesses. State your evidence tiers openly.

What’s the weirdest IEEE 754 precision bug you’ve ever encountered in production? Let me know in the comments below!

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.