10 Guide Art Pipeline
bryanthaboi edited this page 2026-10-20 17:01:40 -04:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Art pipeline

The asset contract, the two ways a mod's art reaches the screen, and the legal posture that binds both.

First replacement: an original overworld sprite

This route is for an artist who has a PNG editor and an existing mod folder. It uses an override, which is a file in your mod that takes the place of a same-named generated asset. No Lua is required for this first result.

For the code route below, keep the complete manifest at mods/my_mod/manifest.json and the entry file at mods/my_mod/main.lua; use the manifest reference for the required fields.

  1. Make an original six-frame walking sheet: 16×16 frames stacked vertically in the order described in the overworld sprite section.
  2. Save it as mods/my_mod/overrides/sprites/red.png.
  3. Start the game with my_mod enabled and walk around. If the sprite does not change, check the file name, dimensions, enabled game, and mod-manager error feed; then see Troubleshooting.
  4. From the engine repository root, run python3 tools/modkit.py lint my_mod before sharing it. An original drawing may be shipped; a recolor made from extracted vanilla pixels must use a transform instead.

Here, generated cache means the imported assets the engine keeps for the player’s own ROM. An override is a same-path replacement supplied by the mod. A transform is a recipe that creates derived art on the player’s machine. Keep those paths separate when deciding what to distribute.

The pixel contract

The importer decodes all vanilla art to 4-shade grayscale PNGs (src/import/ImageWriter.lua): white 255, light 170, dark 85, black 0. Palettes recolor those shades at render time, so art that follows the contract picks up map/battle palettes for free. Battle pics treat shade 0 (white) as transparency; the matte pass strips it.

  • Battle sprites: square, tile-aligned; the species record's frontSize (1–7, in 8px tiles) drives battle layout. Draw scale is moddable per species (battleScaleFront/battleScaleBack, defaults 1x front and 2x back) or per image path (battle_sprite_scales registry); the pic stays grounded at any scale (Reference: Registries).
  • Overworld sprites (sprites registry): 16×16 frames stacked into one sheet; a full walking character is a 16×96 six-frame sheet with walker = true. See Overworld character sprites.
  • Tilesets: a sheet plus block definitions, each block a row of 16 tile ids (Reference: Registries). See Texture packs.
  • Want full color instead? Set trueColor = true on the record (pokemon, tilesets, sprites) and the palette pass leaves your image alone.

Path 1: your own art, by path

Original art ships in the mod and is referenced by path:

For a registry change, the smallest complete main.lua is:

Example status: runnable snippet; placement is the main.lua file shown above.

return function(mod)
  mod.content.pokemon:patch("MODMON", {
    spriteFront = mod.assets:path("assets/front.png"),
  })
end

Replace MODMON with an existing species id and point assets/front.png at your own bundled file. Use the override workflow above when you are replacing an existing generated image without changing a registry record.

Example status: outline; adapt the id and path instead of pasting this fragment alone.

mod.content.pokemon:patch("MODMON", {
  spriteFront = mod.assets:path("assets/front.png"),
})

An overrides/ directory in the mod shadows the generated cache by relative path with no code at all: mods/my_mod/overrides/battle/front/ mew.png replaces Mew's front sprite everywhere it is drawn (src/render/Assets.lua). Hand-authored overrides beat transform outputs; the highest-priority mod wins.

Path 2: derived art, by transform

Art derived from vanilla must never ship. Instead the manifest declares a recipe that runs once on the player's machine against their own imported cache:

"assets_transforms": "transforms.lua"

Example status: outline; this transform needs the surrounding manifest setup.

-- transforms.lua : sandboxed; return function(ctx)
return function(ctx)
  local img = ctx.readImage("battle/front/mew.png")
  ctx.writeImage(ctx.recolor(img, {
    { 255, 255, 255 }, { 255, 170, 200 }, { 180, 60, 120 }, { 40, 0, 20 },
  }), "battle/front/mew.png")
end

The context (src/mods/AssetTransform.lua) is the whole surface:

Member Does
ctx.readImage(rel) read assets/generated/<rel> from the cache
ctx.writeImage(imageData, rel) write save/mod-derived/<id>/<rel>
ctx.exists(rel) probe the cache
ctx.recolor(imageData, shades) remap the 4 gray buckets, lightest first
ctx.blank(w, h, r, g, b, a) new canvas
ctx.blit(target, source, x, y, ...) compose
ctx.matte(image) white-as-transparency matte for battle pics

No require, no love, no io, no os: the recipe can only read the cache and write its own derived root. Outputs shadow the cache by relative path, exactly like overrides/. A stamp (cache marker + recipe hash) makes re-runs free until the cache or the recipe changes; a failing recipe disables that mod's derived art and nothing else, with the error attributed in the manager. assets.transformed fires per mod on a real run.

Palettes, icons, fonts

  • palettes: four colors per record, raw { {r,g,b}, ... } quadruple or { colors = { {r=,g=,b=}, ... } }; exactly 4, validated. To give a species those colors, set palette = "<that id>" on its pokemon record: the battle renderer honors a record's own palette field before the vanilla species→palette map (src/render/PaletteFX.lua), so a new or dex-renumbered species keeps the palette it registers. A species with no palette field falls back to the vanilla mapping (or the MEWMON default) unchanged.

  • icons: party icons keyed by species id, drawn by the party menu (src/ui/PartyMenu.lua) and taking precedence over the vanilla dex-indexed default, so a modded or dex-renumbered species keeps the icon it registers. The value is either:

    • a built-in icon name, one of BALL, BIRD, BUG, FAIRY, GRASS, HELIX, MON, QUADRUPED, SNAKE, WATER (uppercase, exactly as spelled), e.g. icons:register("MODMON", "QUADRUPED"); or
    • a { image, frames? } table pointing at your own bundled art: { image = mod.assets:path("assets/icon.png"), frames = 2 } (two 16×16 frames stacked in a 16×32 sheet for the bounce animation).

    A bare string is a name, not a file: a lowercase "quadruped" or a cache path passed as the string will not resolve. Use the name form to reuse a vanilla icon, the { image } form to ship a new one. The pokemon record's own icon field takes the same value. The { image } path resolves like battle-sprite art: point it at a bundled file, an overrides/icons/<name>.png, or a transform-derived icon at that cache-relative path.

  • font: a bare id registers a glyph page ({ image, base, glyphsPerRow?, advance?, charmap? }); a "charmap:<name>" id binds one text sequence to a glyph code. Pages above the vanilla $60/$80 ranges are free: a kana block registers at base = 0x100 without touching the stock pages.

Overworld character sprites

The walking sprites in the field (the player, rivals, and every NPC) are the sprites registry (Data.sprites), a system entirely separate from a Pokémon's battle sprites (spriteFront/spriteBack on the pokemon record). Ids are the vanilla SPRITE_* names: SPRITE_RED (player), SPRITE_BLUE (rival), SPRITE_OAK, and the NPC set (SPRITE_YOUNGSTER, SPRITE_FISHER, SPRITE_BEAUTY, …); the full list is data/generated/sprites.lua.

The sheet. A record is { image, frames, walker?, trueColor? }. Frames are 16×16, stacked vertically into one PNG in the fixed order stand down, stand up, stand left, walk down, walk up, walk left; the right-facing frames are drawn by horizontally flipping the left ones, so you never author them (src/render/SpriteRenderer.lua). Three shapes exist in the vanilla data:

frames walker sheet use
6 true 16×96 a full walking character (player, rival, most NPCs)
3 false 16×48 a standing NPC, three facings, no walk cycle
1 false 16×16 a single-frame object (boulder, item, fossil)

walker = true is what makes the field animate the walk frame as the sprite steps. Match the frames/walker of the sprite you replace, keep to the 4-shade grayscale contract (or set trueColor = true), and note these draw through the overworld's OBP-palette recolor like everything else in the field.

Two ways to change one. Either through the registry (patch one field, override the whole record, or register a new id, Registries):

Example status: outline; insert this registry patch in your mod entry file.

mod.content.sprites:patch("SPRITE_RED", {            -- reskin the player
  image = mod.assets:path("assets/red_ow.png"),
})
mod.content.sprites:register("SPRITE_MODNPC", {      -- a brand-new NPC sprite
  image = mod.assets:path("assets/modnpc.png"), frames = 6, walker = true,
})

