API reference · layer 1

@latticekit/sim

Idle-economy mathematics in closed form: cost curves, the flow integrator, offline accrual, and capacity gating.

exports50 symbols in 10 modules — start with defineEconomy, advance, advanceOver, maxBuyable
depends on@latticekit/core
environmentisomorphic
gzipped7.58 kB against a 12 kB budget
sourcepackages/sim · README · index.d.ts

@latticekit/sim — Idle-economy mathematics in closed form: cost curves, the flow integrator, offline accrual, capacity gating, and the instant a stock runs out.

sim is the arithmetic of an idle economy in closed form — a production graph you can integrate in one step, a cost curve you can invert, an offline warp on time, capacity gating, and the instant a stock runs out — with no tick, no clock and no state of its own.

The load-bearing half of that sentence is closed form, and the unifying rule the rest of the package is a consequence of:

Everything in sim is linear between commits. Gates, milestones, clamps, purchases, nightfall and a stock hitting zero are the discontinuities, and every one of them is a boundary — an instant at which the caller re-enters. That is what makes one integration of fourteen hours equal to fifty thousand integrations of one second.

A boundary is not a tick. A tick's cost scales with elapsed time; a boundary's cost scales with how many interesting things happened, and this package's job is to find those instants exactly rather than to walk past them at 60 Hz hoping to notice.

What "linear" does and does not rule out — read this before concluding your rate is impossible

**A rate may be any expression you like — , thresholds, milestones, capacity shares, a curve read off a spreadsheet — as long as it is piecewise constant in time. EdgeScale is where those expressions go, and it is the sanctioned way to write them, not a workaround: it is evaluated once per buildFlow and frozen for the integration that follows, so rebuild at every boundary. The one rate sim refuses is one that reads a stock this graph produces**, because that is a discontinuity inside an integral and it makes the same save answer two ways.

The distinction is what the rate is a function of, never what shape it has:

a real idle-game ratefunction oflegal
every 10th press doubles all pressesa purchased countyes — scale: () => milestoneMultiplier(bought, MILESTONES)
output scales with √(prestige)a banked, player-facing totalyes — scale: () => Math.sqrt(prestige)
producers above 100 get 3×a purchased countyes — a threshold inside scale
income scales with how far the road reachesa length the player extends by tappingyes — and with no from, it is a source
output ∝ √(coin you currently hold)a stock this graph producesno, and it must stay no

And a rate may multiply nothing at all: an EdgeSpec with no from is a source, d(to)/dt += per × scale × gate. That is what an idle economy's headline rate usually is, and writing it any other way — nominating a from and dividing it back out in scale — puts a node in EconomySpec.nodes, which is the save's field order, purely to be a multiplicand.

Isomorphic — it runs unchanged in Node with no shims, reads no clock, and takes no delta.

The public surface of this package. Every symbol a consumer may use is re-exported here and nowhere else; .lattice/kit.json lists them, and npm run lint keeps that list honest.

What it promises

  • Closed form, never a loop. maxBuyable is O(1) and 12x faster than a 400-step buy loop; the loop is legitimate only as a test oracle.
  • The economy has no tick. State is (stocks, rates, lastTimestamp) and is integrated on read. sim reads no clock and accepts no delta — every call that moves the anchor takes a required epoch timestamp.
  • The topological order is computed by Kahn and therefore proven. Declared storage order stays separate from evaluation order, so a v4 node cannot move a v1 save's fields.
  • Cycles and self-loops are refused at construction, naming the cycle. A numerical fallback would be a second implementation of the economy that diverges silently on exactly the saves that matter.
  • Offline progress warps time, never yield, and a plan is never re-based. Credit for a resumed absence is W(span) - W(from), which telescopes; restarting the warp at each discovered crossing would pay for K absences instead of one, and each restart is cheaper.
  • The upper clamp on an offline gap is the softcap's flat branch. A device clock a year fast credits eleven hours.

Verbatim from .lattice/kit.json, which npm run lint keeps in step with the code.

graph10 symbols

Declaring a production graph, and proving it has a closed form.

This is the module the source game did not have. It hard-coded a resource enum and a hand-maintained topological array, and asserted the two agreed. That works for one game with fourteen resources and one author; it does not survive a second content update, and it cannot be asked of a kit's users at all. So the order here is computed by Kahn's algorithm and therefore proven, and the graphs that have no order are refused by name.

Two orders live in this file and they are deliberately different things:

orderwho decideswhat it controls
EconomySpec.nodesstoragethe game, by declarationthe field order a save writes. Append-only
Economy.orderevaluationthis module, by Kahnwhich producer is applied before which consumer

Keeping them apart is what lets a v4 node be appended to nodes without moving a single field of a v1 save, while the evaluation order it belongs in is recomputed from the edges. Conflating them — which is what a single hand-maintained array does — means inserting a node in the middle of a chain silently renames every field after it.

There is a third node, and the reason it is worth a paragraph here is that it is in neither order. An EdgeSpec with no from is a source, and a source is an affine term that this module represents as a hidden node pinned to 1. It is not declared, it cannot be named, it is not in nodes and so it is not in any save — it is one reserved element at the end of the integrator's workspace, addressed through Edge.fromIndex. The distinction is the whole value of the feature: a game that spells a source by declaring a real node and holding it at 1 has put a field in its save format, and zeroStocks will set that field to 0 on the next fresh start and silently stop the economy.

Isomorphic and Tier A: nothing here reads a clock, a random source or a platform, and every arithmetic operation is comparison and integer addition.

Stocks type ↳ src/graph.ts:38

type Stocks<N extends string> = Readonly<Record<N, number>>

A stock vector, keyed by node id. Plain JSON: this is what @latticekit/persist writes.

StockVec type ↳ src/graph.ts:41

type StockVec<N extends string> = Record<N, number>

The mutable form. Every hot-path function writes into one of these instead of allocating.

EdgeScale type ↳ src/graph.ts:67

type EdgeScale<N extends string> = (stocks: Stocks<N>) => number

A per-edge multiplier, evaluated once per buildFlow and held constant across the integration that follows.

This is where a rate that is not linear stops being a problem. Because the factor is sampled once and frozen, the expression inside it may be anything at all — a square root, a threshold, a milestone table, a capacity share, a curve read off a design spreadsheet. The constraint sim actually imposes is not linear, it is piecewise constant in time, and every one of those is constant between commits. The milestone mechanic ("every tenth press doubles what all of them make") is this, in one line: scale: () => milestoneMultiplier(game.pressesBought, MILESTONES); a prestige bonus is scale: () => Math.sqrt(game.prestige). Neither is a workaround, and neither costs the closed form anything.

Key it on a quantity that only changes when the player acts. It receives the stock vector at the anchor because that is often where the count lives, and that is also the trap: keying a milestone on an effective count that the flow itself produces puts a rate discontinuity inside an integral, and the same save then answers differently at 10 Hz than it does after one fourteen-hour catch-up. Purchased counts change only at actions. Effective counts change continuously. Use the first.

A source edge's scale is evaluated with the stock vector exactly like any other, which is usually what you want: a headline rate is most often a function of something the player bought.

EdgeSpec interface ↳ src/graph.ts:78

