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.
- Make an original six-frame walking sheet: 16×16 frames stacked vertically in the order described in the overworld sprite section.
- Save it as
mods/my_mod/overrides/sprites/red.png. - Start the game with
my_modenabled and walk around. If the sprite does not change, check the file name, dimensions, enabled game, and mod-manager error feed; then see Troubleshooting. - From the engine repository root, run
python3 tools/modkit.py lint my_modbefore 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_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:
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, 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):
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 (samesrc/render/Assets.luachoke point); copy relative paths from that game's own cache.sprites,tilesets,palettesandiconsroute to Gen 2 tables with Gen 2 record shapes:palettesids are subtables (pokemon,trainers,bg,objects,roofs), andiconstakes a species id (assign a sheet by name) or anICON_*id (a sheet).fontandbattle_sprite_scaleskeep 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/, sooverrides/paths and transforms do not reach native Gen 3 art. Use records instead:pokemonspriteFront/spriteBack(64x64, recentred when smaller).spritesdraw through the field renderer for any NPC whosespritenames one. Gen 3 art is full colour, so every mod sheet draws as true colour;trueColorchanges nothing andpaletteSourceis ignored.paletteskeep the four-row Gen 1 shape and recolour cache art by luminance onto that ramp. Ids are<SPECIES>MON,<SPECIES>MON_SHINYandOBJ_EVENT_GFX_<NAME>;registerthem (no vanilla record tooverride). Mod art is never recoloured.iconsreplace a species' party icon (a string borrows another species' icon).fontpages use the image alpha as an 8x8 glyph mask,basebelow0x400; one-charactercharmap:rows only. Thettfentry draws Latin text through a TTF.tilesets,fieldandbattle_sprite_scaleshave 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.
Start here
Playing
Tutorials
- Choose a tutorial
- 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
Creative guides
Concepts
Recipes and reference
Author and developer tools
Home · Start Playing · Glossary · Troubleshooting · Repository · Community
Game data is imported locally from a supported ROM supplied by the player. Shared mods contain original work or transform recipes, never extracted game content. Check each guide's target game before using its examples.