How I Automated 3D Game Assets with FLUX, Blender and the Roblox API
The problem I build Roblox games on my own, and good-looking 3D models were always my bottleneck. The free models in the Toolbox all look different from each other, and I didn't want to spend hours learning to model ev
The problem
I build Roblox games on my own, and good-looking 3D models were always my bottleneck. The free models in the Toolbox all look different from each other, and I didn't want to spend hours learning to model every crate and lantern by hand when I could be building gameplay.
So I built a pipeline. I type a short description, and a few minutes later a finished, colored, game-ready model is sitting in my Roblox Studio place. It chains four things together: an AI image model (FLUX) for a concept picture, Blender for the actual 3D model, the Roblox Open Cloud API for uploading, and a Luau script that finishes the model inside Studio.
This article walks through each stage with the Python and Luau code I use, and the mistakes I hit along the way. The full code is on GitHub: github.com/TheCreatorGFR/roblox-ai-asset-pipeline.
How the pipeline fits together
The pipeline runs in four stages, and each stage's output is the next one's input:
- Concept image. A text prompt goes to the FLUX image model on Cloudflare Workers AI, which returns a reference picture of the object.
- 3D model. I build the model in Blender to match the picture's shape and colors, then export it as an FBX file.
- Upload. A Python script sends the FBX to the Roblox Open Cloud Assets API and waits for Roblox to process it into an asset ID.
- Finish in Studio. The asset is inserted into my place, and a Luau script sets colors, materials, lights and position.
The glue between them is MCP, the Model Context Protocol. MCP is an open standard that lets an AI coding agent call tools inside other programs. Blender and Roblox Studio both have MCP servers, so my agent can run Python inside Blender and Luau inside Studio directly, instead of me copying code between windows. Every step below also works by hand, so you don't need MCP to follow along.
The Python code uses only the standard library plus requests (pip install requests).
Setup: keeping secrets out of your code
You need four things before any code runs:
- Python 3.10+ with
requestsinstalled - Blender (any recent version) and Roblox Studio
- A free Cloudflare account, with your account ID and an API token that has Workers AI permission
- A Roblox Open Cloud API key with Assets read and write permission, plus your Roblox user ID
The first rule of this project: API keys never go in your code, a chat window or a Git repo. Anyone who sees a Roblox key can upload assets under your account. Instead, store them as environment variables. On Windows, search for "Edit environment variables for your account", add each value under User variables, then restart your terminal and Blender so they pick up the change.
I use these four names:
| Variable | What it holds |
|---|---|
CLOUDFLARE_ACCOUNT_ID |
Your Cloudflare account ID |
CLOUDFLARE_API_TOKEN |
A Workers AI API token |
ROBLOX_API_KEY |
Your Open Cloud API key |
ROBLOX_CREATOR_ID |
Your Roblox user ID |
Then a small config.py reads them and fails loudly if one is missing, so you get a clear error instead of a confusing 401 later:
# config.py
import os
def require_env(name: str) -> str:
"""Read a secret from the environment, failing loudly if it's missing."""
value = os.environ.get(name)
if not value:
raise RuntimeError(f"Missing environment variable: {name}")
return value
CLOUDFLARE_ACCOUNT_ID = require_env("CLOUDFLARE_ACCOUNT_ID")
CLOUDFLARE_API_TOKEN = require_env("CLOUDFLARE_API_TOKEN")
ROBLOX_API_KEY = require_env("ROBLOX_API_KEY")
ROBLOX_CREATOR_ID = require_env("ROBLOX_CREATOR_ID")
Notice the error message prints the variable's name, never its value. If you ever need to check that a key loaded, print bool(value), not the key itself.
Step 1: generate a concept image with FLUX
Before modeling anything, I generate a reference picture. It gives me a concrete target for the shape, proportions and colors, and it's much faster to regenerate an image than to rebuild a model.
I use FLUX.1 [schnell] on Cloudflare Workers AI. It's fast, and Cloudflare's free tier covers a lot of images. The API is a single POST request, and the image comes back as a base64 string inside JSON:
# flux.py
import base64
from pathlib import Path
import requests
from config import CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN
FLUX_URL = (
"https://api.cloudflare.com/client/v4/accounts/"
f"{CLOUDFLARE_ACCOUNT_ID}/ai/run/@cf/black-forest-labs/flux-1-schnell"
)
def generate_concept(subject: str, out_path: Path, steps: int = 4) -> Path:
"""Generate a modeling-reference image of `subject` and save it as a JPEG."""
prompt = (
f"{subject}, single object, three-quarter view, "
"plain light grey background, soft even lighting, "
"clean low-poly style with flat colors, "
"no text, no watermark, no logo"
)
response = requests.post(
FLUX_URL,
headers={"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"},
json={"prompt": prompt, "steps": steps}, # steps: 1-8, higher = more detail
timeout=60,
)
if not response.ok:
raise RuntimeError(f"FLUX request failed: {response.status_code} {response.text}")
image_b64 = response.json()["result"]["image"]
out_path.write_bytes(base64.b64decode(image_b64))
return out_path
if __name__ == "__main__":
generate_concept("wooden treasure chest", Path("TreasureChest.jpg"))
The prompt matters more than the code. A good modeling reference shows the object alone, from a three-quarter angle, on a plain background with even lighting, so nothing hides the silhouette. I also always end with "no text, no watermark, no logo", because without it FLUX sometimes invents a fake watermark bar across the bottom.
The steps parameter trades speed for detail. Four steps is enough for a reference image; I only go up to eight when small details come out muddled. If the result doesn't match what I wanted, I tweak the prompt and run it again. I cap myself at about three tries and then pick the best one.
Step 2: build and export the model in Blender
The concept image is a reference, not something to trace detail for detail. I match its silhouette, proportions and color blocking, and keep it low-poly. Roblox has a few rules that decide whether a model imports cleanly, so I follow these while modeling:
- Model in studs. I treat 1 Blender unit as 1 stud (the export below scales it so Roblox agrees). A default character is about 5–6 studs tall and a door about 7, so I build at the real in-game size.
-
One object per material. Blender material colors don't survive the import; everything arrives a pale default color. Keeping each material as its own object (
TreasureChest_Wood,TreasureChest_Metal) means coloring it later is one property per part. -
Name parts
<Asset>_<Part>. The export script uses this to find everything that belongs to the asset. - Build with the base center at the world origin. Applying transforms then puts every part's origin there, so the model sits on the ground when placed.
- Stay under 20,000 triangles. Roblox rejects anything bigger. Props should be a few hundred to a few thousand.
This script runs inside Blender (paste it into the Scripting tab, or send it through the Blender MCP server). It applies transforms, checks the triangle budget and exports an FBX:
# export_asset.py (runs inside Blender)
from pathlib import Path
import bpy
MAX_TRIANGLES = 20_000 # Roblox rejects meshes above this
def export_asset(asset_name: str, out_dir: str) -> Path:
"""Apply transforms, check the triangle budget and export <asset_name>_* objects as FBX."""
parts = [
obj for obj in bpy.data.objects
if obj.type == "MESH" and obj.name.startswith(f"{asset_name}_")
]
if not parts:
raise ValueError(f"No mesh objects named {asset_name}_*")
if bpy.context.object and bpy.context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.select_all(action="DESELECT")
for obj in parts:
obj.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
# Bake location, rotation and scale into the mesh so nothing imports rotated or stretched.
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
# Count triangles after modifiers, the same way the game will see them.
depsgraph = bpy.context.evaluated_depsgraph_get()
triangles = 0
for obj in parts:
evaluated = obj.evaluated_get(depsgraph)
mesh = evaluated.to_mesh()
mesh.calc_loop_triangles()
triangles += len(mesh.loop_triangles)
evaluated.to_mesh_clear()
if triangles > MAX_TRIANGLES:
raise ValueError(f"{triangles} triangles is over the {MAX_TRIANGLES} limit")
out_path = Path(out_dir) / f"{asset_name}.fbx"
bpy.ops.export_scene.fbx(
filepath=str(out_path),
use_selection=True,
object_types={"MESH"},
global_scale=0.01, # Blender writes FBX in cm; this keeps 1 Blender unit = 1 stud
)
print(f"Exported {out_path} ({len(parts)} parts, {triangles} triangles)")
return out_path
export_asset("TreasureChest", bpy.app.tempdir)
The global_scale=0.01 line matters. Blender writes FBX files in centimeters, so without it Roblox reads every meter as 100 studs. My first test chest came in 310 studs wide instead of 3.1.
The triangle count uses Blender's dependency graph (evaluated_depsgraph_get) so it includes modifiers like Subdivision or Mirror. Counting the raw mesh would under-report and let an oversized model slip through to a failed upload.
Step 3: upload with the Roblox Open Cloud API
Roblox Open Cloud lets you upload assets over HTTP instead of clicking through Studio's importer. Two things make this endpoint different from a simple API call.
First, it's a multipart request. The body carries two parts: a JSON description of the asset (request) and the file itself (fileContent). With requests, you build both through the files argument, using None as the filename for the JSON part so it's sent as a plain form field.
Second, it's asynchronous. Roblox doesn't finish processing the model before it responds. It returns an operation object right away, and you poll that operation until done is true. Only then does the response contain your asset ID. This pattern is common for slow jobs like file processing or video rendering, because holding a request open for a minute is fragile.
# upload.py
import json
import time
from pathlib import Path
import requests
from config import ROBLOX_API_KEY, ROBLOX_CREATOR_ID
ASSETS_URL = "https://apis.roblox.com/assets/v1/assets"
OPERATION_URL = "https://apis.roblox.com/assets/v1/operations/{}"
HEADERS = {"x-api-key": ROBLOX_API_KEY}
def upload_model(fbx_path: Path, display_name: str, description: str) -> str:
"""Upload an FBX as a Roblox Model asset and return its asset ID."""
asset_request = {
"assetType": "Model",
"displayName": display_name,
"description": description,
"creationContext": {"creator": {"userId": ROBLOX_CREATOR_ID}},
}
with fbx_path.open("rb") as fbx_file:
response = requests.post(
ASSETS_URL,
headers=HEADERS,
files={
"request": (None, json.dumps(asset_request), "application/json"),
"fileContent": (fbx_path.name, fbx_file, "model/fbx"),
},
timeout=120,
)
if not response.ok:
raise RuntimeError(f"Upload failed: {response.status_code} {response.text}")
return wait_for_asset(response.json())
def wait_for_asset(operation: dict, interval: float = 3, timeout: float = 300) -> str:
"""Poll an Open Cloud operation until Roblox finishes processing the upload."""
operation_id = operation.get("operationId") or operation["path"].split("/")[-1]
deadline = time.monotonic() + timeout
while not operation.get("done"):
if time.monotonic() > deadline:
raise TimeoutError(f"Operation {operation_id} still processing after {timeout}s")
time.sleep(interval)
response = requests.get(OPERATION_URL.format(operation_id), headers=HEADERS, timeout=30)
if not response.ok:
raise RuntimeError(f"Polling failed: {response.status_code} {response.text}")
operation = response.json()
return operation["response"]["assetId"]
if __name__ == "__main__":
asset_id = upload_model(Path("TreasureChest.fbx"), "TreasureChest", "Low-poly treasure chest")
print(f"Uploaded: rbxassetid://{asset_id}")
The polling loop uses time.monotonic() for its deadline instead of time.time(), because the monotonic clock never jumps if the system clock changes. Errors report the status code and response body, never the request headers, since those contain the API key.
These are the common failures and what fixes them:
| Error | What it means | Fix |
|---|---|---|
| 401 Unauthorized | Roblox rejected the key. It can be wrong or expired, or your IP isn't on the key's allowed list | Compare your current IP with the key's list first, then regenerate the key if needed |
| 403 Forbidden | The key lacks Assets read and write permission | Edit the key in the Creator Dashboard |
| Operation never finishes | The asset is waiting on moderation | Wait and retry; it usually clears within minutes |
Step 4: finish the model in Studio
The upload gives back an asset ID, but the model isn't done yet. Because FBX material colors don't come through, every part arrives the same pale grey. This Luau script inserts the asset, then colors each part, picks real materials, adds a light to glowing parts and sets the model on the ground. Run it from Studio's command bar in Edit mode (or through the Studio MCP server):
-- finish_asset.lua (run in the Studio command bar)
local InsertService = game:GetService("InsertService")
local ASSET_ID = 0 -- paste the asset ID from upload.py
local GROUND_Y = 0 -- top of the default Baseplate; change if your floor is elsewhere
-- One entry per Blender object name
local STYLES = {
TreasureChest_Wood = { Color = Color3.fromRGB(122, 82, 48), Material = Enum.Material.WoodPlanks },
TreasureChest_Metal = { Color = Color3.fromRGB(230, 170, 45), Material = Enum.Material.SmoothPlastic },
TreasureChest_Gem = { Color = Color3.fromRGB(80, 200, 255), Material = Enum.Material.Neon, Light = true },
}
local model = InsertService:LoadAsset(ASSET_ID)
model.Name = "TreasureChest"
model.Parent = workspace
for _, part in model:GetDescendants() do
if part:IsA("MeshPart") then
part.Anchored = true
local style = STYLES[part.Name]
if style then
part.Color = style.Color
part.Material = style.Material
if style.Light then
part.CanCollide = false
part.CastShadow = false
local light = Instance.new("PointLight")
light.Color = style.Color
light.Range = 12
light.Parent = part
end
else
warn("No style for part: " .. part.Name)
end
end
end
-- Move the model so its bottom sits exactly on the ground
local boxCFrame, boxSize = model:GetBoundingBox()
local bottom = boxCFrame.Position.Y - boxSize.Y / 2
model:PivotTo(model:GetPivot() + Vector3.new(0, GROUND_Y - bottom, 0))
print("Finished", model.Name, "size:", boxSize)
This is where naming parts <Asset>_<Part> in Blender pays off: the style table is keyed by those names, so one lookup colors each part. Any part without a style triggers a warn, which catches typos fast. Neon parts get a PointLight so they actually light up the scene, with collisions and shadows turned off so players don't snag on a decorative glow.
Results and lessons
My test chest went from a one-line prompt to a finished, colored model in Studio in one sitting, and most of that time went to fixing the two bugs below. With those fixed, the only hands-on step left is the Blender modeling. Every asset also comes out in the same low-poly style, which keeps a game's props looking like they belong together.
What I learned building it:
- Secrets break in boring ways. My upload suddenly failed with "401 Invalid API Key" on a key that had worked for weeks. The key was fine: my home IP had changed, and the key only accepted my old one. Adding the new IP fixed it with no code changes.
- Fail loudly and early. Checking for missing variables and the triangle limit before uploading turns a confusing API error into a one-line message that says exactly what's wrong.
- Async APIs need patience. The upload responds instantly with an operation, not an asset ID. In my test it took two polls before Roblox handed back the ID.
- AI is the starting point, not the finish. FLUX gives a great reference, but the model still needs a human eye on proportions, and the Studio pass is what makes it look finished.
-
Units will bite you. My first chest imported 100 times too big because Blender exports FBX in centimeters. One export setting,
global_scale=0.01, fixed it.
Next, I want to batch-generate whole sets of matching props from one style prompt, and try the same pipeline for other engines that accept FBX.
The full code is on GitHub: github.com/TheCreatorGFR/roblox-ai-asset-pipeline.
I built this pipeline with an AI coding assistant, which also helped me draft and test this article.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.

