Skip to content

Game-optimized asset generation (Blender MCP) ​

Fill in the brief, then give the whole file to an agent session that has the Blender MCP server connected (claude mcp get blender reports it connected) and Blender open.

Asset brief (fill in) ​

  • Name: kebab-case, e.g. bld-fire-station, newsstand (colour variants get -a, -b, -c)
  • What it is: one or two sentences, and the satirical twist if there is one (told through shapes and colours, never text)
  • Kind: building, prop, structure or terrain
  • Kit: downtown (real-size city buildings and street furniture: the default), city (the unit-scaled pieces of the test levels: only if asked) or a new kit (only if asked)
  • Size: footprint width × depth and height in metres, or "pick a plausible real size"
  • Variants: how many, and which colour roles change between them
  • Placement: "kit only", a recipe and position, or "add it to main-hillside's generator"

You are building one asset for Five On It, a top-down, low-poly, satirical open-city driving game, in Blender through the Blender MCP server. The asset is code: a function in a kit script under assets/blender/kits/, run in Blender and exported to the kit's .glb and .foi.json. It must match the existing kit's style, density and palette, stay within the triangle budget, and contain no overlapping coplanar faces (z-fighting).

Read first ​

  • AGENTS.md (branching, gates, the level pipeline) and assets/README.md (kit conventions)
  • assets/blender/foi_kit.py (the Builder: face, box, prism, cylinder, cone, wedge, object) and assets/blender/foi_export.py (export_kit)
  • The target kit script, in full: assets/blender/kits/downtown.py is the reference for style, proportions and palette
  • documents/GAME_DESIGN.md: the pillars ("Readable from above") and the art style

How the game sees an asset ​

These facts are why the rules below exist.

  • Camera: 30 m above the player when standing still, 52 m at top speed, 18° off vertical, field of view 0.85 rad (apps/client/src/renderer/camera.ts). Roofs and tops are most of what a player sees; facades show only as a band.
  • Shading: every colour becomes one matte Babylon StandardMaterial (the colour as diffuse, specular 0.02). Faces are flat shaded with one normal per face, under a sky light and a low sun from one side, so a wall reads by the side it faces. Back faces are culled. There are no textures, UVs, vertex colours or transparency (the game itself fades roofs over the player).
  • Depth: near plane 0.5 m, far plane 2000 m. Two surfaces 0.02 m apart resolve cleanly at play distance. Two surfaces in the same plane never resolve: which one wins changes from pixel to pixel and from frame to frame. That is the grey/beige flicker on the roof of downtown/bld-shops-a.
  • Instancing: a level draws one instance per placement, so the triangles of a piece placed a hundred times (a tree, a lamp) count a hundred times.

Art direction ​

  • A toy-city diorama: clean, high contrast, slightly saturated, matte. Readability beats realism.

  • Readable from above at a glance: a strong outline in plan and a roof that says what the building is (a dome, a tank, a sign, rails, a helipad). Use one dominant colour, two or three accents, and dark details (frames, doors) for contrast.

  • Shapes, not surface detail: boxes, prisms, wedges, cones, and cylinders with 6–8 sides (12 at most for something big and round). Details are proud boxes 0.06–0.2 m deep. Nothing thinner than about 0.05 m: it shimmers at distance.

  • Real proportions (taken from downtown.py):

    • storeys: a storey is 3.4 m (FLOOR)
    • doors: 1.8 × 2.6 m
    • windows: about 1.3 × 1.8 m on a 0.9 m sill
    • shopfronts: glass from 0.4 to 3.0 m, frame to 3.4 m, awnings from 3.2 m sloping out 1.4 m
    • roofs: parapets 0.8 m high and 0.35 m thick
    • for scale: a person is 1.8 m tall; a sedan is 4.7 × 1.9 × 1.3 m
  • Satire through shapes and colours: a giant cup on a café, an empty billboard frame on a start-up. Never text, logos or real brands (there are no textures anyway).

  • Palette: use the colour names of the kit's PALETTE.

    • downtown walls: brick, stone, render-*, concrete, corrugated
    • glass: glass, glass-dark, glass-light, spandrel, frame
    • roofs: roof, roof-tile, roof-slate, roof-light
    • details: door, awning-*, metal, metal-dark, paint-*, lamp-*, wood
    • nature: leaves-*, rock-*, soil, water

    Add a colour only when nothing fits. Keep it in the palette's range: matte, values from about 0.18 to 0.95, strong saturation only on small accents (awnings, signs, lamps). Never add a colour a hair away from an existing one.

  • Variants: same geometry with different colour roles, passed as arguments, as shop_row(name, wall, awnings) does.

