Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bake-look-yours-skill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@typeonce/ritmo": patch
---

The `ritmo-pixel-art` skill says that `pixel bake` leaves the look to the agent as well as the design: tones per facing, lines between parts, pictures on faces, and a map for a script to restyle the art.
5 changes: 5 additions & 0 deletions .changeset/bake-look-yours.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@typeonce/ritmo-tools": minor
---

`ritmo-tools pixel bake` leaves the look to you, not only the design. A part takes a ramp step for each way its surfaces face (`"steps": { "top": 2, "right": 1, "left": 0 }`) and a `line` where it meets a part behind it (`true` for the outline's color, `"step"` for one step darker, or a color). A `face` takes `rows`, a picture drawn flat that the bake lays on it and turns with the model. `"map": true` writes `<name>.map.json`, with each pixel's part, face, facing, depth, and place on its face, so a script can restyle the PNG. The camera can be given in a game's own terms, `{ "across": [1, -1, 0], "down": [0.5, 0.5, -1] }` for 2:1, in place of `yaw`, `elevation`, `scale`, and `heightScale`. The outline no longer breaks at a sharp tip such as a roof's eave: the lone-pixel cleanup used to remove an outline pixel that met the rest only at a corner.
1 change: 1 addition & 0 deletions packages/core/PIXEL-ART.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ The skill says to use `pixel bake` for something with a solid shape, not for cha
- Vasseur, on a 3D pipeline for Dead Cells ([link][deadcells]), quoted under One light: the same reason on the light's side, no shadow drawn by hand on every frame.
- **Ritmo:** tested blind on 2026-11-07, on the eight buildings of a game, from one written brief with no references: one agent with a first version of the command, one with only what ships today, free to write its own scripts. The maintainer judged the pictures on the game's real board without knowing which was which, and preferred the baked set with Opus 5.5 High ("especially in terms of 3d volume"), the beacon aside, and with ChatGPT 6.1 Sol High ("more solid and with less perceptive errors"), all eight. One run per side per model. The Opus agent without the command wrote its own renderer anyway, as that game's own scripts had.
- **Ritmo:** the design stays open because of a direction from the maintainer: the tool is for consistent and solid results, and must not hold back unique designs. So a model can be any shape in any color, proportions included, with hand-placed pixels on top; what is odd about one comes back as a report; and a piece can leave the model to be finished by hand.
- **Ritmo:** "so is the look" from two tests on 2026-11-11, each one agent given a written brief and no references: an isometric room from rules taken from a pixel artist's making-of, and five buildings of a game from their role and size. Both agents tried the command, judged its shading worse than their own (a box's top took the same ramp step as a side; parts under 4 pixels deep merged; a door on a face came out a blot), and wrote their own projection script to choose tones per face, lines between parts, and details drawn flat on faces. The maintainer liked both results and preferred the five buildings to the game's own reviewed ones. So the command keeps the geometry and leaves those choices to the model, and its map hands the geometry to a script instead of a second renderer. `across` and `down` because both agents had to work a game's own projection back into yaw, elevation, and scale. "Finish by hand the feature that names the piece" from the rerun with these fields: an agent that baked every piece and finished none by hand won the stilts and the quarry (a slab on posts, a stepped crag) and lost the house, the granary, and the ruin, whose features are a curved cap, a gable with its door, and small pillars, to the earlier run that placed those pixels by hand. That agent reported a cone's rim drawn as a V and a roof box's gable ends in roof material.
- **Ritmo:** not for characters from the maintainer's rejection of every character sprite tried on 2026-11-02. "Under about 24 pixels across, draw a sprite source" from the same game's buildings baked at half size (18 across), which lost what told them apart, and from a fresh agent given only the docs on 2026-11-08, whose small log ends read only once painted by hand.

[saint-consistency]: https://saint11.art/blog/consistency/
Expand Down
2 changes: 1 addition & 1 deletion packages/core/skills/ritmo-pixel-art/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ A `pixel.config.json` at the game's root (`pixel init -p <palette>` writes one)
### Pick the case

