As of 2026-10-15
A spending game can make zero a meaningful state: the timer stops when the balance is exactly empty. That turns an ordinary shopping cart into a useful programming exercise. Every purchase, sale and restored save needs to preserve the same accounting identity, even after a long sequence of clicks.
We build the free spending game at 40jo.fyi. Its English page describes buying and selling items, entering quantities, a receipt and a timer that stops at zero. The code below is an independent teaching example, not a description of the site's implementation. Its prices are invented test inputs, not market prices or values copied from the game.
Choose a unit before choosing a type
Our sample ledger uses integer credits. If an application instead supports dollars and cents, its unit could be cents. Do not let some functions accept whole dollars while others expect cents: a precise type cannot repair an ambiguous unit.
JavaScript's largest safe integer is 2 ** 53 - 1, or 9,007,199,254,740,991. A balance of 27 billion whole credits fits comfortably inside that boundary. A large-looking number alone is therefore not a reason to claim that Number is broken. Here we use BigInt to give an extensible integer ledger one consistent arithmetic type.
BigInt represents integers, not arbitrary decimal fractions. Mixed Number/BigInt arithmetic is generally rejected, and a raw BigInt cannot be serialized by ordinary JSON.stringify without a conversion policy. Those behaviors are specified by ECMAScript's numeric types and serialization rules. The interesting design work is at the boundaries: input, state transitions and storage.
| Boundary | Accepted representation | Rejected example |
|---|---|---|
| Quantity input | Nonnegative decimal digit string | "1.5" |
| Internal amount | Integer credits as BigInt | A floating-point price |
| Saved quantity | Decimal string in JSON | A raw BigInt value |
| Cart update | A complete affordable new quantity | A negative balance |
Make the update atomic
The following program runs as one file with Node.js. It has no dependencies beyond Node's assertion module. Save it as ledger.cjs and run node ledger.cjs. A failed operation throws before changing the cart, so the caller can display the error without repairing partial state.
const assert = require("node:assert/strict");
function unsigned(text) {
if (typeof text !== "string" || !/^(0|[1-9][0-9]*)$/.test(text)) {
throw new TypeError("Expected an unsigned decimal string");
}
return BigInt(text);
}
const budget = 1000n;
const prices = new Map([["book", 125n], ["ticket", 300n]]);
let cart = new Map();
function spent(items) {
let total = 0n;
for (const [id, quantity] of items) {
if (!prices.has(id)) throw new RangeError("Unknown item");
if (typeof quantity !== "bigint" || quantity < 0n) {
throw new RangeError("Invalid quantity");
}
total += prices.get(id) * quantity;
}
return total;
}
function setQuantity(id, text) {
if (!prices.has(id)) throw new RangeError("Unknown item");
const next = new Map(cart);
next.set(id, unsigned(text));
if (spent(next) > budget) throw new RangeError("Over budget");
cart = next;
return budget - spent(cart);
}
assert.equal(setQuantity("book", "2"), 750n);
assert.equal(setQuantity("ticket", "2"), 150n);
assert.throws(() => setQuantity("ticket", "3"), /Over budget/);
assert.equal(budget - spent(cart), 150n);
assert.throws(() => setQuantity("book", "1.5"), /decimal/);
// Selling both tickets restores their full cost.
assert.equal(setQuantity("ticket", "0"), 750n);
assert.equal(setQuantity("book", "8"), 0n);
const saved = JSON.stringify({
version: 1,
quantities: [...cart].map(([id, q]) => [id, q.toString()])
});
const parsed = JSON.parse(saved);
assert.equal(parsed.version, 1);
const restored = new Map(
parsed.quantities.map(([id, q]) => [id, unsigned(q)])
);
assert.equal(spent(restored), budget);
console.log("Ledger checks passed; remaining credits:",
(budget - spent(restored)).toString());
Two books cost 2 × 125 = 250 credits. Two tickets add 2 × 300 = 600, so spending is 850 and the balance is 1,000 - 850 = 150. Asking for a third ticket would make the ticket subtotal 900 and the whole cart 1,150. The rejection leaves the previous 850-credit cart intact.
Selling the tickets returns spending to 250. Setting the book quantity to eight makes it 8 × 125 = 1,000, which reaches exactly zero. No tolerance or rounding rule is involved because every operation is integer addition, multiplication or subtraction.
The receipt should come from the cart
Avoid maintaining independent mutable values for the balance, receipt total and cart total. Three counters require every event handler to update all three correctly. In this example the cart is the state; spending and balance are derived views. A receipt renderer can use the same quantities and prices.
The useful invariant is budget = spent + remaining. Check it after buying, selling, resetting and restoring. Another useful property is reversibility: setting an item's quantity back to its old value should restore the old total, provided the catalog price has not changed.
A real catalog needs a price-version policy. If a saved cart refers to yesterday's prices, should restoring it use the old catalog or recalculate at today's prices? Our small example fixes the catalog for its lifetime. A deployed application should make that choice explicit rather than silently calling a changed receipt the original one.
JSON is an interface, not a backup spell
The sample restores only the JSON it just created. An imported save needs additional validation: object shape, version, allowed item IDs, duplicate IDs, quantity-string length and total affordability. A duplicate ID would otherwise be overwritten by Map, concealing an ambiguous import. Cap input lengths before parsing huge integers as well.
Store an explicit unit and catalog version when the file can leave the application. Strings preserve digits, but the receiver still needs to know what those digits mean. Converting a decimal string through Number before calling BigInt defeats that purpose if the number is outside the safe range.
FAQ
Does a billion-credit budget require BigInt?
No. Check the largest intermediate integer against Number's safe range. BigInt is an explicit integer design choice here.
Can I use BigInt for decimal prices directly?
No. Choose a fixed smallest unit and parse into it, or use a decimal arithmetic design with documented rounding.
Why store quantities instead of the displayed balance?
The quantities and catalog reconstruct the receipt and total. A balance alone cannot explain what was purchased.
Does this reproduce the game's source code?
No. It demonstrates a ledger invariant inspired by the page's exact-zero objective.
The spending game is free to play without signing up.
Top comments (0)