Triangle and colour budget ​

The ranges are measured from the current kits. Stay inside them; if you go over, say why.

WhatTodayTrianglesColours
Small street propbin 28, planter 32, bollard 40, bench 48, hydrant 6820–80 (at most 120)1–4
Treepine 56, street tree 8050–802–4
Large prop, landmarkkiosk 36, obelisk 38, tunnel portal 60, fountain 100, traffic light 128, radio mast 12840–150 (at most 250)2–4
Small buildingcafé 84, townhouse 20080–3004–6
Mid buildingglass tower 240, stone tower 444, office 468, warehouse 512, shop row 672250–8005–12
Large or detailed buildingcorner block 1,332, apartment 1,504 (the hotel's 2,844 is the outlier, not the target)at most 1,500≤ 12

Spend triangles where the camera looks: the roof, the outline, the front. A window box costs 12 triangles, so 60 windows cost 720. Prefer bands of glass or fewer, larger windows on the sides nobody sees from the street.

Construction rules ​

  • Code, not clicks. Write the piece as a function in assets/blender/kits/<kit>.py with foi_kit.Builder, and call it from the kit's build(). Never model it by hand through MCP operators, and never edit an exported .glb or .foi.json. The script is the source of truth: running it again must reproduce the piece exactly, so any randomness needs a fixed seed.
  • Frame and origin. Blender metres, Z up. The front (entrance, shop windows) faces +Y, and +X is the piece's right in the top view. Build at real size; the level places the piece at scale 1. The origin is the centre of the footprint, on the ground (z = 0), and nothing goes below z = 0. (The city kit's unit-size pieces are legacy; don't copy that convention.)
  • Faces. Builder.face takes points counter-clockwise as seen from outside. Builder.object() welds vertices and turns closed shells outward, so keep every shell closed. Builder flat-shades everything. No modifiers, UVs, textures, smooth shading or bevels.
  • One object per piece, linked to the top-level collection of its kind: Buildings, Props, Structures or Terrain. (Roads is for road pieces with centre lines.)
  • The collider custom property:
    • hull (buildings, compact props): the convex hull of the whole piece, so every overhang, arm or canopy becomes a solid volume.
    • trimesh: where a hull would put an invisible wall, as for lamps, traffic lights, trees and the tunnel portal.
    • render: False marks a collider that is never drawn.
  • For a building, report its footprint (x0, x1, y0, y1 in its own frame, including awnings and steps). assets/blender/levels/main_hillside.py needs it in BUILDINGS to place the building without touching anything.

No z-fighting ​

The rule: no two faces of a piece lie in the same plane, face the same way and overlap.Builder.box() always writes all six faces and nothing removes overlaps afterwards, so every box you add is your responsibility.

  1. A cornice, band or cap must never end exactly where the body ends. Either stop the body under it and let the band carry the roof colour (top="roof"), or build it as a ring of four boxes around the roof, the way roof_kit() builds the parapet. Today shop_row, apartment, corner_block and tower_stone break this rule; don't copy them.
  2. A detail on a surface (window, door, sign, panel, frame) starts 0.02 m inside the surface and stands proud of it, following the kit's convention (hd - 0.02 to hd + 0.08). Give each layer its own depth so no two fronts share a plane: glass +0.08, door +0.10, sign +0.12, frame +0.16.
  3. Details never overlap each other in the wall's plane (a door through a window, a sign over a frame). Lay them out on a grid with gaps, and check the door's column against the window columns.
  4. Boxes side by side or stacked must not share a side plane with different colours where they overlap. Inset or outset one of them by at least 0.02 m, or colour the existing box's top or bottom with top= / bottom= instead of adding a slab.
  5. Stripes and paint are either adjacent faces of their own colour (shared edges, no overlap) or a thin box lifted at least 0.02 m off the surface. Never lay a coloured face on top of another.
  6. A box resting on another is fine: its bottom face and the lower box's top face point in opposite directions, and the game culls the hidden one. Avoid them when it costs nothing (they are wasted triangles), but don't contort the model to.
  7. Downward faces on the base plane (z = 0) are never seen; the check skips them.
  8. The ground is the level's. Don't model pavement or grass under the piece. A plinth or pad of the piece's own starts at z = 0 and is at least 0.05 m tall.
  9. Same-colour duplicates don't flicker, but they are wasted triangles. Remove them.

The check ​

Run this through execute_blender_code after every change, with the new pieces selected (or nothing selected to check every mesh). Every new piece must come out with empty z_fighting and duplicate_faces lists, triangles within budget, size_xyz_m at the intended real size and base_z_m 0.0.

To confirm the check works: on today's downtown.glb it reports roof vs stone, facing (0.0, 0.0, 1.0) at z = 8.00 m: 420.00 m² for bld-shops-a. That is the flicker this prompt exists to prevent.

python
# Five On It asset check: z-fighting, duplicate faces, size and budget of kit pieces.
# Run it in Blender with the pieces to check selected (or nothing selected: every mesh).
import bpy

MIN_OVERLAP = 0.01  # m²: smaller overlaps are ignored
PLANE_EPS = 1e-3  # m: faces closer than this to one plane are coplanar


def _clip(poly, a, b):
    out = []
    side = lambda p: (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])
    for i, p in enumerate(poly):
        q = poly[(i + 1) % len(poly)]
        sp, sq = side(p), side(q)
        if sp >= 0:
            out.append(p)
        if (sp >= 0) != (sq >= 0):
            t = sp / (sp - sq)
            out.append((p[0] + t * (q[0] - p[0]), p[1] + t * (q[1] - p[1])))
    return out


