Curve
Curve.new():add(1, 100):add(5, 350):add(20, 1200) holds a handful of designer-tunable (x, y) keys and answers two questions: at(x) — given this x, what’s y — and solve(y) — what’s the first x that reaches this y. The design decision that defines it is refusing to be anything more: no per-key ease overrides, no runtime option overrides, no curve fitting. One Ease and one Extrapolate policy govern the whole curve, set once at construction. XP tables, damage falloff by distance, prices, loot weights — anything that used to be a chain of if x < 5 then ... elseif x < 20 then ... becomes one small object instead of a bespoke ladder rewritten every time a designer wants to add a breakpoint.
Why a Curve, not an if-ladder
Section titled “Why a Curve, not an if-ladder”The pattern Curve replaces looks like this, scattered across a codebase wherever a designer number needs a few tuning breakpoints:
-- Before: a fresh if-ladder every time this mapping needs a new keylocal function xpForLevel(level: number): number if level <= 1 then return 100 elseif level <= 5 then return 100 + (level - 1) / (5 - 1) * (350 - 100) elseif level <= 20 then return 350 + (level - 5) / (20 - 5) * (1200 - 350) else return 1200 endendEvery new breakpoint means a new elseif, rewritten interpolation math, and — inevitably — a copy of this same shape for damage falloff, then again for prices, each with its own subtly different clamp behavior at the edges. The Curve version is the same information with the boilerplate factored out once:
-- After: the breakpoints ARE the data, the math lives in one placelocal XpForLevel = Curve.new():add(1, 100):add(5, 350):add(20, 1200)XpForLevel:at(7) -- same interpolation, no rewritten branchAdding a fourth breakpoint is one more :add(...) call, not a new elseif — and the clamp-vs-extend decision at the edges, and the choice between linear/eased/stepped segments, are made once per curve instead of reinvented (and potentially gotten wrong) at every call site.
Mental model
Section titled “Mental model”A Curve is two parallel arrays, X and Y, kept in ascending-x order, plus two fixed fields, Ease and Extrapolate, read once from the options table passed to Curve.new and never touched again. There is no per-key ease and no per-call override — every segment of a given curve interpolates the same way, and every out-of-range query extrapolates the same way. If a mapping genuinely needs a different shape in one region than another, that’s two Curves, not one with mixed settings.
add(x, y) — sorted insertion, not a sort pass
Section titled “add(x, y) — sorted insertion, not a sort pass”add keeps X/Y sorted incrementally, on every call — there’s no lazy sort deferred until the first at()/solve(). It does this with a linear scan, not a binary search:
local Index = 1while Index <= #self.X and self.X[Index] < x do Index += 1endif self.X[Index] == x then self.Y[Index] = yelse table.insert(self.X, Index, x) table.insert(self.Y, Index, y)endThe scan walks forward until it finds the first key whose x is not less than the new one — that’s the correct insertion slot regardless of what order keys get added in. The dedupe check is fused into that same scan: if the slot it lands on already holds exactly this x, the loop overwrites Y[Index] in place instead of inserting a second entry. So “re-adding an x replaces its y” isn’t a separate cleanup pass — it’s the direct consequence of the insertion slot for an existing x always being that x’s own current index. The comparison is exact (==), not tolerance-based — see the floating-point caution below.
This scan is O(n) per add, which is the right trade for this object: curves are built once at startup from a handful of keys (five, ten, rarely more) and then queried thousands of times over a session. Paying O(n) insertion a few times up front to get O(log n) reads forever after is the entire point.
at(x) — binary search, then ease
Section titled “at(x) — binary search, then ease”Once keys are sorted, at(x) finds the bracketing segment with an actual binary search over X:
local Low, High = 1, Countwhile High - Low > 1 do local Mid = math.floor((Low + High) / 2) if self.X[Mid] <= x then Low = Mid else High = Mid endendlocal Alpha = (x - self.X[Low]) / (self.X[High] - self.X[Low])return segmentValue(self, Low, Alpha)Low/High converge to the two keys bracketing x, Alpha is x’s position between them in [0, 1], and segmentValue turns Alpha into a y according to the curve’s Ease — the same three formulas for every segment on this curve, confirmed straight from source:
Linear(default) — a plain lerp:Y[Low] + (Y[High] - Y[Low]) * Alpha.Smooth— classic smoothstep,Alpha' = Alpha² * (3 - 2·Alpha), then the same lerp withAlpha'in place ofAlpha. This is Ken Perlin’s smoothstep, not a generic “ease” placeholder — the spec pins the exact numbers:Smooth:at(5)on a{0,0}→{10,100}curve is50(Alpha = 0.5, smoothstep(0.5) = 0.5, so no change at the midpoint), whileSmooth:at(2.5)(Alpha = 0.25) is15.625, matchingsmoothstep(0.25) = 3(0.25)² − 2(0.25)³ = 0.15625exactly. Smoothstep’s derivative is zero at both ends of the segment — the curve arrives and leaves each key flat, which is why it reads as “eased” rather than kinked.Step— ignoresAlphaentirely and returnsY[Low]— the lower key of the bracketing segment holds for the whole segment, snapping to the next key’s value only oncexreaches it. The spec confirms the held endpoint directly: over keys{1,100},{5,350},{20,1200}withEase = "Step",at(4.9)is100(still holding thex=1key) andat(5)is already350(the new key takes over exactly at its ownx, not just after it).
Binary search makes at() O(log n) per call — the difference that matters when a curve is queried once per hit (damage falloff evaluated on every shot) or once per frame, rather than once per design session. Traced against the XP curve above (X = {1, 5, 20}), at(7) starts with Low, High = 1, 3; Mid = 2, and X[2] = 5 <= 7, so Low moves to 2; the loop ends (High - Low = 1), landing on the (5, 350)–(20, 1200) segment with Alpha = (7 - 5) / (20 - 5) ≈ 0.133, giving 350 + (1200 - 350) * 0.133 ≈ 463.3 under Linear ease — one comparison to discard each half of the key list instead of walking every key in order.
Extrapolation — outside the key range
Section titled “Extrapolation — outside the key range”Below the first key or above the last, Extrapolate decides what happens, and it’s independent of Ease — even a Smooth curve extrapolates with a plain straight line, not a continuation of the smoothstep tangent (which is flat at the boundary; see the caution below).
Clamp(default) — holds the nearest boundary value: anyx <= X[1]returnsY[1], anyx >= X[Count]returnsY[Count].Linear— continues the boundary segment’s slope. Below the range, the slope is taken from the first segment (X[1]/X[2]); above it, from the last segment (X[Count-1]/X[Count]). The exact boundary key itself (x == X[1]orx == X[Count]) always returns the plain key value, never the extrapolated formula — extrapolation only kicks in strictly past the edge (x < X[1]orx > X[Count]).
A curve with exactly one key is a constant: at() returns that single Y[1] for any x at all, bypassing both the extrapolation and segment logic entirely.
solve(y) — first-crossing inverse, a linear scan
Section titled “solve(y) — first-crossing inverse, a linear scan”solve is the inverse question — what’s the smallest x where the curve reaches y — and it is not a binary search, deliberately: it walks segments left to right in x order and returns the first one whose y-range brackets the target:
for Index = 1, Count - 1 do local A, B = self.Y[Index], self.Y[Index + 1] if y == A then return self.X[Index] end if (y > A and y <= B) or (y < A and y >= B) then if self.Ease == "Step" then return self.X[Index + 1] end local Alpha = (y - A) / (B - A) return self.X[Index] + (self.X[Index + 1] - self.X[Index]) * Alpha endendif self.Y[Count] == y then return self.X[Count]endreturn nilThat’s O(n), not O(log n) — and it can’t be a binary search, because solve doesn’t assume the curve is monotonic. A curve is free to rise then fall (a damage bonus that peaks mid-range and tapers at both ends); binary search only works with an ordering guarantee that a non-monotonic curve doesn’t provide. So solve is exact only for the common case — a monotonic curve, like an XP table where every level costs strictly more than the last — and for anything else it’s genuinely “first-crossing”: if y is reachable at two different x values, solve returns the smaller one and silently ignores the second. For a rise-then-fall curve that crosses y = 500 once on the way up and again on the way down, solve(500) only ever reports the earlier crossing.
For a Step-eased curve, the bracket check still fires on y’s position between A and B, but since Step holds the lower key’s value across a whole segment, the first x at which y actually appears is the upper key of that segment (X[Index + 1]) — the point where the hold value changes to B. That’s what the spec exercises: over {1,100},{5,350}} stepped, solve(350) is 5, not 1.
When y never falls inside any segment’s bracket and isn’t an exact match for the last key either, solve returns nil rather than the nearest key or an error — both “above the top of the curve” and “below the bottom” resolve to nil, matching at()’s clamp behavior having no equivalent on the inverse side (there is no sensible “clamped x” for an unreachable y).
-- src/Shared/Curves.luaulocal Curve = require(game:GetService("ReplicatedStorage").ChloeKernel.Curve)
local XpForLevel = Curve.new() :add(1, 100) :add(5, 350) :add(20, 1200)
print(XpForLevel:at(1)) --> 100 (exact key)print(XpForLevel:at(5)) --> 350 (exact key)print(XpForLevel:at(7)) --> ~463.3, interpolated between the (5, 350) and (20, 1200) keysprint(XpForLevel:at(0)) --> 100, clamped before the first key (default Extrapolate)print(XpForLevel:at(999)) --> 1200, clamped past the last key
-- "What level first reaches 800 xp?"print(XpForLevel:solve(800)) --> ~12.94, linearly inverted between the (5, 350) and (20, 1200) keysA falloff curve built with Curve.from and Linear extrapolation so damage never hard-clamps to zero at extreme range, only keeps sloping down:
local FalloffByDistance = Curve.from({ { 0, 1.0 }, { 20, 1.0 }, { 60, 0.35 },}, { Extrapolate = "Linear" })
local function damageAt(distance: number, baseDamage: number): number return baseDamage * math.max(FalloffByDistance:at(distance), 0)endStep ease suits banded designer values that should never blend into each other — a loot-rarity weight that’s flat within a tier and jumps at the tier boundary rather than smoothly ramping:
local WeightByLuck = Curve.from({ { 0, 1 }, -- common up to Luck 10 { 10, 4 }, -- uncommon from Luck 10 up to 25 { 25, 12 }, -- rare from Luck 25 on}, { Ease = "Step" })
print(WeightByLuck:at(9)) --> 1 (still the common band)print(WeightByLuck:at(10)) --> 4 (uncommon band starts exactly at its own key)print(WeightByLuck:solve(12)) --> 25 (the x where the hold value becomes 12)print(WeightByLuck:keys()) --> { {0, 1}, {10, 4}, {25, 12} }API reference
Section titled “API reference”| Member | Description |
|---|---|
Curve.new(options: CurveOptions?) → Curve |
Constructs an empty curve. options.Ease — "Linear" (default) | "Smooth" | "Step" — and options.Extrapolate — "Clamp" (default) | "Linear" — are read once here and apply to every segment and every query for this curve’s lifetime; there is no per-key or per-call override. |
curve:add(x: number, y: number) → Curve |
Inserts a key in sorted order, or overwrites y if x already exists (exact == match). Both x and y must be real numbers (NaN is rejected). Returns self for chaining. |
curve:at(x: number) → number |
Forward lookup. Binary searches the sorted keys for the bracketing segment, then interpolates per the curve’s Ease; outside the key range, applies Extrapolate. Asserts if the curve has zero keys. |
curve:solve(y: number) → number? |
Inverse lookup. Scans segments left to right and linearly inverts the first one whose range brackets y (exact for monotonic curves, first-crossing otherwise). Returns nil if y is never reached. Asserts if the curve has zero keys. |
Curve.from(points: { {number} }, options: CurveOptions?) → Curve |
Bulk constructor: points is a plain array of two-element {x, y} pairs (not a dictionary) — shorthand for calling add(x, y) once per pair, in array order. |
curve:keys() → { {number} } |
A copy of the current keys as {x, y} pairs, ascending x — safe to inspect or serialize without touching the live curve. |
Curve has no hooks and no bus topics — every operation is a synchronous, pure read or write against the two arrays. Nothing about it depends on the Bus or Hooks; it’s a value type you can build in a spec, in a Bootstrap, or on either side of the client/server boundary identically.