interface EdgeSpec<N extends string, G extends string> {

One production edge: d(to)/dt += rate × stock(from), or — with no fromd(to)/dt += rate.

Non-consuming. The edge adds to to and subtracts nothing from from; a consuming edge would put a negative term on the diagonal, A would stop being nilpotent, and the closed form would stop terminating. A linear drain — lamps burning oil at a fixed rate per lamp — is not that, and is fully supported: it is a forward edge with a negative per. A flat standing charge is a source with a negative per.

5 members
readonly from?: N

The producing stock. Omit it for a source — an edge with no from adds per × scale × gate to to every second, multiplying nothing.

A source is what an idle economy's headline rate usually is: the tick income, the base drip, the thing that pays while the player owns zero of everything, and any rate that is a property of the world rather than of a countable stock — "the road earns k·√reach", "the colony produces 3/s".

Without it a game must nominate an arbitrary from and divide the rate back out by it, guard the zero case so the division is not 0/0, and keep that node in EconomySpec.nodeswhich is the save's field order. The workaround therefore reaches the save file: a persisted field whose only reason to exist is to be a multiplicand. The first game built on this kit did exactly that, and it is why this field is optional.

A from that is present but is not a declared node id is still a mistake and is still reported as one; only absence means source.

readonly to: N
readonly per: number

Units of to per unit of from per second, before scale and before the gate — or, on a source, units of to per second outright.

readonly gate?: G

The capacity that throttles this edge, if any. An untagged edge is never throttled.

readonly scale?: EdgeScale<N>

EconomySpec interface ↳ src/graph.ts:115

interface EconomySpec<N extends string, G extends string> {

Everything a game declares about its economy. Structure only: no balance, no time, no state.

Getting nodes wrong is a save-compatibility bug rather than a maths bug, which is why it is documented at the field rather than here.

3 members
readonly nodes: readonly N[]

Every node, in storage order — the order a save writes its fields in. Deliberately not the evaluation order: append a node in v4 and every v1 save still deserialises with its fields where they were. The evaluation order is computed, not declared.

readonly gates?: readonly G[]

Capacity ids. Declaring one here is what lets an edge name it.

readonly edges: readonly EdgeSpec<N, G>[]

Edge interface ↳ src/graph.ts:135

interface Edge<N extends string, G extends string> {

A validated edge, in the package's own order.

The difference from EdgeSpec is slot and the ordering of the array it lives in: a game's spec file is text a designer reorders freely, and float addition is not associative, so the accumulation order has to be fixed here or a cosmetic diff moves the last bit of every stock in the game.

7 members
readonly from: N | undefined

undefined on a source. Present for diagnostics; the arithmetic reads fromIndex.

readonly to: N
readonly per: number
readonly gate: G | undefined
readonly scale: EdgeScale<N> | undefined
readonly slot: number

This edge's slot in Flow.rates.

readonly fromIndex: number

Where the integrator reads this edge's multiplicand: index[from], or — on a source — the reserved unit slot at Economy.order.length, one past the last node.

This exists so the hot loop never branches on from === undefined. A source is an affine term, and an affine term is the same object as a node pinned to 1: b = A·e for a hidden unit node with no incoming edges. The workspace carries that node as one extra element holding 1, so acc[to] += rate × x[fromIndex] is one code path for both kinds of edge. The slot is workspace only — it is not a node, it is not in Economy.nodes, and it never reaches a stock vector or a save.

Economy interface ↳ src/graph.ts:168

interface Economy<N extends string, G extends string = never> {

A validated, frozen production graph. Build it once at load; it holds no mutable state and two saves may share one.

Every guarantee the rest of the package makes rests on the two invariants this object carries: the edges point strictly forward through Economy.order, and depth bounds the matrix powers. Construct one only through defineEconomy — a hand-built object literal that satisfies the type can still violate both, and the integrator will not terminate.

6 members
readonly nodes: readonly N[]

As declared. The save's field order.

readonly order: readonly N[]

*Computed** topological order: every producer strictly before everything it produces.

readonly index: Readonly<Record<N, number>>

Position in order. For every edge that has a from, index[from] < index[to] — this is the invariant. A source has no from and sits, conceptually, before every node in order.

readonly edges: readonly Edge<N, G>[]

Edges, sorted by index[from], so a game reordering its spec cannot move a single ulp. Sources sort first: the hidden unit node they hang off has no producer, so it precedes every declared node in Kahn order.

readonly gates: readonly G[]
readonly depth: number

Edges on the longest path — the nilpotency bound. A^(depth+1) = 0, so x(t) is a polynomial in t of degree exactly depth and the integrator performs at most depth matrix applications. The source game bounded this by node count (18) for a graph of depth 4; the bound is a property of the graph, not of the vector, and computing it is free.

A source edge counts as an edge here, because it is one: a source into a makes a linear in t where an unfed a was constant, and a source into a with a → b makes b quadratic. Forgetting to count it truncates the polynomial by one term, and a truncated polynomial is still a plausible-looking number — which is why it is spelled out.

defineEconomy functionstart here ↳ src/graph.ts:310

function defineEconomy<N extends string, G extends string = never>(spec: EconomySpec<N, G>): Economy<N, G>

Validate a spec and compute its evaluation order.

The order is derived by Kahn's algorithm and therefore proven, not asserted against a hand-written array the way the source game did it. A kit cannot ask a game author to keep a topological ordering correct by hand across fourteen resources and two content updates; it can compute one, and refuse the graphs that do not have one.

Ties in Kahn — two nodes ready at the same moment — are broken by declaration order, per the Lattice ordering rule. There is no comparator parameter, and there is no case in which the order depends on how the game happened to sort its edge list.

Validation uses core's guard validators, which return the value rather than take a boolean — so every message can name the offending node instead of reporting that something, somewhere, was false.

This is the function every game calls first, and the one most likely to be called wrongly, so its diagnostics carry more weight than the rest of the package's put together. Two rules are held to throughout: the kind of a value is checked before anything is inferred from it, and a message that could send the reader to either of two places names both.

Throws

TypeError — wrong kind of thing — when spec or an edge is not an object, nodes, gates or edges is not an array, a node or gate id is not a string, or scale is not a function. The id check runs ahead of the duplicate scan on purpose; see expectId.

Throws

RangeError — wrong value of the right kind, naming the caller's mistake per house rule 9 — on: an empty node list; a duplicate node or gate, with both indices; an edge naming an undeclared node or gate, with the declared ids listed so a typo is visible; a non-finite per; a self-loop; or any cycle, with the cycle spelled out: sim.defineEconomy: production graph has a cycle: lamp → oil → lamp. ...

There is deliberately no numerical fallback for a cycle. A fallback would be a second implementation of the economy with different answers, and a game would cross the boundary without noticing — the two would then diverge silently on exactly the saves that matter most. Refusing happens at load rather than at hour three, and the message names the edge to delete.

zeroStocks function ↳ src/graph.ts:541

function zeroStocks<N extends string, G extends string>(eco: Economy<N, G>): StockVec<N>

A fresh, fully-populated, all-zero vector, keyed in storage order.

Every key is present and every value is a number, so the object's hidden class never changes under the integrator — a vector that grows a key on the first frame a resource is unlocked deoptimises every call site that has ever seen it. The key order matters for a second reason: it is the order JSON.stringify writes, so two saves of the same economy produce byte- comparable text.

degreeOf function ↳ src/graph.ts:563

function degreeOf<N extends string, G extends string>(eco: Economy<N, G>, node: N): number

The degree of node's trajectory in t: the longest path into it, so 0 is constant, 1 is linear and 2 is quadratic.

Exported because it is the precondition of an exact depletion solve: degree 1 and 2 are algebraic, and a game is entitled to know at design time whether the instant it wants to report is available in closed form or found by bisection. A resource drained by a fixed set of consumers is degree 1, which is most idle games' everything.

A source counts as one edge, so a node fed only by a source is degree 1 — a constant inflow makes a stock linear in time, and solveCrossing answers it with one divide.

Throws

TypeError if node is not a string — checked before the membership test, so passing a node object is not reported as an undeclared node called [object Object].

Throws

RangeError if node is not declared in eco, listing the ids that are.

flow7 symbols

Rates, and the closed-form integrator that consumes them.

The economy has no tick. State is (vector, rates, anchor) and is integrated on read.

A function that steps the economy N times is a bug, not a slow implementation: it answers differently at different frame rates, and it cannot answer "where would this player be after fourteen hours away?" without doing fourteen hours of work.

Why a closed form exists at all

graph guarantees the edges point strictly forward, so written as dx/dt = A·x the matrix A is strictly triangular and therefore nilpotent: A^(depth+1) = 0. Then

x(t) = exp(A·t)·x₀ = Σ_{k≥0} A^k x₀ tᵏ / k!

is a terminating polynomial of degree eco.depth, not a series anyone truncates. There is no step size here to get wrong and no stiffness to be afraid of, and the "matrix exponential" never calls exp — it is + − × ÷ throughout, which is Tier A: bit- identical on every conforming engine given the same rates.

Sources, and the one extra number in the workspace

An edge with no from is a source: d(to)/dt += rate, multiplying nothing. That is an affine term, and an affine term is the same object as a node pinned to 1b = A·e for a hidden unit node e with no incoming edges. So the workspace carries one extra element past the last node, seeded to 1 at the top of every integration, and Edge.fromIndex points a source at it. Nothing produces e, so it sorts first, A stays strictly triangular, the matrix stays nilpotent and the polynomial still terminates — one degree later along a source-fed chain, which Economy.depth already counts. The inner loop never learns that sources exist.

The extra slot is workspace and nothing else: it is not a node, it is not in Economy.nodes, and it cannot reach a stock vector or a save. That is the whole point — the workaround it replaces (a real node held at 1, or a real node divided back out) did reach the save.

What a Flow is, and the two ways to break it

A Flow is a mutable scratchpad belonging to exactly one simulated world. Two states advanced at the same time need two of them, or their intermediate matrix powers interleave and produce garbage that is not obviously garbage. And the rates inside it are cached: rebuild with buildFlow whenever anything feeding a rate has moved — a purchase, a milestone, nightfall, a brownout — or the sun goes down and the oil does not start burning until the player's next click.

Isomorphic: no clock, no randomness, no platform.

Flow interface ↳ src/flow.ts:77

interface Flow {

The evaluated rate of every edge, plus the integrator's workspace.

One Flow per simulated world; see the module header for what sharing one costs. Treat everything but rates as opaque — the remaining fields are the integrator's and the root finder's scratch, they are sized against one particular Economy, and writing to them corrupts the next integration rather than the current one, which is the hardest kind of bug to trace back.

7 members
readonly rates: Float64Array

Effective rate per edge, parallel to Economy.edges. Never resized.

readonly edgeFrom: Int32Array

Workspace: Edge.fromIndex per edge slot — a node's index, or the unit slot. Opaque.

readonly edgeTo: Int32Array

Workspace: Economy.index[edge.to] per edge slot. Opaque.

readonly acc: Float64Array

Workspace: the accumulating Taylor sum, one slot per node. Opaque.

readonly poly: Float64Array

Workspace: the polynomial coefficients of a single node's trajectory. Opaque.

term: Float64Array

Workspace: A^k x₀ and A^(k+1) x₀, one element longer than the node count.

The extra element is the reserved unit slot every source edge multiplies by; see the module header. It is seeded to 1 whenever a vector is read in and is zero from the first matrix application onwards, because the unit node's row of A is empty — which is exactly what makes an affine term compose like a linear one.

Deliberately mutable and deliberately a pair — the integrator swaps the two references rather than copying a vector on every matrix application, which is what keeps the hot path free of both allocation and a width-length memcpy per term.

next: Float64Array

Workspace: the other half of the swap pair. Opaque.

createFlow function ↳ src/flow.ts:111

function createFlow<N extends string, G extends string>(eco: Economy<N, G>): Flow

Allocate the workspace for one simulated world.

Call it once per world at load, next to defineEconomy's result, and keep it. It is the only allocation in the per-frame path of this package, and it happens before the first frame.

GateRatios type ↳ src/flow.ts:134

type GateRatios<G extends string> = Readonly<Record<G, number>>

The ratios in force, one per declared gate. 1 is healthy; 0 stops the tagged edges.

NO_GATES const ↳ src/flow.ts:142

const NO_GATES: GateRatios<never>

For an economy with no gates.

Frozen, and a single shared instance: an economy without gates reads nothing out of it, so there is nothing to allocate per frame and nothing a caller could usefully put in.

buildFlow function ↳ src/flow.ts:182

function buildFlow<N extends string, G extends string>(eco: Economy<N, G>, stocks: Stocks<N>, gates: GateRatios<G>, out: Flow): Flow

Fold per × scale(stocks) × gateRatio into out.rates.

Cheap and allocation-free: one pass over tens of edges. Call it whenever anything that feeds a rate has moved — a purchase, a milestone, nightfall, a brownout. Forgetting to call it after a gate reading changes is the bug where the sun goes down and the oil does not start burning until the player's next click.

The multiplication order is fixed here — per, then scale, then the gate, and the identity factors are skipped rather than multiplied — so a gate ratio of r is bit-identical to the same graph with that edge's per pre-multiplied by r and no gate at all. A gate is exactly a rate multiplier and nothing subtler.

Tier A.

Throws

RangeError if a declared gate is missing from gates or is not finite, or if an EdgeScale returns a non-finite factor. An undefined ratio becomes NaN, and a NaN in a stock vector is a corrupted save that no later call can repair.

integrate function ↳ src/flow.ts:238

function integrate<N extends string, G extends string>(eco: Economy<N, G>, stocks: Stocks<N>, flow: Flow, seconds: number, out: StockVec<N>): StockVec<N>

Integrate the whole vector forward by seconds, exactly, in one step.

Evaluates x(t) = Σ_k A^k x₀ tᵏ/k!, which terminates after eco.depth terms because A is nilpotent. Uses only + − × ÷: Tier A, bit-identical across engines given the same rates, and therefore safe to persist.

Composes exactly the way the underlying flow map does: integrating t₁ + t₂ once and integrating t₁ then t₂ agree in exact arithmetic and differ only by accumulated double rounding — about 1e-13 relative, asserted at 1e-9. That identity is what makes one fourteen- hour catch-up and fifty thousand one-second steps the same code path, and it is the reason there is no clamp anywhere in this function: a clamp is a nonlinearity, the result would stop being the integral of anything, and the discrepancy would depend on how often you called it. Solve for the crossing instead and put a boundary there.

Parameters
seconds

Non-positive is a bit-identical copy of stocks into out: clocks are not monotonic across machines or across a laptop suspend, and time appearing to run backwards must never mint or destroy resources.

out

May alias stocks; the whole vector is read before anything is written.

Returns

out, so a caller can chain. Allocates nothing.

Throws

RangeError if seconds is not finite. Silently producing NaN stocks corrupts a save.

ratesOf function ↳ src/flow.ts:318

function ratesOf<N extends string, G extends string>(eco: Economy<N, G>, stocks: Stocks<N>, flow: Flow, out: StockVec<N>): StockVec<N>

dx/dt at this instant — what a HUD prints as "per second".

This is the derivative now and nothing else. Multiplying it by elapsed time is the classic wrong answer: production arriving during the next minute makes the real accrual super-linear, so the number a player is shown and the number they get disagree — in the player's disfavour, by more the better they are doing. To answer "how much in the next minute", integrate 60 and subtract.

Tier A, allocation-free, and safe against out aliasing stocks.

Returns

out, so a caller can chain.

ledger6 symbols

The ledger, the calendar, and the seam with @latticekit/loop.

sim's entire state is one value: a stock vector and the instant it is true at. Everything else in this package is a function of that value and a number the caller passes in.

The four rules, each a bug if broken in either direction