def _area(poly):
    n = len(poly)
    return 0.5 * sum(poly[i][0] * poly[(i + 1) % n][1] - poly[(i + 1) % n][0] * poly[i][1] for i in range(n))


def check(obj):
    mesh = obj.data
    mesh.calc_loop_triangles()
    base = min(v.co.z for v in mesh.vertices)
    planes = {}
    for tri in mesh.loop_triangles:
        n = tri.normal
        if n.length < 0.5:
            continue
        pts = [mesh.vertices[i].co for i in tri.vertices]
        # A face turned straight down on the piece's base plane rests on the ground: never seen.
        if n.z < -0.999 and abs(pts[0].z - base) < PLANE_EPS:
            continue
        axis = max(range(3), key=lambda i: abs(n[i]))
        d = n.dot(pts[0])
        key = tuple(round(c, 3) + 0.0 for c in n) + (round(d / PLANE_EPS), axis)
        flat = [tuple(p[i] for i in range(3) if i != axis) for p in pts]
        if _area(flat) < 0:
            flat.reverse()
        mat = mesh.materials[tri.material_index].name if mesh.materials else "-"
        planes.setdefault(key, []).append((mat, flat))
    clashes, duplicates = {}, {}
    for key, tris in planes.items():
        axis = key[4]
        where = f"facing {tuple(round(c, 2) + 0.0 for c in key[:3])} at {'xyz'[axis]} = {key[3] * PLANE_EPS / key[axis]:.2f} m"
        for i in range(len(tris)):
            for j in range(i + 1, len(tris)):
                (ma, ta), (mb, tb) = tris[i], tris[j]
                poly = ta
                for k in range(3):
                    if len(poly) < 3:
                        break
                    poly = _clip(poly, tb[k], tb[(k + 1) % 3])
                area = abs(_area(poly)) if len(poly) > 2 else 0.0
                if area < MIN_OVERLAP:
                    continue
                label = f"{min(ma, mb)} vs {max(ma, mb)}, {where}" if ma != mb else f"{ma}, {where}"
                bucket = clashes if ma != mb else duplicates
                bucket[label] = bucket.get(label, 0.0) + area
    return {
        "triangles": len(mesh.loop_triangles),
        "colours": sorted({m.name for m in mesh.materials if m}),
        "size_xyz_m": [round(v, 2) for v in obj.dimensions],
        "base_z_m": round(base, 3),
        "z_fighting": [f"{label}: {area:.2f} m²" for label, area in clashes.items()],
        "duplicate_faces": [f"{label}: {area:.2f} m²" for label, area in duplicates.items()],
    }


