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.

Overwriting

A file whose name matches an official shape (e.g., Meteor Shower.lua) replaces the GitHub version.

Errors

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:

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

p
The part being manipulated.
cen
Center of gravity — usually your character's RootPart.
t
Elapsed time in seconds. Drive rotation and oscillation.
c
Your controls table. Read values by key (e.g., c.k11).
d
Persistent per-part state. Lives until the part is dropped; shape-scratch fields are wiped automatically on shape switch.
x6.pre
Frame-wide cache — save expensive math from M.px here. Survives shape switches, so clear your key in M.cleanup.
x1
Global config (x1.MaxSpeed, x1.k10). Avoid modifying.
x9
Internal physics constants. Ignore.

04 Functions

Every shape module returns a table with these members:

M.px Once Per Frame

Runs before the physics loop. Pre-compute shared math and store it in x6.pre.

M.f2 Per Part Per Frame

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
M.cleanup On Switch / Disable / Stop

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.

M.Testing Module Flag

Set M.Testing = true to announce "still in testing" whenever the shape is selected.

M.Controls UI Settings

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 }
}
Seeding

A missing key seeds from Default (Slider falls back to Min/Div, Toggle to false, TextBox to ""). Any other Type is silently ignored.

Write-Back

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

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.

Floating Ring.lua
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