Pixel art you can cat
tenten is a pixel-art tool with no canvas to click on. A sprite is rows of text, one character per pixel, and the file on disk is JSON with those rows in it. You can read a sprite with cat, see which pixels moved in git diff, and draw one with anything that can write a string, from a shell script to a coding agent over MCP. It exports sheets that Phaser, Pixi and debuff load, and reads and writes .aseprite. Try it with npx -y -p @debuffjp/tenten tenten.
Here is a slime. Six lines of text, then the PNG tenten renders from them after a one-pixel outline op, twice, because the second frame is the same grid shifted down a pixel.
..0000..
.011110.
01122110
01111110
.011110.
..3..3..
. is transparent. 0 to 9, then a to z, then A to Z are palette indices 1 to 61. Four colours is a slime; a 61-colour palette covers most pixel art, and past that the file falls back to bytes.
The file
Save it and this is slime.tt, the whole thing:
{
"format": "tenten/1",
"name": "slime",
"size": [8, 6],
"palette": ["#2d6a4f", "#52b788", "#b7e4c7", "#081c15"],
"layers": [{ "name": "base" }],
"frames": [100, 100],
"tags": [{ "name": "idle", "from": 0, "to": 1, "direction": "forward" }],
"slices": [],
"cels": [
{ "layer": "base", "frame": 0, "rows": ["..0000..", ".011110.", "01122110", "01111110", ".011110.", "..3..3.."] },
{ "layer": "base", "frame": 1, "rows": ["........", "..0000..", ".011110.", "01122110", "01111110", ".011110."] }
]
}Layers, frames with durations, tags and slices are the same things an Aseprite file has, and tenten imports and exports .aseprite so a human can open the same sprite in Aseprite and hand it back. The difference is that this one is text. Move the slime's feet one pixel outward and commit, and the diff is the one row that changed:
- "rows": ["..0000..", ".011110.", "01122110", "01111110", ".011110.", "..3..3.."]
+ "rows": ["..0000..", ".011110.", "01122110", "01111110", ".011110.", ".3....3."]A PNG in the same commit would show as a binary blob. The format page is one screen long.
Drawing without a screen
The same grid is how you draw. tenten's MCP server takes a batch of operations, and the cheapest one is grid:
{ "ops": [
{ "op": "grid", "rows": ["..0000..", ".011110.", "01122110", "01111110", ".011110.", "..3..3.."] },
{ "op": "outline", "c": 4 },
{ "op": "addFrame" }, { "op": "shift", "dx": 0, "dy": 1, "frame": 1 },
{ "op": "tag", "name": "idle", "from": 0, "to": 1 }
] }The result of every draw call is the frame as text again, so whoever sent the ops sees what they drew in the same reply. A coding agent working on a game can draw a placeholder enemy, look at it, fix a pixel and export a sheet without a screenshot round trip, and a human can open the preview page to watch it happen:

Beyond grid there are the usual primitives: pixel, line, rectangle, ellipse, flood fill, a one-pixel outline, shift, flip, colour replace, and a diamond for 2:1 isometric footprints. A batch is one undo step and rolls back whole if any op fails. The same ops are available from the CLI and as a TypeScript library, and the MCP server is one line in .mcp.json:
{ "mcpServers": { "tenten": { "command": "npx", "args": ["-y", "-p", "@debuffjp/tenten", "tenten-mcp"] } } }Projects made with npm create debuff already have that line, and .tt files in assets/ become sprite sheets with no configuration.
Where the idea came from
We built Overbuffed, a small survival game, with an agent drawing all the art in Aseprite through its Lua scripting. In the pass where it drew the sprites by hand (commit 3720b6e), it wrote each one as rows of # and . inside the Lua file and touched about ten Aseprite API calls: make a sprite, an image and a frame, put a cel, draw and read a pixel, make a colour, set a duration, save, export a sheet, close. Everything else was its own code.
So for a script, Aseprite was a pixel buffer with frames, a file format and an exporter, and the sprites were already text. tenten is that with the text made first class, in about 1,200 lines of TypeScript. It is inspired by Aseprite and not a clone of it: no brushes, no selection tools, no GUI.
What this doesn't show
We haven't measured whether hand-drawn pixel art at 32 px with shading reads well as a text grid to a model. Overbuffed's art is flat, one colour per sprite, and small. We expect the text grid to stop being enough somewhere above 48 px or eight colours, with the PNG render taking over, and we will log where that happens in the next game.
The tool is days old. The .aseprite codec is tested against files Aseprite wrote and against the real binary exporting what tenten wrote, but older files, tilemap layers and colour profiles other than sRGB are not covered.
Draw something and send it back:
npx -y -p @debuffjp/tenten tenten show slime.tt