  1. The integrator is driven from a stored epoch timestamp, never from summed dt. This package exposes no function that takes a delta. project and advance take an instant and derive the interval from the ledger, so a builder who sees dt in update() and reaches for it finds no signature to put it in. That is the intended shape of the refusal, not an oversight.
  2. loop's catch-up clamp must never touch the number handed to sim. They bound different things: loop's 250 ms clamp exists so a restored tab does not run 216,000 fixed steps in one frame; sim's warp exists so eight hours of sleep is worth about five. Passing loop's clamped or dropped time here silently steals the player's entire night, and it looks exactly like a working game.
  3. sim never runs inside the fixed-step tick. It is integrated on read. advance inside loop.step reinvents the tick and makes the economy a function of frame rate — and, given rule 2, of whether the tab was visible. Wire it as: project in render, advance in the action handler and at hydrate.
  4. loop owns the wake cadence; sim owns what the wake is worth. Neither package imports the other, and neither should: they are siblings in the layer graph.

The saved-at seam

questionanswer
what does accrual read?ledger.atMs, and only that. It is the instant the vector is true at
is that a save envelope's write stamp?No, and conflating them is the bug. The stamp is when the record was written; the anchor is when the numbers were last true. A debounced write 30 s after the last advance makes them differ by 30 s
which is right for the gap?The anchor. Using the stamp pays a debounce interval twice, or steals it, depending on which way the two drift — every session, invisibly
how do I make them agree?advance immediately before handing state to a writer. Then the stamp equals the anchor, and any mismatch is a bug a test can assert on

Isomorphic, and reads no clock: every entry point that moves the anchor takes atMs as a required, non-optional parameter. There is no overload without it and no default, so omitting it is a compile error rather than an elapsed time of fifty-six years.

Ledger interface ↳ src/ledger.ts:54

interface Ledger<N extends string> {

A stock vector and the instant it is true at. This is the whole of sim's state, and it is a value — JSON-round-trippable as-is, which is what @latticekit/persist writes.

atMs is an epoch timestamp and nothing else will do. Not loop.time, not a duration accumulated on the fixed step, not a monotonic reading: those have no calendar, run at roughly quarter speed in a hidden tab, and compare against a different zero after a reload. core brands EpochMillis precisely so that substitution is a compile error.

2 members
readonly stocks: Stocks<N>
readonly atMs: EpochMillis

expectFiniteStocks function ↳ src/ledger.ts:90

function expectFiniteStocks<N extends string, G extends string>(eco: Economy<N, G>, stocks: Stocks<N>, label: string): Stocks<N>

Validate a stock vector, returning it. guard-shaped, for the same reason core's validators are: a boolean has already thrown away the node that was wrong.

Call it on anything that came out of JSON.parse. A null where a number should be (an Infinity that made a round trip) and a NaN (which JSON.stringify also writes as null) are both caught here, at the one boundary where the value can still be blamed on the save rather than on the arithmetic.

A stock at Infinity means the economy has no answer, not that it has a very large one. It cannot be rendered, compared or spent, and it becomes a NaN downstream on the first 0 × ∞, which poisons every comparison in the game. Growth here is polynomial in elapsed time, so no single absence can carry a sane balance to 1.8e308; what can is compounding across sessions in a graph with no sink, which is exponential in session count. A throw from here is therefore a balance report, and it is designed to arrive at persist's "corrupt save → fresh, with a reported reason" path rather than at a player.

Parameters
label

the caller's symbol. 'sim.load' produces sim.load: stocks.oil is not finite (null).

Throws

RangeError naming the first offending node, in storage order.

elapsedSeconds function ↳ src/ledger.ts:116

function elapsedSeconds<N extends string>(ledger: Ledger<N>, atMs: EpochMillis): number

(atMs − ledger.atMs) / 1000, clamped at zero. The one place the ms→s conversion lives.

Clamped rather than absolute: laptop suspends, NTP corrections and a user changing their system date all produce an atMs behind the anchor, and Math.abs of that would run the economy forwards for time that did not pass. Zero, never negative.

Throws

RangeError if either instant is not finite — a NaN here becomes NaN stocks, and by the time that reaches a save there is nothing left to blame it on.

project function ↳ src/ledger.ts:144

function project<N extends string, G extends string>(eco: Economy<N, G>, ledger: Ledger<N>, flow: Flow, atMs: EpochMillis, out: StockVec<N>): number

Integrate to an instant without committing, into a caller-owned vector.

This is what a HUD calls every frame. It changes nothing, allocates nothing, and always integrates from the same anchor — so the answer is one expression evaluated at a later t, not an accumulation. Folding a per-frame projection back into the anchor is arithmetically fine and reproducibility poison: the state then depends on how many frames the player's laptop managed, which is the end of replay from a seed and an input log.

Deliberately does not check the result for finiteness. This is per-frame, and a non-finite out is garbage on screen for one frame, which is visible and harmless. The check belongs at the boundary between a number and a durable number — see advance.

Returns

the seconds integrated — elapsedSeconds(ledger, atMs), i.e. 0 for a backwards clock.

advance functionstart here ↳ src/ledger.ts:176

function advance<N extends string, G extends string>(eco: Economy<N, G>, ledger: Ledger<N>, flow: Flow, creditedSeconds: number, atMs: EpochMillis): Ledger<N>

Move the anchor, crediting creditedSeconds of production.

Two parameters, deliberately: the anchor always lands on atMs, and the production credited for getting there is whatever the caller says. Live play passes elapsedSeconds(...). An absence with a schedule uses advanceOver instead, which is the only function in this package permitted to apply a warp, because distributing one across phases is the thing you must not do by hand.

An atMs earlier than the anchor returns the ledger unchanged — it neither credits nor moves the anchor backwards, because an anchor that can be walked back is an interval that can be credited twice. Correcting a bad clock is reanchor, deliberately a different call.

Allocates one Ledger and one vector. It is a boundary call — an action, a save, a hydrate — not a per-frame one.

Throws

RangeError if the resulting vector is not finite, naming the node. One pass over the nodes at a boundary call is free, and the alternative is writing an Infinity that serializes to null with a perfectly valid checksum.

reanchor function ↳ src/ledger.ts:213

function reanchor<N extends string>(ledger: Ledger<N>, atMs: EpochMillis): Ledger<N>

Move the anchor without crediting anything, in either direction.

The clock-correction tool, and the only function here that may move an anchor backwards. advance deliberately refuses to.

It exists because of the forward clock jump. A phone whose date is a year ahead hands the game a gap of 31.5 million seconds; the credit for that is capped by the offline curve's flat branch, but the anchor is not — it lands a year in the future, and when the clock is corrected every subsequent read sees time running backwards and credits zero. The economy then freezes for a year, with no error and a save that looks fine. That is the more damaging half of a forward jump and no cap on the credit prevents it.

So: a game that detects atMs < ledger.atMs by more than a plausible drift — a few seconds of NTP correction — calls this, keeps its stocks, forfeits nothing it had earned, and is running again on the next frame. Only the game knows its own session cadence, so only the game can set that threshold.

Throws

RangeError if atMs is not finite.

offline5 symbols

The warp on time, never on yield.

Offline progress answers one question — how many seconds does the player get credit for? — and hands the answer to the same closed-form integrator live play uses. Scaling the output instead is a dupe with a plausible-looking formula: a player returns from fourteen hours with more of a downstream resource than their producers could have made in the credited window. Warping the clock cannot do that, because every edge in the graph sees the same shortened interval.

The curve

With U = uncappedSeconds, e = exponent, F = flatAfterSeconds:

