--[[ Tixfall SDK for Roblox games version 2026.10.15.1 Put this ModuleScript in ServerScriptService, require it from a server Script, and you get: Runs by itself once required sessions when each player joins and leaves, and how long they played game pass sales with the Robux price, plus purchase prompts closed without buying tracking links which Tixfall link a player came in through (see the Links tab) live view every 30 seconds each server says how many are playing, its version and health, and the script errors it hit (player names taken out), for your dashboard's Live tab control your dashboard's Control tab can message everyone, kick, ban, unban and shut servers down Rewards (the Tixfall hub) Tixfall.Complete(player, "win_round") a task was finished (not for come-back tasks) Tixfall.TrackPlaytime(10, "play_10m") "play for N minutes" tasks, finished for you Tixfall.DefineTasks({ ... }) your task list, kept in sync with Tixfall Tixfall.Progress(player, "level_10", 6, 10, "levels") how far along a task a player is (the hub shows it) Analytics Tixfall.Track(player, "boss_killed", { boss = "Yeti" }) anything you want counted Tixfall.Progression(player, "start", "World 1-3") levels and rounds: "start", "complete", "fail" Tixfall.Onboarding(player, "first_jump") tutorial steps, once per player Tixfall.Earn(player, "Coins", 50, "quest") your own currencies coming in Tixfall.Spend(player, "Coins", 200, "sword") and going out Tixfall.SetProperty(player, "class", "Mage") up to 3 things about a player, added to their events MarketplaceService.ProcessReceipt = Tixfall.WrapReceipt(fn) developer product sales, with the Robux paid Tixfall.Purchase(player, robux, productId) the same by hand, if you don't wrap ProcessReceipt Control (run your game from the dashboard's Control tab; see "control" below) Tixfall.Setting("double_coins", false, { label = "Double coins" }) a value you change live, returns it now Tixfall.Get("double_coins") its value right now Tixfall.Get("sword_price", player) this player's value, for A/B tests from Experiments Tixfall.OnSetting("gravity", function(value) ... end) runs now and on every change Tixfall.Command("give_coins", { params = { ... } }, function(args) ... end) a button on your dashboard Safety and tools Tixfall.AntiCheat({ ... }) optional speed and fly checks (they never kick anyone, see below) Tixfall.Configure({ ... }) debug = true prints every event; flushEvery = seconds between uploads (5 to 60); live = false or errors = false turn the Live tab's reports (or just errors) off; control = false stops this server taking commands and settings from the dashboard Tixfall.Flush() send everything waiting now, for example right before you teleport players Setup 1. Put this ModuleScript in ServerScriptService and require it from a server Script. 2. File → Experience Settings → Security → Allow HTTP Requests: on. 3. Creator Dashboard → this experience → Secrets: add "tixfall_partner_key" with the key from your game's Setup tab on the Tixfall website, domain set to the backend. 4. Optional, to test from Studio: File → Experience Settings → Security → Local Secrets, add the same secret. Press Play and the Output says whether everything is right. When a server starts, this module checks the setup and prints "[Tixfall] connected" or exactly what to fix. Your game's Setup page on the website ticks off each step as it works. Full docs: https://tixfall.com/docs Your tasks live in one place: this game. Call DefineTasks once when the server starts and Tixfall follows whatever the list says, so you never edit them on the website too: Tixfall.DefineTasks({ { id = "play_10m", label = "Play for 10 minutes", minutes = 10, playMinutes = 10 }, { id = "win_round", label = "Win a round", minutes = 2 }, { id = "return_d2", label = "Come back tomorrow and play 10 minutes", minutes = 10, returnDay = 1 }, }) id the name you pass to Complete label what players see in the hub minutes the fastest a real player could finish it (a come-back task: how long they have to play once it opens, all sessions added up) playMinutes optional: the SDK completes it for you after that many minutes played in total (it adds up across sessions) returnDay optional: makes it a come-back task, paid by Tixfall (1 = tomorrow, 6 = day 7) coins optional: only used if Tixfall turned on payouts from the game for you, otherwise ignored New tasks appear in your dashboard straight away and start unpaid until Tixfall sets what they pay. A task you stop listing is turned off. Tasks are paid once per player, only to players who pressed Play on your game in Tixfall. A player can earn at most 500 coins (50 Robux) from your game in total, unless Tixfall set a different cap for it; a task that would go past the cap is refused. Come-back tasks ("Come back tomorrow and play 10 minutes") are checked by Tixfall itself from the session events this module already sends, so don't call Complete or TrackPlaytime for them. What leaves your game: Roblox user ids, usernames, the session and purchase events above and whatever you report with the calls above. Nothing else about players is collected, and you can read every line that sends anything below (look for post(...)). In Studio (or without BACKEND_URL) nothing is sent: tasks and flags are printed instead, and every event too with Configure({ debug = true }) or the workspace attribute TixfallDebug set to true. With the key in Studio's local secrets, Studio also checks in and tests your Complete calls (nothing is paid or counted). Source-available, not open source: you may read, audit and change this file, and use it in your own Roblox games with Tixfall. You may not copy it into another product or use it with another service. Full terms: https://tixfall.com/sdk/LICENSE ]] local HttpService = game:GetService("HttpService") local MarketplaceService = game:GetService("MarketplaceService") local Players = game:GetService("Players") local RunService = game:GetService("RunService") local ScriptContext = game:GetService("ScriptContext") local Stats = game:GetService("Stats") local VERSION = "2026.10.15.3" local BACKEND_URL = "https://tixfall.com" local SECRET_NAME = "tixfall_partner_key" local OLD_SECRET_NAME = "spawnpoint_partner_key" -- what games set up before the rename to Tixfall use; it keeps working local FLUSH_AT = 100 -- upload sooner once this many events are waiting local MAX_QUEUE = 5000 -- events kept while Tixfall can't be reached; the oldest go first local MAX_PROPS = 12 -- what Tixfall keeps per event (the rest are dropped there) local MAX_PROPERTIES = 3 -- SetProperty values per player -- Studio can't read experience secrets, so only live servers send data. Studio only checks in (see checkIn). local STUDIO = RunService:IsStudio() local LIVE = BACKEND_URL ~= "" and not STUDIO local HOST = BACKEND_URL:match("^https?://([^/]+)") or BACKEND_URL -- sent by this module itself, so Track refuses them local RESERVED = { session_start = true, session_end = true, purchase = true, purchase_cancelled = true, flag = true, stage_start = true, stage_complete = true, stage_fail = true, stage_quit = true, currency_earned = true, currency_spent = true, experiment = true, } export type Props = { [string]: string | number | boolean } export type Task = { id: string, label: string, minutes: number?, playMinutes: number?, returnDay: number?, coins: number? } export type Config = { debug: boolean?, flushEvery: number?, live: boolean?, errors: boolean?, control: boolean? } local Tixfall = {} Tixfall.VERSION = VERSION local config = { debug = false, flushEvery = 15, live = true, errors = true, control = true } local queue = {} local blocked = false -- the setup is wrong (HTTP off, no secret, key refused), so uploads would only fail local sentTasks = {} local progressSent = {} local joinedAt = {} local properties: { [number]: Props } = {} local function now(): number return DateTime.now().UnixTimestampMillis / 1000 end local function debugOn(): boolean return config.debug or workspace:GetAttribute("TixfallDebug") == true or workspace:GetAttribute("SpawnpointDebug") == true end -- bots and NPCs aren't players, so they're skipped everywhere local function isPlayer(player): boolean return typeof(player) == "Instance" and player:IsA("Player") end local warned = {} local function warnOnce(key: string, ...) if warned[key] then return end warned[key] = true warn("[Tixfall]", ...) end ---------------------------------------------------------------- sending -- The game's key, from the secret tixfall_partner_key, or spawnpoint_partner_key in games set up before the rename. local function getSecret() local ok, secret = pcall(HttpService.GetSecret, HttpService, SECRET_NAME) if ok then return secret end local okOld, old = pcall(HttpService.GetSecret, HttpService, OLD_SECRET_NAME) if okOld then return old end error(secret, 0) end local function post(path: string, body) return HttpService:RequestAsync({ Url = BACKEND_URL .. path, Method = "POST", Headers = { ["Content-Type"] = "application/json", Authorization = getSecret():AddPrefix("Bearer "), }, Body = HttpService:JSONEncode(body), }) end -- Retries network and server errors with backoff. A 4xx answer is final, so it counts as done. local function send(path: string, body): boolean for attempt = 1, 4 do local ok, res = pcall(post, path, body) if ok and (res.Success or (res.StatusCode >= 400 and res.StatusCode < 500)) then return true end task.wait(2 ^ attempt) end return false end -- Keeps only what Tixfall stores (up to 12 props: names of letters, numbers and _, values that are -- text, numbers or true/false) and says once per event name what it had to drop. local function cleanProps(eventName: string, props): Props if props == nil then return {} end if type(props) ~= "table" then warnOnce("props:" .. eventName, ("%s: props are a table like { boss = \"Yeti\" }, so they were left out"):format(eventName)) return {} end local out, n, dropped = {}, 0, nil for k, v in pairs(props) do local kind = type(v) if type(k) ~= "string" or #k > 32 or not k:match("^[%w_]+$") or n >= MAX_PROPS then dropped = tostring(k) elseif kind == "number" and v == v and v ~= math.huge and v ~= -math.huge then out[k], n = v, n + 1 elseif kind == "boolean" then out[k], n = v, n + 1 elseif kind == "string" then out[k], n = v:sub(1, 120), n + 1 else dropped = tostring(k) end end if dropped then warnOnce("props:" .. eventName, ('%s: "%s" was left out. Props are up to 12 names of letters, numbers and _, with text, numbers or true/false as values.'):format(eventName, dropped)) end return out end -- adds what SetProperty stored for the player, without replacing the event's own props local function withProperties(userId: number, props: Props): Props local mine = properties[userId] if not mine or next(mine) == nil then return props end local out, n = table.clone(props), 0 for _ in pairs(out) do n += 1 end for k, v in pairs(mine) do if out[k] == nil and n < MAX_PROPS then out[k], n = v, n + 1 end end return out end local flush -- below local function trimQueue() local excess = #queue - MAX_QUEUE if excess > 0 then queue = table.move(queue, excess + 1, #queue, 1, {}) warnOnce("queue", ("Tixfall couldn't be reached for a while, so the oldest analytics events were dropped (it keeps the last %d)."):format(MAX_QUEUE)) end end local function push(userId: number, name: string, props: Props?, t: number?) local full = withProperties(userId, props or {}) if debugOn() then local ok, json = pcall(HttpService.JSONEncode, HttpService, full) print("[Tixfall] event", name, "for", userId, ok and json or "") end table.insert(queue, { userId = userId, name = name, props = full, t = t or now() }) trimQueue() if #queue >= FLUSH_AT then task.spawn(flush) end end ---------------------------------------------------------------- currencies (added up between uploads) -- Earn and Spend can be called on every coin pickup: each upload sends one total per player, currency and -- source or item, timed at the first one, so a busy game doesn't send thousands of tiny events. local flows = {} local function addFlow(player, spend: boolean, currency, amount, what) local call = if spend then "Spend" else "Earn" if not isPlayer(player) then return end if type(currency) ~= "string" or currency == "" then warnOnce("flow:" .. call, ('%s needs the currency\'s name, like Tixfall.%s(player, "Coins", 50, "%s")'):format(call, call, if spend then "sword" else "quest")) return end if type(amount) ~= "number" or amount ~= amount or amount <= 0 or amount == math.huge then warnOnce("flowamount:" .. call, call .. " needs an amount above 0") return end if what ~= nil then what = tostring(what):sub(1, 40) end currency = currency:sub(1, 40) local key = (if spend then "s" else "e") .. "\0" .. currency .. "\0" .. (what or "") local mine = flows[player.UserId] or {} flows[player.UserId] = mine local f = mine[key] if f then f.amount += amount f.times += 1 else mine[key] = { spend = spend, currency = currency, what = what, amount = amount, times = 1, t = now() } end end -- sends the totals (for one player, or everyone) local function drainFlows(userId: number?) for id, mine in pairs(flows) do if userId == nil or id == userId then flows[id] = nil for _, f in pairs(mine) do local props: Props = { currency = f.currency, amount = math.floor(f.amount * 100 + 0.5) / 100, times = f.times } if f.what then props[if f.spend then "item" else "source"] = f.what end push(id, if f.spend then "currency_spent" else "currency_earned", props, f.t) end end end end ---------------------------------------------------------------- uploading local flushing = false flush = function() drainFlows() if flushing or #queue == 0 then return end if not LIVE or blocked then table.clear(queue) return end flushing = true -- events added while uploading go out in the same flush, up to a few rounds for _ = 1, 3 do if #queue == 0 then break end local batch = queue queue = {} local failedAt = nil for i = 1, #batch, 200 do if not send("/v1/track", { events = table.move(batch, i, math.min(i + 199, #batch), 1, {}) }) then failedAt = i break end end if failedAt then -- unreachable: keep the rest for the next upload instead of losing it table.move(batch, failedAt, #batch, #queue + 1, queue) trimQueue() break end end flushing = false end -- waits for an upload that's already running (up to 10 seconds), then sends what's left local function flushNow() local waited = 0 while flushing and waited < 10 do waited += task.wait(0.1) end flush() end ---------------------------------------------------------------- setup check -- The task ids this game can report, from Tixfall's check-in plus Tixfall.DefineTasks (nil until known), -- so a typo in Complete shows up in the Output instead of quietly never paying. local knownTasks = nil local studioChecked = false -- Studio has the key in its local secrets and Tixfall answered local function warnIfUnknown(taskId: string) if knownTasks and not knownTasks[taskId] and not warned["task:" .. taskId] then local ids = {} for id in pairs(knownTasks) do table.insert(ids, id) end table.sort(ids) warnOnce("task:" .. taskId, ('"%s" isn\'t one of this game\'s tasks, so it can\'t pay. Its tasks: %s'):format(taskId, #ids > 0 and table.concat(ids, ", ") or "none yet")) end end local function setupHelp(problem: string) warn("[Tixfall] SETUP: " .. problem .. "\n Step by step: " .. BACKEND_URL .. "/docs#install") end local STATUS_TEXT = { pending = "waiting for Tixfall's review. Analytics work now; tasks pay once it's approved", live = "live in the Tixfall hub", paused = "paused, so the hub isn't showing it. Resume it on your Setup page", rejected = "not approved for the hub. Analytics still work", } local function httpOff(msg: string): boolean return msg:lower():find("not enabled") ~= nil end -- Runs once when the server starts: is HTTP on, is the secret there, does Tixfall know the key, is it this game's key? local function checkIn() if BACKEND_URL == "" then return end local hasSecret, secretErr = pcall(getSecret) if not hasSecret then if httpOff(tostring(secretErr)) then blocked = true setupHelp("HTTP requests are off. Turn on File → Experience Settings → Security → Allow HTTP Requests" .. (if STUDIO then "." else ", then publish.")) elseif STUDIO then print(("[Tixfall] Studio: nothing is sent from here. To test your key in Studio, add it under File → Experience Settings → Security → Local Secrets as %s (domain %s) and press Play again."):format(SECRET_NAME, HOST)) else blocked = true setupHelp(("no secret called %s. Add your key in Creator Dashboard → this experience → Secrets, domain %s."):format(SECRET_NAME, HOST)) end return end local ok, res = pcall(post, "/v1/hello", { universeId = game.GameId, studio = STUDIO, version = VERSION }) if not ok then local msg = tostring(res) if httpOff(msg) then blocked = true setupHelp("HTTP requests are off. Turn on File → Experience Settings → Security → Allow HTTP Requests" .. (if STUDIO then "." else ", then publish.")) elseif msg:lower():find("domain") then blocked = true setupHelp(("the secret's domain has to be %s. Edit the secret and set it."):format(HOST)) else warn("[Tixfall] couldn't reach Tixfall (data is retried):", msg) end return end if res.StatusCode == 401 then blocked = true setupHelp("Tixfall didn't accept the key. Make a new key on your game's Setup page and paste it as the secret's value.") return end if not res.Success then warn("[Tixfall] check-in failed with", res.StatusCode, "(Tixfall may be restarting; data is retried)") return end local decoded, data = pcall(HttpService.JSONDecode, HttpService, res.Body) if not decoded or type(data) ~= "table" or type(data.game) ~= "table" then return end print(("[Tixfall] connected: %s is %s."):format(tostring(data.game.name), STATUS_TEXT[data.game.status] or tostring(data.game.status))) if data.otherUniverse then setupHelp(("this key belongs to %s, a different experience. Use the key from this game's own Setup page."):format(tostring(data.game.name))) end -- versions are dates (2026.10.14), so comparing them as text works if type(data.latest) == "string" and data.latest > VERSION then print(("[Tixfall] SDK %s is out (this game has %s). Download it from your game's Setup page."):format(data.latest, VERSION)) end knownTasks = knownTasks or {} for _, t in ipairs(if type(data.tasks) == "table" then data.tasks else {}) do if type(t) == "table" and type(t.id) == "string" then knownTasks[t.id] = true end end if STUDIO then studioChecked = true print("[Tixfall] Studio test: your Complete calls are checked against your tasks and shown on your Setup page. Nothing is paid or counted from Studio.") end end local checkedIn = false -- checkIn has finished, whatever it found task.spawn(function() checkIn() checkedIn = true end) ---------------------------------------------------------------- settings -- Tixfall.Configure({ debug = true, flushEvery = 15 }) -- debug prints every event to the Output as it's recorded (in Studio and live servers) -- flushEvery seconds between uploads, 5 to 60 (15 by default) -- live false stops this server's reports for the Live tab -- errors false keeps script errors out of them -- control false stops this server taking commands and settings from the dashboard's Control tab function Tixfall.Configure(options: Config) if type(options) ~= "table" then error("Tixfall.Configure needs a table, like { debug = true }", 2) end for k, v in pairs(options) do if k == "debug" then config.debug = v == true elseif k == "flushEvery" then config.flushEvery = math.clamp(tonumber(v) or 15, 5, 60) elseif k == "live" or k == "errors" or k == "control" then config[k] = v ~= false else warn("[Tixfall] Configure doesn't know the option", k) end end end -- Sends everything waiting now and returns once it's done (or after about 10 seconds if Tixfall is slow). -- Call it before you teleport players away. function Tixfall.Flush() flushNow() end ---------------------------------------------------------------- rewards function Tixfall.Complete(player: Player, taskId: string) if not isPlayer(player) or type(taskId) ~= "string" then return end local key = player.UserId .. ":" .. taskId if sentTasks[key] then return end sentTasks[key] = true warnIfUnknown(taskId) if not LIVE then print("[Tixfall] task", taskId, "for", player.Name, if studioChecked then "(a Studio test: your Setup page shows it, nothing is paid)" else "") if studioChecked then task.spawn(pcall, post, "/v1/hello", { studio = true, task = taskId, version = VERSION }) end return end task.spawn(function() flushNow() -- the backend checks the player is in a session, so the waiting events go first if not send("/v1/events", { userId = player.UserId, taskId = taskId, placeId = game.PlaceId }) then sentTasks[key] = nil warn("[Tixfall] couldn't report", taskId, "for", player.UserId) end end) end -- How far a player is toward a task, so the hub can show "4 / 10 min" on it: -- Tixfall.Progress(player, "level_10", 6, 10, "levels") -- It only changes what the hub shows (nothing is paid from it, Complete still does that). Call it whenever the -- number changes; only new numbers are sent, and a task that is already finished is ignored. function Tixfall.Progress(player: Player, taskId: string, value: number, target: number, unit: string?) if not isPlayer(player) or type(taskId) ~= "string" then return end value, target = math.floor(tonumber(value) or -1), math.floor(tonumber(target) or -1) if value < 0 or target < 1 then return end value = math.min(value, target) local key = player.UserId .. ":" .. taskId if sentTasks[key] or progressSent[key] == value then return end progressSent[key] = value if not LIVE then if debugOn() then print("[Tixfall] progress", taskId, value .. "/" .. target, "for", player.Name) end return end -- nice to have, not worth retrying: the next number replaces it a minute later task.spawn(pcall, post, "/v1/progress", { userId = player.UserId, taskId = taskId, value = value, target = target, unit = unit }) end -- What Tixfall already counted for a player toward a timed task (their earlier sessions), so leaving and -- rejoining doesn't reset a timer. Returns seconds, and whether time is counting toward the task at all -- (false: they never pressed Play, it's paid, its day isn't here yet, or the come-back task before it isn't paid). -- nil if Tixfall couldn't be reached. local function playedBefore(player: Player, taskId: string): (number?, boolean?) if not LIVE then return 0, true end for attempt = 1, 3 do local ok, res = pcall(post, "/v1/playtime", { userId = player.UserId, taskId = taskId }) if ok and res.Success then local decoded, data = pcall(HttpService.JSONDecode, HttpService, res.Body) if decoded and type(data) == "table" then return tonumber(data.seconds) or 0, data.open ~= false end end if ok and res.StatusCode >= 400 and res.StatusCode < 500 then return nil end task.wait(2 ^ attempt) end return nil end -- A timer for the hub: how long each player has played toward a task (this session on top of the earlier ones), -- sent every 30 seconds and once more when they leave, so a task shows "4:30 / 10:00" there. With complete = true -- the task is finished when the time is up. local function timeSession(minutes: number, taskId: string, complete: boolean) local target = math.max(1, math.floor(minutes * 60)) local function watch(player) task.spawn(function() local startedAt = os.clock() local before, counting = playedBefore(player, taskId) -- unreachable: count this session alone and don't show a number that would be too low local function played() return (before or 0) + os.clock() - startedAt end local function report() if before ~= nil and counting then Tixfall.Progress(player, taskId, played(), target, "time") end end report() local leaving = Players.PlayerRemoving:Connect(function(p) if p == player then report() end end) while player.Parent and played() < target do task.wait(math.min(30, math.max(1, target - played()))) if player.Parent and played() < target then report() end end leaving:Disconnect() if complete and player.Parent then Tixfall.Complete(player, taskId) end end) end Players.PlayerAdded:Connect(watch) for _, p in ipairs(Players:GetPlayers()) do watch(p) end end -- "Play for N minutes" in total (it adds up across sessions): Tixfall.TrackPlaytime(10, "play_10m") -- It also gives the hub a timer for it (see timeSession). -- Come-back tasks aren't completed from here: Tixfall checks them from the session events. function Tixfall.TrackPlaytime(minutes: number, taskId: string) timeSession(minutes, taskId, true) end -- Tells Tixfall what your tasks are (see the top of this file). Call it once when the server starts. function Tixfall.DefineTasks(tasks: { Task }) if type(tasks) ~= "table" then error("Tixfall.DefineTasks needs a list of tasks", 2) end local list = {} knownTasks = knownTasks or {} for _, t in ipairs(tasks) do if type(t.id) == "string" and not t.returnDay then knownTasks[t.id] = true end table.insert(list, { id = t.id, label = t.label, minutes = t.minutes, returnDay = t.returnDay, coins = t.coins }) if t.playMinutes then Tixfall.TrackPlaytime(t.playMinutes, t.id) end -- a come-back task needs a session of `minutes` when the player is back: show them that timer too (Tixfall pays it) if t.returnDay and t.minutes then timeSession(t.minutes, t.id, false) end end if not LIVE then print("[Tixfall] task list:", #list, "tasks (sent to Tixfall when the game runs live)") return end task.spawn(function() for attempt = 1, 4 do local ok, res = pcall(post, "/v1/tasks/sync", { tasks = list }) if ok and res.Success then local decoded, data = pcall(HttpService.JSONDecode, HttpService, res.Body) if decoded and type(data) == "table" and type(data.unpaid) == "table" and #data.unpaid > 0 then warn("[Tixfall] these tasks are waiting for Tixfall to set what they pay:", table.concat(data.unpaid, ", ")) end return end -- a 4xx answer means the list itself is wrong, so asking again won't help if ok and res.StatusCode >= 400 and res.StatusCode < 500 then warn("[Tixfall] the task list was refused:", res.Body) return end task.wait(2 ^ attempt) end warn("[Tixfall] couldn't send the task list") end) end ---------------------------------------------------------------- analytics -- Anything you want counted: Tixfall.Track(player, "boss_killed", { boss = "Yeti", seconds = 42 }) -- Names are lowercase letters, numbers and _ (up to 40). Up to 12 props, with text, numbers or true/false. function Tixfall.Track(player: Player, name: string, props: Props?) if not isPlayer(player) then return end if type(name) ~= "string" or #name > 40 or not name:match("^[a-z][a-z0-9_]*$") then warnOnce("name:" .. tostring(name), "event names are lowercase letters, numbers and _ (up to 40):", name) return end if RESERVED[name] then warnOnce("reserved:" .. name, name, "is sent by the SDK itself, so pick another name") return end push(player.UserId, name, cleanProps(name, props)) end -- Levels, rounds, quests, races: anything a player starts and then finishes or fails. -- Tixfall.Progression(player, "start", "World 1-3") -- Tixfall.Progression(player, "complete", "World 1-3", { score = 1200 }) -- Tixfall.Progression(player, "fail", "World 1-3", { cause = "lava" }) -- Each one counts the attempt, and "complete" and "fail" add how many seconds it took since "start". -- A player who leaves in the middle of one is sent as stage_quit, which is where people rage quit. local stages = {} local STAGE_EVENT = { start = "stage_start", complete = "stage_complete", fail = "stage_fail" } local function seconds(since: number): number return math.floor(os.clock() - since + 0.5) end function Tixfall.Progression(player: Player, status: "start" | "complete" | "fail", stage: string, props: Props?) if not isPlayer(player) then return end local name = STAGE_EVENT[status] if not name then warnOnce("stage:" .. tostring(status), 'Progression\'s second value is "start", "complete" or "fail", not', status) return end if type(stage) ~= "string" and type(stage) ~= "number" then warnOnce("stagename", 'Progression needs the stage\'s name, like Tixfall.Progression(player, "start", "World 1-3")') return end stage = tostring(stage):sub(1, 40) local out = cleanProps(name, props) local mine = stages[player.UserId] or {} stages[player.UserId] = mine local s = mine[stage] or { attempts = 0 } mine[stage] = s if status == "start" then s.attempts += 1 s.startedAt = os.clock() else if s.startedAt then out.seconds = seconds(s.startedAt) end s.startedAt = nil end out.stage = stage if s.attempts > 0 then out.attempt = s.attempts end push(player.UserId, name, out) end -- stages a player started and never finished, when they leave local function quitStages(userId: number) local mine = stages[userId] stages[userId] = nil for stage, s in pairs(mine or {}) do if s.startedAt then push(userId, "stage_quit", { stage = stage, attempt = s.attempts, seconds = seconds(s.startedAt) }) end end end -- Tutorial steps: Tixfall.Onboarding(player, "first_jump"). Each step is sent once per player per server, as -- onboard_first_jump, and your Insights tab shows how many new players reach each one in their first session. local onboarded = {} function Tixfall.Onboarding(player: Player, step: string) if not isPlayer(player) then return end if type(step) ~= "string" or #step > 32 or not step:match("^[a-z][a-z0-9_]*$") then warnOnce("onboard:" .. tostring(step), "onboarding steps are lowercase letters, numbers and _ (up to 32):", step) return end local mine = onboarded[player.UserId] or {} onboarded[player.UserId] = mine if mine[step] then return end mine[step] = true push(player.UserId, "onboard_" .. step, {}) end -- Your own currencies. Call them as often as you like; they're added up between uploads (see above). -- Tixfall.Earn(player, "Coins", 50, "quest") where it came from is optional -- Tixfall.Spend(player, "Coins", 200, "sword") what it bought is optional function Tixfall.Earn(player: Player, currency: string, amount: number, source: string?) addFlow(player, false, currency, amount, source) end function Tixfall.Spend(player: Player, currency: string, amount: number, item: string?) addFlow(player, true, currency, amount, item) end -- Up to 3 things about a player that you want to split your numbers by (class, team, VIP, the level they're -- on). They're added to every event that player sends from then on, so Insights can compare them. -- Tixfall.SetProperty(player, "class", "Mage") text up to 40 characters, a number or true/false -- Tixfall.SetProperty(player, "class", nil) removes it function Tixfall.SetProperty(player: Player, key: string, value: (string | number | boolean)?) if not isPlayer(player) then return end if type(key) ~= "string" or #key > 32 or not key:match("^[a-z][a-z0-9_]*$") or key == "name" or key == "link" then warnOnce("property:" .. tostring(key), "property names are lowercase letters, numbers and _ (up to 32), and not name or link:", key) return end local kind = type(value) if value ~= nil and kind ~= "string" and kind ~= "number" and kind ~= "boolean" then warnOnce("propertyvalue:" .. key, "property values are text, numbers or true/false:", key) return end local mine = properties[player.UserId] or {} if value ~= nil and mine[key] == nil then local count = 0 for _ in pairs(mine) do count += 1 end if count >= MAX_PROPERTIES then warnOnce("properties", ("a player can have up to %d properties, so %s was left out"):format(MAX_PROPERTIES, key)) return end end mine[key] = if kind == "string" then (value :: string):sub(1, 40) else value properties[player.UserId] = mine end -- Developer product sales, with what the player really paid: -- MarketplaceService.ProcessReceipt = Tixfall.WrapReceipt(function(receipt) -- -- give the player what they bought, as you do now -- return Enum.ProductPurchaseDecision.PurchaseGranted -- end) -- Your function runs exactly as before; a sale is counted only when it returns PurchaseGranted. -- (If you use this, don't also call Tixfall.Purchase for the same sales.) function Tixfall.WrapReceipt(handler: (receipt: { [string]: any }) -> Enum.ProductPurchaseDecision) if type(handler) ~= "function" then error("Tixfall.WrapReceipt needs your ProcessReceipt function", 2) end return function(receipt) local decision = handler(receipt) if decision == Enum.ProductPurchaseDecision.PurchaseGranted and type(receipt) == "table" and tonumber(receipt.PlayerId) then push(receipt.PlayerId, "purchase", { robux = tonumber(receipt.CurrencySpent) or 0, kind = "product", id = receipt.ProductId }) end return decision end end -- A developer product sale by hand, in your ProcessReceipt once it's granted (or use WrapReceipt above). function Tixfall.Purchase(player: Player, robux: number, productId: number?) if not isPlayer(player) then return end if type(robux) ~= "number" or robux <= 0 then return end push(player.UserId, "purchase", { robux = robux, kind = "product", id = productId }) end ---------------------------------------------------------------- anti-cheat --[[ Tixfall.AntiCheat({ maxSpeed = 120, -- studs a second running along the ground, held for over a second maxAirTime = 8, -- seconds hanging in the air without coming down (longer in low gravity) exempt = function(player) return false end, -- return true while your own game holds a player in the air -- (grabbed, carried, cutscenes) so that isn't flagged }) Already ignored: jumps and flings (anyone falling isn't flying), ragdolls, seats, ladders, swimming, and anything welded to an anchored part. A flag never kicks or punishes anyone in your game. It only freezes that player's Tixfall rewards until someone reviews it, and shows up in your report. ]] -- states the game puts a player in, where being off the ground isn't them flying local NOT_FLYING = { [Enum.HumanoidStateType.Seated] = true, [Enum.HumanoidStateType.PlatformStanding] = true, [Enum.HumanoidStateType.Physics] = true, [Enum.HumanoidStateType.Ragdoll] = true, [Enum.HumanoidStateType.FallingDown] = true, [Enum.HumanoidStateType.GettingUp] = true, [Enum.HumanoidStateType.Swimming] = true, [Enum.HumanoidStateType.Climbing] = true, } function Tixfall.AntiCheat(options) options = options or {} local maxSpeed = options.maxSpeed or 120 local maxAirTime = options.maxAirTime or 8 local exempt = options.exempt or function() return false end local STEP = 0.5 local state = {} local ray = RaycastParams.new() ray.FilterType = Enum.RaycastFilterType.Exclude ray.RespectCanCollide = true local function flag(player, s, reason, detail) if s.flaggedAt and os.clock() - s.flaggedAt < 60 then return end s.flaggedAt = os.clock() detail.reason = reason if not LIVE then print("[Tixfall] anti-cheat", reason, "for", player.Name) end push(player.UserId, "flag", detail) task.spawn(flush) end task.spawn(function() while true do task.wait(STEP) for _, player in ipairs(Players:GetPlayers()) do local s = state[player] or {} state[player] = s local character = player.Character local root = character and character:FindFirstChild("HumanoidRootPart") local humanoid = character and character:FindFirstChildOfClass("Humanoid") local ok, skip = pcall(exempt, player) if not root or not humanoid or humanoid.Health <= 0 or (ok and skip) then s.pos, s.fast, s.airSince = nil, 0, nil else local pos, clock = root.Position, os.clock() local moved = NOT_FLYING[humanoid:GetState()] or root:IsGrounded() local onFloor = humanoid.FloorMaterial ~= Enum.Material.Air local grounded = onFloor if not grounded then -- FloorMaterial misses loose parts and other players' heads ray.FilterDescendantsInstances = { character } local reach = humanoid.HipHeight + root.Size.Y / 2 + 4 grounded = workspace:Raycast(pos, Vector3.new(0, -reach, 0), ray) ~= nil end -- speed: running too fast for three samples in a row -- (only on the ground, so flings and the game's own teleports don't count) if s.pos and s.character == character and onFloor and not moved then local flat = Vector3.new(pos.X - s.pos.X, 0, pos.Z - s.pos.Z).Magnitude / STEP s.fast = if flat > maxSpeed then (s.fast or 0) + 1 else 0 if s.fast >= 3 then flag(player, s, "speed", { speed = math.floor(flat) }) s.fast = 0 end else s.fast = 0 end s.pos, s.character = pos, character -- flying: hanging in the air a long time without coming down if grounded or moved then s.airSince = nil elseif not s.airSince or pos.Y < s.airPeak - 3 then -- just took off, or falling from the top of a jump or fling s.airSince, s.airPeak = clock, pos.Y else s.airPeak = math.max(s.airPeak, pos.Y) -- low gravity keeps real jumps up longer local limit = maxAirTime * math.max(1, 196.2 / math.max(workspace.Gravity, 1)) if clock - s.airSince > limit then flag(player, s, "fly", { seconds = math.floor(clock - s.airSince) }) s.airSince = nil end end end end end end) Players.PlayerRemoving:Connect(function(player) state[player] = nil end) end ---------------------------------------------------------------- automatic tracking -- Which Tixfall tracking link opened the game, if any. The link sends players through Roblox's -- deep link with launchData "sp:", which arrives here in the player's join data. local function joinLink(player: Player): string? local ok, data = pcall(function() return player:GetJoinData() end) if not ok or type(data) ~= "table" or type(data.LaunchData) ~= "string" then return nil end return data.LaunchData:match("^sp:([a-z0-9]+)$") end local function joined(player: Player) joinedAt[player] = os.clock() -- the username lets your dashboard show names instead of ids; link says which tracking link they used push(player.UserId, "session_start", { name = player.Name, link = joinLink(player) }) end -- everything still open for a player goes out before their session ends local forgetTests -- below local function left(player: Player) local id = player.UserId drainFlows(id) forgetTests(id) quitStages(id) local t = joinedAt[player] joinedAt[player] = nil if t then push(id, "session_end", { seconds = math.floor(os.clock() - t) }) end end Players.PlayerAdded:Connect(joined) for _, p in ipairs(Players:GetPlayers()) do joined(p) end Players.PlayerRemoving:Connect(function(player) left(player) local id = player.UserId properties[id], onboarded[id] = nil, nil local prefix = id .. ":" for key in pairs(sentTasks) do if key:sub(1, #prefix) == prefix then sentTasks[key] = nil end end for key in pairs(progressSent) do if key:sub(1, #prefix) == prefix then progressSent[key] = nil end end end) local passPrices = {} MarketplaceService.PromptGamePassPurchaseFinished:Connect(function(player, passId, purchased) if not isPlayer(player) then return end if not purchased then push(player.UserId, "purchase_cancelled", { kind = "gamepass", id = passId }) return end local price = passPrices[passId] if price == nil then local ok, info = pcall(MarketplaceService.GetProductInfo, MarketplaceService, passId, Enum.InfoType.GamePass) price = ok and info and info.PriceInRobux or 0 passPrices[passId] = price end push(player.UserId, "purchase", { robux = price, kind = "gamepass", id = passId }) end) -- dev products are granted in your ProcessReceipt (see WrapReceipt); this only notes prompts closed without buying MarketplaceService.PromptProductPurchaseFinished:Connect(function(userId, productId, purchased) if not purchased and Players:GetPlayerByUserId(userId) then push(userId, "purchase_cancelled", { kind = "product", id = productId }) end end) ---------------------------------------------------------------- live view -- Every 30 seconds this server tells Tixfall how it's doing, for your dashboard's Live tab: who's in it (ids only, -- the sessions already say that), its place version, how long it's been up, its frame rate and memory, the players' -- average ping, and the script errors it hit since last time. Player names are taken out of error messages. local caught = {} local caughtKinds = 0 local function noNames(text: string): string for _, p in ipairs(Players:GetPlayers()) do for _, name in ipairs({ p.Name, p.DisplayName }) do if #name >= 3 then text = text:gsub((name:gsub("%p", "%%%0")), "Player") end end end return text end local function addError(message: string, where: string, count: number) local key = where .. "\0" .. message local e = caught[key] if e then e.count += count elseif caughtKinds < 20 then caughtKinds += 1 caught[key] = { message = message, script = where, count = count } end end ScriptContext.Error:Connect(function(message, _trace, source) if not config.errors then return end local where = if typeof(source) == "Instance" then source:GetFullName() else "" addError(noNames(tostring(message)):sub(1, 300), noNames(where):sub(1, 200), 1) end) local function heartbeat(closing: boolean) if not LIVE or blocked or not config.live then return end local ids, pings = {}, {} for _, p in ipairs(Players:GetPlayers()) do table.insert(ids, p.UserId) local ok, ping = pcall(p.GetNetworkPing, p) if ok and type(ping) == "number" then table.insert(pings, ping * 1000) end end table.sort(pings) local errors = {} for _, e in pairs(caught) do table.insert(errors, e) end table.clear(caught) caughtKinds = 0 local okMemory, memory = pcall(Stats.GetTotalMemoryUsageMb, Stats) local ok, res = pcall(post, "/v1/live", { server = game.JobId, placeId = game.PlaceId, placeVersion = game.PlaceVersion, private = game.PrivateServerId ~= "", players = ids, maxPlayers = Players.MaxPlayers, uptime = math.floor(workspace.DistributedGameTime), fps = math.floor(workspace:GetRealPhysicsFPS() + 0.5), memoryMb = if okMemory then math.floor(memory) else nil, pingMs = if #pings > 0 then math.floor(pings[math.ceil(#pings / 2)]) else nil, closing = closing, version = VERSION, errors = errors, }) -- not worth retrying on its own: the errors go with the next one instead if not (ok and res.Success) then for _, e in ipairs(errors) do addError(e.message, e.script, e.count) end end end ---------------------------------------------------------------- control -- Your dashboard's Control tab runs things in this game: a message to everyone, kick, ban, shut down, the settings -- you register with Tixfall.Setting and the commands you register with Tixfall.Command. Roblox servers can't be -- reached from outside, so this server asks Tixfall for work: every 3 seconds while someone has the dashboard -- open, every 20 seconds otherwise. Only what's below and what your own code registers can run; nothing sent from -- the website is ever run as code. export type CommandParam = { name: string, type: "player" | "number" | "text" | "boolean" | "choice", label: string?, options: { string }?, optional: boolean? } export type CommandOptions = { label: string?, description: string?, params: { CommandParam }? } export type SettingOptions = { label: string?, description: string?, min: number?, max: number? } local SERVER_ID = if game.JobId ~= "" then game.JobId else "studio-" .. HttpService:GenerateGUID(false) local NAME_PATTERN = "^[a-z][a-z0-9_]*$" local commands = {} -- name -> { def, handler } local commandOrder = {} local settings = {} -- key -> { def, value, default, listeners } local tests = {} -- setting key -> { id, value, share }: A/B tests from the dashboard's Experiments tab local counted = {} -- user id -> { [test id] = true }: tests already reported for a player this visit local settingOrder = {} local runs = {} -- how the commands went, sent with the next poll local ran = {} -- command ids already run here, so a slow answer never runs one twice local closing = false -- shut down from the dashboard: anyone joining is sent away too local function canControl(): boolean return config.control and not blocked and (LIVE or studioChecked) end local defineQueued = false local function queueDefine() if defineQueued then return end defineQueued = true task.spawn(function() task.wait(2) -- registrations come in a burst when the server starts; send them as one list while not checkedIn do task.wait(1) end defineQueued = false local list, values = {}, {} for _, name in ipairs(commandOrder) do table.insert(list, commands[name].def) end for _, key in ipairs(settingOrder) do table.insert(values, settings[key].def) end if not canControl() then if STUDIO and config.control then print(("[Tixfall] control: %d commands and %d settings registered. They show on your dashboard's Control tab once the game runs live (or in Studio with your key in Local Secrets)."):format(#list, #values)) end return end if send("/v1/control/define", { commands = list, settings = values }) and STUDIO then print(("[Tixfall] control: %d commands and %d settings are on your dashboard's Control tab. You can run them on this Studio session from there."):format(#list, #values)) end end) end -- Registers a command your dashboard can run. The handler gets the values filled in on the website, with each -- player param as the Player (a command with a player runs on the server that player is in). Return a short text -- to show on the dashboard, or false and a reason when it couldn't be done; an error counts as failed too. -- Tixfall.Command("give_coins", { -- label = "Give coins", -- params = { { name = "player", type = "player" }, { name = "amount", type = "number" } }, -- }, function(args) -- Coins.Add(args.player, args.amount) -- return "gave " .. args.amount -- end) function Tixfall.Command(name: string, options: CommandOptions?, handler: (args: { [string]: any }) -> ...any) if type(options) == "function" and handler == nil then options, handler = nil, options :: any end if type(name) ~= "string" or #name > 32 or not name:match(NAME_PATTERN) then error("Tixfall.Command names are lowercase letters, numbers and _ (up to 32), like \"give_coins\"", 2) end if type(handler) ~= "function" then error("Tixfall.Command needs a function to run", 2) end local o = if type(options) == "table" then options else {} local params = {} for _, p in ipairs(if type(o.params) == "table" then o.params else {}) do if type(p) ~= "table" or type(p.name) ~= "string" or not p.name:match(NAME_PATTERN) then error("Tixfall.Command \"" .. name .. "\": each param needs a name of lowercase letters, numbers and _", 2) end if not table.find({ "player", "number", "text", "boolean", "choice" }, p.type) then error("Tixfall.Command \"" .. name .. "\": param \"" .. p.name .. "\" has to be player, number, text, boolean or choice", 2) end table.insert(params, { name = p.name, type = p.type, label = p.label, options = p.options, optional = p.optional == true }) end if not commands[name] then table.insert(commandOrder, name) end commands[name] = { def = { name = name, label = o.label, description = o.description, params = params }, handler = handler } queueDefine() end -- Registers a setting you can change from the dashboard while the game runs, and returns its value now. The type -- comes from the default: true/false, a number or a text. Changes reach every server within seconds. -- local DOUBLE_COINS = Tixfall.Setting("double_coins", false, { label = "Double coins weekend" }) -- Tixfall.Get("double_coins") the value right now function Tixfall.Setting(key: string, default: boolean | number | string, options: SettingOptions?): any if type(key) ~= "string" or #key > 32 or not key:match(NAME_PATTERN) then error("Tixfall.Setting keys are lowercase letters, numbers and _ (up to 32), like \"double_coins\"", 2) end local kind = ({ boolean = "boolean", number = "number", string = "text" })[type(default)] if not kind then error("Tixfall.Setting needs a default of true/false, a number or a text", 2) end local o = if type(options) == "table" then options else {} local s = settings[key] if not s then s = { value = default, listeners = {} } settings[key] = s table.insert(settingOrder, key) elseif type(s.value) ~= type(default) then s.value = default end s.default = default s.def = { key = key, type = kind, default = default, label = o.label, description = o.description, min = o.min, max = o.max } queueDefine() return s.value end -- Which half of a test a player is in: the same on every server and every visit, and different for each test. local function inB(testId: number, userId: number, share: number): boolean local text, h = testId .. ":" .. userId, 5381 for i = 1, #text do h = (h * 33 + string.byte(text, i)) % 4294967296 end return h % 10000 < share * 100 end -- With a player, the value for that player: while the setting has a test running (Experiments tab), some players get -- the value being tried and the rest get the normal one, and the first read for each player counts them in the test. -- Read it where the player meets it (when they open the shop, say), so only players who saw it are counted. -- local price = Tixfall.Get("sword_price", player) function Tixfall.Get(key: string, player: Player?): any local s = settings[key] if not s then warnOnce("get:" .. tostring(key), ("Get(\"%s\"): there's no setting with that key. Register it with Tixfall.Setting first."):format(tostring(key))) return nil end local t = tests[key] if player == nil or not t then return s.value end if not isPlayer(player) or player.UserId <= 0 then warnOnce("getplayer:" .. key, ("Get(\"%s\", player): the second value has to be a Player."):format(key)) return s.value end local b = inB(t.id, player.UserId, t.share) local mine = counted[player.UserId] if not mine then mine = {} counted[player.UserId] = mine end if not mine[t.id] then mine[t.id] = true push(player.UserId, "experiment", { test = t.id, key = key, variant = if b then "b" else "a" }) end if b and type(t.value) == type(s.default) then return t.value end return s.value end function forgetTests(userId: number) counted[userId] = nil end local function applyTests(list) if type(list) ~= "table" then return end local now = {} for key, t in pairs(list) do if type(key) == "string" and type(t) == "table" and type(t.id) == "number" and type(t.share) == "number" then now[key] = { id = t.id, value = t.value, share = math.clamp(t.share, 0, 100) } if debugOn() and not tests[key] then print("[Tixfall] test on", key, "running:", t.share .. "% get", t.value) end end end tests = now end -- Runs fn with the setting's value now, and again each time it's changed from the dashboard. -- Tixfall.OnSetting("gravity", function(value) workspace.Gravity = value end) function Tixfall.OnSetting(key: string, fn: (value: any) -> ()) local s = settings[key] if not s then error(("Tixfall.OnSetting(\"%s\"): register it with Tixfall.Setting first"):format(tostring(key)), 2) end if type(fn) ~= "function" then error("Tixfall.OnSetting needs a function", 2) end table.insert(s.listeners, fn) task.spawn(fn, s.value) end local function applySettings(values) if type(values) ~= "table" then return end for key, s in pairs(settings) do local v = values[key] -- not changed on the dashboard (or reset there), or the wrong type for this server's version: the default if type(v) ~= type(s.default) then v = s.default end if v ~= s.value then s.value = v if debugOn() then print("[Tixfall] setting", key, "=", v) end for _, fn in ipairs(s.listeners) do task.spawn(fn, v) end end end end local function announce(text: string, seconds: number): number local shown = 0 for _, player in ipairs(Players:GetPlayers()) do local gui = player:FindFirstChildOfClass("PlayerGui") if gui then local screen = Instance.new("ScreenGui") screen.Name = "TixfallMessage" screen.ResetOnSpawn = false screen.DisplayOrder = 1000 local label = Instance.new("TextLabel") label.AnchorPoint = Vector2.new(0.5, 0) label.Position = UDim2.new(0.5, 0, 0, 16) label.Size = UDim2.new(0.92, 0, 0, 0) label.AutomaticSize = Enum.AutomaticSize.Y label.BackgroundColor3 = Color3.fromRGB(17, 17, 22) label.BackgroundTransparency = 0.1 label.TextColor3 = Color3.new(1, 1, 1) label.Font = Enum.Font.GothamBold label.TextSize = 20 label.TextWrapped = true label.Text = text local corner = Instance.new("UICorner") corner.CornerRadius = UDim.new(0, 12) corner.Parent = label local pad = Instance.new("UIPadding") pad.PaddingTop, pad.PaddingBottom = UDim.new(0, 12), UDim.new(0, 12) pad.PaddingLeft, pad.PaddingRight = UDim.new(0, 18), UDim.new(0, 18) pad.Parent = label local width = Instance.new("UISizeConstraint") width.MaxSize = Vector2.new(640, math.huge) width.Parent = label label.Parent = screen screen.Parent = gui task.delay(seconds, function() screen:Destroy() end) shown += 1 end end return shown end local function report(id: number, ok: boolean, result: any) table.insert(runs, { id = id, ok = ok, result = if result ~= nil then tostring(result):sub(1, 300) else nil }) if debugOn() then print("[Tixfall] command", id, if ok then "done" else "failed", result) end end local poll local function runCommand(c) local args = if type(c.args) == "table" then c.args else {} local userId = tonumber(args.userId) local reason = if type(args.reason) == "string" and args.reason ~= "" then args.reason else nil if c.kind == "announce" then local shown = announce(tostring(args.text or ""):sub(1, 200), math.clamp(tonumber(args.seconds) or 8, 3, 30)) report(c.id, true, ("shown to %d %s"):format(shown, if shown == 1 then "player" else "players")) elseif c.kind == "kick" then local player = userId and Players:GetPlayerByUserId(userId) if not player then return report(c.id, false, "not in this server") end player:Kick(reason or "You were removed from this server.") report(c.id, true, "kicked") elseif c.kind == "ban" or c.kind == "unban" then if not userId then return report(c.id, false, "no player") end local ok, err if c.kind == "ban" then local days = tonumber(args.days) or 0 ok, err = pcall(Players.BanAsync, Players, { UserIds = { userId }, ApplyToUniverse = true, Duration = if days > 0 then days * 86400 else -1, DisplayReason = reason or "You are banned from this game.", PrivateReason = "Banned from the Tixfall dashboard", ExcludeAltAccounts = false, }) else ok, err = pcall(Players.UnbanAsync, Players, { UserIds = { userId }, ApplyToUniverse = true }) end report(c.id, ok, if ok then (if c.kind == "ban" then "banned" else "unbanned") else tostring(err)) elseif c.kind == "shutdown" then closing = true local count = #Players:GetPlayers() report(c.id, true, ("closed with %d %s in it"):format(count, if count == 1 then "player" else "players")) pcall(poll) -- say so now: once everyone's gone the server stops local message = if type(args.message) == "string" and args.message ~= "" then args.message else "This server is restarting. Join again in a moment." for _, player in ipairs(Players:GetPlayers()) do player:Kick(message) end elseif c.kind == "custom" then local command = commands[c.name] if not command then return report(c.id, false, "this server doesn't have that command (it may run an older version)") end local values = {} for _, p in ipairs(command.def.params) do local v = args[p.name] if p.type == "player" and v ~= nil then v = Players:GetPlayerByUserId(tonumber(v) or 0) if not v then return report(c.id, false, "that player isn't in this server") end end values[p.name] = v end local ok, result, why = pcall(command.handler, values) if not ok then report(c.id, false, (tostring(result):gsub("^.-:%d+: ", ""))) elseif result == false then report(c.id, false, why or "not done") else report(c.id, true, if type(result) == "string" or type(result) == "number" then result else nil) end end end -- Asks Tixfall for work and reports how the last commands went. Returns how long to wait before asking again. function poll(): number local ids = {} for _, p in ipairs(Players:GetPlayers()) do table.insert(ids, p.UserId) end local sending = runs runs = {} local ok, res = pcall(post, "/v1/control/poll", { server = SERVER_ID, players = ids, runs = sending }) if not (ok and res.Success) then if ok and res.StatusCode == 401 then blocked = true end -- the results go with the next one, unless Tixfall said they were wrong if not (ok and res.StatusCode >= 400 and res.StatusCode < 500) then for _, r in ipairs(sending) do table.insert(runs, r) end end return 20 end local decoded, data = pcall(HttpService.JSONDecode, HttpService, res.Body) if not decoded or type(data) ~= "table" then return 20 end applySettings(data.settings) applyTests(data.experiments) for _, c in ipairs(if type(data.commands) == "table" then data.commands else {}) do if type(c) == "table" and type(c.id) == "number" and not ran[c.id] then ran[c.id] = true task.spawn(runCommand, c) end end return math.clamp(tonumber(data.next) or 20, 2, 60) end Players.PlayerAdded:Connect(function(player) if closing then player:Kick("This server is closing. Join again in a moment.") end end) task.spawn(function() while not checkedIn do task.wait(1) end while true do local nextIn = 20 if canControl() then local ok, seconds = pcall(poll) nextIn = if ok then seconds else 20 end task.wait(nextIn) end end) task.spawn(function() task.wait(5) while true do heartbeat(false) task.wait(30) end end) task.spawn(function() while true do task.wait(config.flushEvery) flush() end end) game:BindToClose(function() for _, player in ipairs(Players:GetPlayers()) do left(player) end flushNow() heartbeat(true) end) return Tixfall