ai used: https://chatgpt.com/share/6a0eff71-3318-8327-9c9d-afb0b73686e7 https://claude.ai/share/1da48d01-6fb3-4d34-bed5-381d1adc98bd https://gemini.google.com/share/c4877f5a9191 https://chatgpt.com/share/6a218332-2058-8394-adf0-bdd2b446b729 https://claude.ai/chat/a0003dec-8dab-4b1a-9d5c-35a6f1cb2ca3
A lightweight Lua library for scripting Beat Saber modcharts.
Base Map → Lua Script → Exported Map
Built on the Heck infrastructure (Noodle Extensions + Chroma), with a clean API inspired by ReMapper.
- Lightweight — pure Lua, zero external dependencies
- Fast — direct table mutation, no intermediate copies
- Typed — fluent setter API with clear naming conventions
- Full Heck support — AnimateTrack, AssignPathAnimation, AssignTrackParent, AssignPlayerToTrack
- Full Chroma support — colors, gradients, environment enhancements, geometry, materials, fog
- Full Noodle support — coordinates, localRotation, worldRotation, fake notes, point definitions, player movement
- Animation proxy — write
note.animation.offsetPosition = {...}directly
Copy the beatforge/ folder into your project root. If you use Fluxion, drop it alongside your map files.
your-map/
├── beatforge/ ← library folder
│ ├── init.lua
│ ├── core/
│ ├── modules/
│ └── utils/
├── script.lua ← your script
└── ExpertPlus.dat
local bf = require("beatforge")
local map = bf.load("ExpertPlus.dat")
-- Move all notes up by 2 units
map:notes(function(n)
n.animation.offsetPosition = { 0, 2, 0 }
end)
-- Color every red note magenta (Chroma)
map:notes(function(n)
if n:getColor() == 0 then
n:setChromaColor(bf.chroma.hex("#FF00FF"))
end
end)
map:save("ExpertPlus.dat")local map = bf.load("ExpertPlus.dat") -- load existing
local map = bf.new() -- empty map| Method | Description |
|---|---|
map:notes(fn) |
Iterate all color notes |
map:walls(fn) |
Iterate all obstacles |
map:bombs(fn) |
Iterate all bomb notes |
map:events(fn) |
Iterate basic beat events |
map:filterNotes(predicate) |
Returns filtered Note[] |
map:addNote(note) |
Append a new note |
map:addWall(wall) |
Append a new obstacle |
map:addCustomEvent(ev) |
Append a Heck custom event |
map:addEnvironment(env) |
Append a Chroma environment entry |
map:setSettings(tbl) |
Set _settings (requirements, suggestions, modifiers) |
map:save(path?) |
Write to disk |
local Note = bf.Note
local n = Note.new(beat, x, y, color, direction)Base fields
n:getBeat() n:setBeat(v)
n:getX() n:setX(v)
n:getY() n:setY(v)
n:getColor() -- 0=red, 1=blue
n:getDirection() -- 0=up … 8=dotNoodle Extensions
n:setTrack("MyTrack")
n:setFake(true)
n:setInteractable(false)
n:setCoordinates({-1.5, 0}) -- {x, y} override
n:setLocalRotation({0, 0, 45}) -- Euler degrees
n:setWorldRotation({0, 90, 0})
n:setDisableNoteLook(true)
n:setNoteJumpStartBeatOffset(-2)Animation
-- Proxy shorthand (recommended):
n.animation.offsetPosition = { 0, 2, 0 }
n.animation.offsetWorldRotation= {{0,0,0,0},{0,360,0,1,"easeInOutSine"}}
n.animation.dissolve = {{0,0},{1,0.1},{1,0.9},{0,1}}
n.animation.scale = { 2, 2, 2 }
-- Or via setters:
n:setOffsetPosition({ 0, 2, 0 })
n:setDissolve({{1,0},{0,0.8}})All animation properties
| Property | Description |
|---|---|
offsetPosition |
World-space translation offset |
offsetWorldRotation |
World-space rotation offset |
localPosition |
Local position |
localRotation |
Local rotation |
definitePosition |
Absolute world position (overrides spawn movement) |
scale |
Scale multiplier |
dissolve |
Note opacity (0=invisible) |
dissolveArrow |
Arrow opacity separately |
interactable |
Hit detection |
color |
Animated RGBA color |
Chroma
n:setChromaColor({1, 0, 1, 1}) -- {r,g,b,a}
n:setSpawnEffect(false)
n:setDisableDebris(true)local w = bf.Wall.new(beat, x, y, duration, width, height)
w:setTrack("WallTrack")
w:setFake(true)
w:setSize({3, 2, 0.1}) -- {width, height, depth}
w:setChromaColor({0, 1, 1, 0.4})
w.animation.dissolve = {{0,0},{1,0.5},{0,1}}local b = bf.Bomb.new(beat, x, y)
b:setFake(true)
b:setChromaColor({1, 0.5, 0})
b.animation.offsetPosition = { 0, 3, 0 }local e = bf.Event.new(beat, type, value, float)
e:setChromaColor({1, 0, 0, 1})
e:setLightID({1, 2, 3})
e:setLightGradient(bf.chroma.lightGradient(
bf.chroma.hex("#FF0000"),
bf.chroma.hex("#0000FF"),
4, -- beats
"easeInOutSine"
))local heck = bf.heck
-- AnimateTrack: animate a named track over time
map:addCustomEvent(heck.animateTrack(
"MyTrack", -- track name (or table of names)
16, -- beat
{ -- animation properties
offsetPosition = {{0,0,0,0},{0,5,0,0.5,"easeOutSine"},{0,0,0,1}},
scale = { 2, 2, 2 },
},
8, -- duration in beats
"easeInOutSine" -- optional global easing
))
-- AssignPathAnimation: per-object lifetime animation
map:addCustomEvent(heck.assignPathAnimation(
"BallTrack", 0,
{ definitePosition = {{-5,1,10,0},{5,1,10,1,"easeInOutSine"}} }
))
-- AssignTrackParent: parent one track under another
map:addCustomEvent(heck.assignTrackParent("ChildTrack","ParentTrack", 0))
-- AssignPlayerToTrack + animate player
map:addCustomEvent(heck.assignPlayerToTrack("PlayerTrack", 0))
map:addCustomEvent(heck.animateTrack("PlayerTrack", 8,
{ offsetPosition = {{0,0,0,0},{0,3,0,1}} }, 4
))
-- Easing constants (matches Heck string names)
heck.easing.inOutSine -- "easeInOutSine"
heck.easing.outBounce -- "easeOutBounce"
-- etc.local chroma = bf.chroma
-- Color constructors
chroma.rgb(1, 0, 0) -- {1,0,0,1}
chroma.rgb255(255, 128, 0) -- converts to 0-1
chroma.hex("#FF00FF") -- parses hex string
chroma.hsv(0.6, 1, 1) -- HSV to RGB
chroma.lerpColor(colorA, colorB, 0.5)
-- Color scheme (map customData)
map._data.customData.colorScheme = chroma.colorScheme(
chroma.hex("#FF0000"), -- left/red
chroma.hex("#0000FF"), -- right/blue
chroma.hex("#880000"), -- env left
chroma.hex("#000088") -- env right
)
-- Environment: target existing objects
map:addEnvironment(chroma.envPlace(
"PillarPair\\[\\d+\\]", -- Regex ID
{0, 4, 20}, -- position
{0, 0, 0}, -- rotation
{2, 2, 2}, -- scale
"PillarTrack" -- optional track
))
-- Environment: geometry primitives
map:addEnvironment(chroma.envGeometry(
"Cube",
chroma.material("OpaqueLight", chroma.hex("#00FFFF")),
{0, 2, 10},
nil,
{3, 0.1, 3},
"FloorTrack"
))
-- Fog animation
map:addCustomEvent(chroma.fogEvent(0, 16,
{{7,0},{3,0.5},{7,1}}, -- attenuation keyframes
{{0,0}}, -- offset
{{-1,0}}, -- startY
{{6,0}} -- height
))
-- Light gradient
e:setLightGradient(chroma.lightGradient(
chroma.hex("#FF0000"),
chroma.hex("#FF00FF"),
2,
"easeLinear"
))local noodle = bf.noodle
-- Lane/row to Noodle coordinate system
noodle.coords(0, 0) -- {-1.5, 0} (lane 0, row 0)
noodle.coords(3, 2) -- { 1.5, 2} (lane 3, row 2)
-- Bulk track assignment
noodle.assignNoteTrack(map, "Drop", 64, 96) -- notes between beats 64-96
-- Point definitions (reusable keyframe lists)
noodle.registerPointDef(map, "BouncePath",
{{0,0,0,0},{0,4,0,0.5,"easeOutBounce"},{0,0,0,1,"easeInSine"}}
)
-- Then reference by name on an object:
n.animation.offsetPosition = "BouncePath"
-- Player movement (convenience wrapper)
noodle.movePlayer(map, "PlayerTrack", 16, 8,
{ offsetPosition = {{0,0,0,0},{0,3,0,1,"easeInOutCubic"}} }
)
-- _settings helpers
map:setSettings(noodle.settings.requireAll())
-- { requirements = {"Noodle Extensions", "Chroma"} }local m = bf.math
m.lerp(0, 10, 0.5) -- 5
m.clamp(1.5, 0, 1) -- 1
m.vecLerp({0,0,0},{1,1,1},0.5) -- {0.5,0.5,0.5}
m.ease.inOutSine(0.5) -- Lua easing function
m.applyEasing("easeOutBounce", 0.75) -- apply by Heck name string
m.beatToSeconds(16, 120) -- 8.0 (seconds at 120 BPM)
-- Sample a path into keyframes
local frames = m.sampleKeyframes(function(t)
return { math.sin(t * math.pi * 2), t * 4, 0 }
end, 16, "easeLinear")BeatForge uses the standard Heck keyframe array format throughout:
-- Constant (no time component, single frame)
{ 0, 1, 0 }
-- Single timed keyframe
{ x, y, z, time }
-- With easing
{ x, y, z, time, "easeInOutSine" }
-- Full animation sequence
{
{ 0, 0, 0, 0 },
{ 0, 5, 0, 0.5, "easeOutSine" },
{ 0, 0, 0, 1, "easeInSine" },
}
-- Reference a point definition by name
"MyPointDef"Time values:
- In AnimateTrack → beats elapsed since the event fired (0 to duration)
- In AssignPathAnimation → normalized note lifetime 0 → 1
See the examples/ folder:
examples/dissolve_entrance.lua— Notes dissolve in from aboveexamples/spinning_walls.lua— Walls orbit the player on a parent trackexamples/chroma_gradient.lua— Hue-shifted note colors across the map
| Feature | Mod Required |
|---|---|
setTrack, AnimateTrack, AssignPathAnimation |
Noodle Extensions |
setFake, setCoordinates, setDisableNoteLook |
Noodle Extensions |
setChromaColor, environment enhancements |
Chroma |
lightGradient, lightID, fog |
Chroma |
| Custom events (heck module) | Heck (base) |
BeatForge outputs v3 beatmap format (Beat Saber 1.29+).
MIT — do whatever you want, just don't upload broken maps.