           ⎧ T                  0 ≤ T ≤ U     second for second
    W(T) = ⎨ U · (T/U)^e        U < T ≤ F     softcapped
           ⎩ U · (F/U)^e        T > F         flat — the curve stops rising

Where the middle branch comes from, and the constant of integration

Think of a credit rate w(t) = dW/dt — the fraction of a second you are paid for the t-th second away. It is 1 while t ≤ U, then decays as a power law:

    w(t) = e · U^(1−e) · t^(e−1)                 for U < t ≤ F
    W(T) = U + ∫_U^T w = U + U^(1−e)·(T^e − U^e)
         = U + U^(1−e)·T^e − U          ← the two U terms cancel exactly: C = 0
         = U · (T/U)^e

The U^(1−e) normalization is the whole trick, and there are two near-misses that pass a casual test:

  • W(T) = U + T^e drops the normalization and jumps by U^e at the knot — about 259 credited seconds at 3 h / 0.6 — so returning at 3h00m01s pays more than at 2h59m59s. A visible, farmable step.
  • W(T) = U + (T − U)^e is continuous and wrong more subtly: its slope at U⁺ is infinite because e < 1, so for the first seconds past the knot the player earns faster than live, and closing the tab at 2h59m becomes optimal play. A softcap that opens by paying a bonus is not a softcap.

The form here does neither: W(U) = U exactly, and the slope steps down from 1 to e.

W does not compose, and must never be asked to

It is strictly concave, therefore subadditive: two twelve-hour gaps credit more than one twenty-four-hour gap. No choice of curve fixes this, because a softcap that composed additively would be linear, i.e. not a softcap. So apply it exactly once per return, over the one gap between the ledger's anchor and now. A player who genuinely opened the tab at hour twelve was away for two gaps and is correctly paid more, because they did in fact come back. Splitting is generous, which is the safe direction: nobody is punished for a visit the game failed to record.

A schedule inside one absence is not a second application — see schedule.ts, which distributes this function by evaluating it at phase boundaries and never restarts it.

Determinism

A fractional power is Tier B in general. It is Tier A when exponent is a dyadic rational with denominator ≤ 64 — 0.5, 0.75, 0.625, … — because the implementation then computes it as a chain of Math.sqrt and multiplies, both exactly specified by ECMA-262. A game that needs credited time to be bit-identical across engines picks 0.625 instead of 0.6 and gets it for free; the demo does exactly that.

Isomorphic: no clock, no randomness, no platform.

OfflineCurve interface ↳ src/offline.ts:141

interface OfflineCurve {

Uncapped for uncappedSeconds, softcapped at exponent, flat after flatAfterSeconds.

The shipping numbers in the game this kit came from: 3 h, 0.6, 24 h. Every field is a balance decision, and a balance pass is a data diff — so a wrong number arrives as data, and each is validated where it is read rather than three hours into a run.

3 members
readonly uncappedSeconds: number

How long an absence is credited second for second. Below this, W is the identity.

A design constraint, not just a generosity dial. If any standing charge in the economy accrues on a cycle — a nightly oil bill, an upkeep that only bites while it is dark — then U must exceed that cycle's period by a wide margin, or the cycle is skippable. The warp shrinks credited time, and shrinking credited time shrinks the charge as well as the income: a player who closes the tab during the night skips most of the oil bill and most of the darkness. Nothing is minted — burn and income shrink together — so this is not a dupe, but it is an incentive pointing exactly away from the intended play.

Keep U well above the cycle period and a single-cycle absence is credited in full, so there is nothing to skip. Set it below the period and every absence discounts the charge. The other two ways out are the designer's, not this package's: let the night earn as well as cost, or make the night's punishment state rather than flow — lamps that go out and stay out, which solveCrossing gives you exactly.

readonly exponent: number

In (0, 1]. Above 1 pays a bonus for leaving, which is not a softcap.

Prefer a dyadic rational with denominator ≤ 64 — 0.5, 0.625, 0.75 — and credited time becomes Tier A, i.e. bit-identical on every engine, for free. 0.625 and 0.6 are three per cent apart in reward and a whole determinism tier apart in kind.

readonly flatAfterSeconds: number

The horizon. Past it the curve is flat: nothing later credits anything.

Must be finite, and this is the upper clamp on the offline gap — the whole of it. A device clock a year fast credits maxOfflineCredit(curve), not a year, because the input is clamped here before the power. There is no second cap to add and no configuration for one; the flat branch of the softcap is the ceiling, which is why this curve has three parameters rather than two.

It is also a bound on work: a schedule walk stops at the first phase beginning at or after this, because every later one credits exactly zero.

offlineCredit function ↳ src/offline.ts:229

function offlineCredit(elapsedSeconds: number, curve: OfflineCurve): number

Credited seconds for a single contiguous absence.

Apply it once per return, over the one gap between the ledger's anchor and now — see the module header for why it does not compose, and advanceOver for the only correct way to spread it across a schedule.

The result is clamped at the elapsed time it was given, so W(T) ≤ T holds to the bit rather than to within a rounding error near the knot. Math.min is exactly specified, so the clamp costs nothing in determinism: a dyadic exponent stays Tier A through it.

@tier B in general — a fractional power. Tier A when exponent is a dyadic rational with denominator ≤ 64, which is computed as a Math.sqrt chain rather than a pow.

Parameters
elapsedSeconds

Real seconds away. Non-positive credits 0: a backwards clock must never mint or destroy resources.

Throws

RangeError if elapsedSeconds is not finite, or the curve is degenerate.

maxOfflineCredit function ↳ src/offline.ts:250

function maxOfflineCredit(curve: OfflineCurve): number

The most any absence can ever be worth. Derived from the curve, never restated.

What a "you have banked the maximum" HUD line reads, and the number a reviewer should compare against a game's own plausibility threshold for a bad device clock: about 37.6 ks — 10.4 hours — at 3 h / 0.6 / 24 h.

Throws

RangeError if the curve is degenerate.

offlineElapsed function ↳ src/offline.ts:274

function offlineElapsed(creditedSeconds: number, curve: OfflineCurve): number

W⁻¹ — the real elapsed time at which creditedSeconds of credit had accrued.

The map back from the physics to the calendar, and the reason a game can say "the lamps went out at 3:41 into the second night" rather than "at 1:52 of credited time, which is not a thing the player experienced". Under a warp the two clocks differ by a factor that grows with the absence, so a toast built from the credited number is confidently wrong about the player's own evening.

Closed form: the identity below U, and U·(c/U)^(1/e) above it. Tier A when 1/e is a whole number or a dyadic rational — e = 0.5 gives 1/e = 2, one multiply — and Tier B otherwise, including for e = 0.625, whose reciprocal 1.6 is not dyadic. The forward direction and the inverse therefore do not always share a tier, which is worth knowing before a game hashes either.

Returns

0 for a non-positive credit; Infinity for a credit above maxOfflineCredit — no amount of real time reaches it, which is what "flat" means.

Throws

RangeError if creditedSeconds is not finite, or the curve is degenerate.

offlineCreditRate function ↳ src/offline.ts:296

function offlineCreditRate(elapsedSeconds: number, curve: OfflineCurve): number

dW/dt — "the next second away is worth this much of a second". A read for the UI; the integrator never needs it.

Reported as the right derivative at both knots, because what a player wants to know is what the next second pays: it steps 1 → exponent at U, decays, and is 0 from F onwards. W is continuous there and w is not, which is the honest thing to show on a meter — a value interpolated across the kink would tell the player the softcap is gentler than it is.

Throws

RangeError if elapsedSeconds is not finite, or the curve is degenerate.

schedule5 symbols

An alternating rate, warped, without a tick — and the crossings hidden inside it.

Production runs at one rate by day and another by night, and the boundaries move, because every night is longer than the last. A constant-rate accrual warp is the obvious offline design and it cannot cross those boundaries. This module is the shape that can.

The insight

W warps a scalar, so it distributes across a partition of the absence by evaluation at the boundaries, not by re-application. For an absence [0, T] cut into phases 0 = a₀ < a₁ < … < a_K = T, phase i is credited

