Plugin Developer Guide
How to Make
Custom Shapes
A concise guide to creating shapes for Project Gravity via the plugin system.
return (target_position - p.Position) * (x1.k10 * x9.c1), target_position
01 File Location
Drop a .lua or .txt file into the GravityShapes folder inside
your executor's workspace. It shows up in the shape dropdown automatically.
A file whose name matches an official shape (e.g., Meteor Shower.lua) replaces the
GitHub version.
A syntax error blocks only that shape and warns in the F9 console; everything else keeps running.
02 Template
Minimal boilerplate — copy, fill in, save:
local M = {}
function M.px(t, c, x6, x9, x1)
end
function M.f2(p, cen, d, t, c, x1, x6, x9)
return (cen - p.Position) * (x1.k10 * x9.c1), cen
end
M.Controls = {}
return M
03 Variable Reference
pcentcc.k11).dx6.preM.px here. Survives
shape switches, so clear your key in M.cleanup.x1x1.MaxSpeed, x1.k10). Avoid
modifying.x904 Functions
Every shape module returns a table with these members:
Runs before the physics loop. Pre-compute shared math and store it in x6.pre.
Returns the target velocity as a Vector3. Also return the raw target position as a
second value — the engine uses it for feed-forward smoothing (no jitter on
skipped frames) and lifts that part's speed cap.
return (target_position - p.Position) * (x1.k10 * x9.c1), target_position
Optional. Called when the shape is switched away, disabled, stopped, or torn down. Release
Instances you created and clear your x6.pre keys — it survives shape switches.
Set M.Testing = true to announce "still in testing" whenever the shape is selected.
Three control types. All Sliders need Min, Max and
Key; the rest of the fields are optional:
M.Controls = {
-- Min/Max are DISPLAY units; Default and stored values are pre-Div
{ Type = "Slider", Name = "Radius", Min = 5, Max = 100, Key = "k11" },
-- Div: display = stored * Div. ExactMax stops the +300 widening
-- that any slider whose Name contains "speed" gets.
{ Type = "Slider", Name = "Spin Speed", Min = 0, Max = 20, Key = "k12", Div = 10, Default = 2, ExactMax = true },
-- IntOnly: integer / enum picker
{ Type = "Slider", Name = "Pair Count", Min = 2, Max = 200, Key = "k13", IntOnly = true },
{ Type = "Toggle", Name = "Cut In Half", Key = "k14", Default = true },
-- MaxChars truncates on focus loss
{ Type = "TextBox", Name = "Label", Key = "k15", Default = "hello", MaxChars = 24 }
}
A missing key seeds from Default (Slider falls back to
Min/Div, Toggle to false, TextBox to ""). Any
other Type is silently ignored.
On panel open, stored values outside the display range [Min, Max] (or
Max + 300 for speed sliders) are clamped and saved — keep your config
defaults inside it.
Read toggle values in your loop by key:
if c.k14 then
-- Toggle is ON: Apply the effect
radius = radius / 2
end
05 Important Rules
- Axes:
+Yis Up,+Xis Right,-Zis Forward. - Scale: Multiply returns by
(x1.k10 * x9.c1)— the same gain every official shape uses (3.0 by default: 20 × 0.15). Don't hardcode large constants; they fight the built-in PID controller and speed limit. - Spacing: Offset targets per part using
d, or everything collapses into a single point. - Scratch:
dfields are wiped on shape switch,x6.prekeys are not — clear yours inM.cleanup.
06 Full Example: "Floating Ring"
A complete working shape with controls, pre-computation, and per-part offsets. Save as
Floating Ring.lua in your shapes folder.
local M = {}
function M.px(t, c, x6, x9, x1)
local memory = x6.pre["Floating Ring"]
if not memory then
memory = {}
x6.pre["Floating Ring"] = memory
end
local dt = t - (memory.last_t or t)
memory.last_t = t
local speed = c.k13 or 10
memory.rotation = (memory.rotation or 0) + (dt * speed)
end
function M.f2(p, cen, d, t, c, x1, x6, x9)
local memory = x6.pre["Floating Ring"] or {}
local rotation = memory.rotation or 0
local radius = c.k11 or 20
local height = c.k12 or 5
if not d.my_spot then
d.my_spot = math.random() * (math.pi * 2)
end
local x = math.cos(rotation + d.my_spot) * radius
local z = math.sin(rotation + d.my_spot) * radius
local target_position = cen + Vector3.new(x, height, z)
return (target_position - p.Position) * (x1.k10 * x9.c1), target_position
end
M.Controls = {
{ Type = "Slider", Name = "Ring Radius", Min = 5, Max = 100, Key = "k11" },
{ Type = "Slider", Name = "Ring Height", Min = -50, Max = 50, Key = "k12" },
{ Type = "Slider", Name = "Spin Speed", Min = 0, Max = 100, Key = "k13" }
}
function M.cleanup(x6, x1)
if not x6.pre then
return
end
x6.pre["Floating Ring"] = nil
end
return M