MoGen DSL reference
mogen reads .mog source files, lowers them to an intermediate scene graph, and exports glTF 2.0 GLB. This document is the authoritative reference for the surface language: every node kind, every attribute, and the little bits of grammar that sit between them. For a conceptual overview of the whole pipeline, see ROADMAP.md; for a worked catalog of reusable modules, see modules.md.
- Grammar at a glance
- Values and expressions
- Common attributes
- Placement shortcuts
- Scene structure
- Primitives
- Materials
- Connectors
- Replicators:
mirror,array,stack,grid - CSG:
union/difference/intersect - Modules:
moduleanduse - Animation:
joint,clip, templates - Full example
Grammar at a glance
A .mog file is a sequence of nodes. Every node shares the same shape:
kind ["optional name"] [(attr=value, ...)] [{ child_nodes... }]
kindis an identifier likebox,cylinder,group,scene,material,joint, …nameis a quoted string. Some kinds require it (material,module,joint,clip,connector); most geometry kinds treat it as optional — when omitted the node's name defaults to its kind.attr_listis a comma-separatedkey=valuesequence in parentheses.blockis a brace-delimited list of child nodes.
Comments run from // to the end of the line. Whitespace between tokens is insignificant. The top of the file may contain material, module, joint, and clip declarations; scene { ... } holds the geometry itself.
Global settings
Top-level directives that tune the build itself rather than describing geometry. They sit at the file level (alongside material / module) and are consumed during lowering.
| directive | value | effect |
|---|---|---|
lod_scale (value=N) |
number, default 1.0 |
multiplies primitive default segments / rings / samples. 0.5 halves them, 2.0 doubles them. icosphere subdivisions step by round(log2(N)) instead, since each step quadruples its triangle count. Per-primitive values (segments=24, rings=16, …) are absolute and are not scaled. |
lod_scale (value=0.5)
scene {
sphere "head" (radius=0.5) // 12 rings, 12 segments (default 16/24 halved)
sphere "lod0" (radius=0.5, segments=48, rings=32) // explicit values keep 48/32
}
The studio's "LOD scale" slider (under the build summary) edits this directive in place — drag it down to iterate quickly on big scenes, then drag back to 1.0 for export. The slider clears the directive when it returns to 1.0 so saved files stay clean by default.
Values and expressions
Every value on the right side of an attribute is one of:
| form | example | notes |
|---|---|---|
| number | 0.5, -90, 1 |
parsed as f32 |
| vec3 | [1.0, 0.5, 0.0] |
three expressions, comma-separated |
| list | [0, 90], [1, 2, 3, 4] |
arbitrary arity |
| string | "wood" |
used for names and references |
| ident | wood, y |
no quotes; used for axes and enum-like values |
| expression | $height * 0.5, $r + 0.1, ($a - $b) / 2 |
arithmetic over $param refs |
Inside module bodies, any expression may reference a declared parameter as $name. Expressions support + - * / with conventional precedence and parentheses. An expression is evaluated at module-expansion time — by the time the scene graph is built, every $name has been replaced with a concrete number.
Common attributes
These apply to every geometry node (box, cylinder, …) and to group:
| attribute | value | effect |
|---|---|---|
pos |
vec3 |
translation in the parent's frame; default [0, 0, 0] |
rot |
vec3 (Euler XYZ in degrees) or a list |
rotation applied after translation; default identity |
scale |
scalar or vec3 |
uniform/per-axis scale; default 1 |
mat |
string or ident | references a declared material by name |
role |
string or ident | semantic label; written into the GLB extras block |
tags |
comma-separated string | free-form labels; also in extras |
Transforms compose from child → parent along the scene hierarchy, exactly as in glTF.
Placement shortcuts
Every node accepts a family of ergonomic shortcuts on top of the classic pos/rot/size vec3s. They exist for one reason: an LLM should never need to do arithmetic that the DSL can do for it. Mix and match freely.
Per-component shortcuts
| shortcut | replaces / overrides | notes |
|---|---|---|
x=, y=, z= |
individual components of pos |
missing axes default to pos's value, or 0 |
rx=, ry=, rz= |
individual components of rot (degrees) |
same fallback; great for single-axis spins |
w=, h=, d= |
individual components of size (X, Y, Z) |
for 2D primitives, w/d are used on plane/curved_plane (XZ) and w/h on quad (XY) |
box (y=1.5, size=1) // equivalent to pos=[0, 1.5, 0], size=[1,1,1]
box (size=[2, 2, 0.1], h=3) // h overrides the middle component — width=2, height=3, depth=0.1
cylinder (rx=90, radius=0.2, height=1) // lay a cylinder on its side
Scalar size (cube shorthand)
Any primitive that takes size=[…] also accepts size=<number>, which expands to a uniform vec3. box (size=0.5) is a half-metre cube.
from / to — axis-aligned box by corners
On any primitive that uses size, from=[x1,y1,z1] + to=[x2,y2,z2] sets size to |to − from| and pos to their midpoint. No "shift by half" math:
box (from=[-2, 0, -1.5], to=[2, 2.8, -1.4], mat="wall")
// equivalent to: box (pos=[0, 1.4, -1.45], size=[4.0, 2.8, 0.1])
anchor — place by face, not centre
Every primitive's pos controls where its anchor point lands, not where its centre lands. The default anchor is center; anchor=bottom puts the primitive's bottom face at pos, which is usually what "sit on the ground" means. Values are underscore-joined tokens drawn from center, top, bottom, left, right, front, back:
box (y=0, size=[1, 2, 1], anchor=bottom) // bottom face on y=0
box (xyz, size=2, anchor=bottom_left_front) // corner at the origin
Internally the anchor shifts the mesh vertices so the chosen point is at the local origin. The six default face connectors (top, bottom, left, …) move with the shift, so attach/connector math stays correct.
Relative placement: above, below, left_of, right_of, in_front_of, behind
Set one of these to the name of a prior sibling in the same parent; the node is translated so its matching face is flush against the sibling's opposite face, optionally plus gap. At most one may be set per node.
group "chests" {
box "chest_lo" (size=[0.8, 0.6, 0.5])
box "chest_hi" (above="chest_lo", gap=0.02, size=[0.8, 0.6, 0.5])
}
Resolution happens after the target's subtree is fully lowered, so nested geometry is included in the AABB. Lookup is scoped to siblings in the same parent, so replicated subtrees (array, grid) don't collide with identically named nodes elsewhere.
Scene structure: scene, group
scene {
group "chair" (pos=[0, 0, 0]) {
box "seat" (pos=[0, 0.5, 0], size=[1.0, 0.1, 1.0])
}
}
sceneis the root container. Exactly one per file is expected in practice; top-level nodes outside anysceneare also lowered and become extra roots.groupis a transform-only container — no geometry of its own, used to compose children and receivepos/rot/scale.solidbehaves likegroupin the scene tree, but its same-material leaf children are CSG-unioned into a single mesh at export time. See Solid groups below.
Primitives
All primitives accept the common attributes above (pos, rot, scale, mat, role, tags) plus the kind-specific attributes below.
| kind | required attrs | other attrs |
|---|---|---|
box |
size=[x,y,z] |
— |
plane |
size=[x,_,z] (Y ignored) |
— |
quad |
size=[w,h] or vec3 (w,h,_) |
— |
cylinder |
radius, height |
segments (default 24) |
cone |
radius, height |
segments (default 24) |
sphere |
radius |
rings (16), segments (24) |
capsule |
radius, height |
rings (8), segments (24) |
torus |
major, minor |
major_segments (24), minor_segments (12) |
prism |
size=[x,y,z] |
triangular prism along +Z |
pyramid |
radius, height, sides |
N-sided pyramid base |
disc |
radius |
segments (24) |
icosphere |
radius |
subdivisions (2) |
rounded_box |
size=[x,y,z], radius |
segments per corner (4) |
ellipsoid |
size=[x,y,z] |
rings (16), segments (24); independent radii per axis |
superellipsoid |
size=[x,y,z] |
ew, ns (1 = sphere, > 1 boxy, < 1 pinched), rings (16), segments (24) |
curved_plane |
size=[x,z] or vec3 |
bend_u, bend_v (degrees; arc angle along X/Z), segments_u/segments_v (12) |
lathe |
profile=[[r,y], …] |
segments (24), cap_ends (1 = capped); profile authored bottom-to-top in (radius, y) pairs |
spline_tube |
points=[[x,y,z], …] |
radius (scalar) or radii=[…] (per-point), segments (12), samples (8), cap_ends (1) |
slab |
size=[x,y,z] |
box alias; default anchor bottom (sits on ground) |
post |
size=[x,y,z] |
box alias; default anchor bottom (pillar/leg) |
panel |
size=[x,y,z] |
box alias; default anchor back (flat panel flush to a surface) |
wall |
size=[x,y,z] |
holes=[[x,y,w,h], …] — rectangular cutouts through the Z axis |
plane and quad are both flat single-quad meshes; plane is XZ-aligned, quad is XY-aligned (useful for UI-style panels).
superellipsoid is the workhorse for smooth organic bodies (eggs, pears, bullet shapes) and stylised soft boxes — pick ew/ns together for a symmetric shape, or split them for asymmetric profiles like an apple (ew=1.2, ns=0.8).
curved_plane, lathe, and spline_tube accept nested list literals: points=[[0, 0, 0], [1, 0.5, 0]], profile=[[0.2, 0], [0.5, 0.4]]. Inner lists must be constant (no $param) — parameterise the whole node via a module wrapper instead. spline_tube runs a Catmull–Rom curve through its control points and uses a parallel-transport frame so the cross-section doesn't flip at inflection points.
slab, post, and panel are box aliases that exist only to change the default anchor — their geometry is identical to box. Use them to make "this sits on the ground" or "this is a wall-hung panel" the one-line thing it should be, without anchor=… on every row. You can still override anchor= explicitly if you need something different.
wall is a box with rectangular cutouts along Z. Each hole is a 4-element sublist [cx, cy, w, h] in the wall's local frame (X/Y are the face plane; the Z thickness axis is cut all the way through). Cutouts are applied via CSG difference at lowering time and the result is welded/cleaned, so a single wall node becomes one watertight mesh — no nested difference idiom needed:
wall "barracks" (size=[3, 3, 0.1], holes=[
[-0.75, -0.4, 0.9, 2.0], // door
[ 0.9, 0.3, 0.8, 0.8], // window
])
Default values mean that cylinder "leg" with no attrs is a 1 m unit-radius cylinder centered on the origin. Every primitive is authored in its local frame and then positioned via pos/rot/scale.
Materials
material "wood" (color=[0.55, 0.35, 0.18], metallic=0.0, roughness=0.8)
material "glass" (color=[0.9, 0.95, 1.0], alpha=0.3, roughness=0.05, transmission=0.9)
material "neon" (color=[1, 0.2, 1], emissive=[1, 0.2, 1], emissive_strength=8.0)
material "leaf" (color=[0.25, 0.6, 0.2], alpha_mode="mask", alpha_cutoff=0.5)
Declared at the top of the file or inside scene { ... }. Attributes:
color— vec3[r, g, b]in linear space; alpha defaults to1.0.alpha— optional alpha override for transparency. Settingalpha < 1without an explicitalpha_modeauto-selects"blend".metallic—0.0–1.0, default0.0.roughness—0.0–1.0, default0.9.normal_strength— slope multiplier baked into the derived normal map bymogen textures. Larger = more pronounced bumps. Range~0..8, default1.5. Has no effect ifnormal_textureis authored directly.occlusion_strength—0.0–1.0ceiling on how dark the derived AO map can get.0emits flat white (no darkening),1lets cavities reach black. Default0.7. Has no effect ifocclusion_textureis authored directly.alpha_mode—"opaque"(default),"blend"(translucent), or"mask"(1-bit cutout, e.g. foliage).alpha_cutoff— threshold foralpha_mode="mask", default0.5.emissive— vec3 glow colour added on top of PBR shading. Use this for screens, embers, lava. Default[0, 0, 0].emissive_strength— HDR multiplier onemissive(KHR_materials_emissive_strength). Values> 1.0drive bloom and produce the saturated, "fluorescent paint" look. Default1.0.transmission—0.0–1.0fraction of light that passes through the surface (KHR_materials_transmission).0is opaque PBR,1is perfectly clear glass. Orthogonal toalpha_mode— use this for glass and water,alpha/alpha_modefor gels, tints, and smoke.double_sided—0(default) or1. When1, the renderer draws both faces of the triangle (glTFdoubleSided). Use for leaves, fins, flags, cloth, and any thincurved_plane/plane/disc/quadwhose underside can be seen. This is the correct fix for tilted or bent single-sided geometry — mirroring a bentcurved_planealong its bend axis does not produce a double-sided surface; it produces two sheets curling away from each other.uv_mode—"tile"(default) or"fit". Controls how textures map onto the geometry."tile"emits world-space UVs so 1 world unit = 1 texture tile (scaled byuv_scale). Texel density is identical across every primitive that uses the material — the right choice for repeating surfaces like stone walls, wood planks, fabric, ground, and roof shingles."fit"falls back to per-face[0, 1]²UVs so every face of the primitive shows the full image once — the right choice when the texture is the picture: signs, paintings, decals, stained-glass panes, anything whose image must land at a specific place on a specific face. Pick"fit"for image-as-texture; leave the default for material-as-texture.uv_scale—1.0(default), a scalar (uv_scale=2), or a vec2 (uv_scale=[2, 1]). Intilemode this is "tiles per world unit":2doubles the tiling density (smaller bricks),0.5halves it (bigger bricks). Infitmode it multiplies the[0, 1]coords —> 1repeats the image inside a face,< 1zooms into a sub-region. Per-axis vec2 form lets you stretch a texture asymmetrically (planks on a floor, bands on a column).base_color_texture— string path to an.png/.jpgfile on disk, resolved relative to the.mogfile. Multiplied againstcolor. sRGB.metallic_roughness_texture— packed metal/rough map (glTF convention: green = roughness, blue = metallic). Linear.normal_texture— tangent-space normal map. Linear.occlusion_texture— ambient occlusion (red channel). Linear.emissive_texture— emissive colour map, multiplied againstemissive. sRGB.
Example:
material "oak" (
color=[1, 1, 1],
roughness=0.8,
base_color_texture="textures/oak_albedo.png",
normal_texture="textures/oak_normal.png"
)
Texture files are embedded in the output GLB, so the resulting .glb is self-contained and can be moved without the source images. Missing files are a hard error at export.
Reference a material on any geometry or group via mat="wood". The lookup is by exact string match; unknown names are a hard error at lowering.
Connectors
Connectors are oriented frames that a node exposes so other nodes can attach to it. They do not produce geometry.
box "seat" (pos=[0, 0.5, 0], size=[1.0, 0.1, 1.0]) {
connector "top" (at=[0, 0.05, 0], dir=[0, 1, 0], tag=seat_top)
connector "bottom" (at=[0, -0.05, 0], dir=[0, -1, 0], tag=seat_bottom)
}
Attributes:
| attribute | value | default |
|---|---|---|
at |
vec3 |
[0, 0, 0] |
dir |
vec3 (any nonzero) |
[0, 1, 0] |
tag |
string or ident | empty |
radius |
number | — (unset) |
Internally a connector is stored as a position plus a quaternion that rotates canonical +Y onto dir. tag groups compatible attach points (e.g. every leg top shares tag=leg_top) so downstream fitting logic can pair them.
When a node is the child of an attach, its pos / rot are still honoured as a local offset on top of the alignment — pos shifts the anchor in the parent's frame and rot rotates the aligned node around its anchor — so a Studio gizmo drag persists across rebuilds.
Replicators: mirror, array, stack, grid
Wrapper nodes that create one parent group and either replicate or lay out their children. All four accept the usual transform attributes so the whole cluster can be positioned as a unit.
mirror
mirror "pair" (axis=x) {
sphere "ball" (pos=[0.5, 0.5, 0], radius=0.25)
}
axis is x, y, or z (ident or string). The body is emitted twice — once unchanged and once with the named axis negated. Use it for left/right symmetry where only one side is authored by hand.
array
array "legs" (count=4, around=y) {
group "offset" (pos=[0.45, 0, 0.45]) {
cylinder "leg" (radius=0.05, height=0.5)
}
}
Attributes:
count— number of copies (integer); default1.around—x/y/zident; the rotation axis. Defaulty.start_angle— degrees offset of the first copy; default0.
The children are cloned count times; the i-th copy is rotated by start_angle + 360° * i / count around around. Combine with an offset group (as above) to place the first copy off the rotation axis; the array then fans it into a ring.
stack
Lay children out along one axis, using each child's computed AABB as its "slot". No half-size math, no accumulated offsets to maintain by hand.
stack "cake" (axis=y, gap=0.02) {
slab "tier_a" (size=[1.4, 0.25, 1.4])
slab "tier_b" (size=[1.0, 0.20, 1.0])
slab "tier_c" (size=[0.6, 0.15, 0.6])
}
Attributes:
| attribute | value | default | effect |
|---|---|---|---|
axis |
x, y, z |
y |
stacking direction |
gap |
number | 0 |
spacing between consecutive children |
align |
center, start, end |
center |
alignment on the two perpendicular axes |
pack |
start, center, end |
start |
where the whole stack sits along axis: start keeps the first child at origin; center centres the stack; end puts the last child's far face at origin |
Each child keeps its own declared pos/x/y/z as an additive offset inside its slot — stack computes the slot position, your pos nudges within it.
grid
N-dimensional replicator. Creates count[0] × count[1] × count[2] copies of the body, each offset by step[0..3] * [i, j, k]:
grid "tiles" (count=[5, 1, 3], step=[0.6, 0, 0.6], center=1) {
slab "tile" (size=[0.55, 0.05, 0.55])
}
Attributes:
| attribute | value | default |
|---|---|---|
count |
vec3, list, or scalar | [1, 1, 1] |
step |
vec3, list, or scalar | [0, 0, 0] |
center |
0 / 1 |
0 — when 1, the grid is centred on the wrapper origin |
A scalar count/step applies to X only (useful for 1D rows); a 2-element list applies to X/Z (floor patterns). For 3D, pass a vec3.
CSG: union / difference / intersect
CSG ops fold their children into a single mesh that hangs off the op node itself — the operand children do not become separate scene nodes.
difference "wall_with_door" (mat="concrete") {
box "wall" (size=[4.0, 3.0, 0.2])
box "doorway" (pos=[0, -0.5, 0], size=[0.9, 2.0, 0.5])
}
union— N ≥ 1 operands; the union of all.difference— the first operand minus every subsequent operand.intersect— N ≥ 2 operands; the shared volume.
Operand transforms are baked into the vertices at evaluation time, so each operand lives in the parent's frame regardless of its local pos/rot. Connectors and material children declared directly on the CSG node still apply; any on operand children are ignored.
The output is cleaned (vertex welding, degenerate-tri cull, normal recompute) to give the exporter a watertight mesh.
Solid groups: solid
solid { … } is a group-like container that defers CSG union to export time. Its same-material, non-skinned leaf children are merged into a single mesh, so overlapping or touching primitives of the same material read as one hollow shape — interior faces where pieces meet get eliminated.
solid "shell" (mat="stone", cleanup="coplanar") {
box "floor" (pos=[0, 0.1, 0], size=[6.2, 0.2, 4.2])
box "north" (pos=[0, 1.7, 2.0], size=[6.0, 3.0, 0.2])
box "south" (pos=[0, 1.7,-2.0], size=[6.0, 3.0, 0.2])
box "east" (pos=[ 3.0, 1.7, 0], size=[0.2, 3.0, 4.0])
box "west" (pos=[-3.0, 1.7, 0], size=[0.2, 3.0, 4.0])
}
- Children lower as normal scene nodes — you can still
attachto them, put modules inside, author connectors, and so on. The merge is export-time, scoped to that subtree. The in-memory scene graph the editor sees keeps every child as a distinct, editable node. - Only same-material leaf siblings merge together. Different-material children (
mat="glass"next tomat="stone") stay as separate nodes so textures and PBR factors are preserved. - Skinned meshes, joint-referenced nodes, and groups are never merged; they pass through unchanged.
cleanup="coplanar"
When set, the merged output gets one extra pass that drops triangle pairs which share a plane and have opposite-facing normals. This catches the case CSG union can't resolve on its own: two boxes that touch along a face without overlapping — e.g. perpendicular walls meeting at a corner. Without the cleanup, both sides of the seam survive; with it, they cancel.
Values: "coplanar" (enable) or "none" (default).
Modules: module and use
Modules are parametric sub-graphs. A declaration lives at the top level of the file:
module "leg" (height=0.5, radius=0.05) {
cylinder "leg" (pos=[0, $height * 0.5, 0],
radius=$radius, height=$height, mat="wood") {
connector "top" (at=[0, $height * 0.5, 0], dir=[0, 1, 0], tag=leg_top)
}
}
Parameters:
- Each parameter has a scalar default (number or expression).
vec3,list, string, or ident defaults are rejected —$paramsubstitution is numeric. - Parameters are referenced inside the body as
$name. They participate inpos,rot,scale,radius,height, etc., and inside nestedvec3expressions like[0, $h * 0.5, 0].
Invoke a module with use:
scene {
group "chair" {
use "leg" (height=0.6, radius=0.04)
array "legs" (count=4, around=y) {
group "offset" (pos=[0.45, 0, 0.45]) {
use "leg" (height=0.5, radius=0.05)
}
}
}
}
Rules:
usetakes the module's declared name. Unknown names fail with a clear error.- Omitted arguments fall back to declared defaults. Unknown argument names are a hard error (catches typos).
- Modules may call other modules. Recursion is detected and rejected.
- Expansion is lexically scoped —
$paramreferences outside a module body are rejected.
Animation: joint, clip, templates
All animation in v1 lowers to glTF node-transform animation tracks. There is no skeleton / skinning (see M10 for that).
Joints
A joint names an articulation, picks a DOF type, and points at the scene node that rotates/translates when the joint moves.
joint "door_hinge" (type=hinge, axis=[0, 1, 0], limits=[0, 100], pivot="door")
| attribute | value | notes |
|---|---|---|
type |
hinge, slider, ball, rotor |
DOF kind |
pivot |
string — a node name | required |
axis |
vec3 | default [0, 1, 0] |
limits |
[lo, hi] list |
optional; degrees (rotary) or meters (slider) |
Authored clips
clip "open" (seconds=1.0) {
track "door_hinge" (from=0, to=90)
}
clipholds a single duration and an ordered list oftrackchildren.tracktargets a joint (by name) or a scene node directly. When targeting a node, addprop="translation"|"rotation"|"scale"to pick the channel.from/toare scalars. For rotation they're degrees around the joint'saxis; for translation they're distance along the axis; for scale they're the uniform factor.- Two keyframes are emitted at
0andseconds; the exporter linearly interpolates between them.
Procedural templates
One-line declarations that expand into a full clip. They all take a target="name" pointing at a joint or a scene node.
| template | extra attrs | effect |
|---|---|---|
spin |
axis, rpm (60) |
continuous rotation |
open_close |
axis, angle (90), seconds (1.0) |
0° → angle → 0° swing |
wave |
axis, amplitude (15°), hz (1.0) |
sinusoidal wobble |
flap |
axis, amplitude (30°), hz (2.0) |
faster wobble, bigger amplitude |
idle |
amplitude (0.02 m), hz (0.5) |
tiny translation breathe |
When the target is a joint, its axis is used by default; when it's a node, pass axis explicitly.
spin "rotor_spin" (target="rotor", axis=[0, 0, 1], rpm=30)
open_close "door_swing" (target="door_hinge", angle=90, seconds=1.2)
Full example
A door in a wall, with a swinging-open animation, built end-to-end:
material "wood" (color=[0.55, 0.35, 0.18], roughness=0.8)
material "concrete" (color=[0.78, 0.78, 0.78], roughness=0.85)
scene {
difference "wall_with_door" (mat="concrete", role="wall") {
box "wall" (size=[4.0, 3.0, 0.2])
box "door_gap" (pos=[0, -0.5, 0], size=[0.9, 2.0, 0.5])
}
// Hinge at the left edge: offset the panel by half-width inside the group.
group "door" (pos=[-0.45, 1.0, 0]) {
box "panel" (pos=[0.45, 0, 0], size=[0.9, 2.0, 0.04], mat="wood")
}
}
joint "door_hinge" (type=hinge, axis=[0, 1, 0], limits=[0, 100], pivot="door")
clip "open" (seconds=1.2) {
track "door_hinge" (from=0, to=90)
}
Compile with mogen build examples/<file>.mog -o out.glb and open in any glTF-2.0 viewer or game engine.
Diagnostics and tooling
mogen check <file>.mogvalidates without building. Pass--jsonfor machine-readable diagnostics (the format the LLM repair loop consumes).mogen dump-scene <file>.mog --jsonprints the lowered graph for debugging.mogen inspect <file>.glbreads back a GLB and prints its top-level structure.
See ROADMAP.md §8 for the full diagnostic catalog.