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_scalesregistry); the pic stays grounded at any scale (Reference: Registries). - Overworld sprites (
spritesregistry): 16×16 frames stacked into one sheet; a full walking character is a 16×96 six-frame sheet withwalker = 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 = trueon 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, setpalette = "<that id>"on itspokemonrecord: the battle renderer honors a record's ownpalettefield 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 nopalettefield falls back to the vanilla mapping (or theMEWMONdefault) 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 owniconfield takes the same value. The{ image }path resolves like battle-sprite art: point it at a bundled file, anoverrides/icons/<name>.png, or a transform-derived icon at that cache-relative path. - a built-in icon name — one of
-
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/$80ranges are free — a kana block registers atbase = 0x100without 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_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:
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:
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.
Start here
Concepts
Tutorials
- The ladder
- 01 Sprite and Text Tweak
- 02 Balance Patch
- 03 New Species
- 04 New Item and Ball
- 05 New Move
- 06 NPC and Dialogue
- 07 New Map
- 08 Quest
- 09 Custom Music
- 10 New Mechanic
- 11 Custom UI Screen
- 12 Mini Total Conversion
Reference
Guides
- Art Pipeline
- Audio Authoring
- Preparing Your Mod for Gen 2
- Translations
- Total Conversions
- Link Compatibility
- Publishing a Mod
- Docs Style Guide
Playing
Engine 1.0.0 · mod API 2 · repository · Discord
This project ships no ROM data. All game content is decoded on the player's machine from their own verified Pokemon Red ROM; mods ship recipes and original assets, never extracted content.