Skip to content

Guide Art Pipeline

bryanthaboi edited this page Jul 28, 2026 · 7 revisions

Art pipeline

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

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:

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"
-- 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):

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_REDred.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:

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, day/night tint 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:

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.

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.

Clone this wiki locally