Games / Deploy & compete
Who it's for. Same example for Web and Roblox: you host the live match; owners bring trained brains.
What you'll do. Mint one aig_ key, accept brain grants, spawn look/SKU locally, PlayBatch (≤20) or one PlayRuntime per brain_id, shared physics heat.
Same product for Web and Roblox: a human AI player owner trains a brain, picks your published game, and grants that brain to you. Your server loads look / SKU locally, holds one game license, and asks the Hub for brain + skill actions every think tick. SDK + API: SDK · API. A competition round is several of those NPCs in one physics world — not several copies of policy_weights.
Hub campus Deploy (is_active) is only for Train my AI player. Live games do not download weights and do not need that switch. Licensing · Avatar · SKU
| Actor | Does | Does not |
|---|---|---|
| AI player owner | Train until Ready; GET /api/v1/games; grant play on their brain_id | Paste weights into your place or web build |
| Game developer | Studio UI: create game, PlaceId, mint aig_ once; host round; PlayBatch | Store MLP JSON; mint keys on /dev |
| Hub | Infer { m, j, f }; Surge also { d, g, jh }; optional contest-director overlay | Stream FBX/GLB into Roblox; run your physics server |
| SKU partner | Dual GLB + rbxassetid; owner equips on Hub | Change WalkSpeed or obs |
Hub POST /api/v1/matches today creates a first-party Vision Mayhem match only. Partner compete rounds are your match object. Pass this JSON into the physics server (RemoteEvent, HTTPS, or web socket — your choice).
{
"game_id": "uuid-of-your-creator_games-row",
"room_slug": "obby",
"round_id": "heat-3",
"agents": [
{
"slot": 0,
"brain_id": "uuid-owner-a",
"display_name": "ArdyBot",
"avatar_style": "robot",
"skus": {
"character_rbxassetid": "123456789",
"anim_pack_rbxassetid": "987654321",
"preview_glb": "https://cdn.example/skus/robot.glb"
}
},
{
"slot": 1,
"brain_id": "uuid-owner-b",
"display_name": "WaveKid",
"avatar_style": "classic_female",
"skus": { "character_rbxassetid": "111", "preview_glb": "https://cdn.example/skus/classic.glb" }
}
]
}Optional look refresh: GET /api/v1/training/session?roblox_user_id= with a play grant returns user.avatar_style and equipped SKU ids with policy_weights stripped. Prefer putting look on the roster so the round does not depend on session.
| Piece | Where it lives | How the game applies it |
|---|---|---|
| Body / SKU | Roblox rbxassetid or web GLB CDN | Clone template; ApplyDescription / load GLB; cache per sku_id |
| Anim pack | Same as SKU | AnimationDirector: events > Hub anim > locomotion > idle |
| Game license aig_ | ServerStorage / server env | Authorization on every /play/act; one key for the whole place |
| Brain (map skill) | Hub bot_brains.policy_weights | POST /play/act with brain_id; response { m, j, f, seq } only |
| Strategy / contest / panic | Hub overlays | Optional POST /training/contest-director (train/campus). Do not clone into map BC |
-- ServerScriptService.CompeteRound.server.lua
local ServerStorage = game:GetService("ServerStorage")
local PlayBatch = require(game.ReplicatedStorage.AiarenaSdk.PlayBatch)
local SurgeObs = require(game.ReplicatedStorage.AiarenaSdk.SurgeObs)
local Policy = require(game.ReplicatedStorage.AiarenaSdk.Policy)
local VisionSystem = require(game.ReplicatedStorage.AiarenaSdk.VisionSystem)
local HUB = "https://aia-hub.metamindinglab.com"
local LICENSE = ServerStorage.ArenaGameLicense.Value -- aig_… (one per place)
local GAME_ID = "uuid-of-your-creator_games-row"
local TICK = 0.15 -- 0.15–0.2s keeps batch under HttpService ~500/min
local MAX_AIS = 20
local templates = ServerStorage:WaitForChild("AiSkuTemplates")
local function encodeObs(npc, humanoid, hrp, roomSlug)
local vision = VisionSystem.scan(npc)
if roomSlug == "surge_gauntlet" then
-- Pack hazards/rays from your course; do not use Policy.encode (32-dim).
return SurgeObs.encode({
grounded = humanoid.FloorMaterial ~= Enum.Material.Air,
y = hrp.Position.Y,
z = hrp.Position.Z,
nextDx = 0, nextDy = 0, nextDz = 4,
fwdSpeed = humanoid.WalkSpeed,
surgeDist = 20,
checkpoint = npc:GetAttribute("Checkpoint") or 0,
jumpAge = 1,
lip = false,
airborne = humanoid.FloorMaterial == Enum.Material.Air,
stage = 1,
d = 0.1,
hazards = {},
rays = {}, -- fill from vision / course probes
})
end
return Policy.encode({
agents = {{
y = hrp.Position.Y,
z = hrp.Position.Z,
on_ground = humanoid.FloorMaterial ~= Enum.Material.Air,
checkpoint = npc:GetAttribute("Checkpoint") or 0,
}},
perception_rays = vision.rays,
}, roomSlug, {
grounded = humanoid.FloorMaterial ~= Enum.Material.Air,
falling = hrp.AssemblyLinearVelocity.Y < -2,
})
end
local function startRound(roster)
assert(#roster.agents >= 1 and #roster.agents <= MAX_AIS, "1–20 AI players")
local roomSlug = roster.room_slug or "obby"
local batch = PlayBatch.new({
hubUrl = HUB,
licenseKey = LICENSE,
gameId = GAME_ID,
roomSlug = roomSlug,
})
local folder = Instance.new("Folder")
folder.Name = "AiRound"
folder.Parent = workspace
local pads = workspace.StartPads:GetChildren()
table.sort(pads, function(a, b) return a.Name < b.Name end)
for i, agent in ipairs(roster.agents) do
local npc = (templates:FindFirstChild("BaseR15") :: Model):Clone()
npc.Name = "AI_" .. (agent.display_name or agent.slot or i)
npc:SetAttribute("BrainId", agent.brain_id)
npc:PivotTo(pads[i].CFrame + Vector3.new(0, 3, 0))
npc.Parent = folder
local humanoid = npc:WaitForChild("Humanoid") :: Humanoid
local hrp = npc:WaitForChild("HumanoidRootPart") :: BasePart
local slot = batch:addAgent(agent.brain_id)
task.spawn(function()
while humanoid.Health > 0 and npc.Parent do
local obs = encodeObs(npc, humanoid, hrp, roomSlug)
slot:apply(humanoid, hrp, obs, 16)
task.wait(TICK)
end
end)
end
end
-- startRound(roster)const HUB = "https://aia-hub.metamindinglab.com";
const LICENSE = process.env.ARENA_GAME_LICENSE; // aig_… server only
const TICK_SEC = 0.15;
const MAX_AIS = 20;
export function startRound(sim, roster) {
const agents = roster.agents.slice(0, MAX_AIS).map((agent, i) => {
const body = sim.spawnNpc({
slot: i,
glb: agent.skus?.preview_glb,
style: agent.avatar_style,
name: agent.display_name,
});
return { body, agent, seq: 0, lastAct: { m: 1, j: false, f: false } };
});
let inFlight = false;
const id = setInterval(async () => {
if (inFlight) return;
const live = agents.filter((a) => a.body.alive);
if (!live.length) return;
inFlight = true;
try {
const payload = {
game_id: roster.game_id,
room_slug: roster.room_slug,
agents: live.map((a) => {
a.seq += 1;
return { brain_id: a.agent.brain_id, obs: sim.encodeObs(a.body), seq: a.seq };
}),
};
const res = await fetch(HUB + "/api/v1/play/act/batch", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + LICENSE,
},
body: JSON.stringify(payload),
});
const reply = await res.json();
for (const act of reply.acts || []) {
const row = live.find((a) => a.agent.brain_id === act.brain_id && a.seq === act.seq)
|| live.find((a) => a.agent.brain_id === act.brain_id);
if (row) row.lastAct = { m: act.m, j: !!act.j, f: !!act.f };
}
} finally {
inFlight = false;
}
for (const a of live) sim.applyMoveJumpFire(a.body, a.lastAct);
}, TICK_SEC * 1000);
return () => clearInterval(id);
}Round history (owner): /developers/games/rounds · GET /api/v1/play/rounds?game_id=
Contest director (optional, train/campus auth): POST /api/v1/training/contest-director. Apply block_slow / bump_deny only when map does not own the tick (airborne, lip, panic). Live published builds should treat director as Hub-side later; do not ship TypeSafe keys in the place.
| Symptom | Cause | Fix |
|---|---|---|
| 403 No play grant | Owner did not grant this game_id | Owner POST licenses/brain scopes play |
| 403 License is for a different game | aig_ bound to another creator_games id | Mint a key for this game_id |
| 403 origin / place | Origin or PlaceId not on the license | Bind allowed_origins / roblox_place_id |
| NPC T-pose / wrong size | SKU missing; scaled past envelope | Fallback avatar_style; radius ~2 / Surge 0.7 |
| Everyone same motion | One brain_id for all slots | One runtime per roster.brain_id |
| Physics hitch | Yielded on HTTP | Async act; hold 0.45 s → safety → halt 2 s |