    W(a_{i+1}) − W(a_i)          seconds

and the pieces sum to exactly W(T), because the sum telescopes. W is evaluated at absolute offsets from the start of the absence and never restarted, so the once-per-return rule is not merely preserved — it is the mechanism. Each piece is then one exact closed-form integration with that phase's gate ratios in force.

Why this is not a tick

A tick's step size is arbitrary and its count scales with elapsed time; halving the step changes the answer, and the answer converges rather than being right. Here the pieces are the instants at which the rate actually changed — nightfall, dawn, a purchase, a lamp guttering — every piece is integrated exactly, and a schedule with no changes is one step however long the absence.

The schedule itself is the game's

sim does not generate one, does not know what a day is, and has no calendar. It consumes Phase[]. A cycle clock is about eight lines of game code and they are the right eight lines to write in the game.

Isomorphic: no clock, no randomness, no platform.

Phase interface ↳ src/schedule.ts:48

interface Phase<G extends string> {

One piece of a piecewise-constant schedule.

2 members
readonly atSeconds: number

Offset in seconds from the start of the absence at which this phase begins. Strictly ascending across the array, and the first must be 0.

The start of the absence is the ledger's anchor when CatchUp.fromSeconds is 0, and ledger.atMs − fromSeconds·1000 otherwise. That is deliberate: the phase array is generated once, from the game's day/night clock, and stays valid across every re-entry into the same absence even though the ledger's anchor moves each time.

readonly gates: GateRatios<G>

The gate ratios in force during it.

CatchUp interface ↳ src/schedule.ts:70

interface CatchUp<G extends string> {

An absence, and the schedule that ran during it.

The coordinate system is real seconds from the start of the absence. fromSeconds says where the ledger's anchor currently sits in it and spanSeconds says where this call stops; the phases are absolute in the same frame and never need re-basing.

4 members
readonly fromSeconds: number

How much of the absence has already been credited, in real seconds from its start.

Required, not optional, and the reason is an exploit rather than an ergonomic. Every guttered lamp is a commit partway through an absence: the crossing is discovered, the game advances to it, extinguishes a lamp, rebuilds the flow, and comes back in for the rest. Re-entering with a fresh spanSeconds and no fromSeconds restarts W — the player is paid for K absences instead of one, and because each restart begins in the uncapped region again, each one is cheaper in real time than the last. The exploit climbs back in through the very function written to close it.

The credit for a call is W(spanSeconds) − W(fromSeconds), which telescopes across the whole re-entry sequence to exactly W(T) — one absence, paid once, however many boundaries were discovered inside it. Crossing.atSeconds is in these coordinates precisely so it can be handed straight back as the next call's fromSeconds.

Writing fromSeconds: 0 is the deliberate act that says "this is the start of the absence". A reviewer can grep for the field and see every re-entry in the codebase.

readonly spanSeconds: number

The real-time span of the absence in seconds — elapsedSeconds(ledger, atMs).

readonly phases: readonly Phase<G>[]

Ascending phases covering [0, min(spanSeconds, curve.flatAfterSeconds)].

Generating phases beyond the horizon is harmless and pointless: every one of them credits exactly zero seconds, and the walk stops before visiting them. That bound is what keeps this finite — for 45 s days and 15 + 9d second nights, a 24-hour horizon is about 270 pieces, and so is a six-month absence.

readonly curve: OfflineCurve | null

The warp, or an explicit null for live time.

Required and nullable rather than optional, because this field is the upper clamp on the offline gap and a forgotten optional is how a device clock jump becomes a finished game. null says "I know this interval is short" — a live frame, an action boundary — and at a hydrate seam it is always wrong. A reviewer can grep for it.

Crossing interface ↳ src/schedule.ts:114

interface Crossing {

Where a crossing landed, in both clocks.

3 members
readonly atSeconds: number

Real seconds from the start of the absence — what a player experienced, and exactly what CatchUp.fromSeconds takes on the next iteration of a guttering loop. Infinity if it never crosses.

readonly creditedSeconds: number

*Credited** seconds from the start of the absence — where it sits in the physics.

readonly phase: number

Index into plan.phases, or -1 for no crossing.

advanceOver functionstart here ↳ src/schedule.ts:254

function advanceOver<N extends string, G extends string>(eco: Economy<N, G>, ledger: Ledger<N>, flow: Flow, plan: CatchUp<G>, atMs: EpochMillis): Ledger<N>

Advance across a piecewise-constant schedule, applying the warp once across the whole span and distributing it across the phases by evaluation at their boundaries.

This takes a curve rather than a number — the opposite of advance, and the asymmetry is the point. With one phase there is nothing to distribute and the caller may as well warp the scalar itself. With a schedule, distributing the warp by hand is exactly the thing that goes wrong: restarting W at each phase pays a player for K absences instead of one, it is invisible for short gaps because W is the identity below U, and the error grows without bound with the length of the absence.

Cost is O(visited phases × edges × depth) — semantic boundaries, not fixed steps. Doubling the length of the absence past the horizon does not change it at all: with a curve, the walk stops at the first phase beginning at or after curve.flatAfterSeconds, because every later one credits exactly zero. That makes the horizon a hard bound on work as well as on reward, so a phase array generated from a bad device clock cannot cost anything either.

An atMs earlier than the anchor returns the ledger unchanged, for the same reason advance does. The anchor otherwise lands on atMs even when nothing was credited — a fully consumed absence still happened.

Leaves flow holding the last visited phase's rates. Rebuild it before the next live frame.

Throws

RangeError if the phases are empty, do not start at 0, are not strictly ascending, or name a gate the economy did not declare; if fromSeconds or spanSeconds is negative or not finite; or if the resulting vector is not finite, naming the node.

solveCrossingOver function ↳ src/schedule.ts:302

function solveCrossingOver<N extends string, G extends string>(eco: Economy<N, G>, ledger: Ledger<N>, flow: Flow, plan: CatchUp<G>, node: N, level: number): Crossing

The same solve as solveCrossing, across a whole schedule: walk the phases, integrate each exactly, and solve inside the first one whose interior contains the crossing.

The two clocks in Crossing are why this exists rather than being a loop in game code. The physics happens in credited time; the sentence the player reads is in real time, and the map between them is offlineElapsed evaluated at the phase's own offset. Getting that backwards produces a toast that is confidently wrong about when the lights went out — by a factor that grows the longer the player was away.

Pass the same plan you will pass to advanceOver, including the same fromSeconds. The two functions walk identically, so the crossing this reports is exactly the instant that one will integrate to.

Leaves flow holding the rates of the phase the crossing was found in — which is the flow the caller wants anyway, since the next thing it does is commit at that instant.

Allocates one Crossing and two stock vectors. A hydrate-boundary call.

Throws

RangeError on the same plan and node mistakes as advanceOver.

crossing1 symbol

Solving for the instant a stock reaches a level.

sim does not clamp. A game whose oil hits zero does not want a clamped integral — it wants the instant, so it can put a commit there, extinguish the top lamp, rebuild the flow and carry on. That turns a nonlinearity into a boundary, and the guttering sequence into a loop bounded by the number of lamps rather than by time.

Why this is a root-find and not a search through time

Because the graph is nilpotent, x_node(t) is a polynomial of degree degreeOf(eco, node) whose coefficients are Aᵏx₀/k! — the same terms the integrator already computes. So:

degreemethodexactness
0constant; crosses only if it already equals levelexact
1t = (level − x₀)/c₁exact, one divide, Tier A
2quadratic formula, cancellation-safe branchexact, Tier A (Math.sqrt)
≥ 3isolate on the derivative's roots, bisect each monotone segmentfirst root guaranteed; 60 Horner evaluations per segment

The degree-≥3 path is still not a tick, and the difference is the whole argument. A tick's cost scales with the length of the interval; bisection's cost scales with the number of *bits in the answer*. A fourteen-hour horizon and a one-second horizon both cost 60 iterations, the result is accurate to an ulp rather than to a frame, and it does not change if the player's machine is slower.

Isolating on the derivative's roots is what makes it find the first crossing rather than whichever one the bracket happened to contain: a stock that dips, is rescued, and drains again must report the dip.

A source edge raises the degree of everything downstream by one, and that changes the root structure rather than merely the arithmetic: a stock that was constant becomes linear, one that was linear becomes a parabola with a turning point, and a drain that would never have recovered now might. All of that is handled — the coefficients come from the same augmented matrix the integrator uses, and the unit slot is seeded in coefficients — but it is the reason a depletion solve written against "the lowest-order term is linear" is wrong once a game adds its first flat drip.

Above degree 4 there is no algebraic alternative to want — Abel–Ruffini says the general quintic has no solution in radicals, so "closed form for any graph" is not a thing anyone can ship. That is a theorem, not a budget.

What it will not find

A tangential touch — a repeated root, a stock that grazes level and turns back — is found only when the evaluation happens to land exactly on zero. Bisection is a sign-change method and a graze has no sign change. That is the right behavior for a game as well as the honest one: a lamp that reaches exactly zero oil for one instant and is refilled did not go out.

Do not "improve" the bisection with a Newton step. Newton from an arbitrary start is what turns a deterministic 60 iterations into a platform-dependent answer, and root-finding from coefficients is already ill-conditioned at high degree.

Isomorphic: no clock, no randomness, no platform. Tier A throughout — + − × ÷, Math.sqrt and a fixed iteration count.

solveCrossing function ↳ src/crossing.ts:289

function solveCrossing<N extends string, G extends string>(eco: Economy<N, G>, stocks: Stocks<N>, flow: Flow, node: N, level: number, horizonSeconds: number): number

The first instant within [0, horizonSeconds] at which node reaches level, or Infinity if it does not.

level is usually 0. Crossings in either direction are found; the caller knows which side it started on. If node is already at level the answer is 0 — a stock sitting at zero has already run out, and reporting anything else would make the guttering loop skip a lamp.

The rates it reads are whatever buildFlow last put in flow, so a crossing is answered for the economy as it is now. Change a gate and the answer changes, which is the point: the caller re-solves after every commit.

Allocates one small array, plus one per level of the degree-≥3 recursion. This is a boundary call — per lamp, per commit — never a per-frame one.

Parameters
horizonSeconds

Must be finite. Bisection needs a bounded bracket, and "ever" is not a question a game can act on anyway: pass the horizon you would actually do something about, such as the seconds until dawn.

Returns

seconds from stocks, or Infinity. Never negative, never NaN.

Throws

TypeError if node is not a string — checked before the membership test, so a node object is not reported as an undeclared node called [object Object].

Throws

RangeError if node is not a node of eco, or if level or horizonSeconds is not finite.

capacity4 symbols

Gating as a first-class primitive.

Power supply multiplies every producer, so the fourth server rack browns out the whole campus at once. That single mechanic is what turns an idle curve into a game — it is the first moment the player's own success is the thing hurting them — and no idle library has it as a primitive.

There are two curves here and they are not interchangeable. Choosing wrongly is the most consequential balance mistake this package can be an accessory to:

shapefor
capacityWall1 at parity, falling linearly to 0 at blackoutAta constraint the player must fear: power, wicks, anything whose breach is an event
capacitySharemin(1, supply/demand)a constraint that merely limits: a road that holds only so many pilgrims, a market that absorbs only so much

Using the wall where you meant the share makes a full road destroy the pilgrims past capacity. Using the share where you meant the wall makes a brownout a tax you can ignore for forty minutes — a bot in the source game did exactly that, sitting at 136 MW of draw against 20 MW of supply and running at a fifth speed indefinitely, because it could.

Where supply and demand come from is the game's business, and that is the design. sim cannot know that a building under construction supplies nothing and draws nothing — and it must not: a substation that browns out the campus for the forty-five seconds before it helps reads as a bug no matter how defensible the simulation is. The game computes two numbers per frame and hands in a ratio.

Two things to check before filing a brownout as a bug in this file:

