--!nocheck --[[ Trampoline Bridge (Roblox Studio plugin) Connects the open place to the Trampoline web builder. The web app queues operations (create instances, write scripts, read the tree...). This plugin long-polls for them, applies them with undo support, and posts results back. It also mirrors the Output window to the web app so the AI can debug, and pushes a compact snapshot of the place whenever it changes so the AI knows what is there without asking. Install: Studio > Plugins tab > "Plugins Folder", drop this file in, restart Studio. Then click the Trampoline toolbar button, paste the pairing code from the web app and press Connect. Studio will ask you to allow HTTP requests to the server the first time. ]] local HttpService = game:GetService("HttpService") local ScriptEditorService = game:GetService("ScriptEditorService") local ChangeHistoryService = game:GetService("ChangeHistoryService") local LogService = game:GetService("LogService") local RunService = game:GetService("RunService") local DEFAULT_SERVER = "http://localhost:3000" local SETTING_SERVER = "sculpt_server" local SETTING_CODE = "sculpt_code" local SETTING_CONNECTED = "sculpt_connected" local serverUrl = plugin:GetSetting(SETTING_SERVER) or DEFAULT_SERVER local pairCode = plugin:GetSetting(SETTING_CODE) or "" local connected = false local sessionId = 0 ------------------------------------------------------------------------------- -- HTTP ------------------------------------------------------------------------------- local function request(method, path, body) local ok, res = pcall(function() return HttpService:RequestAsync({ Url = serverUrl .. path, Method = method, Headers = { ["Content-Type"] = "application/json", ["Authorization"] = "Bearer " .. pairCode, }, Body = body and HttpService:JSONEncode(body) or nil, }) end) if not ok then return nil, tostring(res) end if not res.Success then return nil, ("HTTP %d %s"):format(res.StatusCode, tostring(res.Body):sub(1, 200)) end local decoded = {} if res.Body and #res.Body > 0 then local ok2, d = pcall(HttpService.JSONDecode, HttpService, res.Body) if ok2 and type(d) == "table" then decoded = d end end return decoded, nil end ------------------------------------------------------------------------------- -- Output mirroring (runs in edit mode AND in play sessions) ------------------------------------------------------------------------------- local consoleBuffer = {} local function levelFor(messageType) if messageType == Enum.MessageType.MessageError then return "error" elseif messageType == Enum.MessageType.MessageWarning then return "warn" elseif messageType == Enum.MessageType.MessageInfo then return "info" end return "print" end local function startConsoleForwarding(isActive) LogService.MessageOut:Connect(function(message, messageType) if not isActive() then return end if string.sub(message, 1, 12) == "[Trampoline]" then return end table.insert(consoleBuffer, { level = levelFor(messageType), text = message, ts = DateTime.now().UnixTimestampMillis, }) if #consoleBuffer > 200 then table.remove(consoleBuffer, 1) end end) task.spawn(function() while true do task.wait(1.5) if isActive() and #consoleBuffer > 0 then local batch = consoleBuffer consoleBuffer = {} request("POST", "/api/plugin/console", { lines = batch }) end end end) end -- In a Play/Run session only forward the console; the edit-mode instance does the rest. if not RunService:IsEdit() then if plugin:GetSetting(SETTING_CONNECTED) and pairCode ~= "" then startConsoleForwarding(function() return true end) end return end ------------------------------------------------------------------------------- -- Path helpers and value coercion ------------------------------------------------------------------------------- local function resolvePath(path) if type(path) ~= "string" or path == "" or path == "game" then return game end path = string.gsub(path, "/", ".") local segments = string.split(path, ".") local node = game local startIndex = (segments[1] == "game") and 2 or 1 for i = startIndex, #segments do local seg = segments[i] local child if node == game then local okSvc, svc = pcall(game.GetService, game, seg) child = (okSvc and svc) or game:FindFirstChild(seg) else child = node:FindFirstChild(seg) end if not child then error(("Path not found: %s (no child named '%s')"):format(path, seg), 0) end node = child end return node end local function three(v) if type(v) ~= "table" then error("Expected [x, y, z]", 0) end return tonumber(v[1] or v.x or v.X) or 0, tonumber(v[2] or v.y or v.Y) or 0, tonumber(v[3] or v.z or v.Z) or 0 end local function toColor3(v) if type(v) == "string" then local hex = string.gsub(v, "#", "") if #hex == 6 then return Color3.fromHex(hex) end local bc = BrickColor.new(v) return bc.Color elseif type(v) == "table" then local r, g, b = three(v) if math.max(r, g, b) > 1 then return Color3.fromRGB(r, g, b) end return Color3.new(r, g, b) end error("Bad color value", 0) end local function toEnum(current, value) if typeof(value) == "EnumItem" then return value end if type(value) == "string" then local enumName, item = string.match(value, "^Enum%.([%w_]+)%.([%w_]+)$") if enumName then return Enum[enumName][item] end return current.EnumType[value] elseif type(value) == "number" then for _, item in ipairs(current.EnumType:GetEnumItems()) do if item.Value == value then return item end end end error("Bad enum value " .. tostring(value), 0) end local function coerce(instance, prop, value) local okCur, current = pcall(function() return instance[prop] end) local t = okCur and typeof(current) or "unknown" if t == "Vector3" then return Vector3.new(three(value)) elseif t == "Vector2" then local x, y = three(value) return Vector2.new(x, y) elseif t == "Color3" then return toColor3(value) elseif t == "BrickColor" then if type(value) == "string" then return BrickColor.new(value) end return BrickColor.new(toColor3(value)) elseif t == "CFrame" then if type(value) == "table" and value.position then local p = Vector3.new(three(value.position)) if value.lookAt then return CFrame.lookAt(p, Vector3.new(three(value.lookAt))) elseif value.orientation then local ox, oy, oz = three(value.orientation) return CFrame.new(p) * CFrame.fromOrientation(math.rad(ox), math.rad(oy), math.rad(oz)) end return CFrame.new(p) end return CFrame.new(three(value)) elseif t == "EnumItem" then return toEnum(current, value) elseif t == "UDim2" then return UDim2.new(tonumber(value[1]) or 0, tonumber(value[2]) or 0, tonumber(value[3]) or 0, tonumber(value[4]) or 0) elseif t == "UDim" then return UDim.new(tonumber(value[1]) or 0, tonumber(value[2]) or 0) elseif t == "NumberRange" then return NumberRange.new(tonumber(value[1]) or 0, tonumber(value[2] or value[1]) or 0) elseif t == "Instance" or t == "nil" then if type(value) == "string" then return resolvePath(value) end return value elseif t == "number" then local n = tonumber(value) if n == nil then error("Expected a number for " .. prop, 0) end return n elseif t == "boolean" then return value == true or value == "true" elseif t == "string" then return tostring(value) end -- Unknown current type: light heuristics. if type(value) == "string" then local enumName, item = string.match(value, "^Enum%.([%w_]+)%.([%w_]+)$") if enumName then return Enum[enumName][item] end end return value end local function setSource(scriptInstance, source) source = string.gsub(tostring(source or ""), "\r\n", "\n") local ok = pcall(function() ScriptEditorService:UpdateSourceAsync(scriptInstance, function() return source end) end) if not ok then scriptInstance.Source = source end end -- Attributes accept a few value types; JSON arrays of numbers become vectors. local function toAttributeValue(value) if type(value) == "table" then local n = #value if n == 3 then return Vector3.new(three(value)) elseif n == 2 then return Vector2.new(tonumber(value[1]) or 0, tonumber(value[2]) or 0) end error("Attribute values must be numbers, strings, booleans or [x, y, z]", 0) end return value end local function applyProperties(instance, props) local warnings = {} if type(props) ~= "table" then return warnings end for key, value in pairs(props) do if key == "Parent" then -- handled by the caller elseif key == "Source" and instance:IsA("LuaSourceContainer") then setSource(instance, value) elseif key == "Tags" then -- Sets the tag list: tags not in the array are removed. if type(value) == "table" then local wanted = {} for _, tag in ipairs(value) do wanted[tostring(tag)] = true end for _, existing in ipairs(instance:GetTags()) do if not wanted[existing] then instance:RemoveTag(existing) end end for tag in pairs(wanted) do instance:AddTag(tag) end else table.insert(warnings, "Tags: expected an array of strings") end elseif key == "Attributes" then if type(value) == "table" then for attr, attrValue in pairs(value) do local okAttr, errAttr = pcall(function() instance:SetAttribute(tostring(attr), toAttributeValue(attrValue)) end) if not okAttr then table.insert(warnings, ("Attributes.%s: %s"):format(tostring(attr), tostring(errAttr))) end end else table.insert(warnings, "Attributes: expected an object") end else local ok, err = pcall(function() instance[key] = coerce(instance, key, value) end) if not ok then table.insert(warnings, ("%s: %s"):format(tostring(key), tostring(err))) end end end return warnings end ------------------------------------------------------------------------------- -- Text tree (get_tree results and the place snapshot) -- -- One line per instance, indented by depth. Compact on purpose: every line -- ends up in the model's context, and the snapshot is re-sent when it changes. ------------------------------------------------------------------------------- local function fmtNum(n) local r = math.round(n * 10) / 10 if r == 0 then r = 0 -- normalises -0 end if r == math.floor(r) then return string.format("%d", r) end return string.format("%.1f", r) end local function describeInstance(inst, label) if inst:IsA("Terrain") then return "Terrain (Terrain)" end local s = (label or inst.Name) .. " (" .. inst.ClassName if inst:IsA("LuaSourceContainer") then local okSrc, src = pcall(function() return inst.Source end) if okSrc and type(src) == "string" then s ..= string.format(", %d lines", #string.split(src, "\n")) end if inst:IsA("BaseScript") and inst.Enabled == false then s ..= ", disabled" end end s ..= ")" if inst:IsA("BasePart") then local p, sz = inst.Position, inst.Size s ..= string.format(" @%s,%s,%s size %sx%sx%s", fmtNum(p.X), fmtNum(p.Y), fmtNum(p.Z), fmtNum(sz.X), fmtNum(sz.Y), fmtNum(sz.Z)) if not inst.Anchored then s ..= " unanchored" end elseif inst:IsA("Model") then local okPivot, pos = pcall(function() return inst:GetPivot().Position end) if okPivot then s ..= string.format(" @%s,%s,%s", fmtNum(pos.X), fmtNum(pos.Y), fmtNum(pos.Z)) end end return s end -- levels: how many levels to show including the root. maxLines / maxChildren -- cap the output; the caller learns whether anything was cut off. local function renderTree(root, levels, maxLines, maxChildren, rootLabel) local lines = {} local count = 0 local truncated = false local function visit(inst, level, label) if #lines >= maxLines then truncated = true return end count += 1 local index = #lines + 1 lines[index] = string.rep(" ", level - 1) .. describeInstance(inst, label) if inst:IsA("Terrain") then return end local children = inst:GetChildren() if #children == 0 then return end if level >= levels then lines[index] ..= string.format(" [+%d inside]", #children) return end local shown = 0 for i, child in ipairs(children) do if child:IsA("Camera") then continue end shown += 1 if shown > maxChildren then table.insert(lines, string.rep(" ", level) .. string.format("... +%d more", #children - i + 1)) truncated = true break end visit(child, level + 1) end end visit(root, 1, rootLabel) return table.concat(lines, "\n"), count, truncated end -- Services worth showing the model, with a per-service line budget so a busy -- Workspace cannot crowd out the scripts. local SNAPSHOT_SERVICES = { { "Workspace", 70 }, { "ServerScriptService", 25 }, { "ServerStorage", 15 }, { "ReplicatedStorage", 25 }, { "ReplicatedFirst", 10 }, { "StarterGui", 20 }, { "StarterPack", 8 }, { "StarterPlayer", 15 }, { "Lighting", 8 }, { "Teams", 8 }, } local SNAPSHOT_LEVELS = 4 local SNAPSHOT_MAX_CHILDREN = 20 local function buildSnapshot() local parts = {} for _, entry in ipairs(SNAPSHOT_SERVICES) do local name, budget = entry[1], entry[2] local okSvc, svc = pcall(game.GetService, game, name) if okSvc and svc then if #svc:GetChildren() == 0 then table.insert(parts, name .. " (empty)") else local text, _, truncated = renderTree(svc, SNAPSHOT_LEVELS, budget, SNAPSHOT_MAX_CHILDREN, name) -- Services render as just their name; the class adds nothing. text = string.gsub(text, "^" .. name .. " %(" .. name .. "%)", name, 1) table.insert(parts, text) if truncated then table.insert(parts, " ... (more not shown; use get_tree for detail)") end end end end return table.concat(parts, "\n") end local lastSnapshotSent = nil ------------------------------------------------------------------------------- -- Op handlers ------------------------------------------------------------------------------- local handlers = {} local READ_ONLY = { ping = true, get_tree = true, read_script = true } function handlers.ping() return { pong = true, place = game.Name } end function handlers.get_tree(input) local root = resolvePath(input.path or "Workspace") local levels = math.clamp(tonumber(input.depth) or 3, 1, 6) local text, count, truncated = renderTree(root, levels, 300, 40, root:GetFullName()) return { path = root:GetFullName(), count = count, truncated = truncated, text = text } end function handlers.create_instance(input) local parent = resolvePath(input.parent) local name = input.name or input.class_name -- unique: reuse an existing instance of the same name and class (templates -- use it for their folders and sample parts so re-inserting never duplicates). if input.unique then local existing = parent:FindFirstChild(name) if existing and existing.ClassName == input.class_name then return { path = existing:GetFullName(), existed = true } end end local inst = Instance.new(input.class_name) inst.Name = name local warnings = applyProperties(inst, input.properties) inst.Parent = parent local result = { path = inst:GetFullName() } if #warnings > 0 then result.warnings = warnings end return result end function handlers.set_properties(input) local inst = resolvePath(input.path) local warnings = applyProperties(inst, input.properties) if type(input.properties) == "table" and input.properties.Parent ~= nil then inst.Parent = resolvePath(input.properties.Parent) end local result = { path = inst:GetFullName() } if #warnings > 0 then result.warnings = warnings end return result end -- Compile before saving so a typo comes back to the model as a tool error -- instead of surfacing for the user at Play time. local function checkSyntax(source, chunkName) local fn, err = loadstring(source, "=" .. tostring(chunkName)) if not fn then error("Syntax error, nothing was saved: " .. tostring(err), 0) end end function handlers.write_script(input) local source = string.gsub(tostring(input.source or ""), "\r\n", "\n") checkSyntax(source, input.name or "Script") local parent = resolvePath(input.parent) local scriptType = input.script_type or "Script" local existing = parent:FindFirstChild(input.name) local replaced = false local target if existing then if existing:IsA("LuaSourceContainer") then replaced = true if existing.ClassName ~= scriptType then existing:Destroy() else target = existing end else error(("%s already exists under %s and is a %s, not a script"):format(input.name, input.parent, existing.ClassName), 0) end end if not target then target = Instance.new(scriptType) target.Name = input.name target.Parent = parent end if input.run_context and target:IsA("Script") then local okCtx = pcall(function() target.RunContext = Enum.RunContext[input.run_context] end) if not okCtx then warn("[Trampoline] Unknown RunContext " .. tostring(input.run_context)) end end setSource(target, source) return { path = target:GetFullName(), replaced = replaced } end function handlers.read_script(input) local inst = resolvePath(input.path) if not inst:IsA("LuaSourceContainer") then error(input.path .. " is not a script", 0) end local okSrc, src = pcall(ScriptEditorService.GetEditorSource, ScriptEditorService, inst) return { path = inst:GetFullName(), source = okSrc and src or inst.Source } end -- Plain-text (no patterns) search helpers for edit_script. local function countPlain(haystack, needle) local count, init = 0, 1 while true do local s, e = string.find(haystack, needle, init, true) if not s then break end count += 1 init = e + 1 end return count end local function replacePlain(haystack, needle, replacement, all) local out, init = {}, 1 while true do local s, e = string.find(haystack, needle, init, true) if not s then break end table.insert(out, string.sub(haystack, init, s - 1)) table.insert(out, replacement) init = e + 1 if not all then break end end table.insert(out, string.sub(haystack, init)) return table.concat(out) end -- The model often writes indentation with spaces while Studio files use tabs; -- try the text as written, then with leading whitespace converted either way. local function indentVariants(text) local function mapLeading(fn) local mapped = string.gsub("\n" .. text, "\n([ \t]+)", function(ws) return "\n" .. fn(ws) end) return string.sub(mapped, 2) end return { text, mapLeading(function(ws) return (string.gsub(ws, " ", "\t")) end), mapLeading(function(ws) return (string.gsub(ws, " ", "\t")) end), mapLeading(function(ws) return (string.gsub(ws, "\t", " ")) end), } end function handlers.edit_script(input) local inst = resolvePath(input.path) if not inst:IsA("LuaSourceContainer") then error(tostring(input.path) .. " is not a script", 0) end local edits = input.edits if type(edits) ~= "table" and type(input.find) == "string" then edits = { input } end if type(edits) ~= "table" or #edits == 0 then error("edits must be a non-empty array of { find, replace }", 0) end local okSrc, source = pcall(ScriptEditorService.GetEditorSource, ScriptEditorService, inst) if not okSrc or type(source) ~= "string" then source = inst.Source end source = string.gsub(source, "\r\n", "\n") for i, edit in ipairs(edits) do local find = type(edit) == "table" and edit.find or nil if type(find) ~= "string" or find == "" then error(("edit #%d: find must be a non-empty string"):format(i), 0) end find = string.gsub(find, "\r\n", "\n") local replacement = string.gsub(tostring(edit.replace or ""), "\r\n", "\n") local matched, count = nil, 0 for _, variant in ipairs(indentVariants(find)) do count = countPlain(source, variant) if count > 0 then matched = variant break end end if not matched then error(("edit #%d: text not found in %s. Read the script and copy the exact lines, indentation included."):format(i, inst:GetFullName()), 0) end if count > 1 and not edit.all then error(("edit #%d: found %d matches; include more surrounding lines to make it unique, or set all=true."):format(i, count), 0) end source = replacePlain(source, matched, replacement, edit.all == true) end local fn, err = loadstring(source, "=" .. inst.Name) if not fn then error("edits would produce a syntax error, nothing was saved: " .. tostring(err), 0) end setSource(inst, source) return { path = inst:GetFullName(), applied = #edits, lines = #string.split(source, "\n"), class_name = inst.ClassName, source = source, } end function handlers.delete_instance(input) local inst = resolvePath(input.path) if inst == game or inst.Parent == game then error("Refusing to delete a service", 0) end local path = inst:GetFullName() inst:Destroy() return { deleted = path } end function handlers.run_luau(input) local fn, err = loadstring(tostring(input.source or "")) if not fn then error("Syntax error: " .. tostring(err), 0) end local output = {} local env = setmetatable({ print = function(...) local parts = {} for i = 1, select("#", ...) do parts[#parts + 1] = tostring(select(i, ...)) end table.insert(output, table.concat(parts, " ")) end, }, { __index = getfenv() }) setfenv(fn, env) local packed = table.pack(pcall(fn)) if not packed[1] then error(tostring(packed[2]), 0) end local returned = {} for i = 2, packed.n do returned[#returned + 1] = tostring(packed[i]) end return { output = output, returned = returned } end local function withHistory(label, fn) local recording = ChangeHistoryService:TryBeginRecording(label) local ok, res = pcall(fn) if recording then ChangeHistoryService:FinishRecording( recording, ok and Enum.FinishRecordingOperation.Commit or Enum.FinishRecordingOperation.Cancel ) end if not ok then error(res, 0) end return res end ------------------------------------------------------------------------------- -- UI ------------------------------------------------------------------------------- local toolbar = plugin:CreateToolbar("Trampoline") local toggleButton = toolbar:CreateButton("Trampoline", "Connect this place to the Trampoline builder", "rbxassetid://4458901886") toggleButton.ClickableWhenViewportHidden = true local widget = plugin:CreateDockWidgetPluginGui( "TrampolineBridgeWidget", DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Right, false, false, 320, 300, 260, 220) ) widget.Title = "Trampoline" widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling local COLORS = { bg = Color3.fromRGB(38, 42, 51), well = Color3.fromRGB(31, 35, 43), text = Color3.fromRGB(232, 235, 242), muted = Color3.fromRGB(164, 172, 190), accent = Color3.fromRGB(61, 220, 151), accentText = Color3.fromRGB(18, 22, 28), danger = Color3.fromRGB(255, 122, 138), } local function new(className, props, parent) local inst = Instance.new(className) for k, v in pairs(props) do inst[k] = v end inst.Parent = parent return inst end local root = new("Frame", { Size = UDim2.fromScale(1, 1), BackgroundColor3 = COLORS.bg, BorderSizePixel = 0, }, widget) new("UIPadding", { PaddingTop = UDim.new(0, 14), PaddingBottom = UDim.new(0, 14), PaddingLeft = UDim.new(0, 14), PaddingRight = UDim.new(0, 14) }, root) new("UIListLayout", { Padding = UDim.new(0, 8), SortOrder = Enum.SortOrder.LayoutOrder }, root) local function label(text, order, color) return new("TextLabel", { Text = text, LayoutOrder = order, Size = UDim2.new(1, 0, 0, 16), BackgroundTransparency = 1, TextColor3 = color or COLORS.muted, Font = Enum.Font.GothamMedium, TextSize = 12, TextXAlignment = Enum.TextXAlignment.Left, }, root) end local function textbox(placeholder, value, order) local box = new("TextBox", { Text = value, PlaceholderText = placeholder, LayoutOrder = order, Size = UDim2.new(1, 0, 0, 32), BackgroundColor3 = COLORS.well, BorderSizePixel = 0, TextColor3 = COLORS.text, PlaceholderColor3 = COLORS.muted, Font = Enum.Font.Code, TextSize = 13, ClearTextOnFocus = false, TextXAlignment = Enum.TextXAlignment.Left, }, root) new("UICorner", { CornerRadius = UDim.new(0, 8) }, box) new("UIPadding", { PaddingLeft = UDim.new(0, 10), PaddingRight = UDim.new(0, 10) }, box) return box end label("Server", 1) local serverBox = textbox(DEFAULT_SERVER, serverUrl, 2) label("Pairing code (from the web app)", 3) local codeBox = textbox("ABC-123", pairCode, 4) local connectButton = new("TextButton", { Text = "Connect", LayoutOrder = 5, Size = UDim2.new(1, 0, 0, 36), BackgroundColor3 = COLORS.accent, BorderSizePixel = 0, TextColor3 = COLORS.accentText, Font = Enum.Font.GothamBold, TextSize = 14, AutoButtonColor = true, }, root) new("UICorner", { CornerRadius = UDim.new(0, 10) }, connectButton) local statusLabel = label("Not connected", 6, COLORS.muted) statusLabel.Size = UDim2.new(1, 0, 0, 18) statusLabel.TextSize = 13 local logLabel = new("TextLabel", { Text = "", LayoutOrder = 7, Size = UDim2.new(1, 0, 1, -190), BackgroundColor3 = COLORS.well, BorderSizePixel = 0, TextColor3 = COLORS.muted, Font = Enum.Font.Code, TextSize = 11, TextWrapped = true, TextXAlignment = Enum.TextXAlignment.Left, TextYAlignment = Enum.TextYAlignment.Bottom, }, root) new("UICorner", { CornerRadius = UDim.new(0, 8) }, logLabel) new("UIPadding", { PaddingLeft = UDim.new(0, 8), PaddingRight = UDim.new(0, 8), PaddingTop = UDim.new(0, 6), PaddingBottom = UDim.new(0, 6) }, logLabel) local logLines = {} local function log(text) table.insert(logLines, os.date("%H:%M:%S") .. " " .. text) while #logLines > 8 do table.remove(logLines, 1) end logLabel.Text = table.concat(logLines, "\n") end local function setStatus(text, color) statusLabel.Text = text statusLabel.TextColor3 = color or COLORS.muted end toggleButton.Click:Connect(function() widget.Enabled = not widget.Enabled end) widget:GetPropertyChangedSignal("Enabled"):Connect(function() toggleButton:SetActive(widget.Enabled) end) ------------------------------------------------------------------------------- -- Connection loop ------------------------------------------------------------------------------- local function processOp(op) local handler = handlers[op.type] local result if not handler then result = { id = op.id, ok = false, error = "Unknown op type " .. tostring(op.type) } else local ok, res = pcall(function() if READ_ONLY[op.type] then return handler(op.input or {}) end return withHistory("Trampoline: " .. tostring(op.type), function() return handler(op.input or {}) end) end) if ok then result = { id = op.id, ok = true, output = res } else result = { id = op.id, ok = false, error = tostring(res) } end end log((result.ok and "ok " or "fail ") .. tostring(op.type)) local _, err = request("POST", "/api/plugin/result", result) if err then log("result not delivered: " .. err) end end -- Send the place snapshot when it differs from the last one the server got. local function pushSnapshot() local ok, text = pcall(buildSnapshot) if not ok or text == lastSnapshotSent then return end local _, err = request("POST", "/api/plugin/snapshot", { snapshot = text }) if not err then lastSnapshotSent = text end end local function pollLoop(session) while connected and session == sessionId do local data, err = request("GET", "/api/plugin/poll", nil) if session ~= sessionId then break end if err then setStatus("Reconnecting...", COLORS.danger) log(err) task.wait(3) else setStatus("Connected to " .. serverUrl, COLORS.accent) local ops = data.ops or {} for _, op in ipairs(ops) do processOp(op) end if #ops > 0 then pushSnapshot() end end end end -- Catch the user's own edits in Studio too, not just the AI's ops. task.spawn(function() while true do task.wait(3) if connected then pushSnapshot() end end end) local function disconnect() connected = false sessionId += 1 lastSnapshotSent = nil plugin:SetSetting(SETTING_CONNECTED, false) connectButton.Text = "Connect" connectButton.BackgroundColor3 = COLORS.accent setStatus("Not connected", COLORS.muted) log("disconnected") end local function connect() serverUrl = string.gsub(serverBox.Text, "/+$", "") pairCode = string.upper(string.gsub(codeBox.Text, "%s", "")) if pairCode == "" then setStatus("Enter the pairing code first", COLORS.danger) return end plugin:SetSetting(SETTING_SERVER, serverUrl) plugin:SetSetting(SETTING_CODE, pairCode) setStatus("Connecting...", COLORS.muted) local okSnap, snapshot = pcall(buildSnapshot) local data, err = request("POST", "/api/plugin/hello", { placeName = game.Name, studioVersion = version(), snapshot = okSnap and snapshot or nil, }) if err then setStatus("Could not connect: " .. err, COLORS.danger) log(err) return end if okSnap then lastSnapshotSent = snapshot end connected = true sessionId += 1 plugin:SetSetting(SETTING_CONNECTED, true) connectButton.Text = "Disconnect" connectButton.BackgroundColor3 = COLORS.well log("paired with project " .. tostring(data.projectName)) task.spawn(pollLoop, sessionId) end connectButton.MouseButton1Click:Connect(function() if connected then disconnect() else connect() end end) startConsoleForwarding(function() return connected end) plugin.Unloading:Connect(function() connected = false sessionId += 1 end) -- Auto-reconnect on Studio start if the user was connected last time. if plugin:GetSetting(SETTING_CONNECTED) and pairCode ~= "" then task.defer(connect) end