
Tile Artist
- 1 installs
- 1 repo stars
- Updated August 3, 2026
- ar9av/game-exa
tile-artist is a game-build pipeline skill that generates a tileset PNG and manifest metadata from a GDD tile palette, using GPT Image 2 by default and procedural flat-color cells as a fallback.
About
This skill generates a tileset PNG and its manifest metadata from a game design document's tilesetPalette. By default it calls GPT Image 2 once per tile type (512x512 downscaled to 32x32) for real pixel-art textures, and falls back to procedural flat-color cells when FAL_KEY is absent. A developer runs it after the game-designer stage, in parallel with sprite and background art generation.
- Generates tiles.png via GPT Image 2 (one call per tile) with a procedural flat-color fallback when FAL_KEY is absent
- Writes tile ids and passable flags into manifest.json
- Transparent SKY chroma-key trick lets a parallax background show through
Tile Artist by the numbers
- 1 all-time installs (skills.sh)
- Ranked #218 of 247 Game Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
tile-artist capabilities & compatibility
Free in procedural fallback; GPT Image 2 mode needs FAL_KEY and costs ~$0.01-0.05 per tile.
- Capabilities
- audio composer · palette enforcer · image generation
- Works with
- openai
- Use cases
- image generation
- Pricing
- Bring your own API key
What tile-artist says it does
Generates a tileset PNG from the GDD's tilesetPalette.
Falls back to procedural flat-color cells if FAL_KEY is absent.
npx skills add https://github.com/ar9av/game-exa --skill tile-artistAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 1 |
| Last updated | August 3, 2026 |
| Repository | ar9av/game-exa ↗ |
What it does
Generate a coherent tileset and manifest metadata for a generated 2D game from its tile palette.
Who is it for?
Producing pixel-art tilesets for an AI-generated 2D game.
Skip if: Non-game art or non-tile-based graphics.
When should I use this skill?
After game-designer produces a GDD with tilesetPalette; runs in parallel with sprite-artist and bg-artist.
What you get
tiles.png plus manifest tile ids and passable flags for the game to consume.
- public/assets/tiles.png
- manifest tiles entry
By the numbers
- GPT Image 2 cost ~$0.01-0.05 per tile at low quality
- tiles generated 512x512 then downscaled to 32x32
Files
Tile Artist
Generates tiles.png and tile metadata. GPT Image 2 is the default — one API call per non-SKY tile, each generated at 512×512 then downscaled to 32×32. Falls back to procedural flat-color cells when FAL_KEY is unavailable.
Two modes
| Mode | When | Cost | Output |
|---|---|---|---|
| GPT Image 2 (default) | FAL_KEY present | ~$0.01–0.05 per tile at low quality | Real pixel-art textures (cobblestone, brick mortar, grass blades, etc.) |
| Procedural fallback | No API key | Free | Flat-colored cells with a subtle 1-pixel darker border |
Both produce identical manifest entries — downstream Preload and Game.js are unaware which was used.
When to use
After game-designer produces a GDD with tilesetPalette. Runs in parallel with sprite-artist and bg-artist.
GDD palette format
Each entry in gdd.tilesetPalette:
{
"id": "STONE", // used as tile key in levels and manifest.ids
"color": "#607070", // fallback color if GPT call fails
"passable": false, // false = solid collision, true = walk-through
"desc": "gray cobblestone dungeon floor, beveled stone blocks with mortar cracks"
// ↑ optional but strongly recommended — used as the GPT Image 2 prompt
}If desc is omitted, the skill falls back to a built-in description map keyed on id (covers common ids: STONE, BRICK, SPIKE, LADDER, PIPE, FLOOR, ACID, GROUND, WALL, PROP, GRASS, WATER, FLOWER, TREE). If the id is not in the map, it constructs a generic prompt from the id and color.
How GPT generation works
For each palette entry (skipping SKY / #FF00FF tiles):
1. Build a prompt: "Pixel art game tile, flat seamlessly tileable surface texture: {desc}. {genre} game aesthetic. Seamlessly tileable, 16-bit retro pixel art, chunky well-defined pixels, clean sharp edges. No text, no characters, no HUD elements, no border frame." 2. Call fal.run/openai/gpt-image-2 at image_size: { width: 512, height: 512 }, quality: low (configurable). 3. Downscale the 512×512 result to tileSize × tileSize (default 32) using sharp. 4. Copy into position i * tileSize in the horizontal output strip. 5. If the call fails, paint the solid color value for that tile (no crash).
SKY tiles are filled with solid magenta (#FF00FF) — Game.js hides them via setAlpha(0) so the parallax background shows through.
Output
public/assets/tiles.png— horizontal strip:(tileSize × numTiles) × tileSizepixels, one tile per palette entry.- Manifest entry under
tiles:
{
"tiles": {
"relSheet": "assets/tiles.png",
"tileSize": 32,
"ids": ["SKY", "STONE", "BRICK", "SPIKE", "LADDER"],
"passable": [true, false, false, true, true]
}
}ids[i] and passable[i] both correspond to tile index i in any level's tiles[][] array.
Implementation
The core function is generateTilesetGPT() in src/lib/sprites.js. It is called by scripts/gen_game.mjs for all example games. For agent-driven pipeline use, invoke it from codesmith or a custom orchestration script:
import { generateTilesetGPT } from '../src/lib/sprites.js';
const tileset = await generateTilesetGPT({
palette: gdd.tilesetPalette, // array of { id, color, passable, desc? }
outPath: 'public/assets/tiles.png',
tileSize: 32,
genre: gdd.genre, // added to each tile prompt for style coherence
tagline: gdd.tagline, // added to each tile prompt
quality: 'low', // low | medium | high
log: console.log,
});
// → { sheet, tileSize, ids }Fallback scripts (kept for reference / standalone use):
scripts/paint_tiles.mjs <project-dir>— procedural flat-color strip, no API key needed.scripts/generate_tiles_gpt.mjs <project-dir> [--quality low]— thin CLI wrapper aroundgenerateTilesetGPT.
Dependencies
sharp— raw RGBA buffer → PNG, resize.FAL_KEYin env or~/.all-skills/.env— for GPT Image 2 calls. Falls back gracefully if missing.
Why GPT tiles look better than procedural
Procedural tiles are obviously synthetic — flat green for grass, flat brown for dirt. GPT Image 2 produces texture variation: grass blades, dirt speckles, brick mortar lines, pipe rivets. At 32×32 the detail reads clearly. Combined with bg-artist's parallax background, the visual quality jumps from "tech demo" to "real game".
Transparent SKY trick
For platformers and action games, the first palette entry is { id: "SKY", color: "#FF00FF", passable: true }. The tile artist fills it with solid magenta. In Game.js:
const skyIdx = manifest.tiles.ids.indexOf('SKY');
if (skyIdx >= 0) {
this._tileLayer.forEachTile(t => { if (t.index === skyIdx) t.setAlpha(0); });
}This makes the sky tile invisible, letting the bg-artist background show through — no extra draw calls, no extra texture.
#!/usr/bin/env node
// Generate a real pixel-art tileset via GPT Image 2 (default provider: fal.ai),
// then chroma-key the magenta cell(s) to alpha. Replaces the procedural strip
// from paint_tiles.mjs when you want richer tiles.
//
// Usage: node generate_tiles_gpt.mjs <project-dir> [--quality low|medium|high]
import { resolve, join } from 'node:path';
import { mkdir, writeFile, readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { existsSync } from 'node:fs';
import sharp from 'sharp';
const args = process.argv.slice(2);
const projectDir = resolve(args[0] ?? '.');
const quality = args[args.indexOf('--quality') + 1] || 'low';
const state = JSON.parse(await readFile(join(projectDir, 'game-state.json'), 'utf8'));
if (!state.gdd?.tilesetPalette) { console.error('no tilesetPalette in GDD'); process.exit(3); }
const palette = state.gdd.tilesetPalette;
if (palette.length < 2 || palette.length > 4) {
console.error(`tile-artist (gpt) supports 2-4 tiles; GDD has ${palette.length}. Use the procedural paint_tiles.mjs for larger palettes.`);
process.exit(2);
}
async function findApiKey() {
if (process.env.FAL_KEY) return { key: process.env.FAL_KEY, provider: 'fal' };
const envFile = join(homedir(), '.all-skills', '.env');
if (existsSync(envFile)) {
const raw = await readFile(envFile, 'utf8');
const m = raw.match(/^\s*FAL_KEY\s*=\s*(.+?)\s*$/m);
if (m) return { key: m[1].replace(/^["']|["']$/g, ''), provider: 'fal' };
}
if (process.env.OPENAI_API_KEY) return { key: process.env.OPENAI_API_KEY, provider: 'openai' };
return null;
}
const auth = await findApiKey();
if (!auth) { console.error('FAL_KEY (preferred) or OPENAI_API_KEY required'); process.exit(3); }
// 2x2 grid satisfies the 3:1 ratio cap; if palette has 3 tiles, last cell stays magenta.
const cellSrc = 416;
const W = cellSrc * 2, H = cellSrc * 2;
const slots = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
const cellPrompts = palette.map((p, i) => {
if (p.passable) {
return `${slots[i]} cell: completely empty, leave the entire ${cellSrc}x${cellSrc} cell as solid #FF00FF magenta with nothing drawn in it.`;
}
// Solid impassable tiles get a brief description from the GDD palette
return `${slots[i]} cell: a ${p.color} ${p.id.toLowerCase().replace(/_/g, ' ')} tile, filling the entire ${cellSrc}x${cellSrc} cell edge to edge with no magenta showing through. Pixel-art texture appropriate for the tile type.`;
});
// Pad with explicit "fill with magenta" instructions for unused cells
while (cellPrompts.length < 4) {
cellPrompts.push(`${slots[cellPrompts.length]} cell: completely empty, leave the entire ${cellSrc}x${cellSrc} cell as solid #FF00FF magenta with nothing drawn in it.`);
}
const prompt = `A pixel art tileset on a solid bright magenta background, color #FF00FF.
The image is exactly ${W} by ${H} pixels, arranged as a 2-column by 2-row grid of equal ${cellSrc} by ${cellSrc} cells.
${cellPrompts.join('\n\n')}
Strict pixel art, chunky pixels, no anti-aliasing on edges, vivid 8-bit retro color palette, no text or labels. Each non-magenta cell completely fills its ${cellSrc}x${cellSrc} area edge to edge.`;
console.error(`tile-artist (gpt): ${palette.length} tiles, provider=${auth.provider}, quality=${quality}`);
let imgBuf;
if (auth.provider === 'fal') {
const res = await fetch('https://fal.run/openai/gpt-image-2', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Key ${auth.key}` },
body: JSON.stringify({ prompt, image_size: { width: W, height: H }, quality, num_images: 1, output_format: 'png' }),
});
if (!res.ok) { console.error('GPT Image 2 (fal):', res.status, await res.text()); process.exit(4); }
const data = await res.json();
imgBuf = Buffer.from(await fetch(data.images[0].url).then((r) => r.arrayBuffer()));
} else {
const res = await fetch('https://api.openai.com/v1/images/generations', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.key}` },
body: JSON.stringify({ model: 'gpt-image-2', prompt, size: `${W}x${H}`, quality, n: 1 }),
});
if (!res.ok) { console.error('GPT Image 2 (openai):', res.status, await res.text()); process.exit(4); }
const data = await res.json();
imgBuf = Buffer.from(data.data[0].b64_json, 'base64');
}
// Downscale to 64x64 (2x2 of 32x32 cells) with nearest-neighbor for crisp pixel art
const cellOut = 32;
const small = await sharp(imgBuf).resize(cellOut * 2, cellOut * 2, { kernel: 'nearest' }).png().toBuffer();
// Magenta -> alpha
const meta = await sharp(small).metadata();
const raw = await sharp(small).ensureAlpha().raw().toBuffer();
let stripped = 0;
for (let i = 0; i < raw.length; i += 4) {
if (raw[i] > 200 && raw[i + 1] < 80 && raw[i + 2] > 200) { raw[i + 3] = 0; stripped++; }
}
const assetsDir = join(projectDir, 'public', 'assets');
await mkdir(assetsDir, { recursive: true });
const outPath = join(assetsDir, 'tiles.png');
await sharp(raw, { raw: { width: meta.width, height: meta.height, channels: 4 } }).png().toFile(outPath);
const tiles = {
relSheet: 'assets/tiles.png',
tileSize: cellOut,
ids: palette.map((p) => p.id),
passable: palette.map((p) => !!p.passable),
};
state.assets = state.assets || { sprites: [] };
state.assets.tiles = tiles;
await writeFile(join(projectDir, 'game-state.json'), JSON.stringify(state, null, 2) + '\n');
const manifestPath = join(assetsDir, 'manifest.json');
let manifest = { sprites: [], tiles: null };
try { manifest = JSON.parse(await readFile(manifestPath, 'utf8')); } catch { /* fresh */ }
manifest.tiles = tiles;
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
console.log(JSON.stringify({ ok: true, tiles: tiles.ids.length, tileSize: cellOut, provider: auth.provider, stripped, total: meta.width * meta.height }));
#!/usr/bin/env node
// Paint a tileset PNG from gdd.tilesetPalette and update manifest.
// Usage: node paint_tiles.mjs <project-dir> [--tile-size 16]
import { resolve, join } from 'node:path';
import { mkdir, writeFile, readFile } from 'node:fs/promises';
import { generateTileset } from '../../../src/lib/sprites.js';
const args = process.argv.slice(2);
const projectDir = resolve(args[0] ?? '.');
const tileSize = parseInt(args[args.indexOf('--tile-size') + 1] || '16', 10);
const state = JSON.parse(await readFile(join(projectDir, 'game-state.json'), 'utf8'));
if (!state.gdd?.tilesetPalette) { console.error('no tileset palette in GDD'); process.exit(3); }
const assetsDir = join(projectDir, 'public', 'assets');
await mkdir(assetsDir, { recursive: true });
const tileset = await generateTileset({ palette: state.gdd.tilesetPalette, outPath: join(assetsDir, 'tiles.png'), tileSize });
const tiles = {
relSheet: 'assets/tiles.png',
tileSize,
ids: tileset.ids,
passable: state.gdd.tilesetPalette.map((t) => !!t.passable),
};
state.assets = state.assets || { sprites: [] };
state.assets.tiles = tiles;
await writeFile(join(projectDir, 'game-state.json'), JSON.stringify(state, null, 2) + '\n');
const manifestPath = join(assetsDir, 'manifest.json');
let manifest = { sprites: [], tiles: null };
try { manifest = JSON.parse(await readFile(manifestPath, 'utf8')); } catch { /* fresh */ }
manifest.tiles = tiles;
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
console.log(JSON.stringify({ ok: true, tiles: tileset.ids.length, tileSize }));
Related skills
FAQ
What if FAL_KEY is missing?
It falls back to procedural flat-colored cells with a subtle 1-pixel darker border, at no cost.
How is the SKY tile handled?
SKY tiles are filled with solid magenta and hidden at runtime via setAlpha(0) so the parallax background shows through.