Skip to content

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.

The pattern Curve replaces appears wherever a designer number needs a few breakpoints:

-- Before: a new branch and new interpolation math for every breakpoint
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

Each 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 place
local 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.

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(x, y) keeps the arrays sorted on every call, with a linear scan:

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

Inside the key range, at(x) binary searches for the bracketing segment:

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])

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 and at(2.5) is 15.625, which is smoothstep(0.25) = 0.15625 times 100.
  • Step: returns Y[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 and at(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.

Extrapolate decides what happens below the first key and above the last, independently of Ease:

  • Clamp (default): any x <= X[1] returns Y[1], and any x >= X[Count] returns Y[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(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
end
end
if self.Y[Count] == y then
return self.X[Count]
end
return nil

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

src/Shared/Game/Config/Curves.luau
--!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 0
Curves.Falloff = Curve.from({
{ 0, 1 },
{ 20, 1 },
{ 60, 0.35 },
}, { Extrapolate = "Linear" })
-- Banded loot weight: flat inside a tier, jumps at the tier's key
Curves.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 Curves
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.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.