Vectors, seeded RNG, debug lines, and profiling.
Math & debug
Small helpers for moving numbers around and for seeing what your script thinks it is doing. Four modules: demi.math (vector and scalar math plus seeded randomness), demi.debug (on-screen lines), demi.profile (timing scopes), and the text helpers in demi.text and demi.regex.
Why a math module
Lua has no built-in vector type. The math services speak in Vec2/Vec3 numeric arrays — fixed-length tables indexed from 1 — and return single numbers for lengths and dot products, so results drop straight into specs and hit tables without repacking.
local Vector3 = require("demi.math.vector3")
local sum = Vector3.add({ 1, 2, 3 }, { 0.5, 0, 0 })
-- sum: { 1.5, 2, 3 } — a 3-number array
local len = Vector3.length({ 3, 4, 0 }) -- 5
local n = Vector3.normalized({ 0, 2, 0 }) -- { 0, 1, 0 } (unit length)
Call (vector2 / vector3) | Takes | Returns |
|---|---|---|
add | Two vector arrays | Component-wise sum as one array |
subtract | Two vector arrays | Component-wise difference as one array |
scale | One vector array plus one number | Scaled vector array |
length | One vector array | Single number: the magnitude |
normalized | One vector array | Unit-length vector array |
dot | Two vector arrays | Single number: the dot product |
cross | Two vector arrays (vector3 only) | Cross product as one array |
lerp | Two vector arrays and a t number 0…1 | Interpolated vector array |
vector2 takes and returns two-number arrays; vector3 takes and returns three-number arrays. A 3D example: Vector3.add({ 1, 0, 0 }, { 0, 1, 0 }) returns { 1, 1, 0 }. Read arrays with v[1], v[2], v[3] (Lua indexes from 1).
local Vector3 = require("demi.math.vector3")
local Scalar = require("demi.math.scalar")
local t = Scalar.smoothstep(0.2, 0.8, 0.5) -- eased 0…1, clamped to the range
local m = Vector3.lerp({ 0, 0, 0 }, { 10, 0, 0 }, t)
Scalar.smoothstep(edge0, edge1, x)→ one number. Mapsxinto a smooth 0…1 ramp between the two edges, clamped outside them. Use it for eased fades and camera blends.Vector3.lerp(a, b, t)→ interpolated array;t = 0isa,t = 1isb.
Seeded randomness
Unseeded math.random cannot be reproduced. The random service keeps an explicit seed so replays and tests match.
local Random = require("demi.math.random")
Random.seed(20260926) -- restart the sequence from a known seed
local snap = Random.state() -- exact state string of the current position
print(Random.value()) -- float in [0, 1)
print(Random.range(-2, 2)) -- float between -2 and 2
print(Random.integer(1, 6)) -- integer in [1, 6] inclusive
Random.restore(snap) -- rewind to a saved state
Random.seed(n)— starts a new sequence from integern. Returns nothing.Random.state()→ exact unsigned 64-bit state string, safe to keep in JSON saves and pass torestore.Random.restore(state)— rewinds to that state string. Returnstruewhen the state was accepted.Random.value()→ float in[0, 1).Random.range(min, max)→ float between the two numbers.Random.integer(min, max)→ integer, both ends included.
Snapshots let you branch (“roll both outcomes and pick one”) and still continue the main sequence. See Replay & profiler for whole-run reproduction.
On-screen debug output
While iterating on Skyforge you often want information on screen without building UI. Debug output is temporary developer tooling, not player-facing HUD.
local Debug = require("demi.debug")
Debug.log("wave 3, enemies left: 2") -- log channel (console/overlay)
Debug.line(0, 0, 4, 2, 1, 0, 0, 1) -- draw a world-space segment this frame
Debug.clear_lines() -- wipe drawn segments
Debug.log(message)— records a message to the log. Returns nothing.Debug.line(x1, y1, x2, y2, r?, g?, b?, a?, width?)— draws one line segment between two world points this frame; color channels and width are optional. Returns nothing.Debug.clear_lines()— removes drawn segments. Returns nothing.
Profiling scopes
When a frame stutters, measure instead of guessing. A scope wraps a function and reports how long it took.
local Profile = require("demi.profile")
print(Profile.enabled()) -- true while profiling is active
Profile.scope("skyforge_enemy_ai", function()
update_all_enemies()
end)
Profile.enabled()— function returningtruewhile profiling is on (CLI:--profiler).Profile.scope(name, fn)— runsfnand records its duration undername. Returnstruewhen the scope ran and was recorded.
Aggregate and per-frame reports come from the CLI (--profile-report, --profile-frames); see Replay & profiler.
Text and patterns
Characters are not always one byte: an emoji or accented letter can be several. These helpers count and slice by user-visible characters (graphemes) so a player name is never cut in half.
local Text = require("demi.text")
local Regex = require("demi.regex")
print(Text.grapheme_count("Skýforge")) -- 7 visible characters
local piece = Text.grapheme_slice("Skýforge", 1, 3)
-- piece: first 3 graphemes as a string (one-based start index)
local laid = Text.layout("Ready?", 120, 16)
-- laid: table with width, height, grapheme_count, truncated, lines
local rich = Text.parse_rich("[b]Ace[/b]")
-- rich: { text, spans, diagnostics } describing styled runs
print(Regex.is_valid("[0-9]+")) -- true (pattern compiles)
print(Regex.matches("wave 3", "[0-9]+")) -- true
Text.grapheme_count(s)→ integer number of visible characters.Text.grapheme_slice(s, first, count)→ string of that many graphemes starting at the one-basedfirstindex, ornilwhen out of range.Text.layout(s, width, font_size, max_lines?)→ table withwidth,height,grapheme_count,truncated,valid_utf8, and per-linelines.Text.parse_rich(s, strict?)→ table with the plaintext, styledspans, anddiagnostics.Regex.is_valid(pattern)→ boolean,falsefor a pattern that does not compile.Regex.matches(value, pattern, case_sensitive?)→ boolean; the value comes first, the pattern second. Patterns use ECMAScript regular-expression syntax.
Next steps
- Transform API — where the vector scalars go.
- Replay & profiler — reading the numbers these scopes produce.
- HUD API — player-facing text instead of debug overlays.