Pixel-accurate Figma → Roblox UI exporter using the PNG-slice pipeline.
Extracts any Figma frame and generates a production-ready .rbxmx file: every visual element becomes a PNG ImageLabel, dynamic text becomes TextLabel, and layout hierarchy is preserved as nested Frame containers. Drop shadows, gradients, strokes, and effects are all baked into the PNGs — zero approximation, pixel-perfect results.
Every Layer Sliced — FigmaForge classifies each node as one of three types:
| Classification | Roblox Instance | Logic |
|---|---|---|
| PNG | ImageLabel |
Any leaf node or subtree without dynamic text → rasterized via exportAsync |
| Dynamic Text | TextLabel |
Text nodes with $ prefix or matching dynamic patterns (price, level, etc.) |
| Container | Frame |
Parent nodes with dynamic text descendants → preserves hierarchy, self rasterized as background |
Complex Figma features (radial gradients, blurs, complex strokes, shadows) all "just work" because they're baked into the PNG.
Figma Desktop (MCP Bridge plugin)
↓ figma_execute → extraction script runs in Figma sandbox
JSON Manifest (IR with node tree + base64 PNGs)
↓ figma-forge-cli.ts --resolve-images
Pipeline: dedup → classify → upload PNGs → assemble .rbxmx → Rojo safety check
↓
.rbxmx + .bindings.json ──→ Rojo auto-sync ──→ Roblox Studio
| Module | Purpose |
|---|---|
figma-forge-ir.ts |
TypeScript IR interfaces — node tree, fills, strokes, text, effects, _renderBounds |
figma-forge-extract.ts |
Builds the JS extraction script for Figma sandbox — node serialization, render bounds, layer classification, text-stroke deduplication |
figma-forge-assemble.ts |
.rbxmx XML generator — node classification, positioning, ImageLabel/TextLabel/Frame emission, auto-layout → UIListLayout |
figma-forge-images.ts |
Image upload pipeline — base64 PNG → Roblox Open Cloud API → rbxassetid://, content-hash caching |
figma-forge-shared.ts |
Font mapping (Figma→Roblox), dynamic text classification (SSOT), scroll detection, config compilation, XML/Lua escaping |
figma-forge-cli.ts |
CLI orchestrator — arg parsing, config loading, Rojo safety validator, binding manifest generation |
figma-forge-bindings.ts |
Binding manifest generator — walks IR tree to detect buttons, tabs, templates, text bindings, scroll containers |
figma-forge-export.ts |
Batch PNG export script generator — chunked exportAsync for Figma's 30s timeout |
figma-forge-diff.ts |
Incremental re-export — structural hash diffing, --incremental support, saves ~80% upload time |
figma-forge-animations.ts |
Prototype transition → TweenService Luau code generation |
figma-forge-kit.ts |
UI Kit page extraction — component sets with variants → Lua Kit module with state switching |
- Node.js 18+
- Figma Desktop with the MCP Desktop Bridge plugin running
- Rojo serving your project (for
.rbxmxauto-sync) - Roblox Open Cloud API key (see Image Pipeline section)
cd tools/FigmaForge
npm install
npx tsc --outDir distnpx ts-node figma-forge-cli.ts \
--input manifest.json \
--output ../../src/StarterGui/MyFrame.rbxmx \
--resolve-images \
--api-key YOUR_ROBLOX_API_KEY \
--creator-id YOUR_ROBLOX_CREATOR_ID \
--verboseOptions:
--input, -i Path to FigmaForge manifest JSON (required)
--output, -o Path for generated .rbxmx (default: <input>.rbxmx)
--scale, -s PNG export scale factor (default: 2)
--config, -c Path to custom figmaforge.config.json
--text-export Text export mode: 'all' (default), 'dynamic', 'none'
--resolve-images Upload exported PNGs to Roblox Cloud
--merge-images Path to exported-images JSON to merge into manifest
--api-key Roblox Open Cloud API key (highest priority)
--creator-id Roblox creator/user ID for asset ownership
--skip-dedup Skip text-stroke deduplication pass
--responsive Use scale-based sizing proportional to root
--incremental <prev> Path to previous manifest for incremental re-export
--save-manifest Save current state for future --incremental exports
--verbose, -v Show detailed processing info
--help, -h Show help
The CLI produces two files:
<name>.rbxmx— the Roblox UI tree (auto-synced via Rojo)<name>.bindings.json— binding manifest listing buttons, text bindings, templates, tabs, scroll containers
When extraction identifies PNG nodes, they're rasterized via exportAsync at 2× scale. The CLI uploads them to Roblox Cloud and patches rbxassetid:// URIs into the .rbxmx.
Config priority for Roblox API credentials:
- CLI arguments (recommended) —
--api-key YOUR_KEY --creator-id YOUR_ID - Environment variables:
ROBLOX_API_KEY+ROBLOX_CREATOR_ID .envfile in FigmaForge directoryscripts/roblox-config.json— project-level fallback
Warning
Always clear stale env vars before running CLI: $Env:ROBLOX_API_KEY=$null; $Env:ROBLOX_CREATOR_ID=$null
FigmaForge is genre-agnostic. Override default text and button detection heuristics:
{
"dynamicPrefix": "$",
"textExportMode": "dynamic",
"dynamicNamePatterns": [
"^price", "^level", "^score", "^amount"
],
"dynamicTextPatterns": [
"^\\\\{[^}]+\\\\}$",
"^[\\\\d,]+$"
],
"interactivePatterns": [
"btn", "button", "tab_"
]
}textExportMode(config default:"dynamic", CLI default:"all"):"all": Every text node becomes a RobloxTextLabel."dynamic": Only text nodes matching the dynamic patterns becomeTextLabels. All other text is baked into the rasterized PNG background."none": All text is baked into the background PNG.
Uploaded images are cached by content hash in .figmaforge-image-cache.json. Re-exports reuse existing rbxassetid:// URIs. Delete the cache file to force re-uploads.
Important
This is a critical architectural detail for pixel-perfect exports.
Figma's exportAsync() renders at absoluteRenderBounds (includes effects like drop shadows, blurs), but the node's .width/.height properties only report the logical bounding box. Without correction, PNGs with shadow padding get squeezed into too-small ImageLabels.
FigmaForge handles this automatically:
- Extraction (
figma-forge-extract.ts): ComparesabsoluteRenderBoundsvsabsoluteBoundingBoxfor nodes with visible effects or strokes. Stores the delta as_renderBoundsin the IR. - Assembly (
figma-forge-assemble.ts): Uses_renderBoundsfor ImageLabel position and size when present, falling back to standardx/y/width/heightotherwise.
The assembler (figma-forge-assemble.ts → classifyNode) determines how each IR node is emitted:
| Criterion | Classification | Output |
|---|---|---|
| Text matching config rules / export mode | text_dynamic |
TextLabel |
| Has children with dynamic text descendants | container |
Frame (with background ImageLabel if hybrid) |
| Everything else (leaf, no dynamic children) | png |
ImageLabel |
Nodes with a single solid fill (no strokes, no gradients) skip rasterization entirely — they're emitted as native Roblox Frame with BackgroundColor3, avoiding unnecessary PNG uploads.
FigmaForge recognizes special suffixes in Figma layer names:
| Suffix | Effect |
|---|---|
[Flatten] / [Raster] |
Force-rasterize entire subtree as one PNG |
[Template] |
Mark as template node for dynamic list cloning |
[Scroll] |
Force ScrollingFrame output |
$ prefix |
Force dynamic text classification (TextLabel) |
Designers can annotate nodes via:
- Name suffix:
BulletPoint[Template],ContentPane[Scroll] - Name pattern:
*Btn→ button,Tab_*→ tab,$Price→ dynamic text - Figma description:
@template,@scroll,@bind:key,@button,@tab
The CLI validates the generated .rbxmx before writing, catching:
<token>tags for properties Rojo expects as<int>(AutomaticSize, ScrollBarThickness, etc.)- Mismatched
<Item>open/close tags (XML well-formedness) - Duplicate referent IDs (causes Rojo to silently merge nodes)
Build fails fast with actionable error messages if any check fails.
- Roblox font mapping — Figma fonts mapped to closest Roblox equivalent (Inter→BuilderSans). Some fonts may not have exact matches.
- Per-corner radius — Roblox
UICorneronly supports uniform radius. Per-corner is approximated with max value. SPACE_BETWEENlayout — No Roblox equivalent, falls back toMINalignment.- Component instances — Exported as their expanded tree, not as Roblox component references.
| Symptom | Cause | Fix |
|---|---|---|
Rojo: invalid digit found in string |
Negative value in <token> tag |
Tokens must be unsigned ints (0+) |
Rojo: duplicate referent |
Two nodes share same referent ID | Check assembler assigns unique referents |
| Empty/white ImageLabels | Unresolved image hashes | Run with --resolve-images |
| Squished buttons/shadows | Missing render bounds | Ensure extraction captures absoluteRenderBounds |
[Flatten] in node name |
Rojo interprets brackets | Post-process: strip [Flatten] tags from .rbxmx |
| Rojo won't re-sync destroyed instance | $ignoreUnknownInstances: true |
Disconnect and reconnect Rojo plugin |
| Images fail to load | Stale image cache | Delete .figmaforge-image-cache.json and re-run |
| Dark/squished text | Static text rasterized as PNG | Check textExportMode — use all to keep all text as TextLabel |
MIT License — see LICENSE for details.
