Skip to content

Guide Art Pipeline

bryanthaboi edited this page Jul 23, 2026 · 8 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.
  • 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).
  • 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.

  • 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 like "assets/generated/icons/quadruped.png" 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.

  • 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.

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.

Clone this wiki locally