Skip to content

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.

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 key
local 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
end
end

Every 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 place
local XpForLevel = Curve.new():add(1, 100):add(5, 350):add(20, 1200)
XpForLevel:at(7) -- same interpolation, no rewritten branch

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

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 = 1
while Index <= #self.X and self.X[Index] < x do
Index += 1
end
if self.X[Index] == x then
self.Y[Index] = y
else
table.insert(self.X, Index, x)
table.insert(self.Y, Index, y)
end

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

Once keys are sorted, at(x) finds the bracketing segment with an actual binary search over X:

local Low, High = 1, Count
while High - Low > 1 do
local Mid = math.floor((Low + High) / 2)
if self.X[Mid] <= x then
Low = Mid
else
High = Mid
end
end
local 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 with Alpha' in place of Alpha. 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 is 50 (Alpha = 0.5, smoothstep(0.5) = 0.5, so no change at the midpoint), while Smooth:at(2.5) (Alpha = 0.25) is 15.625, matching smoothstep(0.25) = 3(0.25)² − 2(0.25)³ = 0.15625 exactly. 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 — ignores Alpha entirely and returns Y[Low] — the lower key of the bracketing segment holds for the whole segment, snapping to the next key’s value only once x reaches it. The spec confirms the held endpoint directly: over keys {1,100},{5,350},{20,1200} with Ease = "Step", at(4.9) is 100 (still holding the x=1 key) and at(5) is already 350 (the new key takes over exactly at its own x, 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.

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: any x <= X[1] returns Y[1], any x >= X[Count] returns Y[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] or x == X[Count]) always returns the plain key value, never the extrapolated formula — extrapolation only kicks in strictly past the edge (x < X[1] or x > 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
end
end
if self.Y[Count] == y then
return self.X[Count]
end
return nil

That’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.luau
local 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) keys
print(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) keys

A 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)
end

Step 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} }
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.