Add portraits to your characters
This page explains how to add avatars and full-body portraits to the characters in your world. It is written for world authors. You do not need to know how to program. You do need to run commands in a terminal and edit JSON files.
When you finish, your world package has a set of character images and a manifest. After a player starts a session, these images appear in the "Character Portraits" panel on the right. In stage view, they are the portraits standing in front of the scene. You only need to generate the images once. After that, they are reused every time a session starts, and they cost nothing more.
The images can be generated by a script that calls an image model. They can also be pictures you drew yourself, or made with another tool. The second half of the process is the same for both sources.
Before you start
- You have run Covel from source. The scripts are all in the repository.
- You have a world that passes validation. See your first world.
- Keep the world folder in the repository's
worlds/directory, for exampleworlds/tidelamp/. The scripts only look in this place. If your world is in~/.covel/worlds/, first move the whole folder here, and move it back when you finish the images. Do not keep a copy in both places. Two worlds cannot have the same ID. - If you generate images with the script, you also need:
- An image model. In
llm.toml, it is a model role, for example[covel.image]. See the model role reference. - The command-line tool
cwebp, which compresses images to WebP. On macOS, runbrew install webp. On Debian or Ubuntu, runapt install webp.
- An image model. In
Run all the commands below from the root directory of the repository. The world ID in the examples is tidelamp.
CAUTION
Generating images calls a paid image service. Every command on this page that has --dry-run makes no network calls and costs nothing. Remove that flag to generate the images for real.
Install cwebp before you generate anything. The script checks for it before it asks the service for an image: without cwebp it stops with an error, and nothing is charged.
The whole process
| Step | What you do | What you get |
|---|---|---|
| 1 | Write a manifest that describes each character's look | media/portraits.json |
| 2 | Preview the prompts without generating images | The prompts on screen |
| 3 | Generate the images, or put in your own images | One .webp per character in media/portraits/ |
| 4 | Generate the manifest that says which image belongs to which character | media/presence.json |
| 5 | Validate, then start a session to see the result | The portraits in the game |
1. Write the portrait manifest
Create worlds/tidelamp/media/portraits.json:
{
"world": "tidelamp",
"style": {
"background": "transparent",
"prefix": "Full-body character art for a coastal mystery tabletop game, painterly semi-realistic style, cold sea-fog palette with warm lamp light, vertical 2:3 framing, isolated character on a transparent background, ",
"suffix": ", consistent art style, clear silhouette, complete feet with a little empty space below",
"negative": "text, caption, watermark, logo, frame, multiple people, extra fingers, deformed hands, background scenery"
},
"defaults": { "size": "1024x1536", "quality": "medium" },
"characters": [
{
"id": "he-sanniang",
"characterId": "npc-he-sanniang",
"name": "何三娘",
"filename": "he-sanniang.webp",
"subject": "a sturdy innkeeper in her early fifties, greying hair pinned up, plain indigo apron over a quilted jacket, holding a brass storm lantern, a level unhurried gaze"
}
]
}The prompt for each image is made of three parts: style.prefix + the character's subject + style.suffix. negative lists what you do not want. It is added at the end as "Avoid: …".
| Field | Meaning |
|---|---|
style.prefix, style.suffix | The art style shared by all characters. The style stays consistent because of these two parts. Write only the technique, colors, and framing here |
style.negative | What should not appear, shared by all characters |
style.background | Write "transparent" for a transparent background. The script checks that each image really has an alpha channel. If you do not want a transparent background, leave this out |
defaults.size | Image size. The default is 1024x1536 (portrait orientation) |
defaults.quality | Image quality. The default is medium |
characters[].id | The name of this image in the manifest. Used when you regenerate one character |
characters[].characterId | The ID of this character in the game. It must be exactly the same as the id of that character in characters/characters.json. Otherwise the image cannot be matched to the person |
characters[].name | The name shown |
characters[].filename | The file name to save as. End it with .webp |
characters[].subject | The description that belongs only to this character: age, build, hair, clothing, what they hold, and expression |
A few tips for writing subject:
- Write it in English. Image models understand English more reliably.
- Describe what sets this character apart from others: a signature prop, an expression, or a detail of the world.
- Age and identity follow the character's record in the character file. Looks and clothing details that the setting does not specify belong to the picture. Do not turn them into new settings and write them back into the world.
NOTE
characterId is the character's id in characters/characters.json, copied exactly: no prefix is added or removed.
Different expressions and outfits of the same character
Add variants to a character. Each item is another image:
{
"id": "he-sanniang",
"characterId": "npc-he-sanniang",
"name": "何三娘",
"filename": "he-sanniang.webp",
"subject": "a sturdy innkeeper in her early fifties, …",
"visual": {
"id": "apron-calm",
"outfit": "apron",
"expression": "calm",
"pose": "default"
},
"variants": [
{
"id": "apron-alarmed",
"filename": "he-sanniang-apron-alarmed.webp",
"outfit": "apron",
"expression": "alarmed",
"pose": "default",
"subject": "the same sturdy innkeeper in her early fifties, …, eyes wide, lantern raised"
}
]
}visualsays what outfit, expression, and pose the default image has. If it is not written, the values aredefault/neutral/default.- Each variant needs its own
idandfilename. Ifsubjectis not written, the character'ssubjectis used. - When the story needs an expression that has not been drawn, the stage falls back to the default image. It is never left empty.
WARNING
The script generates each image separately from its text. It does not refer to existing images. So writing "the same …" in a variant does not guarantee the same face. To make a variant show the same person as the original, use a tool that can edit an image using another image as a reference. Use the approved image as the reference, and then handle the result as described in step 3, "Put in your own images".
2. Preview without generating
pnpm exec tsx scripts/generate-portraits.mjs tidelamp --dry-runIt prints the file name and the full prompt of each image, and then it stops. Using the built-in Harukaze Academy world as an example, add --limit 1 to look at only the first image:
pnpm exec tsx scripts/generate-portraits.mjs haruka-academy --limit 1 --dry-runworld=haruka-academy slot=gpt-image-2 size=1024x1536 quality=medium — 1 portrait(s) queued (dry-run, no network calls):
kamishiro-mio → kamishiro-mio.webp
Character sprite for a visual novel, clean anime style, soft cel-shading, crisp lineart, …, a gentle reserved seventeen-year-old girl, the class representative and literature-club president, …
Avoid: photorealistic, realism, 3d render, dark, gritty, horror, …Read each prompt through. Editing the manifest at this step costs nothing.
--dry-run does not check the model configuration or the API key. So a passing preview does not mean that step 3 will be able to connect to the service.
3. Get the images
Generate them with the script
pnpm exec tsx scripts/generate-portraits.mjs tidelamp --slot imageThe value after --slot is the name you gave the image model in llm.toml. If you leave it out, the script looks for a role named gpt-image-2. Your configuration probably does not have that name, so always write the slot.
The script generates several images at the same time (5 by default). It prints one line each time an image finishes, and a summary at the end:
world=tidelamp slot=image (provider=openai, wire=openai-images) model=… size=1024x1536 quality=medium concurrency=5
generating 1 portrait(s) concurrently → worlds/tidelamp/media/portraits/
✅ 何三娘 (he-sanniang.webp) 141 KB · 38.2s
done: 1 ok, 0 failedThe images are saved in worlds/tidelamp/media/portraits/.
- Images that already exist are skipped. So if a few failed, running the same command again only fills in the missing ones.
- To redraw one image you are not happy with, change that character's
subject, then run--only he-sanniang --force. - To change the style for the whole set, change
style, then add--forceto redraw everything.
Where the script reads the model configuration and the key
| What is read | Where it is read from |
|---|---|
| Model configuration | The file that the environment variable COVEL_LLM_TOML points to. If it is not set, ~/.covel/llm.toml is read |
| Key | Looked up in this order, and the first one found is used: the environment variable COVEL_IMG_KEY; the environment variable <provider>_API_KEY; the item of the same name in ~/.covel/keys.env; OPENAI_API_KEY |
In other words, by default the script uses the same configuration as the desktop app (the files under ~/.covel/). The script does not read llm.toml, .env, and .env.llm in the repository root by itself. If your configuration is in the repository root, run it like this:
COVEL_LLM_TOML=llm.toml pnpm exec tsx \
--env-file-if-exists=.env --env-file-if-exists=.env.llm \
scripts/generate-portraits.mjs tidelamp --slot image --dry-runWhen you are sure everything is right, remove --dry-run.
No service address is hard-coded in the script. To switch to another image service, you only change llm.toml and the key. The providerRequestMetadata.imageWire of the role decides which interface format is used. If it is not written, the format is openai-images.
All options
| Option | What it does |
|---|---|
--dry-run | Only prints the prompts. No network calls, and no images are generated |
--slot <name> | Which role in llm.toml to use. The default is gpt-image-2 |
--only <id>[,<id>] | Process only these characters. Giving a character's id includes all of its variants. Giving characterId:variantId processes only that one variant |
--limit <N> | Process only the first N images in the manifest |
--force | Overwrite images that already exist |
--size <width x height> | Change the size for this run. It takes priority over defaults.size |
--quality <quality> | Change the quality for this run. It takes priority over defaults.quality |
--concurrency <N> | How many images to generate at the same time. The default is 5 |
--model <model name> | Use a different model for this run. It takes priority over the model set in the role |
--chroma | For services that do not support a transparent background: draw on a plain green background instead, and save as .png. See the note below |
When the service does not support a transparent background. Add --chroma. The script no longer requires a transparent background. Instead, it asks the model to draw the character on a plain green (#00FF00) background. The image is saved as a .png next to the target file. Then remove the green background with an image editing tool, and convert it to WebP with the method in "Put in your own images" below.
Put in your own images
You can use images you drew yourself, or images made or edited with another tool:
Put the PNG in
worlds/tidelamp/media/portraits/. The file name must match thefilenamein the manifest, except that the extension is.png.Convert it to WebP:
bashnode scripts/encode-world-webp.mjs worlds/tidelamp/media/portraits/he-sanniang.pngIt writes a
.webpwith the same name next to the PNG, and it prints the sizes before and after. You can pass several files at once.Delete the PNG. If you keep it, both files are imported as portraits when a session starts.
Requirements for the images:
- The size must match the manifest's
defaults.sizeexactly (the default is1024x1536). - When the manifest says
"background": "transparent", the image must really have transparent pixels. A transparency channel that is fully opaque does not count. Painting a checkerboard into the image is even worse. - The image must not contain caption text or a watermark.
Even if the images were not made by the script, you still need to write portraits.json. The next step uses it to know which image belongs to which character.
4. Generate the manifest that matches characters to images
node scripts/emit-presence.mjs tidelampwrote worlds/tidelamp/media/presence.json with 1 presence record(s)presence.json records which image each character uses. It identifies images by a checksum calculated from the image content, not by file name. So do not write this file by hand, and do not edit it by hand.
IMPORTANT
Whenever any image in media/portraits/ changes (regenerated, recompressed, or replaced), run this command again. Otherwise the manifest still records the checksum of the old image, and the game cannot find the image.
If some images are not yet made, the script skips those characters. At the end, it lists the missing file names.
If the world has a second language, the translated names are written in media/presence.<language>.json. Each item has only characterId and displayName. This file holds no image information, so you do not need to change it when you regenerate the images. For how to write it, see the language files in the world data reference.
5. Make the world use these images
You register nothing. media/portraits/ and media/presence.json are agreed locations, and the scripts above write only there. The images and the manifest placed there are imported automatically: the images go into this session's media library, and the manifest tells the game which image each character uses. The same holds when the world has data/world.data.yaml: that file writes only what differs from the conventions. For the format, and how to write the sources for other locations, see the world data reference.
This session must enable the plugin that shows portraits. The built-in one is "Character Profiles" (character-blueprint). It is included in the "Dialogue Mode" gameplay pack (dialogue-mode). It is not in the "Traditional Story" pack (traditional-story). Use either of these two ways:
- Add
character-blueprinttopluginPolicy.plusinworld.yaml, or changepluginPolicy.presettodialogue-mode. - Or, on the session-prep screen, expand "Customize plugins and advanced settings" and select "Character Profiles".
If the plugin is not enabled, the images and their manifest are skipped, and a notice is shown when the session starts.
6. Validate and check in the game
pnpm validate:world worlds/tidelampThen start Covel (pnpm dev), and find your world in the world list:
| Where to look | What you should see |
|---|---|
| The world list and world details, before starting a session | The world's images show these portraits |
| The "Character Portraits" tab in the right panel, after you start a new session | A portrait for each character. Click it to enlarge |
| Switch to the "Stage" view and wait for the character to appear | The character's portrait stands in front of the scene |
The images are imported when a game session is created. After you change an image, you must start a new session to see it. A session that has already started does not change.
When you check the images themselves, look at these things: the detail at the original size, whether the character is still recognizable when shrunk to an avatar, how they look on both a light and a dark background, and what gets cut off at phone width.
When it does not work as expected
| Symptom | Cause and fix |
|---|---|
The script reports that worlds/<id>/media/portraits.json cannot be found | The world is not in the repository's worlds/ directory, or the manifest is in the wrong place or has the wrong file name |
Slot [covel.gpt-image-2] missing in …/llm.toml | --slot was not written, or the role name you wrote does not exist in this llm.toml. The path in the message is the file the script actually reads |
No API key. Set …_API_KEY in the environment or …/keys.env | The key cannot be found. Set it in one of the places listed in "Where the script reads the model configuration and the key" |
Unknown image wire "…" | providerRequestMetadata.imageWire in the role is written wrong |
cwebp is not installed; install libwebp | cwebp is not installed. The script stopped before it requested an image, so nothing was charged. Install it and run again |
… has no PNG alpha channel; expected transparency | The manifest requires a transparent background, but the image the service returned is opaque. Use a model that supports transparent backgrounds (--model), or use --chroma instead |
… has 1024x1024; expected 1024x1536 | The image size does not match the manifest's defaults.size. Either the service did not produce the requested size, or your own image must be resized to the size in the manifest |
The last line is done: 3 ok, 2 failed | Some images failed. The reason is on each failed image's line. Running the same command again retries only the failed ones |
emit-presence shows missing portraits, skipped: … | These images are not in media/portraits/ yet, so the characters were not written into the manifest. Add the images and run again |
| A character has no portrait in the game | Check in order: whether you started a new session; whether this session enables "Character Profiles"; whether characterId is exactly the same as the character's ID; whether you ran emit-presence again after changing an image |
| The same portrait appears twice in the world's images | Both .png and .webp remain in media/portraits/, so both were imported. Delete the PNG |
After the images are done
- Portraits are part of the world content, and they are sent to others together with the world package. The repository accepts only WebP. PNG files under
media/portraits/are ignored by Git. They are only working files on your machine. - Do not compress a WebP that has already been compressed once. The quality keeps getting worse. When you need to change an image, convert it again from the original, uncompressed image.
How the built-in worlds are made
The four built-in worlds each have their own art style. You can open their manifests and use them as examples:
| World | Manifest | Art style | Framing | People |
|---|---|---|---|---|
Harukaze Academy haruka-academy | portraits.json | Anime visual-novel portraits, soft cel shading, spring pastels | Portrait orientation, head to thigh, transparent background | 8 |
Mistport mistport | portraits.json | Realistic painted noir in fog, cold grey, teal, and rust | Half body, three-quarter turn, grey fog background | 7 |
Emberback emberback | portraits.json | Bright retro sci-fi adventure poster, cream, terracotta, and cobalt blue | Full body, with an environment for each person's job | 5 |
Lantern Barrow lantern-barrow | portraits.json | Realistic skin and worn cloth, warm key light with cool rim light | Portrait orientation, full body, transparent background | 6 |
Two things worth learning from:
- Mistport puts world details into each person's
subject: tide-line marks on the wrist, a translucent left arm, and a fragment of a relic they keep turning in their hand. So the portraits carry the setting on their own. - The Emberback characters stand in their own work environments (a repair bay, a station platform, an observation deck), not on a transparent background. A transparent background suits stage view. Character cards with an environment suit traditional text narrative. Choose based on how your world is played.
For what each world should convey, and what must not be drawn wrong, see world art direction.
Next
- Add background pictures to places. See add scene pictures to your world.
- Set the art style for the whole world, and know what to check before delivery. See world art direction.
- Show the pictures in the world list and world details, with an introduction. See gallery information.