-
Notifications
You must be signed in to change notification settings - Fork 18
Guide Art Pipeline
The asset contract, the two ways a mod's art reaches the screen, and the legal posture that binds both.
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.
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.
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")
endThe 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— 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.
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.
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.
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.
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.
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.
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.
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.
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.
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
- Translations
- Total Conversions
- Link Compatibility
- Publishing a Mod
- Docs Style Guide
Playing