Curve
Piecewise curves over a few authored keys, with one ease and one extrapolation rule per curve and a first-crossing inverse, for XP tables, falloff and pricing.
- Realm
- Shared
- Require
ReplicatedStorage.ChloeKernel .Curve - Since
- v0.7.2
- Source
- Curve.luau
Curve.new():add(1, 100):add(5, 350):add(20, 1200) holds a few designer-tunable (x, y) keys and answers two
questions: at(x), the y for this x, and solve(y), the first x that reaches this y. XP tables, damage falloff by
distance, prices and loot weights stop being if x < 5 then ... elseif x < 20 then ... ladders. The decision that
defines it is how little it does: one Ease and one Extrapolate rule govern the whole curve, set at construction.
There are no per-key eases, no per-call overrides and no curve fitting. Curve has no kernel dependency, so one shared
config module can hold curves that the server and every client evaluate alike.
Curves replace if-ladders
Section titled “Curves replace if-ladders”The pattern Curve replaces appears wherever a designer number needs a few breakpoints:
-- Before: a new branch and new interpolation math for every breakpointlocal 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 endendEach new breakpoint is another branch with its own arithmetic, and the next mapping (falloff, prices) copies the shape with slightly different edge behavior. The curve version is the same information as data:
-- 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)Another breakpoint is one more add. The edge behavior and the segment shape are chosen once per curve.
Mental model
Section titled “Mental model”A curve is two parallel arrays, X and Y, kept in ascending x order, plus Ease and Extrapolate, read once from
the options passed to Curve.new. Every segment of one curve interpolates the same way and every query outside its
keys extrapolates the same way. A mapping that needs a different shape in one region is two curves.
add: sorted insert
Section titled “add: sorted insert”add(x, y) keeps the arrays sorted on every call, with a linear scan:
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 stops at the first key not less than x, which is the right slot whatever order keys arrive in. If that slot
already holds exactly x, its y is replaced instead of a second key being inserted. The comparison is exact ==.
Both x and y must be numbers and not NaN (Curve key x must be a number).
The scan is O(n) per add. Curves are built once from a handful of keys and queried many times, so paying linear
insertion up front for O(log n) lookups afterwards is the right trade.
at: binary search, then ease
Section titled “at: binary search, then ease”Inside the key range, at(x) binary searches for the bracketing segment:
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])Alpha is x’s position between the two keys, from 0 to 1, and the curve’s Ease turns it into a y:
Linear(default):Y[Low] + (Y[High] - Y[Low]) * Alpha.Smooth: smoothstep,Alpha * Alpha * (3 - 2 * Alpha), then the same lerp. Its slope is zero at both ends of a segment, so the curve arrives at and leaves every key flat. On keys(0, 0)and(10, 100),at(5)is 50 andat(2.5)is 15.625, which is smoothstep(0.25) = 0.15625 times 100.Step: returnsY[Low]. The lower key’s value holds across the whole segment and switches exactly at the next key. On keys(1, 100),(5, 350),(20, 1200),at(4.9)is 100 andat(5)is 350.
For the XP curve, at(7) lands in the (5, 350) to (20, 1200) segment with Alpha = 2 / 15, giving about 463.3 under
Linear.
Outside the keys
Section titled “Outside the keys”Extrapolate decides what happens below the first key and above the last, independently of Ease:
Clamp(default): anyx <= X[1]returnsY[1], and anyx >= X[Count]returnsY[Count].Linear: continues the slope of the first segment below the range and of the last segment above it. The boundary keys themselves return their own values; the formula applies only strictly outside.
A curve with one key is a constant: at returns that key’s y for every x.
solve: first crossing, left to right
Section titled “solve: first crossing, left to right”solve(y) walks segments left to right and inverts the first one whose y range contains 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 nilIt is O(n) because it cannot assume the curve is monotonic, and binary search needs that ordering. A curve may rise and
then fall (a bonus that peaks mid-range). For a monotonic curve such as an XP table the answer is exact. For any other
curve it is the first crossing: a y reached at two places returns the smaller x.
Every segment inverts linearly, whatever the ease. On a Step curve a matching segment returns its upper key, the first
x where the held value reaches or passes y: on keys (1, 100) and (5, 350), solve(350) is 5. A y that no
segment brackets returns nil, above the curve and below it alike. There is no clamped x for an unreachable y.
--!strict-- Curves: progression and combat tuning shared by server and client.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Curve = require(ReplicatedStorage.ChloeKernel.Curve)
local Curves = {}
Curves.XpForLevel = Curve.new():add(1, 100):add(5, 350):add(20, 1200)-- at(1) = 100, at(7) = about 463.3, at(0) = 100 and at(999) = 1200 (clamped)-- solve(800) = about 12.94: the level where 800 xp is first reached
-- Falloff keeps sloping past 60 studs instead of holding at 0.35; callers floor it at 0Curves.Falloff = Curve.from({ { 0, 1 }, { 20, 1 }, { 60, 0.35 },}, { Extrapolate = "Linear" })
-- Banded loot weight: flat inside a tier, jumps at the tier's keyCurves.WeightByLuck = Curve.from({ { 0, 1 }, -- common below Luck 10 { 10, 4 }, -- uncommon from 10 { 25, 12 }, -- rare from 25}, { Ease = "Step" })-- at(9) = 1, at(10) = 4, solve(12) = 25
function Curves.damageAt(distance: number, baseDamage: number): number return baseDamage * math.max(Curves.Falloff:at(distance), 0)end
return CurvesAPI reference
Section titled “API reference”| Member | Description |
|---|---|
Curve.new(options?) -> Curve |
An empty curve. options.Ease is "Linear" (default), "Smooth" or "Step"; options.Extrapolate is "Clamp" (default) or "Linear". Both are read once and apply to the whole curve. |
Curve.from(points, options?) -> Curve |
Builds a curve from an array of { x, y } pairs by calling add for each, in array order. |
curve:add(x, y) -> Curve |
Inserts a key in order, or replaces the y of an existing x (exact match). Asserts both are numbers and not NaN. Returns the curve for chaining. |
curve:at(x) -> number |
Binary searches for the segment and applies the ease; outside the keys, applies Extrapolate. Raises the curve has no keys on an empty curve. |
curve:solve(y) -> number? |
Linearly inverts the first segment, left to right, whose range holds y. Nil when no segment does. Raises on an empty curve. |
curve:keys() -> { { number } } |
A copy of the keys as { x, y } pairs in ascending x. |
Curve publishes nothing and has no hooks. Every call is synchronous and touches only its two arrays.