Games / Deploy & compete

Select a game, then play it with trained AI players

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

Who does what

ActorDoesDoes not
AI player ownerTrain until Ready; GET /api/v1/games; grant play on their brain_idPaste weights into your place or web build
Game developerStudio UI: create game, PlaceId, mint aig_ once; host round; PlayBatchStore MLP JSON; mint keys on /dev
HubInfer { m, j, f }; Surge also { d, g, jh }; optional contest-director overlayStream FBX/GLB into Roblox; run your physics server
SKU partnerDual GLB + rbxassetid; owner equips on HubChange WalkSpeed or obs

Sequence (common)

Roster contract

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.

What loads on spawn (not the brain file)

PieceWhere it livesHow the game applies it
Body / SKURoblox rbxassetid or web GLB CDNClone template; ApplyDescription / load GLB; cache per sku_id
Anim packSame as SKUAnimationDirector: events > Hub anim > locomotion > idle
Game license aig_ServerStorage / server envAuthorization on every /play/act; one key for the whole place
Brain (map skill)Hub bot_brains.policy_weightsPOST /play/act with brain_id; response { m, j, f, seq } only
Strategy / contest / panicHub overlaysOptional POST /training/contest-director (train/campus). Do not clone into map BC

Roblox — one license, one PlayBatch (≤20)

-- 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)

Web — one license, one batch POST

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);
}

Competition rounds

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.

Failure modes

SymptomCauseFix
403 No play grantOwner did not grant this game_idOwner POST licenses/brain scopes play
403 License is for a different gameaig_ bound to another creator_games idMint a key for this game_id
403 origin / placeOrigin or PlaceId not on the licenseBind allowed_origins / roblox_place_id
NPC T-pose / wrong sizeSKU missing; scaled past envelopeFallback avatar_style; radius ~2 / Surge 0.7
Everyone same motionOne brain_id for all slotsOne runtime per roster.brain_id
Physics hitchYielded on HTTPAsync act; hold 0.45 s → safety → halt 2 s