…or the overrides/ folder with no code at all: a PNG at overrides/sprites/<name>.png shadows the cached assets/generated/sprites/<name>.png wherever it is drawn, exactly like the battle/front/ example above. The <name> is the record's image basename (SPRITE_RED → red.png):

mods/my_mod/overrides/sprites/red.png

The rule below applies either way: an original sprite you drew ships freely; a recolor derived from the vanilla sprite must go through an asset transform, never shipped pixels.

Player trainer art

The field sprite above is only how the player looks walking. The trainer art is a third set, separate from both the sprites registry and any pokemon record:

pic vanilla asset size drawn by
battle back battle/redb.png 32×32 at 2x the battle intro, until "Go!"
catch-tutorial back battle/oldmanb.png 32×32 at 2x the old man's demo battle
front trainer_card/red.png 56×56 at 1x Oak's intro, trainer card, Hall of Fame

overrides/battle/redb.png and overrides/trainer_card/red.png replace them with no code, exactly like the sprite sheets above. To point at your own paths instead (a total conversion whose hero has nothing to do with the vanilla layout), patch field.playerPics:

Example status: outline; insert this registry patch in your mod entry file.

mod.content.field:patch("playerPics", {
  back  = mod.assets:path("art/hero_back.png"),
  front = mod.assets:path("art/hero_front.png"),
})

Authoring the back pic. It draws at 2x, which is why the vanilla art is a 32×32 sheet that reads as 64×64 on screen: author at the small size and let the engine double it, or the pixels will not match the rest of the frame. Placement is measured, not fixed: the engine finds the transparent rows below your art and sits the last opaque row on the text-box top, so height is yours to choose. battle_sprite_scales takes a different multiplier keyed on your path (R50 in the Cookbook).

Both pics are also live behind the player.sprite hook, which is how a pic follows an outfit or story choice the registry cannot see.

Texture packs

A texture pack is the overrides/ path taken all the way: a mod whose entry file is empty and whose whole payload is a tree of PNGs. You do not invent the filenames. The import writes the decoded cache to assets/generated/ under the LÖVE save directory (Getting Started), and Assets.resolve (src/render/Assets.lua) rewrites every load of assets/generated/<rel> to mods/<id>/overrides/<rel> when that file exists. So the cache is the naming guide: open it, copy the relative path, put your PNG at the same place:

mods/my_pack/
  manifest.json
  main.lua                  -- may be empty; `entry` is still required
  overrides/
    tilesets/overworld.png  -- shadows assets/generated/tilesets/overworld.png
    sprites/red.png
    battle/front/mew.png
    icons/quadruped.png

Nothing declares the tree. There is no asset list in the manifest, no register call, no naming convention to learn beyond the cache's own: a file that matches a cache path is used, a file that matches nothing is ignored silently. The highest-priority enabled mod wins a contested path, and a hand-authored override beats any transform output.

The tileset sheets

Nineteen sheets cover all 24 TILESET_* ids, so several ids repaint together: the Museum rides on gate.png, the Mart on pokecenter.png:

File Size Tiles Tileset ids drawing from it
overworld.png 128×48 96 OVERWORLD
gate.png 128×48 96 GATE, FOREST_GATE, MUSEUM
pokecenter.png 128×48 96 POKECENTER, MART
gym.png 128×48 96 GYM, DOJO
reds_house.png 128×40 80 REDS_HOUSE_1, REDS_HOUSE_2
cemetery.png 128×48 96 CEMETERY
facility.png 128×48 96 FACILITY
forest.png 128×48 96 FOREST
house.png 128×48 96 HOUSE
interior.png 128×48 96 INTERIOR
lab.png 128×48 96 LAB
lobby.png 128×48 96 LOBBY
mansion.png 128×48 96 MANSION
ship.png 128×48 96 SHIP
ship_port.png 128×48 96 SHIP_PORT
cavern.png 128×40 80 CAVERN
club.png 128×40 80 CLUB
plateau.png 128×40 80 PLATEAU
underground.png 128×16 32 UNDERGROUND

Three more live in the same directory and are not tilesets at all: flower1.png, flower2.png, flower3.png (8×8 each, the animated flower cycle) and spinners.png (32×8, the four spinner-arrow blur frames). They resolve through overrides/tilesets/ like everything else.

