Compositing Character Sprites at Runtime with Ren'Py's LayeredImage
Ren'Py's layeredimage statement composites character sprites from separate art layers at runtime, cutting asset count and enabling dynamic variations. Covers block syntax, auto-discovery, conditional attributes, and verification.
28 Aug 2026, 18:52 UTC

The asset explosion problem
Visual novels routinely need dozens of expressions, a handful of outfits, and multiple poses for each character. If you render every combination as a standalone PNG, the asset count multiplies fast — five expressions × three outfits × two poses already means 30 files per character, and that's before route-specific variations. Managing that many files by hand is error-prone, and shipping them bloats the download.
Ren'Py's layeredimage statement tackles this by compositing layers at runtime. You define a character with named groups (base, eyes, mouth, outfit) and tag each layer with attributes. When you write show eileen happy dress, Ren'Py picks the layers tagged happy and dress, composites them into a single displayable, and shows the result. The artist draws each layer once; the engine handles the combinations.
Defining a layered character
A definition lives in a .rpy file using the layeredimage block statement. Groups are declared in back-to-front order so later groups draw on top:
layeredimage eileen:
always:
"eileen_base"
group eyes:
attribute open default:
"eileen_eyes_open"
attribute happy:
"eileen_eyes_happy"
group mouth:
attribute closed default:
"eileen_mouth_closed"
attribute smile:
"eileen_mouth_smile"
group outfit:
attribute uniform default:
"eileen_outfit_uniform"
attribute dress:
"eileen_outfit_dress"
Each group block defines a layer group. The attribute lines name the variants available for that group and the displayable each maps to; default marks the fallback when no matching attribute is supplied in the show statement. The always block draws unconditionally.
Groups also support an auto property, which tells Ren'Py to scan the images directory for files matching the pattern eileen_<group>_<attribute>.png (for example eileen_eyes_happy.png) and register attributes automatically. This eliminates manual layer lists but demands a strict naming convention — a mismatched filename simply fails to register an attribute rather than raising an error, so the layer renders as nothing. Verify new files appear by showing the character with each attribute during development.
layeredimage eileen:
always:
"eileen_base"
group eyes auto
group mouth auto
group outfit auto
Conditional attributes for route-specific content
Attributes can include an if clause evaluated when the image is shown. This gates expressions behind variables without duplicating the whole definition:
layeredimage eileen:
always:
"eileen_base"
group eyes:
attribute open default:
"eileen_eyes_open"
attribute happy if persistent.route == 'eileen':
"eileen_eyes_happy"
group mouth auto
group outfit auto
When persistent.route is 'eileen', the happy eye layer becomes available; otherwise the attribute is unknown to the image. Because the condition is evaluated at show time, changing the flag mid-scene won't update a character already on screen — hide and re-show to refresh the composite.
Worked example: showing Eileen in a dress, happy
With the definition above and matching PNGs in images/:
label start:
show eileen happy dress at center
e "I'm ready for the festival!"
pause
hide eileen
return
At the show line, Ren'Py selects the happy eyes, the dress outfit, the default mouth, and the always-on base, then composites them into one displayable. The composite behaves like a single image: with dissolve, ATL transforms such as at center, and pause all apply to the whole character. If you need runtime extras like a blush overlay driven by a Python variable, wrap the layered image in a LiveComposite or add a group whose attribute displayables are built from ConditionSwitch.
Verification checklist
- Run the project in developer mode and open the developer console (Shift+O). Evaluate
renpy.get_registered_image("eileen")and inspect the image attributes to confirm every group and attribute registered as expected. - Exercise each attribute combination with
showstatements and use the image location picker (Shift+I while hovering, in developer mode) to confirm which file backs the displayed image. - Test conditional attributes by setting the flag, then hiding and re-showing the character without restarting.
- Build a distribution from the launcher and confirm the packaged game runs with the layers intact; layer PNGs are archived by the normal
build.classifyrules like any other image. - On low-end targets, watch frame time with the FPS display (
config.show_fps = True) when complex characters appear. If it spikes, merge static layers (base plus outfit) into a single pre-composited image.
Limitations to keep in mind
- Auto-discovery fails quietly. A mismatched filename means the attribute never registers. Check registration in the console after adding files.
- Conditional attributes need a hide/show cycle. Mid-scene flag changes don't refresh a visible composite.
- No per-layer shaders. A shader applies to the whole composited displayable; for per-layer effects, use a
LiveCompositewrapper. - Very deep stacks cost memory. Each layer is a texture; many large layers on older mobile GPUs can hurt. Profile on real hardware rather than assuming.
Next steps
Start a test project: define one character with the layeredimage statement, add a handful of PNGs following your naming pattern, and run the checklist above. The asset count drops immediately, and the workflow scales to dozens of expressions without multiplying files. Syntax shown targets Ren'Py 7.4+/8.x; check the current documentation for your exact version before relying on newer properties like per-group auto refinements.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.