2D art from code: Paper Forge
Paper Forge makes 2D game art from a short JavaScript recipe, with no pictures needed. Shapes (paths, tapered strokes, circles, blobs) are raised into a shallow relief, painted with generated colour ramps and procedural textures (brush strokes, wood grain, planks, cracked stone, water, moss drips), lit, and inked, so the result reads as hand-drawn art instead of flat rectangles. Because each shape is real geometry, every bake also gives you a normal map and an emission map for 2D lights, frames posed from bones, and a relief model for 2.5D cameras.
Make an asset
Ask the AI ("make a lantern prop from code") or call the paper_forge tool yourself:
paper_forge {name: "crate", recipe: "pf.asset(\"crate\") ... "}
The recipe is saved to Assets/PaperForge/crate.pf.js; the art goes to Assets/2D/paperforge/crate/. The result gives you a sprite { ... } line to paste (for the platformer kit's scale, change pixels_per_unit 2 to 1). paper_forge {action: "api"} lists every call; kit_playbook {kit: "2d-paper-forge"} gives the order of work.
What a bake writes
| File | Use |
|---|---|
name.png, name@2x.png | the painted, lit sprite (works in every scene today) |
name.albedo.png | the same paint without the key light, for real 2D lights |
name.normal.png, name.emissive.png | relief normals (x right, y up, z out) and glow, read by the 2D lights |
name.height.png | relief height |
name_parts.png | rigs: every part at rest, for cut-out animation |
name@2x.tileset.json | tilesets: drop into tilemap { tileset ... } |
name.paperforge.json | frame size, pivot, clips, bones, sockets, palette, and whether it used zero images |
Add mesh: true to also write Assets/Forge/<name>_relief.glb, the same parts as layered relief plates that hold up when the camera turns 30-45 degrees.
Strict: no images at all
paper_forge {name: "hero", mode: "strict"} makes Assets/Forge/hero.strict.glb: the same recipe as relief geometry with flat palette colours, no pictures in or out. Use camera_look { style "stylized" } so the renderer draws the light bands and ink outlines (and rain for weather). Tilesets give a whole level as one mesh: paper_forge {name: "harbor_tiles", mode: "strict", rows: [...]}. max_triangles and max_colors keep it in budget (a hero fits 20,000 triangles). The default "baked" mode is honest about its pictures: its manifest says "mode": "baked" and how many runtime images it wrote. See Scenes/PaperForge_Strict.
Zero image inputs, or a palette from a picture
By default everything comes from the recipe. To match a reference picture's colours, add pf.paletteFrom("ref.png", {colors: 8}): every ramp snaps to that picture's colours and the manifest records it.
The Storm Harbor example (Scenes/PaperForge_Harbor) is made entirely from code: hero, three enemies, tileset, lanterns, sky, far coast, sea and rain.
Detail blocks (Paper Forge Pro, awaiting build and picture checks)
The blocks below add ordinary Paper Forge layers to the current part. Units are 1x art pixels, with Y up. They need no imported images. Material options take an existing material name or a #rrggbb colour; a colour creates a small generated ramp. Use named materials when several parts must share exactly the same palette.
pf.asset("portrait", {scales:[1,2]})
pf.style("painterly")
pf.part("head", {pivot:[0,32]})
pf.face({center:[0,42], size:[18,22], skin:"#d99c72", expression:"happy",
eyes:{color:"#252233", white:"#fff2d6", size:1, spacing:0.23},
brows:{color:"#52382d", width:0.8}, mouth:{width:0.7},
nose:{size:1}, blush:{color:"#ca776b", amount:0.5}})
pf.hair({center:[0,42], size:[20,24], color:"#644137", style:"swept",
locks:6, strands:2, highlight:{color:"#c79059", amount:0.45}})
pf.face supports neutral, happy, angry, hurt and surprised. It draws a head unless head:false; with that option, draw skin in the same part before calling it. eyes, brows, mouth, nose and blush each accept false or 0 to omit the feature. Eye size scales the eye, spacing is a share of face width, and shine:false omits catchlights. Keep the eyes at least one pixel wide at the game's actual display scale. The block records tagged eye layers and sockets for the bake critic. Hair and hoods drawn afterwards can still cover the eyes, so inspect the finished face, especially when adding a new facing.
pf.hair supports short, long, swept and curly. locks is 2–32 shaped clumps; strands is 0–8 carved lines per clump. depth sets the relief height. Highlights are painted; strands change the normal map. highlight:false omits highlights. Give hair the same centre as the face; its size describes the head it frames. The cap sits above the eye line, while long locks extend down the outer sides.
pf.part("coat", {pivot:[0,12]})
pf.cloth(pf.path([[-10,10],[10,10],[8,31],[-8,31]], {round:1}), {
material:"#527b86", depth:3, bevel:3,
folds:{count:4, depth:0.7, width:1.2, sway:0.12},
seams:{width:0.55, depth:0.3}, hem:{width:0.9, depth:0.35},
trim:{color:"#c5a45f", width:1}
})
pf.cloth fills the shape, carves folds and seams into its relief, and paints optional trim. Folds therefore change the normals used by scene lights. folds:4 is shorthand for {count:4}; folds:0, seams:false and hem:false omit those details. Seams and hems are on by default; trim is off by default. Standard fill options profile, depth, bevel, stack, ink and inkScale remain available.
Folds run roughly down the shape's bounding box; sway bends alternate folds sideways. Contour seams and trim currently support paths, boxes, circles, ellipses and blobs. Stroke and capsule garments still get folds and hems, but no contour trim. Generated folds, seams, hems and trim clip to the garment shape, so details outside an irregular edge do not carve skin or accessories sharing the part. Use separate parts for garments that need independent bones. These are authored relief patterns, not cloth simulation. Check small sleeves and sharply concave hems by eye.
Boolean feature options accept numbers (0 off, nonzero on). Layer ink also accepts a number as its ink scale. Shape smooth remains a whole subdivision count, such as 4; a fractional value reports the option and recipe line. A material can now take a colour-list ramp directly:
pf.material("stitch", {ramp:["#3b3036", "#bc9c79", "#f1ddac"], unlit:false})
The critic's scores are measurements to guide another pass, not artistic approval. Its pixel checks use each frame at 1x; tagged eye coverage follows every rendered pose and view, including part motion, depth and clips. Warnings name the frame, facing and covering part. Inspect the finished art as well as the score. Build, render and in-scene acceptance for the Pro additions are tracked in the worktree's STATUS.md; this API description does not claim those checks have passed.
One character, four views
Give parts a turnDepth (distance toward the viewer) and turnWidth (profile width relative to the front). Use views to draw details only in the appropriate facings. A back-only hood seam and a side-only face are authored shapes; the turn does not invent the hidden side of a character.
pf.part('face', {parent:'head', pivot:[0,55], z:5, turnDepth:5, turnWidth:0.5,
views:['front','threequarter']});
pf.face({center:[0,55], size:[18,20], expression:'happy'});
pf.views({idle:{frames:4, fps:6, bones:{body:{move:[0,0.4], bob:true}}},
walk:{frames:8, fps:12, bones:{left_leg:{swing:18}, right_leg:{swing:18, phase:0.5}}}});
Add the body's named parts before this example. pf.views writes four view_* stills and idle_down, idle_up, idle_side, walk_down, walk_up, walk_side. A top-down body with animate true selects those clips; left movement mirrors the side view. stills:false omits the four sample frames; a list selects only those named facings. Ordinary pf.clip and pf.cycle also accept view:'front'|'back'|'side'|'threequarter'. All frames share a box and foot pivot. Pose values [angle, dx, dy, z] can explicitly move an arm behind its parent; omitted z uses the projected part depth. Copy frame dimensions and pivot from the new manifest whenever a recipe changes.
Surface detail and painted ground
pf.wear({edges:0.3, scratches:0.2, grime:0.3, chips:0.15, scale:8, seed:27}) weathers the current part's finished relief. Raised edges wear; recesses collect dirt. Scratches and chips also change the normal map. Material texture helpers include wood (grain and knots), stone (chipped edges), metal (scratches), and fabric (weave). Their amount controls colour, bump controls relief, and scale controls mark size in art units. Use restrained amounts so the material does not compete with the face or silhouette.
pf.style('artist') supplies a painted starting point. hueJitter and valueJitter vary pigment inside fills; brush breaks up shading; inkWeight thickens shadow-side lines; bands and soft control painted shading steps; rim, ao and shadow add edge light and contact shading. wobble is an optional displacement in art units. The generated normals and emissive pages remain ordinary sprite maps.
pf.asset('plaza', {scales:[1]});
pf.style('artist', {ink:0});
pf.ground({width:320, height:180, base:'grass', blend:10, seed:49,
scatter:{moss:0.25, cracks:0.15, leaves:0.2, pebbles:0.2}, regions:[
{kind:'cobble', shape:pf.ellipse([160,90],115,70)},
{kind:'dirt', shape:pf.stroke([[0,50],[150,80],[320,140]], {width:28,smooth:4})}
]});
Ground is one continuous canvas, with no repeated tile outline. Regions share its coordinates; blend feathers a region's border in art units. Kinds are grass, cobble, stone, dirt, water and water_edge. A region can override blend, scatter or material. Place the ground sprite below actors and above the existing collision tilemap. Collision still comes from the level, not from the painted pixels.
For depth, export separate foreground and distant recipes. pf.depthLayer({name:'haze', shape:pf.box([160,90], 320,180), haze:0.25, color:'#bdcec7', feather:12}) makes a translucent plane; a framing layer uses an authored shape and material with haze:0. Its z controls order inside the bake. Scene sprite layers control order between exported images. Keep normal and emissive pages beside every sprite when checking both backends. pf.material(name, {unlit: true}) and unlit: false are accepted flags. Numeric strengths such as unlit: 0.4 remain supported, including in saved Paper Forge JSON.
Split a flat map into layers
art2d_split_map extracts existing pixels using explicit polygon selections. Give Assets-relative source and a new output folder, plus layers: [{name: "roof", polygons: [[[x,y], ...]]}]. Coordinates start at the image's top-left. Layers may contain several polygons; separate layers cannot overlap. Each PNG retains the full canvas and the same origin, so ground, walls and roofs line up without repositioning. The source is untouched. The tool reports transparent ground holes in layers.json; supply a same-size underlay image with reviewed ground art to fill them. A flattened picture does not reveal what lies behind a roof. The tool does not infer semantic regions. Review the selections and the underlay before assigning roof fades. Limits: eight layers, 2048 by 2048 pixels, 32 polygons per layer, and 128 vertices per polygon.