Keep the grid

TileRenderer.new (src/render/TileRenderer.lua) cuts the atlas into hard 8×8 quads and indexes them by the record's tilesPerRow. A tile id is a position in that grid, so an upscaled sheet does not render upscaled; it renders as the wrong tiles. Replacement art keeps the original pixel dimensions exactly; a 2x repaint is a different tileset record, not a drop-in override.

Everything else in the contract still applies: stay on the four gray shades and the sheet picks up every map palette and the GBC pack for free, because the recolor runs off the resolved image. To hand-paint real color instead, set trueColor = true on the tilesets record, the one thing in a texture pack that needs Lua:

Example status: runnable insertion; place this patch in the mod entry file.

mod.content.tilesets:patch("OVERWORLD", { trueColor = true })

Block definitions, collision, door and warp tiles are untouched by any of this. A pack repaints what a tile looks like; it never moves what a tile does. Changing the layout means patching blocks on the record (Reference: Registries), which is a map mod, not a texture pack.

Beyond tilesets

The same mechanism covers every generated root, which is what makes a whole-game pack possible without a line of code: sprites/ (overworld characters), battle/front/ and battle/back/, icons/, fonts/, title/, townmap/, intro/, credits/, slots/, trade/, fx/, trainer_card/, emotes.png. Match each one's shape; the sheet layouts are in the pixel contract and Overworld character sprites.

Gen 2 and Gen 3

Everything above is the Gen 1 contract. The registry names are shared on every game, but where the art lives differs:

  • Gen 2 (Gold, Silver, Crystal). overrides/ shadowing and asset transforms work unchanged (same src/render/Assets.lua choke point); copy relative paths from that game's own cache. sprites, tilesets, palettes and icons route to Gen 2 tables with Gen 2 record shapes: palettes ids are subtables (pokemon, trainers, bg, objects, roofs), and icons takes a species id (assign a sheet by name) or an ICON_* id (a sheet). font and battle_sprite_scales keep the Gen 1 target; Gold's 48x48 back pics default to 1x, not 2x. See the Gen 2 guide.
  • Gen 3 (FireRed, LeafGreen, Ruby, Sapphire, Emerald). The GBA cache lives outside assets/generated/, so overrides/ paths and transforms do not reach native Gen 3 art. Use records instead:
    • pokemon spriteFront / spriteBack (64x64, recentred when smaller).
    • sprites draw through the field renderer for any NPC whose sprite names one. Gen 3 art is full colour, so every mod sheet draws as true colour; trueColor changes nothing and paletteSource is ignored.
    • palettes keep the four-row Gen 1 shape and recolour cache art by luminance onto that ramp. Ids are <SPECIES>MON, <SPECIES>MON_SHINY and OBJ_EVENT_GFX_<NAME>; register them (no vanilla record to override). Mod art is never recoloured.
    • icons replace a species' party icon (a string borrows another species' icon).
    • font pages use the image alpha as an 8x8 glyph mask, base below 0x400; one-character charmap: rows only. The ttf entry draws Latin text through a TTF.
    • tilesets, field and battle_sprite_scales have no Gen 3 target.

Under api 3, a registry with a native Gen 2 or Gen 3 shape and no canonical adapter (palettes among them) passes through in the running game's native shape, and mod:supports("content", name) is true wherever the game has a target; write those records in the native shape for the game, and branch on mod.generation when the shapes differ.

The rule

Distribute the transform, never the pixels. modkit lint (MK301–MK304) and modkit pack compare candidate assets against cache-derived signatures and refuse matches, byte-identical and perceptually near- duplicate. Original art is yours to ship freely; it never hits the lint.

This is the line a texture pack has to watch, because both halves live in the same overrides/ tree and look identical on disk. A sheet you drew is a shipped PNG. A sheet you made by opening the extracted one and recoloring it is derived, however much you changed: it ships as a recipe in transforms.lua, writing to the same cache-relative path, and the player's own cache supplies the pixels.

Check and recover

After modkit lint my_mod succeeds, the expected result is no finding for the asset and a playable mod that still loads its generated cache. If the sprite or lint result is wrong, disable the mod and remove only the new override or transform output, then rerun lint before editing the image again.