  1. Are unfinished buildings in the demand sum? That is trap 12 and it is almost always this.
  2. Are the supply-side edges untagged? Curtailment sheds load; it does not shut down the generator. If the edges that produce the gated capacity are throttled by it, a total blackout is unrecoverable and the save is dead. A fail state you cannot dig out of is not a stake, it is a dead save.

Isomorphic and Tier A throughout: comparison, multiplication, division.

CapacityCurve interface ↳ src/capacity.ts:41

interface CapacityCurve {

The wall's one parameter.

1 member
readonly blackoutAt: number

Demand ÷ supply at which output reaches zero. Must be > 1. The source game ships 1.5.

A brownout is a wall, not a tax. The first version of this clamped the ratio at 0.2, which meant a player could sit at seven times over-draw indefinitely, running at a fifth speed and simply ignoring it. A constraint you can shrug off is not a constraint, so there is deliberately no floor here and no way to add one.

capacityWall function ↳ src/capacity.ts:69

function capacityWall(supply: number, demand: number, curve: CapacityCurve): number

The wall: 1 at or under parity, falling linearly to 0 at blackoutAt times over-draw.

Written against supply · blackoutAt rather than against demand / supply so that both endpoints are exact: parity returns exactly 1 and the blackout point returns exactly 0, with no rounding sliver of production left on at the moment the game is telling the player the lights went out.

supply <= 0 with any demand is 0. demand <= 0 is 1. A NaN demand — which is a bug in the game's own supply/demand sum — reads as no demand rather than as a blackout: a NaN that blacks out the grid is unrecoverable, and one that reads as healthy leaves the game playable while the real bug is found. Never returns NaN.

Throws

RangeError if blackoutAt is not finite or is not > 1 — at exactly 1 the curve is a step from full production to nothing with no interval to see it happen in.

capacityShare function ↳ src/capacity.ts:92

function capacityShare(supply: number, demand: number): number

The share: min(1, supply / demand). A queue, not a wall — everyone present gets a slice and nothing collapses.

demand <= 0 is 1 and supply <= 0 is 0; for any finite positive supply the result is strictly positive, which is the property that distinguishes a queue from a wall. Never returns NaN.

capacityLoad function ↳ src/capacity.ts:111

function capacityLoad(supply: number, demand: number): number

demand / supply, for the meter — the number a HUD paints amber at 0.8, so that 18 of 20 does not look like 6 of 20.

0 when demand is zero, Infinity when supply is zero and demand is not; never NaN, because a NaN reaches the player as an empty progress bar rather than as an error anyone can act on.

This is a derived read and it must never be stored. Infinity serializes to null with a perfectly valid checksum, and a game that writes this number into its save has put a hole in it that no layer downstream can detect. It is the one value in this package's surface that is deliberately allowed to be infinite.

cost6 symbols

The cost curve, in closed form.