targets = [o for o in bpy.context.selected_objects if o.type == "MESH"]
targets = targets or [o for o in bpy.context.scene.objects if o.type == "MESH"]
result = {o.name: check(o) for o in targets}

Workflow ​

  1. Check Blender first with get_objects_summary. The kit scripts call clear_scene(), which deletes everything in the open file. If the file holds anything other than a previous kit build, stop and ask the user.

  2. Write the piece. Read the kit script, write the new piece's function, and call it from build(). Reuse the helpers (facade_windows, shopfront, door, awning, roof_kit) only where they keep to the rules above; otherwise write your own.

  3. Build the kit in Blender through execute_blender_code. This lays the kit's pieces out 40 m apart along x:

    python
    import bpy, importlib.util, os, sys
    REPO = r"C:\Users\ggcap\repos\five-on-it"
    KIT = "downtown"
    spec = importlib.util.spec_from_file_location(KIT, os.path.join(REPO, "assets", "blender", "kits", f"{KIT}.py"))
    mod = importlib.util.module_from_spec(spec)
    sys.modules[KIT] = mod
    spec.loader.exec_module(mod)
    mod.build()  # mod.main() builds and exports the kit
    result = sorted(o.name for o in bpy.context.scene.objects)
  4. Check and iterate. Select the new objects and run the check. Fix problems in the script, never in Blender, and rebuild until the check is clean.

  5. Look at it.

    • Put it next to two existing pieces of the kit, with something for scale: import apps/client/public/models/character.glb or vehicles.glb into the open scene, and never save it.
    • Set the viewport to Solid with Color set to Material (or use Material Preview).
    • Look from straight above, from the game's angle (18° off vertical, about 40 m away) and from street level. Orbit and zoom to catch any flicker. Take screenshots with get_screenshot_of_area_as_image.
    • Judge it: does it read at a glance from above? Does it match its neighbours in palette and detail density?
  6. Export with mod.main(), which rebuilds the whole kit and writes apps/level-editor/assets/kits/<kit>.glb and .foi.json. Then:

    • the sidecar must list the new piece with the right kind and collider;
    • the sidecar's diff must only add things;
    • no other file may change.
  7. Place it, only if the brief asks. Ask the user to close the Babylon.js Editor first.

    • A recipe placement: add the entry, run npm run level:recipe -- <recipe> --scene <name>, then npm run level:export.
    • A building in main-hillside: add its footprint to BUILDINGS and a share to DISTRICTS in assets/blender/levels/main_hillside.py, then regenerate as docs/guide/levels.md describes. Ask the user before you do this: the generator's random picks run from a fixed seed, so a new entry shifts every later pick and reshuffles which building stands where across the whole town.
    • packages/world/src/levels.test.ts must stay green.
  8. Docs and gates.

    • Name the piece in the kit script's docstring and in the kit's row of assets/README.md.
    • Follow AGENTS.md: a feature branch off develop, a row in the e2e plan only if a level now shows the piece, npm run validate green, and a pull request to develop.

Report back ​

  • Name, kit, kind, collider and footprint of each piece
  • Triangles and colours per piece, and the check's output
  • Screenshots: from above and from the game's angle
  • The files changed
  • Anything you did differently from this prompt, and why

Vehicles and the character are not kit pieces. They are models (assets/blender/models/), sized to packages/sim/src/vehicle-classes.ts, with hulls from npm run models:hulls. The art direction and the z-fighting rules apply to them too, but the workflow above does not.