- **Drawing a sprite with code.** Write a sprite source (below), then `pixel render sprite.txt --scale 8`: it writes `.png`, `@8x.png`, `.svg`, and `.preview.png`, and lints the art. Fix every error in the source, never in the PNG. The preview shows the pixels; whether the sprite reads is judged in a game capture ([Judging](#judging)).
- **Something with a solid shape** (a building, a vehicle, a prop, a turret), above all one of a set or seen from several angles. Describe it in 3D as `<name>.bake.json` and run `pixel bake`: the project's one camera, light, and ramps (`bake` in `pixel.config.json`) keep the set one set, and the design is yours, as exaggerated or odd as the style wants. Change the model, not the PNG; to finish a piece by hand, carry on from its `.sprite.txt` (it then no longer bakes). Not for characters, anything soft, or under about 24 pixels across: draw those as a sprite source. Reference: the package's README, Baking.
- **Something with a solid shape** (a building, a vehicle, a prop, a turret), above all one of a set or seen from several angles. Describe it in 3D as `<name>.bake.json` and run `pixel bake`: the project's one camera, light, and ramps (`bake` in `pixel.config.json`) keep the set one set, and the design is yours, as exaggerated or odd as the style wants. So is the look: the light only fills in what you leave open. Give a part its tones per facing (`steps`) and a `line` where it meets what is behind it, draw a door or a dial flat as a face's `rows`, and with `"map": true` restyle the PNG from a script of your own instead of writing your own renderer. A game that already says where a point lands gives the camera as `across` and `down`. Bake the masses, then finish by hand the feature that names the piece wherever it is curved or a few pixels small (a cap, a door, a gable): a bake draws flat and angular shapes evenly and loses the rest. Change the model, not the PNG; to finish a piece by hand, carry on from its `.sprite.txt` (it then no longer bakes). Not for characters, anything soft, or under about 24 pixels across: draw those as a sprite source. Reference: the package's README, Baking.
- **A picture from an image generator.** `pixel check pic.png` first. `fixable` → `pixel fix pic.png` writes the true-size `.png`, `.svg`, a JSON map, a `.sprite.txt` (to edit by text), its `.recipe.json`, and a `.preview.png` (the source and its grid beside the fix). Add `--colors N` when there is no palette, `--remove-background` for a sprite, `--cleanup 1` against noise specks.
- **An animation sheet from an image generator** (frames side by side in boxes, often labelled). `pixel sheet sheet.png --colors 16` clears the background and labels, finds the frames, fixes them on one grid and palette, lines them up (by default on the ground: sideways drift removed, hops kept; `--anchor feet` pins the feet to one row), and writes the sheet `.png` and its `.json` (Pixi, Phaser, Aseprite), a `.gif` and an `.onion.png` per animation, `.frames/`, the recipe, and a `.preview.png`. Each row of frames is one animation (`animation-1`, ... in reading order; `animation.animations` in the report lists each one's frame numbers): name them and keep the ones the game uses with `--animations "walk-down=1-4;throw-down=5-8;explode=29-38"` (every frame in one; a row that is not one animation is split or joined here) and `--only walk-down,explode`, then play them by name from the JSON's `animations`. Open the preview: each frame's box must hold the whole frame, and each animation's boxes share a color. Pass `--boxes "x,y,w,h;..."` when one is cut, `--skip 3` to leave a bad frame out, `--anchor center` for things that float, `--fps` and `--pingpong` for the timing, and `--columns N` for rows.

Expand Down
51 changes: 48 additions & 3 deletions packages/tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -626,8 +626,10 @@ all one of a set or one seen from several angles. You describe it in 3D, and the
tool draws it through the project's one camera and light, so every sprite shares
the angle, the shading on the palette's ramps, clean edges, and one outline. The
design stays yours: any shapes, any colors, proportions as exaggerated as the
style wants, and hand-placed pixels for what geometry cannot say. What is odd
about a model comes back in the report, never as a refusal.
style wants, and hand-placed pixels for what geometry cannot say. The look can
be yours too: a part can choose its own tones and lines, a face can carry its
own picture, and `map` hands a script of yours what every pixel shows. What is
odd about a model comes back in the report, never as a refusal.

Not for characters or anything soft. Below about 24 pixels across, a bake loses
what tells things apart: draw those as a sprite source (`pixel render`), from a
Expand Down Expand Up @@ -688,6 +690,13 @@ Every field has that default except `scale` (1), `outline`, and `materials`.
game whose view is not a true projection. A point lands at
`X = x cos(yaw) - y sin(yaw)`, `Y = x sin(yaw) + y cos(yaw)`, then across
`X * scale` and down `(Y * sin(elevation) - z * heightScale * cos(elevation)) * scale`.
A game that already says where a point lands gives that instead, as `across`
and `down`: art pixels across and down for one unit of x, y, and z. A 2:1 view
is `{ "across": [1, -1, 0], "down": [0.5, 0.5, -1] }`, and a three-quarter
view whose point lands at `x` across and `y * 0.75 - z * 0.5` down is
`{ "across": [1, 0, 0], "down": [0, 0.75, -0.5] }`. Then the four above are
left out, `headings` turn the model before it, and `sun` keeps the picture's
axes.
- `light`: `sun` points toward the sun in the screen's axes whatever the `yaw`
(`[0.7, 0.05, 0.7]` is the picture's upper right), and stays put as a model
turns. A ramp's steps fall evenly from `dark` to `bright` on how directly a
Expand All @@ -711,6 +720,7 @@ Every field has that default except `scale` (1), `outline`, and `materials`.
| `cleanup` | `false` keeps lone pixels. |
| `normals` | `true` writes `<name>.normal.png`, from the real surfaces, to light the art in a game; `pixel review` shows it under a light. |
| `shadow` | `true` writes `<name>.shadow.png`, the model flattened along the sun, for a game to draw under it. |
| `map` | `true` writes `<name>.map.json`, what every pixel shows: below. |
| `paint` | Hand-placed pixels, below. |

**Parts.** What is nearest the camera shows, as in 3D, so their order does not
Expand All @@ -722,7 +732,7 @@ walls around a floor.
| `box` | `from` and `to`, two opposite corners. `top: [a, b]` scales its top along x and y: `[1, 0]` is a roof with its ridge along x, `[0, 1]` along y, `[0, 0]` a point, `[0.5, 0.5]` a frustum. `lean: [dx, dy]` moves its top. |
| `cylinder` | `from` and `to`, the corners of the box around it. `axis`: `"z"` (default), `"x"`, or `"y"`. `sides` (20; 8 or 10 look faceted). `taper` scales its far end: `0.05` is a cone. |
| `sphere` | `from` and `to`, the corners of the box around it. |
| `face` | `corners`: a flat convex polygon, `[x, y, z]` going around its edge. It shows over a surface it lies exactly on: doors, windows, stripes, trim, or one side of a part in its own material. |
| `face` | `corners`: a flat convex polygon, `[x, y, z]` going around its edge. It shows over a surface it lies exactly on: doors, windows, stripes, trim, or one side of a part in its own material. `rows` gives it its own picture, below; then it needs no `material`. |
| `mesh` | `points`, and `faces` as lists of positions in `points`. Any shape at all. Each face has to be flat and convex: split one that is not, and the report says which. |

Any part, and any group (`"parts": [...]` in place of `shape`) takes these. A
Expand All @@ -739,6 +749,23 @@ own. A part is animated, turned, then moved, and then its group does the same:
| `animate` | `{ "spin": { "rotate": { "axis": "x", "degrees": 90 }, "move": [0, 0, 2], "wave": false } }`: where it gets to at the animation's end, which is never drawn, so a loop closes cleanly. `wave` swings out to it, back through rest, and out the other way: a bob or a sway. |
| `detail` | `true` counts it three times when pixels are decided, and keeps its lone pixels. Under about two pixels it still comes out a lone pixel: paint that instead. |
| `step` | Holds a ramp on one of its steps, 0 the darkest. |
| `steps` | A step for each way its surfaces face: `{ "top": 2, "right": 1, "left": 0 }`. A top is anything tilted less than 45 degrees from flat; a side faces left or right on the picture. A facing left out takes the light. |
| `line` | Where it meets a part behind it, a line on that part: `true` in the outline's color, `"step"` one step darker, or a color. A roof from its walls, a lid from its box. `false` turns off one its group set. |

A part with `step`, `steps`, or `rows` keeps the tones it chose: `edges` and
`castShadows` leave it alone.

**A face's own picture.** A door, a window, planks, a clock's dial: draw it flat,
as seen from the front, and the bake lays it on the face, so it turns with the
model and its steps follow the face's slant. `rows` are letters, each a
one-color material, and `.` shows what is behind the face. The rows are spread
over the whole face, the first at its top and the first letter at its left as
seen from the viewer's side; on a top, rows run from -y to +y and letters from
-x to +x. As many letters as the face is pixels wide keeps one letter a pixel.

```json
{ "name": "door", "shape": "face", "corners": [[-2, 5, 0], [2, 5, 0], [2, 5, 6], [-2, 5, 6]], "rows": ["WWW", "WYW", "WWW"] }
```

**Paint** is for what geometry cannot say: a flame, a flag, a face, the one
pixel that makes a shape read. It bakes with the model every time.
Expand All @@ -754,6 +781,24 @@ the origin in the PNG, so the PNG's pixel at column `c` and row `r` is
leaves what is there, and `_` clears it. The outline goes around paint;
`"after": true` puts it over the outline instead.

**The map.** With `"map": true`, `<name>.map.json` says what every pixel of the
PNG shows, laid out as the PNG is (a sheet's frames included), so a script of
yours can give the art its own look: recolor by part and facing, draw lines
where faces meet, light it its own way. Pixel `i` is at column `i % width` and
row `Math.floor(i / width)`.

| Field | What it is |
| --- | --- |
| `image`, `width`, `height` | The PNG it maps. |
| `parts` | Every drawn copy of a part: its `path` in the model, `name`, and `material`. |
| `facing` | One row of text per row of pixels: `T` top, `L` left, `R` right, `.` nothing. |
| `part` | Each pixel's place in `parts`, or -1. |
| `face` | Which flat face of the model, so two faces of one part can be told apart. |
| `depth` | Art pixels toward the viewer from the model's origin: more is nearer. |
| `u`, `v` | Where on its face, from 0 to 1: `u` from its left, `v` from its top, as a face's `rows` are laid. |

The map is the geometry: the outline, `line`, and `paint` are not in it.

**What it writes, and the report.** `<name>.png`, `<name>.preview.png`, and for a
still `<name>.sprite.txt`. Several frames make a sheet instead: `<name>.json` in
the format `pixel sheet` writes (each frame's `anchor` is the model's origin, and
Expand Down
40 changes: 39 additions & 1 deletion packages/tools/src/pixel/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,12 @@ const staleBakeFiles = Effect.fn("staleBakeFiles")(function* (base: string, mode
if (!isWritten(sprite) && (yield* exists(sprite)) && (yield* text(sprite)).includes(`Baked from ${model}:`)) stale.push(sprite)
const shadow = `${base}.shadow.png`
if (!isWritten(shadow) && (yield* exists(shadow))) stale.push(shadow)
const map = `${base}.map.json`
if (!isWritten(map) && (yield* exists(map))) {
const raw = yield* text(map)
const image = yield* Effect.try((): unknown => (JSON.parse(raw) as { image?: unknown } | null)?.image).pipe(Effect.orElseSucceed(() => undefined))
if (image === `${name}.png`) stale.push(map)
}
const normal = `${base}.normal.png`
if (!isWritten(normal) && (yield* exists(normal)) && !(yield* exists(`${base}.normals.json`))) stale.push(normal)
const dir = path.dirname(base)
Expand Down Expand Up @@ -661,6 +667,38 @@ const bake = Command.make(
files.normal = yield* writePng(`${base}.normal.png`, { width: atlas.art.width, height: atlas.art.height, data })
}
if (model.shadow) files.shadow = yield* writePng(`${base}.shadow.png`, toRgba(atlasPick((frame) => frame.shadow).art))
if (model.map) {
// The geometry under every pixel of the PNG, laid out as it is, for a script that gives the art its own look.
const width = atlas.art.width
const pixelsOf = (fill: number) => new Array<number>(width * atlas.art.height).fill(fill)
const part = pixelsOf(-1)
const face = pixelsOf(-1)
const depth = pixelsOf(0)
const u = pixelsOf(0)
const v = pixelsOf(0)
const facing = Array.from({ length: atlas.art.height }, () => new Array<string>(width).fill("."))
const placed = result.clips.flatMap((clip) => clip.frames)
atlas.rects.forEach((rect, i) => {
const map = placed[i]!.map
for (let y = 0; y < rect.h; y++) {
for (let x = 0; x < rect.w; x++) {
const from = y * rect.w + x
if (map.part[from]! < 0) continue
const to = (rect.y + y) * width + rect.x + x
part[to] = map.part[from]!
face[to] = map.face[from]!
depth[to] = map.depth[from]!
u[to] = map.u[from]!
v[to] = map.v[from]!
facing[rect.y + y]![rect.x + x] = ".TLR"[map.facing[from]!]!
}
}
})
files.map = yield* writeText(
`${base}.map.json`,
`${JSON.stringify({ image: `${name}.png`, width, height: atlas.art.height, parts: result.partsShown, facing: facing.map((row) => row.join("")), part, face, depth, u, v })}\n`
)
}
const moving = clips.filter((clip) => clip.frames.length > 1)
if (moving.length > 0) {
const gifs: Array<string> = []
Expand Down Expand Up @@ -712,7 +750,7 @@ const bake = Command.make(
})
).pipe(
Command.withDescription(
"Draws a model of 3D shapes (`<name>.bake.json`) through the project's one camera and light: `.png`, `.sprite.txt` for a still or `.json` for a sheet, and `.preview.png`, with `.normal.png` and `.shadow.png` when the model asks for them. For things with a solid shape, seen from any angle; the design is the model's."
"Draws a model of 3D shapes (`<name>.bake.json`) through the project's one camera and light: `.png`, `.sprite.txt` for a still or `.json` for a sheet, and `.preview.png`, with `.normal.png`, `.shadow.png`, and `.map.json` (what every pixel shows) when the model asks for them. For things with a solid shape, seen from any angle; the design is the model's."
)
)

Expand Down
Loading
Loading