  costOfNext  = b · r^k
  bulkCost    = b · r^k · (r^n − 1) / (r − 1)
  maxBuyable  = floor( log_r( c(r−1) / (b·r^k) + 1 ) )

b = base, r = growth, k = owned, n = how many, c = the budget.

Closed form on day one, not as an optimization. "Buy max" at 4,000 owned is 4,000 iterations on a hot path, run once per frame to render a button's label. The naive loop is a legitimate oracle in a test — and this package's tests use one — and a performance bug in a build.

Determinism

b · r^k is the most important arithmetic in an idle game and ** makes it Tier B. owned is an integer, so the price is a chain of multiplications instead: exponentiation by squaring, which is Tier A and bit-identical everywhere. ** would be at most one ulp more accurate and not reproducible, and for a number a player is charged, reproducible wins.

maxBuyable is the one Tier B call left, and it is a seed: Math.log proposes an integer and a bounded correction verifies it with Tier A comparisons. Two engines can only disagree if their logarithms differ by enough to move the answer four whole steps. They do not.

A persisted price is not portable. Recompute costs; never store one and compare it later for equality. And affordability is compared exactlybulkCost <= budget, never with an epsilon — because an epsilon there lets a player buy something they cannot afford.

What floating point does to this, stated as a boundary rather than a defense

A double holds every integer exactly up to 2⁵³. Past that the spacing is 2, then 4, then 128 by 2⁶⁰. Relative precision never degrades, so a cost of 1e300 is still good to fifteen significant figures: the magnitude is fine and the integers are not. Two places it bites, and only two — a balance of 1e17 minus a cost of 3 is the balance again, so the player buys forever; and at growth = 1.07 a price crosses 2⁵³ at about 520 owned and reaches Infinity at about 10,500.

The kit does nothing about it, deliberately: no BigInt, no Decimal, no mantissa/exponent pair. It would infect every signature in the kit, allocate per operation on the hot path this package exists to protect, cost most of a package's whole size budget, and be less reproducible than IEEE-754, which is bit-identical across platforms by specification. What it does instead is refuse rather than lie: bulkCost returns Infinity on overflow, which compares correctly against any finite balance and refuses the purchase rather than silently making it free. The design answer is the real one — a game whose numbers approach 9e15 has a prestige problem, not an arithmetic problem.

Isomorphic: no clock, no randomness, no platform.

CostCurve interface ↳ src/cost.ts:63

interface CostCurve {

cost(k) = base · growth^k. growth must be > 1 in a shipping balance.

2 members
readonly base: number

The price of the first unit. Must be finite and non-negative; 0 is a free item, not a bug.

readonly growth: number

The multiplier per unit owned. Must be finite and > 0.

Values at or below 1 are permitted — a flat price is 1, and a decaying one is a legitimate shape for a test fixture — but a shipping balance wants > 1, because a single tier with linear production and a flat cost has owned-count growing without bound and no decision in it.

costOfNext function ↳ src/cost.ts:128

function costOfNext(curve: CostCurve, owned: number): number

b · r^k — the price of the next single unit. Tier A.

Throws

RangeError on a non-integer or negative owned, or a non-finite curve parameter.

bulkCost function ↳ src/cost.ts:146

function bulkCost(curve: CostCurve, owned: number, count: number): number

b · r^k · (r^n − 1)/(r − 1) — the price of count more, starting from owned. Tier A.

A fixed batch is all or nothing: a ×10 button with funds for six buys nothing, not six. Only maxBuyable resolves a purchase against the balance, and that asymmetry is the point — a partial batch is a different transaction from the one the player pressed.

Returns

0 for count <= 0 or a free curve; Infinity if the geometric term overflows, which compares correctly against any finite balance and therefore refuses the purchase rather than silently making it free.

Throws

RangeError on a non-integer count, a negative owned, or a non-finite parameter.

maxBuyable functionstart here ↳ src/cost.ts:229

function maxBuyable(curve: CostCurve, owned: number, budget: number, cap: number): number

floor( log_r( c(r−1)/(b·r^k) + 1 ) ), corrected for float rounding, clamped to cap.

The guarantee callers rely on is two-sided, and both halves hold on the engine that computed them: bulkCost(curve, owned, maxBuyable(...)) <= budget — a max purchase can never drive a balance negative — and bulkCost(curve, owned, maxBuyable(...) + 1) > budget unless the answer is cap, so the button says what it does.

The correction after the logarithm is at most four steps in each direction. It is a rounding fix, not a search: cap bounds arithmetic, never CPU, and 4,000 owned costs the same as 4.

This result is advisory. Do not persist it and do not send it anywhere.

The seed is Math.log, which ECMA-262 does not require to be correctly rounded. Two conforming engines can therefore disagree in the last bit, and on a cost curve that disagreement can land exactly on a floor boundary: same save, same balance, two clients, two different answers to "how many can I afford" — differing by exactly one. That is bounded and it is the residual the design accepts, because the alternative is a pow-free integer search or an epsilon, and an epsilon here would let a player buy something they cannot afford.

What follows for a caller:

  • Use it for a label and for the size of the purchase you are about to make, on the engine that computed it. Within one client it is exactly consistent with bulkCost.
  • Never store it, never checksum it, and never put it in a replay log or on a wire. Store the inputs — owned count and balance — and recompute. A persisted count computed on Firefox and verified on Safari can differ by one and will look like tampering.
  • The authoritative check is the balance test at purchase time, and it is bulkCost(curve, owned, n) <= budget compared exactly. A verifier that re-derives n and demands equality will reject honest clients on a browser update; a verifier that charges the bulkCost of the n it was handed and refuses when the balance will not cover it cannot.
Parameters
budget

Zero, negative and NaN all yield 0 rather than throwing: an empty wallet is a normal state, not an error. An infinite budget yields cap.

cap

The most the caller will accept. Must be a non-negative integer.

Throws

RangeError on a non-integer or negative owned or cap, or a non-finite curve parameter.

Milestones interface ↳ src/cost.ts:260

interface Milestones {

Ascending thresholds, each multiplying once. The source game ships ×2 at 10 / 20 / 35 / 50.

2 members
readonly thresholds: readonly number[]

Owned counts at which the bonus applies, ascending.

Order does not change the result — the multiplier is the same at every threshold, so the product is the same however they are listed — but a duplicate threshold multiplies twice, which is a real way to spell "×4 at ten" and a real way to spell a typo.

readonly multiplier: number

milestoneMultiplier function ↳ src/cost.ts:286

function milestoneMultiplier(owned: number, milestones: Milestones): number

The multiplier from milestone bonuses at an owned count. Repeated multiplication, so Tier A.

Feed it purchased counts, never effective ones. This is the subtlest bug in the package: a multiplier keyed on a count the flow itself produces changes the rate inside an integral, so a client integrating at 10 Hz places the discontinuity somewhere different from a catch-up integrating once — same save, two answers, neither reproducible. Purchased counts change only at actions, which is exactly the property the closed form needs.

It is a pure function of a number so a game can also use it on a shop card, which is where players actually learn the mechanic exists.

Throws

RangeError if owned, multiplier or any threshold is not finite, naming the index.

ids5 symbols

Identity for a simulated world.

Routed here from core, which will not hold a counter because layer 0 has no module-level mutable state. Accepted: sim owns the shape of a simulated world, and a game that has to invent this reaches for Math.random() or Date.now() — both banned by the constitution, and neither of which replays.

Across a save/load boundary: the counter is saved with the entities, in the same write, and restored before any id is minted or narrowed. An IdSource is JSON-shaped ({ next: 3417 }) and belongs in the game's state next to the ledger. Ids themselves survive as the integers they are; nothing about them is derived from the session, which is what makes a v1→v2 migration that turns lampsLit: number into lamps: EntityId[] writable at all — the migration mints the ids it needs and writes the counter it left off at.

Isomorphic and Tier A: integer addition and comparison, nothing else.

EntityId type ↳ src/ids.ts:30

type EntityId = number & {
    readonly [entityBrand]: true;
}

An identity for a thing in the world — a lamp, a building, a pilgrim with a name.

A number at runtime and in JSON; branded so that a lamp id cannot be passed where a tile index is wanted. The one cast that constructs one lives inside mintId, and the one that re-narrows a saved integer lives inside asEntityId. There is deliberately no third.

IdSource interface ↳ src/ids.ts:39

interface IdSource {

The allocator. Its entire state is one integer, and that integer must be saved.

next is deliberately mutable: this is the one value in the package that is not a value. A counter that is not persisted alongside the entities it named will re-issue live ids on the next session and merge two entities into one — silently, and unrecoverably.

1 member
next: number

createIdSource function ↳ src/ids.ts:51

function createIdSource(next?: number): IdSource

Start or restore an allocator.

Parameters
next

The counter read back from a save, or 0 for a new world.

Throws

RangeError if next is not a non-negative safe integer — a corrupt save, caught at load rather than at the first mint. Above 2⁵³ a double cannot hold consecutive integers, so n + 1 quietly equals n and the allocator stops allocating while appearing to work.

mintId function ↳ src/ids.ts:76

function mintId(source: IdSource): EntityId

Take the next id. Monotone, and never reused.

Recycling a freed id is the ABA bug in a game: a reference held to a lamp that was extinguished silently becomes a reference to the lamp built afterwards, and the symptom appears three systems away. At one mint per millisecond a counter reaches Number.MAX_SAFE_INTEGER in 285,000 years, so there is nothing to reclaim.

Deterministic by construction: ids are handed out in the order actions are applied, so a replay from a seed and an input log mints the same ids for the same things. That is why the counter is here and not derived from a clock or an Rng — a time-derived id cannot replay, and a random one would consume a stream the rest of the game is also drawing from.

Throws

RangeError if the source's counter has left the exactly-representable integers.

asEntityId function ↳ src/ids.ts:94

function asEntityId(value: number, source: IdSource, label: string): EntityId

Narrow a number that came back from a save.

Ids arrive from JSON.parse as plain numbers, so a load boundary needs exactly one checked cast — and that check is worth having for its own sake: an id at or above source.next is proof the counter was not saved with the entities. That save will re-issue live ids and merge two entities into one, which is unrecoverable and silent. Fail at load instead.

Parameters
label

the caller's symbol, for the error message: 'save.lamps[3]'.

Throws

RangeError naming the id and the counter it exceeded.

index1 symbol