World Data
This page explains which files a world package can hold, how to write each one, and when Covel reads them. Read the first half when you write a world and need the file formats or validation errors. Read the second half if you are a plugin author who supplies data to worlds.
If you are making your first world, go through the first world first. To preset quests, items and affinity, see starting content. To set up point buy for the opening, see point buy and dice checks.
Some terms used throughout this page:
- World package: a folder that holds
world.yaml,WORLD.md, and optional data, characters and images. - Source: one data source in a world package. Usually a file. For media, it is a directory.
- Descriptor: an optional file (
data/world.data.yaml) that changes how a conventional file is read, or adds a file outside the conventions. Most worlds do not need it. - Session: one game that a player starts with this world. The world package's data is copied into the session when the session is created. After that, each session changes on its own. Saves in browser-private mode work the same way (Browser-private workspace).
SillyTavern character cards and lorebooks can be converted into this same ordinary package. Conversion reports unmappable fields and creates only fixed content files, without executing extensions or scripts.
World Package
The smallest world package has two files:
world.yaml: the manifest of the world. Three fields are required:name,summary, anddefaultLocale.WORLD.md: the world lore text.
The world's ID is the folder's name by default: the world in worlds/my-world/ is my-world.
When you add a kind of content later, put a file in the agreed location. You do not need to register it anywhere:
| File | Content |
|---|---|
data/dimensions.yaml | World dimensions |
data/lorebook.yaml | Lore entries that are triggered by keywords |
characters/characters.json | Domain character records |
| Path declared by a plugin | Data that the plugin receives, for example data/quests.yaml (quests), media/portraits/ (portraits), data/rules/*.yaml (world rules, one set per file) |
A plugin declares the last row's paths itself. Each installed plugin brings its own agreed paths. The command below lists all the paths in the current version, with an example of each kind of data:
pnpm describe:authoringThe rules for this convention are in import by convention.
Recommended structure:
worlds/my-world/
├── world.yaml
├── WORLD.md # default world lore (fallback for all languages); you can add language versions such as WORLD.en-US.md
├── data/
│ ├── world.data.yaml # optional: needed only to change how a conventional file is read, or to add a file outside the conventions
│ ├── dimensions.yaml
│ ├── memory-blocks.json # optional: genre memory definitions
│ └── rules/ # optional: genre rules, one set per .yaml file, imported into living-world-rules
├── characters/
│ └── characters.json # character records, may carry plugin data (such as roleplay notes)
└── media/
├── portraits.json # optional: portrait generation manifest
├── portraits/ # optional: character portraits
├── presence.json # optional: content-addressed mapping of characters to portraits
├── scenes.json # optional: scene picture generation manifest
├── scenes/ # optional: day / night scene pictures
└── scenes.registry.json # optional: scene registry for scene-stagemedia/portraits.json and media/scenes.json are image generation manifests. Only four scripts read them: scripts/generate-portraits.mjs, scripts/emit-presence.mjs, scripts/generate-scenes.mjs and scripts/emit-scenes.mjs. The server does not import them, and you do not need them while you play. A world that does not plan to generate images again can leave them out. During play, the files that are read are presence.json, scenes.registry.json and the image directories.
world.yaml:
name: 我的世界
summary: 一个示例世界。
defaultLocale: zh-CN
pluginPolicy:
preset: traditional-story
plus:
- affinity
defaultViewMode: stageidcan be left out; the folder's name is used then. Write it only when it must differ from the folder: lowercase letters, digits and-, starting with a letter.pluginPolicychooses the plugins of this world. See plugin selection.worldDatais the path to the descriptor, relative to the world package root. Write it only when the world package has a descriptor.- For all the fields of
world.yaml, see the generated World manifest field table. For dimensions in a separate file, see World dimensions field table. - The manifest reads the current fields. Unknown top-level fields are ignored, with warnings from the loader and
pnpm validate:world. Unknown fields inside known declarations remain errors.
How the lore reaches the story
The model reads WORLD.md on every turn. It is placed in the <world-lore> section of the story prompt.
- Language: if the world has editions in more than one language, Covel reads the matching
WORLD.<locale>.md. Which languages count as an edition is in language files. The session's language is fixed when the session is created, and the lore is chosen by it. - Edited lore comes first: when a player has edited the world lore in the app, the edited version is used for the default language. If the session has its own lore (
loreOverride), the session's copy is used, even when it is empty. - Length limit: about 8,000 tokens. This is an estimate: about one token per Chinese character, and about one token per four other characters. When the text is too long, it is cut after the last full line that fits.
- Narrator-only lore also counts toward this limit, because the model reads it.
pnpm validate:worldgives a warning (prompt-size) when the text is too long. The warning shows the estimate and the limit. The world detail page also shows a note for a world whose lore does not fit.
The framework does not pull special prompt text out of fields such as tone or startingConditions. To have the model know the tone and the starting conditions, write them in the lore or in the dimensions.
The in-app translation and the AI edits both call a model, which takes some time. Before the result is saved, Covel checks again that the world is still the same one. If the world was edited or deleted during that time, or rebuilt with the same ID, the old result is dropped and does not overwrite the current world. When a translation succeeds, only the new language and its lore are added. Edits that were already saved are kept.
Narrator-only lore (narrator-only)
WORLD.md has two kinds of readers. The model reads the whole file. Players can also see it: in the "World" tab of the right panel, in the "World Document" on the session-prep screen, and on the world detail page.
Put content that only the narrator should know (hidden threads, answers to mysteries, and how to run the scene and control the pacing) between two marker lines:
## 人物
- **梅瑞尔**:酒馆老板娘,记性好、嘴巴紧。
<!-- narrator-only -->
## 暗线
灯是守灯人自己吹灭的。前三个房间里不要说破。
<!-- /narrator-only -->
## 开局
雨夜,你推开酒馆的门。- The content between the markers goes into the prompt as usual. Rules and settings written here apply during play, just like anywhere else. The lore the model reads does not include the two marker lines.
- The lore that players see does not include the content between the markers.
- The two marker lines are HTML comments, so they do not show in a Markdown preview. Each marker takes up its own line. Case and spaces do not matter (
<!--Narrator-Only-->also works). One lore text can have several sections. - Language versions (
WORLD.<locale>.md) use the same format, with the markers in the same places in each version. - When you are not sure, treat the text as hidden from players:
- Without an end marker, everything from the start marker to the end of the file is hidden.
- A marker written wrong (for example
<!-- end narrator-only -->) is treated as a start marker. - A marker line inside a code block still counts as a marker.
pnpm validate:worldgives alore-narrator-onlywarning for these cases and points to the line number.
- When a player edits the "World Document" on the session-prep screen, the player sees and edits only the visible part. The content between the markers is kept as it is in the lore that this game uses.
- AI creation can write hidden regions. Translation and targeted repair split the source at its markers, send one fragment per request, and put each result back inside the original program-owned boundaries. Dropping markers from a hidden fragment's answer cannot make it visible. Targeted repair touches only fragments containing generation meta wording. Invalid source markers reject the transformation; a failed repair of protected lore never falls back to whole-document regeneration.
- AI revision supports polishing public prose and editing stories inside hidden regions. When hidden regions exist, the package answer only selects public fragments to rewrite; its public draft is never published directly. Each changed public fragment gets an independent request containing only its original public source, the original public lore, and the author's original request with its source identified. It receives no hidden source, package answer, other fragment answers, or other package fields. Hidden edits go back inside hidden regions; unchanged fragments and
UNCHANGEDkeep the original text. The program retains and reconstructs the markers. File, server-store, and return-only worlds use the same rule. - Adding or removing regions, or explicitly changing visibility, is outside this revision contract when hidden regions already exist. A marker structure/count mismatch, invalid fragment reply, or failed validation prevents publication of a partial result, without falling back to rewriting the whole package. Structure alone cannot identify marker movement with the same count, but text moved into public prose by the package answer is never adopted directly. Lore without hidden regions still uses ordinary whole-section revision.
- These checks constrain fragment sources and boundary structure, not story semantics. The author's original request may itself contain hidden facts; a model may also infer or paraphrase secrets, change facts, or place information in other fields. The program does not guarantee that all spoilers are prevented. Authors must review translations and revisions.
WARNING
This is "no spoilers", not secrecy. The world record, the world package files and the lore returned by the API are all the whole lore text. Anyone who reads the files or calls the API can see it. For content that should be revealed only when a condition is met, use hidden data.
Dynamic world dimensions (dimensions)
A dimension is a piece of world data that is recorded and may change over time, such as reputation, known places, the state of a city wall, or a countdown.
- The author chooses each dimension's ID. There is no fixed set of kinds.
geography,factionsandpowerSystemare only common examples. Money, reputation and a codex can each use their own ID and data structure. - The world package holds the declaration: the structure, the initial value and the update rule. The current value belongs to the session. Two games of the same world each change on their own. They never write back to the initial value in the world package.
This section explains how to declare a dimension. For how a dimension is read and updated during a game, see dynamic world dimensions.
Declaration format
# world.yaml
dimensions:
reputation:
name: 声望 # the translation goes in world.en-US.yaml at the same path
description: 主角在当地的公开声望。
schema:
type: integer
minimum: 0
maximum: 100
initialValue: 0
updateRule: 完成居民委托后增加 5;只计已完成的委托,不计承诺。
discoveries:
name: 已知地点
schema:
type: object
additionalProperties:
type: object
properties:
description: { type: string }
visited: { type: boolean }
required: [description, visited]
additionalProperties: false
initialValue: {}
updateRule: 明确发现具名地点时增加条目,亲自抵达后将 visited 设为 true。| Field | Required | Description |
|---|---|---|
name | yes | Display name. You can translate it. |
description | no | What the dimension means. You can translate it. |
schema | yes | The shape of the value, written with the JSON Schema keywords in the table below |
initialValue | yes | The starting value. It must match the schema. null is a value, and it does not mean delete. |
updateRule | no | The update rule, in plain language. You can translate it. It is kept up to date automatically each turn only when it is not empty in the session's language. |
Rules:
- The ID must match
^[a-z][a-zA-Z0-9_-]{0,63}$. It cannot be__proto__,prototypeorconstructor. - Each declaration accepts only the fields in the table above. Extra fields are rejected.
- You do not need a separate "table / single value" format. The schema can express all of them:
- A scalar is a single value.
- A nested object is a group of related values.
- An array of objects is a multi-row record.
additionalPropertiesis a group of named records that can be added to over time.
- The player can change values, and can add or remove rows that the schema allows. The player cannot create a dimension or change its structure during a game.
Values that change with time (countdowns, deadlines, phases) can be written in two ways:
- Add
x-deriveto the field's schema. The code works out the value from the world clock, and the model is not involved. See fields derived from the clock. - For a rule that depends on time but cannot be written that way, write the
updateRuleas a function of the clock. The model that maintains the dimension can read the settled world clock and the time elapsed since the start on every turn. See frozen reads and maintenance.
Value checks and translation
schema supports a clear, closed set of keywords. It is not the full JSON Schema:
| Category | Supported keywords |
|---|---|
| Type | type: string/number/integer/boolean/null/object/array. A list of types can also be used for a union, such as a nullable value. |
| Common | title, description, enum, const |
| Number | minimum, maximum, exclusiveMinimum, exclusiveMaximum |
| Text | minLength, maxLength |
| Array | A single items schema, minItems, maxItems |
| Object | properties, required, additionalProperties (a boolean or a schema) |
| Localizing | x-i18n: true. title can be translated. x-enumLabels gives display names to the members of a scalar enum. |
| Derived | x-derive. See fields derived from the clock |
Validation rules:
- An unsupported keyword is rejected, not ignored. This includes
$ref, remote schemas, expressions and code. - The initial value, the player's changes and the automatic updates all use the same validation. There is no type conversion, and no default value is filled in automatically.
- Size limits: a nesting depth of 32 levels, 10,000 nodes, and 256 KiB when serialized. The whole dimension table and the schema itself are also held to these limits, not only a single field.
- A single record written to plugin data (the value of one key) must be 256 KiB or less when serialized. Writes made during play have the same limit. A record over the limit gives an error, and the whole import fails. It is not cut short. When you need more content, split it into several records.
Translation. The main file is written in the default language. The translations go in language files.
nameanddescriptionare the labels on the panel. They are shown in the player's interface language.- Text inside values is not translated by default. Only a node that the schema marks with
x-i18n: truecan be translated. Mark natural-language string leaves such as narrative initial values and nested descriptions, even when creating just one language edition: the mark declares where future translations may go. Do not mark stable IDs, enum/const machine values, code, numbers, or booleans. - When a session is imported, these nodes and
updateRulebecome plain text in the session's content language. A session stores only one language (see Dynamic Dimensions).
For example:
# data/dimensions.yaml
harborRumor:
name: 港口传闻
schema: { type: string, x-i18n: true }
initialValue: 灯塔有人守夜。
# data/dimensions.en-US.yaml
harborRumor:
name: Harbor Rumor
initialValue: Someone keeps watch at the lighthouse.title and x-enumLabels affect only how the panel shows the value. The value stays a stable enum ID, and the model, the rules and the validation all use the ID. The keys of x-enumLabels must be scalar members of the node's enum:
# main file
schema:
type: string
title: 状态
enum: [unverified, corroborated]
x-enumLabels:
unverified: 未核实
corroborated: 已佐证
# language file: same path, text only
schema:
title: Status
x-enumLabels:
unverified: Unverified
corroborated: CorroboratedA dimension value that is read is always the raw value. Translation is applied only when the value is shown or queried, according to the labels above. A plain JSON value such as {zh: 1, en: 2} is not treated as a translation. IDs, property names and enum members are not translated.
Two ways for authors to declare dimensions
Both ways use the same declaration format. Pick one:
- Write
dimensionsdirectly inworld.yaml. - One dimension file,
data/dimensions.yaml, that holds the whole dimension table. It is read by convention; you do not register it.
When both are used together:
data/dimensions.yamlreplaces the whole table inworld.yaml. The two are not merged item by item.- Only the final declarations go into a session. The ones that were replaced are not each initialized once.
- The dimension file can also have language files.
- If any declaration fails to read or validate, this load is thrown away, and the previous complete world stays in use.
Initialization, evolution and public reads
For world authors, these are the points to know:
- When a session is created, the final declarations and initial values are imported into the session. This is true no matter which way the declaration was written, and whether or not the world has a descriptor.
- The built-in plugin
world-inithandles initialization and maintenance on each turn. The framework finds the plugin that does this through the capabilityworld.dimensions@1, not by plugin ID, so you can swap in another implementation. - At the start of each turn, the narrative reads the values from the end of the previous turn. After the narrative ends, a model updates the values according to
updateRuleand the turn's story text. When there is no update rule at all, this model call is not made. - The model decides the rules. The code checks the structure and the range. This is not an engine that settles results by formula.
- An update takes effect as one batch. If one change is invalid, none of the batch is written. A record is always left, even when nothing changed.
- When maintenance fails, does not run, or hits a conflict, the turn is marked as pending settlement (
pending-settlement). Before the next narrative, you must retry it, confirm it by hand, or explicitly skip it. - Large values are not put into the prompt in full. The prompt has a summary that fits a length budget. When the model needs more, it queries the built-in tools
world-dimension-listandworld-dimension-get.
Three places to read the current value (used when you write a plugin or a prompt template):
| Place | How to write it |
|---|---|
| Plugin code | ctx.world.dimensions.<id>.value |
| Prompt template | {{ world.dimensions.<id>.value }} |
| Session snapshot | dimensions.<id>.value |
Each item that is read includes the name, the description, the schema, the value and the version. It does not include the update rule or the initial value.
The player changes values through a dedicated entry point, not through the general plugin data write. For the timing, the five settlement states and the read and write boundaries, see World Model. For a call example, see API.
NOTE
Characters, the inventory, affinity and time are still maintained by their own plugins. Do not store a second copy of them in dimensions. Dimensions are also not a mechanism for hidden plot. Not publishing an update rule does not keep the event content secret. For hidden content, use hidden data.
IMPORTANT
Every dimension must be written in the declaration format above. A form that gives only a value (with no schema and no initialValue) is not accepted, and there is no automatic conversion. After you change the format, the affected development worlds, sessions and snapshots must be created again.
AI-generated results and file export
The "AI create world" feature in the app and a world package written by hand use the same format. This section explains what a generated world looks like, and what developers who integrate @covel/create need to know.
What is generated:
- A new world is written in one language only.
defaultLocaleis the requested language (the default iszh-CN), and the package has no language files, so the world has only this one edition. A translation that the model claims to have but did not really generate is not accepted. characterSchemaand other valid manifest content are kept.- When characters are added, the whole group of characters is validated against
characterSchema. If it is still invalid after three attempts, that part is dropped, and a warning is returned. - Lorebook and rule
strategyandpositionvalues are machine enums. Omitted values use defaults; explicit invalid values, including translated enums, are rejected and retried instead of silently changing activation or placement. - When an existing world is edited, all the characters after the merge are validated. If they are not valid, the whole package is retried, and if that also fails, the edit fails. Existing characters are never deleted quietly. The "one language only" rule for new worlds does not apply to edits.
What the generated world package looks like: it is laid out along the convention paths, the same as a hand-written one.
- Dimensions are in
data/dimensions.yaml, characters are incharacters/characters.json, and the lorebook and rules are indata/lorebook.yaml. - There is one
WORLD.mdfor the setting text, written in the world's own language (defaultLocaleinworld.yaml). AWORLD.<locale>.mdis a translation, and a new world does not have one. - When every file is at a convention path, no descriptor is written, and
world.yamlhas noworldData.
Plugin content is specified by the contracts in the creation brief:
- The generator makes records only for the data contracts that are chosen. The schema, the writing prompt and the example of each contract come from the
authoringdeclaration of the receiving plugin (it must declaregenerate). When the receiving plugin is not in the chosen pack, it is added topluginPolicy.plus; akind: coreplugin is not added. - A contract that is not chosen does not go into the prompt. If the model outputs records for a contract that was not chosen, or leaves out a contract that was chosen, that attempt counts as a failure and is retried.
- The records of a contract are written to the file that the receiving plugin declares (the
pathandkindofauthoring.source, for exampledata/memory-blocks.jsonordata/time.yaml). One record is the file content. Several records are written as an array. - In the two cases below, the records are rewritten to
data/contract-<n>.json, and the descriptordata/world.data.yamladds these files as sources. Every other file is at a convention path and is not in the descriptor:- The receiving plugin does not declare a path.
- The declared path cannot be used as a convention: another contract in an installed plugin declares the same path.
- When the receiver declares a lorebook projection, the target is written as
contract:<id>+lorebook.
The @covel/create interface:
createWorld({ llm, concept, ... })generates the content and validates it. On success it returnsid,manifest,lore,locale,packageContentandwarnings. On failure it returnssuccess: falseanderrors.warningsinclude content below the brief's target count and invalid dimensions that were dropped.- The returned
manifesthas already been validated against the schema and normalized. It includes the canonical form of the locale and defaults such ascharacterSchema.types. All three save targets use the same result. - Generation does not write files, and it does not accept
outputDir. A generated manifest must put its data inside the manifest. It cannot refer to aworldDatafile that has not been generated. - Generated plugin content goes in
packageContent.contractDataas{contract,key,value}records. For example, selectingmemory.blocks@1can produce a record with the keyworld. File and non-file outputs use the same data; duplicate targets are rejected. - To get a package of files, call
await writeWorldPackage(outputDir, result, { dataContracts })explicitly. It returns the relative paths that were written. It works on a separate copy, converting the data in the manifest into file references, and the original result does not change. An existing package with the same name is rejected. When two writes go to the same package at once, only one succeeds.
Worlds without a folder. A world saved in the server database (server-store) or in the browser's local storage has no package folder that can be read later. In that case:
- Common characters go into
WorldRecord.metadata.embeddedCharacters, and the lorebook and rules go intoWorldRecord.metadata.embeddedLorebook. - When a session is created, this content is used only when there is no world data in the form of imported files. So the same world is never imported twice.
- This form holds only text. Images still need a media source, real files and a content-addressed index.
NOTE
The root folder of a world package written by the generator contains a .covel-generated.json file. It lists every file the app wrote into the package, and the world record gets metadata.generated: true because of it. While the package holds only those files and their content is unchanged, the app can rewrite the whole package when the player asks (POST /api/ai/revise-world). After you add a portrait or other files by hand, or edit a generated file, "Revise this world" refuses to rewrite the package and names those files. The package stays as it is. Edit the files of the package directly from then on. To use "Revise this world" again, undo those changes first.
Built-in example worlds
A world package does not have to use every capability. Let the genre decide the plugin mix and the data. The four worlds in the repository show different mixes:
| Example | Player experience | Main capabilities | Files to look at |
|---|---|---|---|
worlds/mistport | A dark fantasy investigation, for players who want a heavy story | The traditional-story pack plus character blueprints and story-events. Evolving dimensions 案情板 (case board), 四方立场 (four sides), 深退潮 (deep ebb) and 钥匙碎片 (key fragments). Hidden events with visibility: hidden (story-events). Lore, characters, rules and presence by language. Character attribute schema, portraits, and rules for tides and factions. | world.yaml, WORLD.md / WORLD.en-US.md, data/dimensions.yaml, data/rules/, characters/, media/ |
worlds/haruka-academy | Campus romance with a group of characters (GalGame), with a dialogue and visual novel stage | The dialogue-mode pack (stage, multiple replies) plus affinity and story-events. defaultViewMode: stage. Affinity seeds and evolving dimensions 心之路线 (heart routes), 学园祭筹备 (festival preparation), 文艺部存续审查 (club review), 约定 (promises) and 校园传闻 (campus rumors). Hidden personal-route events and their follow-ups. Transparent portraits, and a day and night scene registry. | world.yaml, WORLD.md, data/dimensions.yaml, data/affinity.yaml, data/rules/, characters/, media/scenes.registry.json |
worlds/emberback | English science fiction rescue, showing how one world carries several languages (English first, with a Chinese edition) | The traditional-story pack plus checks, quests, items, affinity, character blueprints and story-events. Dice checks, quests, items and affinity seeds. Evolving dimensions Crownfire Countdown, Relay Grid, Signal Log and Medical Convoy. Hidden events and event chains. | data/dimensions.yaml, data/quests.yaml, data/items.yaml, data/affinity.yaml, characters/ |
worlds/lantern-barrow | Classic tabletop dungeon exploration (Chinese and English) | The traditional-story pack plus tabletop rules, checks, quests, items, affinity, character blueprints and story-events. Starting point buy from tabletop-rules (contract:tabletop-rules.rules.initial@1), form checks and the dice-check dice pool. .en-US variants of quests, items and affinity. Evolving dimensions 古冢地图 (tomb map), 古冢警戒 (tomb alert), 古冢之灯 (tomb lantern) and 名望 (renown). Hidden encounters and event chains (including .en-US variants). | world.yaml, WORLD.md / WORLD.en-US.md, data/tabletop-rules.json, data/*.en-US.yaml, characters/*.en-US.json |
- None of the four worlds has a descriptor; every file is at a convention path. Rule files sit under
data/rules/, one set per file. - All four worlds enable
story-events. Mistport, Emberback and Lantern Barrow also turn on story planning withpluginSettings.story-events.planner: true. Besides the hidden events the author sets up, it adds follow-up events that fire only once, based on clues left during play. Harukaze Academy's romance routes are arranged one by one by the author, and it does not turn on story planning. - When you make a new world, first copy the structure of the world whose gameplay is closest to yours. Then add or remove characters, rules or media by following the sections below.
Harukaze Academy keeps one relationship account per subject: all eight characters’ affinity belongs only to affinity; the five route characters’ stage, trust and pressure belong only to routes (trust and pressure 0–5); the three supporting characters retain trust, pressure and non-romantic relationship state in their profiles (trust and pressure 0–100), with no conversion. All eight retain publicCloseness in their profiles. Relationship memory records events, promises and details, without a second copy of current values. The character schema removes affection and has no defaults for supporting-character trust/pressure, so defaults do not refill these numbers on route characters or players. Generic character creation does not enforce NPC-only scope for text fields. Disabling affinity removes score tracking and its narrative projection without recreating character copies.
Create fresh Harukaze development sessions from the updated world package; existing sessions, branches and snapshots are not cleaned automatically. Reimport or recreate world copies with old embedded data. Database tables do not need rebuilding.
Display fields
These fields change only how the world is shown. You can leave all of them out.
defaultViewMode: the default way to show the session the first time it is played. Writestage(full-screen stage mode, see ui-panels.md),parsedorplugin:<pluginId>:<viewId>(Plugin views; bridge views request consent first). It is only the starting value. Once the player switches the view at the top of the page, the player's choice wins.version,author,license,homepage: the version and author information of the world package. The fields, limits and translation rules are the same as for plugins. See author information. They show only on the world card. They do not go into session data or the prompt.cover,accentColor,themeMusic: the cover, the accent color, and the music on the world list. See the end of the Background Music section.
Plugin selection (pluginPolicy)
pluginPolicy says which plugins this world would like to use. It does not lock in any plugin. Which ones are finally on is worked out by Covel, from the capabilities and dependencies each plugin declares, the player's authorization, and the player's own checkboxes.
You write it as "one built-in pack, plus a few plugins":
pluginPolicy:
preset: traditional-story # a new session starts from this pack
plus: # the plugins this world also wants
- dice-check
- core-quest
pitch: # optional: how this world presents the pack on the session-prep screen
label: 古冢探险
description: 跑团式的地城探索,带掷骰判定和任务。
reason: 每一步都要检定,灯油和名望都会记在面板上。| Field | Description |
|---|---|
preset | The built-in pack a new session starts from, for example traditional-story or dialogue-mode. The packs are listed in packs/builtin.yaml |
plus | Plugin IDs this world also wants. They stay when the player switches packs. Do not list kind: core plugins; every session has them anyway |
pitch | How the session-prep screen presents the preset pack: label, description and reason, in place of the pack's own text. All three can be translated |
packs | IDs of the built-in packs the player can choose from, listed in this order. When it is left out, every built-in pack is listed. When it is written, preset must be in it |
requires | Contract IDs the world needs (such as action-check@1). Any installed plugin that provides the contract can satisfy it. |
The plugins a new session enables by default are the requested list of the preset pack, plus plus, the core plugins and the dependencies that requires brings in.
presetcan only be a built-in pack. A world cannot define its own pack; for a different set of plugins, choose the closest pack and add what is missing withplus.- The session-prep screen lists the
presetpack first (in the world's words when there is apitch), then the other packs inpacks. pnpm validate:worldchecks these names: apresetthat is not a built-in pack is reported asunknown-preset, an unknown ID inpacksasunknown-pack, and apresetthat is not inpacksaspreset-not-offered.
requires says "what capability this world cannot do without". Write a contract here (a capability name plus a version), not a plugin:
pluginPolicy:
requires:
- action-check@1 # action checks; the built-in dice-check provides it, and you can use another check plugin
plus:
- dice-check # optional: turns it on by default, and chooses which provider to use when there are several- When only one installed plugin provides it, that plugin is turned on automatically.
- When there is no provider, or there are several and neither the world nor the player has chosen one, the session-prep screen says what is missing. The session starts as usual, only without this part of the gameplay. The response that creates the session lists them in
unmetRequirements. - The player can still turn off a provider. That is the player's choice. The session starts as usual, and the session-prep screen shows a notice.
requiresaccepts only contract IDs in the formname@version. A plugin ID written here causes an error when the world loads.
plus holds plugin IDs. The plugin may not be installed on the player's computer:
GET /api/worlds/:id/plugin-planremoves the IDs that are not installed from the world policy and from each pack'srequested. They are listed inmissinginstead (for packs, with apackId).- The session-prep screen uses this to show which plugins are missing. The session can still be created, but the gameplay those plugins provide will not work.
Fields of pluginPolicy that Covel does not know (for example presetId, requested, recommended and preferredTags of the earlier form) are ignored when the world loads, with a manifest-unknown-field warning. packs written as objects (packs the world defined itself, as the earlier form allowed) make the whole pluginPolicy invalid: the world loads as usual, only without a plugin selection, and pnpm validate:world reports manifest-invalid.
World records and world packages
Covel plays the world record in the database. The files of a world package are only the format for installing, importing and exporting a world:
- The startup seed, file reloads, installs and updates read the package's files into the world record.
- New sessions, the world-data preflight and sync use the dimensions and the character schema (
characterSchema) of the world record. They do not read these from the package files. - What the world record does not keep is read from the package: the characters, lorebook entries and plugin data in
data/, and the images inmedia/. The app has no place to edit them, so there is only one copy of them. - Once a player edits a world that came from a world package in the app (the world editor, importing dimensions, or
PATCH /api/worlds/:id), the record is markedpackageModified. From then on the seed, file reloads and updates no longer overwrite it with the files, and a new version of the package is not used automatically. - When package files differ from the last import, the world list shows "Update available". Both bundled and installed packages are checked; a world created only in the app has no badge. Checking reads files without overwriting the world record.
- To apply the update or go back to the package's version, click "Restore the package version" on the world's detail page (
POST /api/worlds/:id/restore-package). World data and settings, including edits made in the app, are replaced with what the package files hold now. The record follows the files again and the "Update available" badge disappears. Saved session data is preserved. Sessions without their own lore (loreOverride) read the restored world lore on the next turn.
Loading a package saves a SHA-256 digest of its file paths and contents in metadata.packageDigest. The manifest, lore, translations, data and media all count. Install receipts, generation markers (.covel-*), .git, .DS_Store and symbolic links do not. Ordinary edits keep the digest; restoring the package version writes a new one. Development records without a saved digest need one package restore before later changes can be compared.
Update checks first compare the same files by relative path, size and modification time (mtimeMs), sorted by relative path. An unchanged signature reuses the content digest in memory without rereading media or other files for hashing; a changed signature triggers a new hash. The cache keeps one entry per real package directory, up to the 256 most recently used directories. The world record still saves a content digest, so a timestamp change alone does not show an update.
Loading at startup and reconciling (seed & reconcile)
When the server starts, each world package in the world directories is written into the database. This step is called seed.
Reading order:
- The built-in directory:
COVEL_WORLDS_DIR. When running from source, the default is the repository'sworlds/. - The user directory:
COVEL_USER_WORLDS_DIR. If it is not set, it is$COVEL_HOME/worlds. IfCOVEL_HOMEis not set either, it is~/.covel/worlds. The desktop app uses<data_root>/worlds.
Seed can run again and again. Each world package has one world record. If the record exists, it is updated. If not, it is added, and it is marked with metadata.source = "file". A record the player has edited (packageModified) is not updated. Seed itself never deletes anything.
Cleanup (reconcile). Adding without ever deleting leaves one problem. A world that was once built in, and later removed from the packages, would stay in the database of older users. So after all the directories have been seeded, old records that are "no longer in any world directory" are deleted. Three safeguards make sure only that kind of record is cleaned up, and no user data is deleted by mistake:
- Player edits are kept: only worlds with
metadata.source === "file"and nopackageModifiedmarker can be cleaned up. An edited world is kept even if its package is gone and it has no sessions. AI-generated worlds (generated/generated-file) and worlds from any other source are never touched, even when they are not in the current world directories. - A world with saved games is kept: an old world that still has sessions is kept, not deleted, and a
warnline is written to the log. Deleting a world that has saved games must be an explicit action by a person. - An incomplete scan means no cleanup: if any world directory cannot be scanned, if any world package fails to read, parse or validate, or if not a single world loaded successfully, cleanup is skipped as a whole. Healthy worlds are still updated as usual.
A user worlds directory that does not exist counts as an empty directory; bundled worlds still refresh. Other directory read errors still make the scan incomplete.
NOTE
To remove a built-in world that still has saved games, you must explicitly delete its world record and the sessions linked to it.
When several directories have the same ID:
- The later directory wins (the user directory wins over the built-in directory). The package that is covered does not rewrite the world record, and it does not notify sessions.
- A duplicate ID inside one directory causes an error, and those packages are not written to the database.
- When the winning package's manifest or dimensions do not load completely, the existing record is kept and cleanup is skipped. The covered package is not used in its place.
- Finding worlds looks only at the direct subfolders of a directory. A package whose manifest is a symbolic link is rejected. Hidden temporary directories are ignored.
- The folder name does not have to match the
id. Creating a session, the preflight check and data sync all find a world package by theidin its manifest.
Browser local storage. When the browser's private storage is used, worlds are kept in the browser:
- The first time an empty database is initialized, the full catalog is imported from
GET /api/worlds. It keeps the manifest ID, the lore, the dimensions, the characters and the package information. - If the catalog request fails, initialization is not counted as done, and it can be retried. Getting an empty catalog successfully is a valid result.
- After initialization, the worlds in the browser are managed only by local edits and deletes. Later starts do not request the catalog again and do not overwrite anything automatically, and the worlds can be read offline. Existing local worlds are never overwritten by initialization.
- Later changes in the server catalog do not update these copies automatically.
Updating files and installing
Where installs go. An installed world package, and an AI-generated world that was saved as files, are both written to the user world directory. The built-in directory is never changed.
- After a successful install, the world appears in the world list at once. No restart is needed (the API returns
restartRequired: false). - If activation fails, only the folder created for this install is removed. You can try again.
- A ZIP install validates the world data first. If there is an error-level problem (a missing descriptor or source, a file that cannot be parsed, or data that does not match its own schema), it returns 400, creates no world record, and removes the folder created for this install. After you fix the problem, you can retry with the same ID. Warnings do not stop an install.
- This check reads only the data in the package. A local override cannot hide errors in the package, and it does not run community code.
- The seed at startup is not affected by this. A package with bad world data is loaded with the old, lenient behavior.
For installing and updating from GitHub, see world catalog and installation.
Automatic reload after a file change. The server watches the built-in and user world directories (including on Linux).
- After a file in a world package changes, Covel waits 500 milliseconds to combine a run of changes, and then reads the package again. Changes to images, audio and editor temporary files do not trigger a reload. Changes to
world.yaml,WORLD.md, language versions,data/,characters/,media/*.jsonand similar files do trigger one. - After reading, Covel finds the world by the manifest's
id. If anything is different, it updates the world record and writes to the log what was reloaded: the lore, the name and summary, the dimensions, the plugin policy and settings, and the world data and settings. - If the manifest or the lore cannot be read, a warning is logged, and the version already loaded keeps being used.
- A world that a player has edited in the app (
packageModified) keeps the edited record. File changes do not overwrite it. See World records and world packages. - A reload does not change the world's source or its creation time. An AI-generated world does not turn into a built-in world that cannot be deleted just because it was reloaded.
What a reload affects:
| Target | Result |
|---|---|
| New sessions | Use the reloaded world record, plus the content of data/ and media/ in the package |
| Lore of existing sessions | The next turn reads the new lore. A session that has its own loreOverride keeps using it. |
| Data of existing sessions | Characters, lorebook entries, dimension values and existing state are not rewritten automatically. Players who want the new starting data need to start a new session. Authors and tools can call the sync API. |
- When a dimension declaration changes, sessions that use this world receive
world.dimensions.changed. This is only a notice. A value that has already changed in a session is not reset to the new initial value. Authors and tools must call the sync API to try to update the session's declarations. Dimensions with conflicts stay as they are. - This is not a hot update of the whole package. Putting a new world folder into the directory only makes it appear after a restart, or through the install entry point. A plugin install also still needs a server restart.
- When the file system does not support watching, a warning is logged at startup. Dimensions can also be imported through
POST /api/worlds/:id/dimensions/import. - The implementation is in
apps/server/src/world-file-watcher.ts. For the install response, see API reference.
The dimension file must be readable. data/dimensions.yaml and other sources targeting world:metadata.dimensions follow the same rule: if the file cannot be read, has a YAML error, or fails validation, the whole world fails to load this time. The previous complete record is kept, no dimension-change notice is sent, and the old world is not cleaned up. Automatic reloading continues once the file is fixed.
Deleting an AI-generated world that was saved as files (generated-file):
- First, check
metadata.storage.pathin the configured world directories. Then find the one world package in that directory that matches the manifest ID. When an old record has no storage location, it must match exactly one package across all directories. - When the location is invalid, the package is missing, or there is more than one candidate, the API returns
409 world_package_unresolved. Neither the files nor the database record are deleted. It never falls back to deleting a package with the same name in another directory. - When deleting, the package is first moved to a temporary location in the same directory, and then the database record is deleted. If the database step fails, the folder is moved back, and you can retry.
- After success, a failure to clean up the temporary files is only logged. That temporary location is never loaded again as a world.
- This only makes sure things can be recovered when an error happens inside the running process. If the process crashes between the two steps, the files and the database are not guaranteed to match.
What stays the same when a world record is edited:
- The world's
metadata.source,metadata.storageandmetadata.packageDigestkeep their original values through a normal update request (PATCH). Editing or browser sync does not change which files the world belongs to. - A world that came from a world package (
metadata.sourceisfileorgenerated-file, or it haspackageManaged) is markedpackageModifiedwhen it is edited. See World records and world packages. - Dimensions follow
metadata.dimensions. The top-leveldimensionsis a way to read the same content. When only top-level dimensions are given, they are written tometadata.dimensions. Whenmetadata.dimensionsis replaced or cleared, the top level is updated too. All three database backends behave the same way.
Default plugin settings (pluginSettings)
At the top level of world.yaml (at the same level as pluginPolicy), you can write pluginSettings to preset this world's default values for plugin settings. The format is plugin ID → setting → value:
pluginSettings:
story-events:
planner: true
chat-mode-narrator:
dialogueRatio: 70The value of a setting is decided in this order. The earlier item wins:
- The value the player set in the settings.
- The world's
pluginSettings. - The default that the plugin declares for itself (
contributes.settings[].default).
- The player can still override every value.
- A setting that the plugin does not declare does not cause an error. It just has no effect.
pnpm validate:worldgives anunknown-settingreminder. A value with the wrong type, range or options gives aninvalid-settingerror. pluginSettingssets only default values. It does not decide which plugins are turned on. That is decided bypluginPolicy.
For plugin authors: after loading, the value is stored in WorldRecord.metadata.pluginSettings. At the start of each turn, it is merged with the player's values (the request header X-Plugin-User-Settings) and put into TurnInput.userSettings. The agent's {{ userSettings.* }}, the guards and the hooks all read the same data.
World memory definitions (memory.blocks@1)
Memory definitions tell the memory plugin which kinds of things this world should remember over the long term, such as clues and promises. They are imported like any other plugin data. data/memory-blocks.json is one object, with id fixed to world. It is not an array:
{
"id": "world",
"blocks": [
{
"label": "clues",
"displayName": { "zh-CN": "线索", "en-US": "Clues" },
"extractionHint": {
"zh-CN": "记录已发现的线索及关联。",
"en-US": "Track discovered clues and their connections."
},
"icon": "Search",
"maxChars": 1200
}
]
}When the file is at data/memory-blocks.json, it is read by convention. When you use a descriptor, write it like this:
sources:
memory-definitions:
kind: json
path: data/memory-blocks.json
schema: contract:memory.blocks@1
to: contract:memory.blocks@1
key: idlabelmust match^[a-z][a-z0-9_]*$.displayNameandextractionHintcan be written separately for each language.maxCharsis a positive integer.- The definitions load in this order: the base definitions fixed by the plugin, then the definitions that other enabled plugins provide (the
memory.block-definitions@1service), and last the world's definitions. When alabelis already taken, the one loaded first is kept. - The object is imported into the
definitions/worldrecord of thememoryplugin itself. The memory content is stored in that plugin'sblocks, and the plugin puts it into the prompt.world.yamlhas no dedicated field for memory definitions. - All four built-in worlds use this file.
Import by convention
Every world package is read by convention. If a file (or, for media, a directory) exists at a convention path, it is a source, and you do not register it. A descriptor only writes what differs from the conventions. See Descriptor.
The conventions come from two places:
- Kernel:
data/dimensions.yaml→ dimensions (world:metadata.dimensions)data/lorebook.yaml→ lorebook (lorebook)characters/characters.json→ characters (characters)
- Plugins: the
kind/path/keythat each data contract declares incontributes.data.<ns>.authoring.source. A path can be one kind of file in a folder, for exampledata/rules/*.yaml: each such file in the folder is a source, imported in file-name order.pnpm describe:authoringlists all of them. For example:
### Starting quests — `quests@1` (plugin `core-quest`)
Importable quest records — world packs may preseed main/side quests; the quest log advances them by name.
How to write it: Seed the main quest and, at most, a few side quests. `status` is `active`, `completed` or `failed`. Each objective has `text` and `done`; give it an `id` so later updates can address it. The quest log advances quests by name, so use the quest and giver names that the lore uses.
Put the file at `data/quests.yaml`: it is imported without a descriptor. The same source in a descriptor:
quests:
kind: yaml
path: data/quests.yaml
schema: contract:quests@1
to: contract:quests@1
key: idRules:
- The import order is: dimensions, then media, then the other contract data, and last
characters. - All four built-in worlds are read this way: they have no descriptor, and every source comes from a convention path.
- The conventions of the plugins enabled in this session are used. A plugin that is not enabled, or that failed to load, does not affect what is read.
- Each conventional source has an ID, which a descriptor's
afterand an override use to name it: for the kernel,dimensions,lorebookandcharacters; for a plugin, its namespace, or<plugin ID>-<namespace>when two plugins use the same namespace; for a file in a folder,<convention ID>-<file name>, for examplerules-tide-mysteryfordata/rules/tide-mystery.yaml. - When two contracts enabled in the same session declare the same path, that path is not a convention, the file is not imported, and the world package must use a descriptor to say which one it is for. The session still starts. The server log records a
[sessions]warning, the world data preflight returns aconvention-collisionwarning, andpnpm validate:worldreports it too (see Static validation).
For plugin authors: path is a path relative to the world package root. It can contain only ASCII letters, digits, _, ., / and -. It cannot start with /, and it cannot contain ... Its last segment can be *.<extension>, meaning every file of that kind in the folder (not for a media source, which is a folder already). It also cannot be a path the kernel or the world package owns: world.yaml, WORLD.md, data/world.data.yaml, data/dimensions.yaml, data/lorebook.yaml, characters/characters.json, media/gallery.json, media/portraits.json, media/scenes.json, the media/gallery/ folder, or a language version of one (such as world.zh-CN.yaml), compared without regard to case. A language version names a two-letter language code (.fr, .zh-CN) or a three-letter one with a region (.yue-HK); a suffix such as .bak or .min is part of the file name, so characters/characters.bak.json is allowed. A plugin manifest that does not follow this fails validation when it loads.
When you still need a descriptor:
- The file is not at a convention path. For example, you want a file name in Chinese, or a contract's file in another place.
- Two plugins declare the same path, and you need to say which one the file is for.
- You need to set the order with
after. - You want to turn off a conventional source, or change one of its fields (for example
visibilityormerge).
Unused files. pnpm validate:world checks whether any file in data/, characters/ and media/ (including subfolders) is not used by any source or framework feature. If one is not, it gives a data-file-unused reminder. Both import methods are checked. Language files of a source that already exists, local schemas, dimension files, theme music and gallery images are not reported by mistake. A README is author documentation and is not checked.
Descriptor
The descriptor is a YAML file, usually called data/world.data.yaml, that world.yaml points to with worldData. It does not repeat the conventions; it writes only what differs:
sources:
# change a conventional source: same path as the convention, only the fields to change
lorebook:
path: data/lorebook.yaml
merge: skipExisting
# turn off a conventional source
portraits:
path: media/portraits
enabled: false
# add a file outside the conventions: write the whole source
harbor-rules:
kind: yaml
path: data/港口规则.yaml
to: contract:world.rules@1+lorebook
key: id
after: dimensionsWhat an entry does depends on its path:
- A
pathequal to the path of a conventional source changes that source. The fields written replace the convention's fields; the others keep the convention's values.enabled: falseturns it off. The source then takes the entry's name as its ID. - Any other
pathadds a source. Write it in full then, with at leastkind,pathandto. - When the conventional source being changed belongs to a plugin this session has not enabled, the entry does nothing.
- Two entries with the same
path, or an added source that takes the ID of a conventional source, are errors.
The import order goes by kind first: dimensions, media, other data, and last characters. Within a kind, conventional sources come first and those the descriptor adds come after; after adjusts from there. An override on the player's machine changes things once more after the descriptor, matching sources by ID.
The fields you use most often in each source:
| Field | Description |
|---|---|
kind | File type: yaml, json, markdown, text, or media |
path | A file or a directory, relative to the world package root |
to | Where the data goes. See Target URI. |
key | The field in each record that is used as its unique ID. Usually id. |
after | The source that this one must be imported after |
schema | What to validate against. Usually you can leave it out (see below). |
For a field-by-field explanation, see the generated World data descriptor field table.
- The name of a source (the key under
sources) must match^[a-z][a-zA-Z0-9_-]{0,63}$. - At the top level, write only
sources, including in override descriptors. Unknown top-level fields are ignored with a warning. Unknown fields inside a source remain errors. - An entry that changes a conventional source needs only
path. An entry that adds a source is written in full, like a source an override adds.
When to write schema:
- When the target is
contract:<id>(including+lorebook), the data is checked against that contract's schema. You can leave it out. - When the target is
world:metadata.dimensions, the data is checked against the dimension schema. You can leave it out. - You need it only when you want to check against a JSON Schema file in the world package.
- When it is left out and no plugin declares a schema for the contract, that source is not checked. When you write
schema: contract:<id>and no schema can be found, that is an error; session imports do not look up schemas for sources they have already skipped.
Media sources need both key: filename and indexTo: contract:<contractId>:
- A missing
keyis an error. - Without
indexTo, nothing records which images exist, so these files are not imported. - When the plugin that receives the image index is not installed or not enabled, the files of this source are not imported either. This is a warning, and it does not affect other data. When the receiving plugin is enabled later in the game, its data is imported once on its own (see Receivers that join after the game starts).
Images visible before a session
The images in a world package can be seen by the player before a session is created. The world list and the world detail page read them directly from the world package through GET /api/worlds/:id/gallery.
- When the world package has
media/gallery.json, the images and descriptions in that manifest are shown, up to thespoilerLevel: openinglevel only. - When it does not, the images in the public
kind: mediasources are shown. Sources withenabled: falseorvisibility: hiddenare not included.
For the API and the display rules, see api.md and world art direction.
Hidden data (visibility: hidden)
A source is public by default. A source with visibility: hidden holds content that must not be known until a condition is met, such as hidden plot.
- It can be given only to a plugin (
to: contract:<contractId>). - After import, it is kept in a separate hidden area of the receiving plugin,
_hidden.<namespace>. For example,eventsofstory-eventsmaps to_hidden.events.
sources:
storyEvents:
kind: yaml
path: data/hidden/story-events.yaml
schema: contract:story.events@1
to: contract:story.events@1
key: id
visibility: hiddenThe framework makes sure hidden data does not leak through these outlets:
| Outlet | How it is handled |
|---|---|
| Prompt | Injecting a hidden namespace with io.selfData causes an error. ctx.pluginData in the extension points (prompt.segment@1, ui.slot@1, session.world-context@1) cannot read hidden data. |
| Model tools | Dataset tools never reach a hidden namespace: dataset names start with a letter. |
| Player-facing API | The plugin data API, the /state data panel and discovery do not list hidden data, and a single read returns 404. Snapshot read APIs (GET / POST /snapshots) also remove hidden namespaces from their responses. Snapshots in storage stay complete. |
| Lorebook and projections | +lorebook is not allowed. worldProjections skip hidden sources. |
| Other targets | characters, lorebook, world:metadata.*, media and indexTo are all rejected. |
| Writes | The REST API and dataset tools cannot write it. Only the owning plugin's own code (function runtimes and the plugin's local tools) can write to its own _hidden.*. |
Only the receiving plugin's own code can read hidden data. When the plugin decides to reveal it:
- It gives the content, during this turn, to the party that needs it (usually the narrative).
- It leaves a reveal record in the public data that does not contain the original text.
The built-in story-events does this. Once revealed, the content is an input to the narrative for this turn, and it appears in the narrative's execution details.
WARNING
Hidden data is meant to prevent spoilers, not to encrypt anything. The world package files are on the player's own computer. In browser local mode, saved progress also holds this data so that it can run. Do not put information that really must stay secret into a world package.
Hidden content added during play
Hidden content can also be created during play. A runtime publishes output with a contract, and the receiving plugin validates it and writes it into its own _hidden.<namespace>.
A runtime that handles this content must declare io.concealed: true. Then its prompts, tool arguments, results and output do not go into traces, the live stream, /view, /turns, or the return values of manual RPC.
The built-in chain works like this:
story-events/plot(an agent, post-turn, runs every 3 turns, and runs when theplannersetting is on) reads the world summary, the protagonist, this turn's narrative, the optional WorldIR extraction for this turn, the dimension values, the world time, and the ledger thatevaluatemakes public. The ledger holds only the IDs and titles of events that have happened and events that have been set up. It does not include the world author's events that have not happened yet. It then callsplan-story-eventsto publishstory-event.plan@1.story-events/intake(a function, post-turn) validates each plan:- Whether the structure of each event is correct.
- Whether the dimensions, time fields and events it refers to exist.
- It cannot overwrite the world author's events or events that have already happened.
- At most 8 planned events can be waiting at the same time.
An event that passes is written to
_hidden.planned, and it fires only once.story-events/evaluateevaluates both_hidden.eventsand_hidden.plannedon every turn. When a condition is met, it is handed to the narrative as usual.
Planned events and author events are kept in different hidden areas, so syncing world data does not overwrite planned events.
Language files (<name>.<locale>.<ext>)
The main file is written in one language only, the defaultLocale in world.yaml. Other languages go in <name>.<locale>.<ext> next to the main file. They hold only the translated text and do not repeat the structure.
The files at the root say which editions a world has: with a world.<locale>.yaml or a WORLD.<locale>.md, the world has an edition in that language, in addition to defaultLocale itself. world.yaml has no field that lists languages.
world.yaml # main file, text in defaultLocale
world.en-US.yaml # English translation only
data/dimensions.yaml
data/dimensions.en-US.yaml
characters/characters.json
characters/characters.en-US.json
WORLD.md
WORLD.en-US.md # lore-type files have no id to line up on, so the whole file is replaced# world.yaml
name: 雾港・裂潮纪
characterSchema:
attributes:
- id: fogRot
name: 雾蚀
type: number
# world.en-US.yaml —— text only
name: "Mistport: The Riftide Chronicle"
characterSchema:
attributes:
- id: fogRot
name: Fog RotFile names:
- A language has one spelling only. When the world has
world.en-US.yaml, that edition isen-US, and the other files are also named….en-US.…. When the edition isfr(world.fr.yaml), the exact name is….fr.…. - A language-only file such as
….en.…is not read. Covel does not guess thatenshould be used when the session isen-US.pnpm validate:worldreports alocale-file-nameerror and gives the file name it should have.- When the server loads the world, it writes the file name and the correct name to the log.
- When an installed or imported community world has this problem, it is rejected with 400 and the same explanation.
<locale>must be a real language tag (en-US,zh-Hant).items.backup.yamlis not treated as a language file.- Fallback between session languages is not affected. For example, a session in
zh-TWreads thezh-CNversion. This chooses among the versions that the world really has.
How a translation lines up with the main file:
- Objects are matched by key.
- Lists of objects are matched first by the source's
key, and then byid. A source can uselocaleArrayKeysto declare extra stable fields in nested lists (for examplelocaleArrayKeys: [label]). For each list, the first field that every element has is used, and the translation must keep it. This way, inserting, deleting or reordering items in the main file does not put a translation in the wrong place. Without these fields, items are matched by position. - A field used for matching is not translated, even when it is Chinese or looks like display text. The translation tools do not extract it either. A field with the same name in other places can still be translated.
- A language file can translate only text that already exists in the main file. A key or ID that is not in the main file, text in a place that is not text, or a changed number or boolean is ignored and reported, and the main file's value is kept.
- Text that is not translated falls back to the main file, so you can translate a little at a time.
- A language file holds only the fields used for matching and the translations. Do not copy things that change, such as media hashes and paths.
- A plain text list (for example aliases or trigger words in
keys) is translated as a whole. The list in the language file replaces the list in the main file. An item written asnullmeans "use the main file's value". Triggered rules andselectivelorebook entries needkeysin the session's language; otherwise, a player using the translated words cannot trigger them.
How the three kinds of file are read:
| File | How it is read | Result |
|---|---|---|
world.yaml, dimension definitions (sources with to: world:metadata.dimensions) | All language files are read together | Text carries all the languages. The world list and the panel labels are shown in the UI language. |
| Other structured sources (JSON / YAML) | Only the one copy that matches the session language is merged. The locale in the file name must match the session language exactly. | Single-language data. Sessions do not store several languages. |
markdown / text / media | A language file replaces the main file as a whole | Same as above |
Other rules:
- The session language is fixed when the session is created, and it is always an edition the world has. The file name must match it exactly.
zh-Hant-TWdoes not read thezhfile. - Language files are part of a source. Changing any one of them counts as the source changing, and sync can see that.
- A main file cannot write text as a multi-language map (
name: { zh-CN: …, en-US: … }).pnpm validate:worldreports this as aninline-locale-maperror. A world package written this way must be split into a main file and language files. - In dimension values, only a text node that the schema marks with
x-i18n: truecan be translated. Translating any other node makes validation of that dimension fail.
For plugin authors: the merge rule is applyLocaleOverlay in @covel/shared. Pass the session’s locale to prepareWorldDataImportForSession and preflightWorldDataForSession. When syncWorldDataForSession receives no locale, it uses session.locale.
Target URI
to says where the data goes. These are supported now:
| URI | When it is imported | Description |
|---|---|---|
world:metadata.<path> | When the world loads | Written to a sub-path of the world record's metadata. For now, only world:metadata.dimensions takes effect. |
contract:<contractId> | When a session is created | Given to every enabled plugin that declares it accepts this contract |
contract:<contractId>+lorebook | When a session is created | The same, and also creates one entry in the session's lorebook at the same time |
lorebook | When a session is created | Written straight into the session's lorebook |
characters | When a session is created | Writes general character records into the session |
media + indexTo | When a session is created | Imports media files, and gives the index to the plugin named by indexTo |
- A media source uses
to: mediawithindexToin contract form. A structured source cannot settotomedia. This kind of wrong combination is rejected during validation. - A path in
world:metadata.<path>cannot contain dangerous segments such as__proto__. Paths other thandimensionsare only recorded for now, and they have no effect. - World data is imported when a session is created. Authors and tools can update existing sessions through
sync-data. Callingpreflightfirst lets you see the diagnostics before anything is written. The app has no sync control for players. - The create-session response lists the data that was not imported in
worldDataNotices(warnings and errors, such as a receiving plugin that is not installed or not enabled), and the game screen shows them to the player.
Receivers that join after the game starts
The prep screen lists selected community plugins whose server code is not yet approved under “Needs approval.” They stay checked, but do not count toward enabled plugins, runtimes, narrative checks or receivers in the world-data preflight. Players can still start the game. The world data those plugins receive is imported after approval and enablement.
A plugin that is not enabled when the session is created gets no world data, for example a community plugin approved after the game starts. When a plugin is enabled in the game (PUT /api/sessions/:id/plugins/:pluginId), Covel imports once for the plugins that just joined:
- It writes data only for joined plugins that have no import ledger rows for this world yet. Other plugins' data and ledger rows stay as they are; turning the same plugin off and on again does not overwrite what the game changed.
- A community plugin's projection code runs only after it is approved for this game.
- When the plugin that joins is the game's first plugin responsible for dimensions (
world.dimensions@1), the dimensions the world declares are imported at once too. Turning on a second plugin responsible for dimensions is refused; see World Model. - The response's
worldDatasays which plugins received data, how many rows were written, and which data is still missing with the current plugins (notices). The game screen replaces its earlier notice with it. - If the import fails, the plugin stays enabled and
noticescarries an error.
Lorebook entries injected by player message
A lorebook is good for a large amount of setting material. An entry enters the prompt only when the player mentions a related keyword, so it does not take up space on every turn.
When the file is at data/lorebook.yaml, it is read by convention, and you do not register it. For another path, add a source with to: lorebook and key: id to the descriptor (see Descriptor).
# data/lorebook.yaml
- id: dragons
content: 龙族是远古时代最强大的种族……
strategy: selective
keys: [龙族, 龙鳞, Drakon]
- id: core-rules
content: 本世界的魔法必须遵守等价交换。
strategy: constantFields of an entry:
| Field | Description |
|---|---|
id | The ID of the entry |
content | The text. Required, and it cannot be empty. |
strategy | constant (injected on every turn) or selective (injected only when a keyword matches). The default is constant. |
kind | Another way to write it: constant or triggered (the same as selective) |
keys | A list of keywords. A selective entry must have a non-empty keys. |
position | Where it is inserted. The default is after_plugin. |
insertionOrder | A number used for ordering. The default is 100. |
enabled | The default is true. |
extra.scanDepth | How many earlier committed messages of the conversation to look back at, besides the current player message (this includes compressed messages). From 0 to 20. The default is 0. |
Matching rules:
- A
selectiveentry checks the current player message, and the most recent conversation messages set byextra.scanDepth. - Keywords in the Latin alphabet match whole words, and they accept plural and possessive endings.
lanternalso matcheslanternsandlantern's, but notlanternfish. - Keywords in Chinese, Japanese and Korean match as substrings.
- Matching is not case sensitive.
- A
selectiveentry withoutkeysis rejected by both the static validation and the import preflight.
Other points:
- An entry imported from a world package belongs to the world. An entry is identified by three things together: the session, the owner and the ID. So it does not overwrite an entry with the same ID that a player or a plugin created.
- A Markdown link in
PLUGIN.mddoes not make Covel load a file.references/*.mdand the customkeywordsin them are not part of the plugin runtime.
Contract resolution and preflight
contract:<contractId> can appear in three places: schema, to, and the indexTo of a media source.
- A contract ID matches
^[a-z][a-z0-9.-]*@[1-9][0-9]*$, for examplecharacter.blueprints@1. +lorebookcan be used only into. It creates one extra lorebook entry that belongs to the world. When there are several receiving plugins, it still creates only one entry.
How the schema and the receivers are found:
schemais looked up incontracts.<id>.schemain the root of a registered plugin. The file is in that plugin's folder.tomatchescontributes.data.<namespace>.acceptsin the plugin root. It is written to each enabled receiver in a fixed order.- These are two separate checks. The data first passes the source's schema, and then each receiver's own schema.
Results:
| Case | Result |
|---|---|
| No registered plugin receives this contract | Session preflight, creation, and later import or sync warn and skip only this contract’s writes. Other data imports as usual. |
| Some plugin receives it, but none is enabled in this game | Warning. Only the write for this contract is skipped. Other sources are handled as usual. |
| Several enabled receivers | Each one gets a copy. The data is not tied to a specific plugin ID. |
| A missing schema, a path that leaves the world package, an unreadable file, or an invalid value | Error. The import is blocked. |
Built-in schemas and schemas local to the world package are checked when the world loads. A contract's schema depends on which plugins are installed, so it is checked when a session is created or during preflight.
In a session without a direct receiver, matching active projections still read, validate, and process the source. Only when neither direct receivers nor projection consumers are available is the source skipped together with its schema checks and media import. Missing plugins do not prevent the game from starting. The author tool pnpm validate:world still reports unresolved contracts under the static validation rules: likely typos and --strict produce errors; other missing contracts warn by default. --plugins adds directories to scan for receiving plugins.
ZIP installation and AI file write-back share package preflight, without local overrides. Generation, revision, and translation validate a staging copy before publishing; failures preserve the original package. This does not require enabled receivers or replace session contract preflight.
WorldIR and plugin projections
This section is for plugin authors. World authors usually do not need it.
contract:world-ir@1 is a neutral format published by the world-ir plugin, used to describe the facts of a world. It lets a world author, or an upstream extraction step, maintain one set of world facts. Each plugin then turns that same input into its own data.
In v1, the top level and each kind of record reject unknown fields. Plugin-specific content can go only in attributes:
schemaVersion: 1
summary: 海滨校园中的人物、事件与规则。
entities: []
relations: []
events: []
statements:
- id: school-closing-time
type: rule
content: 学校每天十八点闭校。
attributes:
title: 闭校时间
kind: constantThe four arrays are always present:
| Array | Required fields | Use |
|---|---|---|
entities | id, type | Stable entities such as people, places, organizations and items. Can have name, description and attributes. |
relations | id, type, from, to | Directed relations between entities. Can have description and attributes. |
events | id, type | Events. Can have participantIds, time, description and attributes. |
statements | id, type, content | Facts, rules, goals, quests or notes. Can have subjectIds and attributes. |
A source runs plugin projections only when it explicitly sets effects: [projections]:
sources:
worldIr:
kind: yaml
path: data/world.ir.yaml
schema: contract:world-ir@1
to: world:metadata.worldIr
effects:
- projectionsOn import, Covel finds the contributes.worldProjections of the plugins that this game has finally enabled. It sorts them by pluginId/projectionId, and runs only those whose from is exactly the same as the source's schema. The handler receives:
{
value: WorldIRV1;
context: {
sessionId: string;
worldId: string;
sourceId: string;
locale?: string;
now: string;
};
}What the handler returns:
- It must be an object. Its keys are the declared output IDs. The value of each output can be one record or an array of records.
- An extra output, a missing key field, a handler path outside the allowed area, or a result that does not match the target's
contributes.dataJSON Schema is rejected. - Each projected record goes through the transaction, the import ledger and
sync-data, the same as normally imported data. Its source is recorded asprojection:<pluginId>/<projectionId>. - When no enabled projection matches, the source writes zero projected records. This is not an error.
Projection of living-world-rules: each source statement ID becomes world-ir- followed by the hexadecimal UTF-8 SHA-256 digest of that ID. The original ID is kept in sourceStatementId. This way, Chinese IDs, IDs with punctuation and very long IDs all meet the target's ID rules. If the source ID changes, the key changes with it. All WorldIR rules use the same mapping. Hand-written rules are not affected.
Execution limits:
preflightchecks only the source, the declarations, the target and the schema. It does not load or run any handler. Which keys a projection actually produces is known only after an import or a sync.Import and sync run only the matching declarations of the plugins enabled in this game. When one projection fails, it only produces a warning. It does not stop the original source from being written, and it does not affect other plugins' projections.
Each declared output either succeeds as a whole group or is not written at all. If any record has an invalid key or schema, the group keeps the last successful content and ledger, and old and new content are never mixed. Only when the handler successfully returns
[]does it mean "there really are no records". Only then are the old records deleted.A built-in plugin's handler can run directly. A community plugin's handler needs this game to explicitly allow it to run server code. Permission cannot be given before the session is created, so at creation a community projection does not run: the import only emits a warning and does not run the code in secret. When the player approves and enables the plugin, the projection runs once with the import for receivers that join after the game starts. To run it again for a newer world package, authors and tools call
sync-data; the app has no sync control for players.Each call runs in its own Worker. Its input is a structured clone:
Limit Value Timeout 1 second V8 old / young generation caps 128 / 32 MiB Stack limit 4 MiB JSON output limit 1 MiB Records per output 1000 Projections per source The first 32 after sorting Worker isolation is only there to stop state from affecting other work and to stop resource abuse. It is not a security sandbox. Code that has been allowed can still reach Node and the network.
The digest of the handler file, the identity of the projection and output, and the records actually produced are all counted in the source digest in the ledger. If only the handler changes and the world data does not, the next sync can still detect the change. If the digest of the handler is different before and after running, the result is thrown away. This prevents wrong source records from being written during a hot update.
When a session is created, Covel first reads the source, runs the projections, and builds an unchangeable plan. This happens outside the lock and the database transaction. Then the plan is applied in one transaction. Sync works the same way: a dry run takes no write lock. The actual write takes the lock only briefly, rechecks the world, the language, the enabled plugins and the permission scope, then checks for conflicts and applies the changes.
Discovery. Development tools and agents can find out how to write the projections effect and the contract URI through GET /api/framework/capabilities. They can then read each plugin's combined worldProjections through GET /api/plugins/:id. These APIs return only declaration information. They do not expose plugin root paths or handler paths, and they cannot call a handler directly.
Static data and extraction during play use the same contract:world-ir@1, but they run in different ways:
- Static world data uses the handlers described above.
- During play, the
world-iragent extracts facts from the narrative output (narrative-engine@2) once. Thencodex,core-quest/log,affinity,inventory/ledgerandnpc-graph/extractorread that one result in parallel.- Quests and items are function runtimes that read fixed fields only. They do not call a model.
- Affinity is still judged by the agent. For its fields, see tools.md · submit-world-facts.
inventory/vocabularyandcore-quest/vocabularyannounce, in the pre-turn stage throughworld-ir.vocabulary@1, the item names, quest names and open objectives being tracked. The extraction uses the same names. Per-objective assessment checks coverage and consistency with quest events.
- When this shared extraction fails, the runtimes that depend on it are skipped. The narrative for the turn can still be committed successfully.
- In a session where dimensions are maintained automatically, a
pending-settlementis still left behind. A failure or a skip must not be treated as "the dimensions did not change". The next narrative is blocked until recovery is complete.
World character schema
The characterSchema at the top level of world.yaml says which types and attributes the characters in this world have:
characterSchema:
types: [npc, companion]
attributes:
- id: affection
name: 好感度 # in world.en-US.yaml, write `name: Affection` under the same id
type: number
min: 0
max: 100
defaultValue: 0
category: socialtypeslists the character types other than the player.playeris a reserved type, so do not write it intypes. A world can have several player characters; which one the player plays right now is decided by the seats.attributesare the attributes a character can have.- When a session is created, the schema is copied into the session. A character's
fieldsmust match it, and a non-player character's type must be intypes. Tools, plugin reads and the final save all use the same validation. - Changing a world's
characterSchemaaffects only new sessions. An existing session must be changed explicitly inside the session.
For plugin authors:
- The proposal that changes a session's schema is
character.schema.set. Its content is{types, attributes}, and the kernel increments the version. - The guard of
world-inituses the schema the session already has first, then the schema the world declares. It calls the model to generate a schema only when neither exists. - Dimensions and character attributes are different things. A dimension's structure does not turn into a character attribute automatically. A schema is not copied from another session or from another plugin's private data.
One character file per world
A world writes all its characters in characters/characters.json, one record per character. When a session is created, the kernel turns each record into a character of that game.
[
{
"id": "npc-mio",
"name": "Mio",
"aliases": ["Class President", "澪"],
"type": "npc",
"description": "A fellow student.",
"fields": { "affection": 20 },
"blueprint": {
"persona": { "voice": "Soft, complete sentences." },
"dialogueExamples": [{ "character": "If you get lost, ask me first." }]
}
}
]| Field | What it is |
|---|---|
id | The character's ID |
name, aliases, description | Name, other names, description |
type | Character type, one of characterSchema.types |
fields | Attribute values, keyed by the attribute IDs of characterSchema |
| A field a plugin declares | One plugin's data about this character, for example blueprint of Character Profiles |
A plugin's data is written in the character record. A plugin declares authoring.characterField on a namespace that receives world data (see plugin manifest). The field of that name in a character record is one record of that plugin's data about the character.
- On import, the kernel gives this field to the plugins enabled in the game that accept the matching contract, keyed by the character's
id. - When the game has no such plugin, the field is ignored and the character is created as usual.
- The field does not go into the character's
fields. A plugin finds its data by the character'sid. pnpm validate:worldchecks the field against the receiving plugin's schema, and warns when no plugin reads it. It also warns when a new session does not enable a receiving plugin by default.- Language files match records by
id, and can translate the text in this field. pnpm describe:authoringlists the field each plugin declares (characterField) with an example.
Order. Domain characters enter the session in the order the author wrote them in the file. The panel and the roster in the prompt both use this order. When several sources write characters, they follow on in the order the sources are imported. Characters created during play come after them. (In the implementation, each character's createdAt is one millisecond later than the one before it on import, and the list is sorted by (createdAt, id).)
Aliases (aliases). Optional. Write the other names the story uses for the same person: a nickname, a title that refers only to this one person, or a name in another writing system. Write them in the form and order you want shown.
- The model that tracks characters, relations and affinity finds people by name. When you write an alias, and the model uses the alias, it finds this character instead of creating a new one.
- One name can belong to only one character. When an alias is the same as another character's name or alias,
pnpm validate:worldreports an error on the later record, and the session is not created with that data. Case, full-width and half-width characters, spaces and middle dots do not count as differences. - Do not write common nouns that could refer to other people, such as "scholar" or "landlady", as aliases.
- In a language file, the
aliaseslist replaces the list in the main file as a whole. - For the full rules, see aliases and name resolution.
ID. On import, the ID in the file (such as npc-mio) is used exactly as written: no prefix is added or removed. Character records are not copied into any plugin's data. The panel and plugins read the same data through ctx.world.characters or the character API.
effects: [characters]. This also writes the {id,name,type,description,fields} of each record of a contract: source as a character. A world's own characters belong in the character file and do not need it.
Roleplay notes (blueprint)
The built-in Character Profiles (character-blueprint) reads a character's roleplay notes from the blueprint field. When it is enabled in the game:
Field in blueprint | Where it goes |
|---|---|
persona (voice, style, summary, traits, goals, fears, secrets), rules, dialogueExamples | The <character-notes> section of the story runtime's prompt. The character is played using these notes. secrets is marked as known only to the narrator. |
scenarioDefaults.relationships, scenarioDefaults.state | The same prompt section. state is listed only as opening facts. It is not saved, and tools cannot change it. |
tags | Saved only; not in the prompt section |
Points to note:
- The prompt section holds only the characters in this game, except the player's character.
- The keys of
scenarioDefaults.relationshipsareplayer, a character'sidor theidof a dimension entry. The prompt section writes them as names. - Name, description, type and attributes belong in the character record only. A
nameor similar field inblueprintfails validation. - A state the model should be able to change must be declared as an attribute of
characterSchema, and written into the character'sfields. A switch written only inscenarioDefaults.statehas no way to be changed. - By default, AI world creation in the app generates only character records, and puts their voice and motives in
description. When the creator ticks "Roleplay notes", the generator also writes ablueprintfor each character.
For the length limits and the trimming order, see the plugin README.
Character presence portraits
Give characters avatars and portraits, which are shown in the character-blueprint portrait panel and in dialogue. For the full image production process, see character portraits. This section covers the data format.
A world package delivers two things: a directory of images, and a mapping file that says which image each character uses. When they are at the convention paths (media/portraits/ and media/presence.json), you do not need a descriptor. In a descriptor, they are two sources:
sources:
portraits:
kind: media
path: media/portraits # one directory level, holds <id>.webp
to: media
indexTo: contract:character.portrait-assets@1
key: filename
after: cast
presence:
kind: json
path: media/presence.json
schema: contract:character.portraits@1
to: contract:character.portraits@1
key: characterId
after: portraits- The first source imports the images under
media/portraits/into the media library, and gives the index tocharacter-blueprint. Each image uses the SHA-256 of its content as its identifier. - The second source,
presence.json, is an array. Each item points theavatar/spriteof the character named bycharacterIdto one image. - Write
characterIdas the originalidfromcharacters/characters.json(such asnpc-kamishiro-mio). After import, the character's ID is the same. When the front end matches, a character ID that is exactly the same, or that ends with-<characterId>, counts as a match.
[
{
"schemaVersion": 1,
"characterId": "npc-kamishiro-mio",
"displayName": "神代澪",
"avatar": {
"id": "0000000000000000000000000000000000000000000000000000000000000000",
"mime": "image/webp",
"size": 2155557
},
"sprite": {
"id": "0000000000000000000000000000000000000000000000000000000000000000",
"mime": "image/webp",
"size": 2155557
},
"visuals": {
"defaultVariant": "uniform-neutral",
"variants": [
{
"id": "uniform-neutral",
"outfit": "uniform",
"expression": "neutral",
"pose": "default",
"sprite": {
"id": "0000000000000000000000000000000000000000000000000000000000000000",
"mime": "image/webp",
"size": 2155557
},
"stage": { "scale": 1, "offsetX": 0, "offsetY": 0 }
}
]
}
}
]IMPORTANT
The id in an image reference must be the 64-character lowercase SHA-256 of that image's content. It must equal the identifier in the media library, or the image cannot be found. Do not calculate it by hand. Run scripts/emit-presence.mjs <world>, which generates presence.json automatically from media/portraits/. After you regenerate the portraits, you must run it again to refresh the hashes.
Variants (visuals). Optional. Use them to give one character several outfits and expressions. avatar / sprite are the base images.
- Each variant must have a unique
idand asprite. You can label it withoutfit,expressionandpose. stage.scale(0.5–2),offsetXandoffsetY(-100 to 100, as percentages) correct the size and baseline on screen of source images that are cropped differently.- The stage looks for an image in this order: the exact variant id, then the combination of outfit, expression and pose, then the default variant, then the base sprite or avatar. So when the story asks for an expression that has not been drawn yet, the character still appears, and the screen is not blank.
scripts/emit-presence.mjsgives each character adefault/default/neutral/defaultvariant by default. The author can keep adding outfit and expression images to the generated result.
Limits and enabling:
- A single media file can be at most 20 MB. The total of one source's directory can be at most 100 MB.
- The extensions that can be imported are
.png,.jpg,.jpeg,.webp,.mp3,.wavand.mp4. - The preflight check requires that the schema of the matching contract and the receiving declaration are registered. The
assetsandpresenceofcharacter-blueprintreceivecharacter.portrait-assets@1andcharacter.portraits@1respectively. - When the receiving plugin is not in the
presetpack, put it inpluginPolicy.plus, and a new session turns it on. When it is not enabled, its data and media index are skipped, and a warning is given; when it is enabled later in the game, its data is imported once.
For real examples, see media/ of worlds/mistport and worlds/haruka-academy.
Scene backgrounds
A scene background is an illustration of a place (a classroom, a club building, a sea wall), with one picture for day and one for night. For the full process, see scene backgrounds. This section covers the data format.
- Backgrounds that come with a world package are imported through
stage.scene-assets@1/stage.scenes@1, and thescene-stageplugin receives them. Using existing images does not require any image model to be set up. - The image role that the plugin's
modelPresetIdpoints to is called only whenscene-stage's automatic image filling is turned on and an image is missing. Installing a community illustration plugin does not choose a model for it. See image generation.
It uses the same image process as portraits, but with a different manifest. The author writes media/scenes.json by hand. A script generates WebP files from the manifest in batch. Then scripts/emit-scenes.mjs generates scenes.registry.json.
Fields of media/scenes.json:
| Field | Description |
|---|---|
id | The scene's ID, and also the file name prefix (<id>-day.webp / <id>-night.webp). |
name | The display name of the scene. |
locationRef | The place name the author defined. Separate the scene’s own place from its sub-locations with / (图书馆/二楼窗边长桌/旧刊架). The first part is the scene’s own place, and every part participates in matching. The built-in geography template's declaration path is geography.initialValue.regions[].name or landmarks[].name in it. This is not a dimension structure the framework requires. |
subject | The English description of the picture (daytime). The final image prompt is style.prefix + subject + style.suffix. |
subjectNight | Optional. A description used only for the night picture. If left empty, the night picture uses subject (together with style.nightSuffix). |
How a location in the narrative report matches a scene. Before comparing, spaces are removed and case is ignored. Use the rules below in order. Look at the next rule only when the previous one gives no result:
- The location equals the scene's
name, or one part of itslocationRef. - The location contains one of the names (the location "图书馆二楼" matches the name "图书馆"). The scene’s own place names (
nameand the firstlocationRefpart) take priority over sub-location names (the later parts). Within the same rank, the longest name wins. For example, "Library window seat" selects the library, even though the classroom’s sub-location "Window Seat" is longer. - One of the names contains the location (the location "海堤" matches the name "樱坂海堤"), and only one scene does this. When two scenes' names both contain the location, nothing matches, and the stage uses the world’s cover.
So write names in the words the narrative will use, and avoid letting two scenes share the same short fragment.
Both sources are required. One imports the pictures into the media library, and one imports the registry. At the convention paths (media/scenes/ and media/scenes.registry.json) both are read by convention. When you write them as sources, a media source must have key and indexTo. Otherwise the pictures are not imported (see the media source note above):
sources:
scenes:
kind: media
path: media/scenes
to: media
indexTo: contract:stage.scene-assets@1
key: filename
after: dimensions
scenesRegistry:
kind: json
path: media/scenes.registry.json
schema: contract:stage.scenes@1
to: contract:stage.scenes@1
key: registryId
after: dimensionsFor a working example, see worlds/haruka-academy/media/.
scenes.registry.json is generated automatically by scripts/emit-scenes.mjs. Do not write it by hand. After you regenerate the scene pictures, you must run the script again to refresh the hashes.
- Its shape is
{schemaVersion, registryId, scenes:[{sceneId,name,locationRef?,day,night}]}.dayandnightare image references identified by SHA-256. - The whole file is imported as one record.
registryId: "scene-registry"is a fixed value, and it also serves as thekey. A plugin reads this one record and gets all the scenes.
The only difference from portraits: a portrait needs only the index of each image. A scene picture needs the index, and also has to hand the whole registry to scene-stage.
TIP
When there are no pictures yet. If media/scenes.registry.json does not exist, world data validation fails, because the source refers to a file that is not there. Run emit-scenes.mjs once on an empty media/scenes/ directory. It produces a valid empty registry ({schemaVersion: 1, registryId: "scene-registry", scenes: []}). Put it in the world package as a placeholder first. When scenes is empty, the stage shows the world's header picture. This is not an error. After you generate the pictures, run emit-scenes.mjs again to overwrite the file.
Background music
A world package's background music is received by the soundtrack plugin (contracts stage.music-assets@1 / stage.music-tracks@1). Playing the music does not need any model. The plugin picks a track from the track list, based on the current scene and mood, and the app plays it (see ui-panels.md).
A world package provides two things:
- Music files under
media/music/:.mp3/.wav. A single file can be at most 20 MB, and the whole directory at most 100 MB. - A hand-written track list,
media/music.yaml.
Tracks (each item in tracks):
| Field | Description |
|---|---|
id | The track's ID. It must be unique in the list. |
title | Optional. The track name shown to the player when it plays. |
file | The file name under media/music/. Do not write a hash. On import, each file gets one index record, keyed by the file name. |
theme | Optional. true makes it the world's theme. It plays when no other track fits. |
scenes | Optional. A list of scene names used in the narrative. When declared, the track plays only in these scenes. A scene name matches when the names are the same, or when one contains the other. |
moods | Optional. The moods this track suits. The world author names them (see below). Case is not significant. |
loop | Optional. false means it plays once. The default is to loop. |
volume | Optional. The volume of this track (0–1). The default is 1. The player's own volume is multiplied on top of it. |
Moods. There is no fixed list of moods. The top level of the track list can have moods, which lists this world's music moods and when each one applies. The narrative sees only the names and descriptions here. When the story's mood becomes one of them, it sends a music.cue:
moods:
- id: battle
when: 刀已出鞘,胜负未分。
- id: 灯下
when: 回到有灯有火的地方,暂时安全。- When the top-level
moodsis not written, the mood list is the set of names that appear in each track'smoods. - In a world where no track has
moods, the narrative is not given a mood list. The music only follows the scene and the theme. Other plugins can decide how to switch later. silenceis a reserved word. It means stop the music.
Track order. The order of tracks is the order of priority. The plugin looks in this order:
- A track that fits the current mood and belongs to the current scene.
- A track that fits the current mood, with no scene limit.
- The current scene's own track.
- The theme.
- Silence.
The mood comes from the music.cue event that the narrative sends. The scene comes from the scene.set event (sent by the plugin that tracks the scene). If you want only one theme that runs through the whole game, put one track with theme: true in the list. It does not depend on any event.
NOTE
Put soundtrack in the world's pluginPolicy. These data are imported and played only after the player turns the plugin on.
How to declare it. Put the files at the two convention paths above. For other paths, write two sources in the descriptor. registryId: music-registry is a fixed value in the track list, and it also serves as the key:
sources:
music:
kind: media
path: media/music
to: media
indexTo: contract:stage.music-assets@1
key: filename
musicTracks:
kind: yaml
path: media/music.yaml
to: contract:stage.music-tracks@1
key: registryId
after: musicThe cover, the theme color and the music on the world list are also written in world.yaml. All three can be left out:
| Field | How to write it | Where it is used | When left out |
|---|---|---|---|
cover | The path of one picture: a .png / .jpg / .webp in a directory one level under media/, at most 20 MB, for example media/gallery/world-cover.webp | The world card, the world detail page, the session-prep screen and the session background | The interface uses its built-in default background |
accentColor | #rrggbb or oklch(72% 0.12 75) | The accent color of cards and titles | A fixed hue is calculated from the world ID |
themeMusic | The path of one audio file: a .mp3 / .wav in a directory one level under media/, for example media/music/theme.mp3 | Plays on the world selection page and the world detail page while that world is shown. No plugin is needed. | Does not play |
- The cover does not have to be listed in
media/gallery.json. If it is listed, the world detail gallery does not show it a second time. themeMusicis usually the same file as the one markedtheme: truein the track list.accentColoraccepts six-digit hex (#rrggbb) oroklch(L C H)with Oklab lightness from 55% to 90%; hex is converted from sRGB first. Loading discards colours with an invalid format or lightness outside this range. The world still loads, and the interface derives its default accent from the world ID. The server logs a warning with the world, field and reason.pnpm validate:worldchecks that the cover can be shown (cover) and that the music can be played (theme-music). An invalidaccentColoris still an error, with a message that explains the allowed formats and lightness range.- AI-generated worlds have no pictures, so they have no
coverand noaccentColor.
Third-party extensions
This section covers two things: how a plugin author lets a world package carry data for that plugin, and how to add data to a world that already exists.
Third-party content can be delivered in two ways: as a standalone world package, or as an override package. The plugin author should publish a versioned contract (contract:<contractId>), and the world package refers to it.
A standalone world package:
worlds/my-world-extra/
├── world.yaml
└── data/
├── world.data.yaml
├── characters/cast.json
└── dimensions.yamlAdding a local override to an existing world:
~/.covel/world-overrides/<world-id>/
├── world.data.override.yaml
└── data/
└── characters/cast-extra.jsonworld.data.override.yaml:
sources:
cast-extra:
kind: json
path: data/characters/cast-extra.json
to: characters
key: idIn an override, path is relative to ~/.covel/world-overrides/<world-id>/. After symbolic links are resolved, the path must still be inside this directory.
Companion plugin data
To see what the current version can preset, how each item is declared, and how to write it, run the command below. The output comes from each plugin's contributes.data.*.authoring declaration. It includes source entries you can paste directly and a valid example:
pnpm describe:authoring # text
pnpm describe:authoring --json # machine-readable
pnpm describe:authoring --plugins ~/.covel/plugins # also scan community pluginsHow a plugin receives world data. Publish both the schema and the receiving declaration in the root PLUGIN.md. Here is the manifest of the plugin social-sim (which is also its directory name):
description: Import relationship records.
contracts:
social.relationships@1:
schema: ./schemas/relationships.schema.json
contributes:
data:
relationships:
accepts: [social.relationships@1]
schema: ./schemas/relationships.schema.json- The schema is a JSON Schema file in the plugin's root folder.
- The same namespace can also be declared as a dataset; the two
schemapaths must be the same file.- Import and sync retain source, contract and
contributes.datavalidation and also check the destination dataset rules. Seeding, replacement and projection output count asset; a true managed removal requiresdelete, while replacement does not require it separately. Replacements use the physicalpluginId,namespaceandkey; changing only a source target URI (for example, adding or removing+lorebook) still counts as replacement. The ledger continues to distinguish target URIs.forcecannot bypass schema, operation or size limits. - A canonical source or embedded character contract block that violates destination rules fails the whole import plan. If any item of an optional projection output is invalid, that entire output gets a warning and is deferred. Sync preserves its old managed records and ledger; independent outputs can proceed.
- Hidden data uses its physical
_hidden.<namespace>policy rather than the public namespace's dataset rules; source and receiver schema checks still apply. Media placeholders first checksetadmission. Read-only preflight writes no media and executes no projections; the actual media index{ ref, filename, sourceId }gets record-schema and final-byte-size checks at apply. See Transactions · World Data import for rollback and media compensation.
- Import and sync retain source, contract and
- The receiving declaration is written only in the package's root
PLUGIN.md. It is not written inRUNTIME.md. - Plugin code reads its own data with
ctx.pluginData.get(namespace,key)/list(namespace). RPC usesctx.store, which is bound to the current session and plugin. Do not read another plugin's private data.
A plugin author must also do three things:
- Write the namespace, the contract, the data shape and an example file clearly in
PLUGIN.md. - Read its own data in a runtime or a tool.
- Declare
authoring.source, so world authors can put the file at a convention path and have it imported without a descriptor.
The world package side. It is best to use an array for data files, with one object per record:
- id: mio-rin
from: kamishiro-mio
to: asakura-rin
type: clubmate
score: 42The matching source:
sources:
social-links:
kind: yaml
path: data/social/links.yaml
schema: contract:social.relationships@1
to: contract:social.relationships@1
key: id
after:
- castImport ledger. When a session is created, each record that is actually written (plugin data, lorebook, characters and media index) is recorded in world_data_import_ledger. The ledger holds target, pluginId, namespace, key, sourceDigest, valueHash, schemaRef and sourceId. The session, plugin data, lorebook, characters and ledger are written in the same transaction. If the import fails, everything is rolled back. Later sync uses this ledger to tell which records came from the world package, and whether they have been changed.
Built-in RPG seed data (quests / items / affinity)
Three built-in RPG plugins accept data preset by a world package. For a step-by-step guide, see starting content. For a finished example, see worlds/emberback/data/.
| Plugin | Schema URI | to | Record shape |
|---|---|---|---|
core-quest | contract:quests@1 | contract:quests@1 | { id, name, description, status?, objectives?: [{id?, text, done?}], giver?, reward? } |
inventory | contract:inventory.items@1 | contract:inventory.items@1 | { id, name, quantity, description?, tags?: string[], equipped?: boolean } |
affinity | contract:character.affinity@1 | contract:character.affinity@1 | { id, name, score (int -100..100), notes? } (derived fields such as tier and history are filled in when a tool first writes them) |
- All three use
key: id. Their convention paths aredata/quests.yaml,data/items.yamlanddata/affinity.yaml. - A preset quest is only advanced by
core-quest, never rebuilt. It is best to give each quest objective anidthat is stable within the quest. Later, even iftextis slightly rewritten, the advance still ticks the same objective. - Preset items are the starting inventory.
- Preset affinity gives key NPCs a non-zero starting point. It can be positive or negative.
Preflight and sync
Preflight (preflight): looks through what this import would write, and what problems it has, without writing anything.
POST /api/worlds/<world-id>/world-data/preflight- When
pluginsis passed, preflight uses that plugin selection. - When
sessionIdis passed, preflight uses the plugins enabled in that existing session. - The built-in interface calls it automatically before the game starts. It shows what will be written, a summary of the targets, and the diagnostics.
Sync (sync): an HTTP API for authors and tools that applies later data changes in a world package to one specified existing session. The app currently has no sync control for players.
Updating a world package, hot-reloading files or approving a plugin's server code does not call sync automatically. Enabling a plugin imports once only for the plugins that just joined and have not received this world's data yet (see Receivers that join after the game starts); it updates nothing else. Players who want the new starting data should start a new session. Existing sessions keep their own data and progress. Lore still follows this page's rules under "Automatic reload after a file change."
POST /api/worlds/<world-id>/sync-dataThe request must include sessionId. By default this is only a trial run (dry-run). It returns the plan and upserted, deleted, unchanged and conflicts, without changing the session. Authors and tools should check the diagnostics and conflicts first, then pass dryRun:false to apply the eligible writes. See API for the request and response.
Sync reads data accepted by the plugins enabled in that session. It can add import records, update records that have not been changed, and delete records removed from the world package. It does not enable plugins, rerun setup or rewrite past story messages.
Sync rules:
- Overwriting and deleting existing records only handles import ledger records where
managed=trueandsourceWorldIdis the current world. Newly imported records are added to the same ledger. - A record is overwritten or deleted automatically only when its current content is the same as when it was imported (the hash matches the ledger's
valueHash). - When a record has been changed by the player or a plugin, the result is
conflicts.reason = "modified". - When the world package still has the record, but the record is gone from the session, the result is
conflicts.reason = "missing". - A record that has been removed from the world package and is also gone from the session is only cleaned up in the ledger. No conflict is reported.
- An ordinary imported record can be overwritten with
force:trueto clear amodifiedormissingconflict. This does not lift the protection on dimension values that have evolved or that are pending settlement. - A record with a conflict stays as it is and is listed in
conflicts. It does not affect the other records. New records that are not in the ledger (sources or portraits added later to the world package) are still written, and unchanged records are still updated or deleted. - A character changed by sync keeps its original
createdAt, so its place in the character list does not change. Characters added to the world package come after the existing characters. - When a source is skipped because it has no receiver, its existing managed data, ledger rows, and media references are preserved, even with
force:true. If only the primary target has no receiver, its direct writes and derived character and lorebook records are preserved while active projections sync independently. Once the receiver returns, successfully read empty data deletes the old records as usual.
Extra rules for dimensions:
- When the author's declaration has not changed, the current progress is kept, and the initial value is not applied again. A new dimension is initialized with its initial value.
- Changing or deleting a dimension that has already evolved, was edited by hand, or still has a turn pending settlement returns a
modifiedconflict. Both the value and the declaration are kept. - The protection for pending settlement does not depend on the import ledger. A dimension that was initialized during play, and so has no ledger record, is protected the same way. When the declaration has not changed, it is not a conflict.
- Only an imported initial value that has not been changed can take the new declaration or be deleted. When the schema changes, old values are not converted automatically.
- Sync checks the hash and version again inside the transaction. Passing preflight does not mean the data can be overwritten without conditions later.
- When a dimension has a conflict, the plan and the conflicts are returned. The conflicting dimension is kept, and other records without conflicts are still applied.
POST /api/worlds/:id/sync-dimensions is also only for authors and tools. It attempts to write directly to the one session specified by sessionId, with no dry-run. Use sync-data to preview first. It syncs only dimensions, using the same ledger and conflict rules. It does not clean up other data or rebuild the lorebook. A notice that a file was hot-updated does not change the session's current values by itself.
Protection of media files. Image and music bytes are stored by content, and several games share them. So the handling of deletes and failures is conservative:
- When sync deletes a media index record, it removes the current session's reference to that file only after the transaction commits. If the file still appears in the session's other index rows (files with the same content share one ID), messages or snapshots, the reference is kept, so pictures in the history stay readable and forking an old snapshot still works. The file itself is reclaimed by media cleanup (GC) when nothing uses it.
- During the import preparation, the file is written together with a temporary reference that belongs to this import (
media-pin:<uuid>). This stops the file from being cleaned up before the import completes. - When the import completes, the real session first takes ownership of the file and creates its reference. Then the temporary reference is released.
- When preparation, the transaction or session admission fails, only this import's temporary reference is released. Shared files are not forcibly deleted, and the references of existing sessions are not touched.
- When cleanup fails while a new session is being created, the session is deleted inside the same session lock, and its media ownership and references are released.
- When the data of a sync has already been committed but the cleanup fails, the temporary reference is kept, so the committed index does not lose its protection.
- When the cleanup of a new session fails, the temporary reference is always released, even when deleting the session fails: the session data left behind still refers to those files, and the scan before cleanup protects them.
- A failure to release a temporary reference is only logged. A process crash, or a failed release, can leave a temporary reference behind. It expires 24 hours after it was created, and
POST /api/media/cleanupreleases it. Until then, cleanup keeps those files (see Media store).
Boundary between a world package and a plugin package
- A plugin package holds the execution logic, the interface, the schema documentation and the default examples.
- A world package holds the concrete world data and media.
When a third party delivers a plugin and world data together, organize it like this (for publishing them together, see collections):
my-covel-pack/
├── plugins/
│ └── social-sim/
│ ├── PLUGIN.md
│ └── handler.js
└── worlds/
└── haruka-social-extra/
├── world.yaml
├── WORLD.md
└── data/
├── world.data.yaml
└── social/links.yamlworld.yaml turns on its plugin with pluginPolicy:
pluginPolicy:
preset: dialogue-mode
plus: [social-sim]When social-sim declares authoring.source, data/social/links.yaml is imported from the path it declares; otherwise add a source to data/world.data.yaml and write worldData in world.yaml.
How to publish an override
To add plugin data to a world that already exists, publish an override package. The installer puts the files here:
~/.covel/world-overrides/haruka-academy/
├── world.data.override.yaml
└── data/social/links.yamlAn override descriptor can add new sources. It can also override fields such as path, enabled and merge of an existing source:
sources:
social-links:
kind: yaml
path: data/social/links.yaml
schema: contract:social.relationships@1
to: contract:social.relationships@1
key: id
merge: skipExistingStatic validation (pnpm validate:world)
pnpm validate:world worlds/my-world
pnpm validate:world --strict --plugins ~/.covel/plugins ~/.covel/worlds/my-worldOutput when it passes:
✓ worlds/emberback/world.yaml- The validator does not create a session, and it does not run plugin code.
- It reuses the world data preflight that runs when a session is created. So data that passes here will not fail later, when a session is created, because of the content of its records.
- Plugins are looked for in the repository's
plugins/and in each--plugins <dir>. - When a world has editions in several languages, preflight runs once for each. This is because language files make different languages read different text. Every language file next to the main file is checked, not only the world's editions.
Checks that do not depend on the schema:
- The character source and
effects: [characters]check types and attributes against the world'scharacterSchema. - Lorebook records imported directly must have
content. - Records imported directly, and
selectiveentries produced by the+lorebookprojection, must both have a non-emptykeys. - Error messages include the file, the record number and the key. When an error is found before import, the whole plan is rejected, and the other valid records are not written either.
- An empty source gives a warning.
- When a receiving plugin declares with
authoring.referencesthat a value is theidof a character attribute, the value is checked against the world'scharacterSchema. A missing attribute, a wrong type or a number outside the attribute's range is anattribute-referenceerror. A world without acharacterSchemais not checked. - When it declares that a value is the ID of a dimension, the value is checked against the dimensions the world declares (
dimensionsinworld.yaml, ordata/dimensions.yaml). A dimension that does not exist, or a path beside it that does not reach a place the dimension'sschemaallows, is adimension-referenceerror. For example, an event condition that writesbarowAlarmforbarrowAlarmnever fires. A world that declares no dimensions is not checked. - Dimension references can also declare
values, for examplevalues: [equals, "in[]"]. Candidates must satisfy the dimension schema at the selected path, including types and enums; failures also reportdimension-reference. If weather allows onlysun/rain,equals: snoworin: [rain, snow]points out the invalid candidate. Seeauthoring.referencesfor the declaration and conservative checking rules. Session import, preflight and sync also check active receivers' dimension references against the saved world record's dimensions; an error prevents every write in the import plan. - In a record or a character field, a field whose name ends in
Ator istimestamp, with an ISO time as its value, is treated as bookkeeping, and the model does not read it. Each such field gives amodel-hidden-fieldwarning. To let the model see a time of the story, use a different name (date,diedOn), or do not write it as an ISO time. - A field of a record whose name is
idor ends inId/Ids, with a UUID as its value, is also treated as bookkeeping. No model reads it, and it gets amodel-hidden-fieldwarning too. Use an ID a person can read, in lower-case words and hyphens, for examplenpc-lin-yao.
Diagnostics at a glance:
| Diagnostic code | Level | Meaning |
|---|---|---|
manifest-invalid | error | world.yaml cannot be parsed or does not match the schema |
manifest-unknown-field | warning | world.yaml has an unknown top-level field, which loading ignores |
default-value | warning | A value that need not be written and can be deleted: characterSchema.types is the default [npc, companion], or pluginPolicy.plus lists a kind: core plugin |
lore-missing | error | One of the world's editions has no lore file |
lore-fallback-missing | warning | There is no WORLD.md, so a session in an undeclared language gets empty lore |
lore-narrator-only | warning | The narrator-only marker is missing its end, missing its start, nested or written wrong. The lore after it is hidden from the player. |
unknown-plugin | error or warning | A plugin ID referenced by pluginPolicy / pluginSettings is not in the scanned directories |
unknown-preset | error | pluginPolicy.preset is not a built-in pack |
unknown-pack | error | pluginPolicy.packs has an ID that is not a built-in pack |
preset-not-offered | error | pluginPolicy.packs is written, but preset is not in it |
pitch-without-preset | warning | pluginPolicy.pitch is written without a preset, so the text is never shown |
unknown-setting | warning | A key in pluginSettings is not a setting the plugin declares, so the value is ignored |
invalid-setting | error | A value in pluginSettings does not match the type, range or options the setting declares |
unprovided-contract | error or warning | A contract in pluginPolicy.requires has no provider |
unresolved-contract | error or warning | A data contract used by a source has no receiving plugin among the scanned plugins |
unrequested-receiver | warning | No plugin a new session enables by default receives this source. The source is not imported until the player turns a receiving plugin on. |
convention-collision | error or warning | More than one plugin declares the path of one of the world's files, and the descriptor does not say which one it is for. A session that enables those plugins together does not import it. |
world-data | same as preflight | The descriptor, source order, file reading, targets, hidden-data rules, and each record checked against its contract schema |
locale-file-name | error | A language file's name gives only the language (.en.) and not the locale of the world's edition (.en-US.). This file is not read. |
locale-overlay | warning | A translation in a language file has no place to go (the main file has no such key or ID, or it changed a value that is not text). That entry is ignored. |
locale-trigger-keys | warning | A triggered entry translates its content or title but inherits the main file's trigger words; add translated keys to that entry in the language file. It remains a warning under --strict. |
locale-script | warning | A language file for a language other than Chinese, Japanese or Korean still contains CJK characters: untranslated text, or a trigger word copied from the main file. |
edition-incomplete | warning | The world has an edition in a language, but that edition is missing some translations. A session in that language reads the world's original default-language text. |
inline-locale-map | error | The main file writes text as a multi-language map. The main file should have one language, and the translations go in language files. |
data-file-unused | warning | A file in data/, characters/ or media/ is not used by any source or framework feature |
attribute-reference | error | A value in a record that names a character attribute (declared by the receiving plugin's authoring.references) is missing from the world's characterSchema, has the wrong type or is out of range |
dimension-reference | error | A declared dimension reference is missing, the path beside it is not allowed by the dimension's schema, or a candidate declared in values does not satisfy the schema at that path |
model-hidden-field | warning | A record has a field that no model reads: a name ending in At or timestamp with an ISO time, or id / a name ending in Id or Ids with a UUID |
cover | error | The file that cover points to cannot be shown by the app |
theme-music | error | The file that themeMusic points to cannot be played in the world list |
prompt-size | warning | The content takes up too much of the prompt. See below. |
For unknown-plugin, unprovided-contract and unresolved-contract: a spelling close to a known ID is an error, and it suggests "Did you mean". Otherwise the default is a warning, because the provider may be a community plugin that was not scanned. With --strict, all of them are errors. pnpm release:preflight uses --strict for the built-in worlds.
The plugins a new session enables by default are the ones the session preparation screen ticks by default: the requested list of the preset pack and pluginPolicy.plus, plus the core plugins and the dependencies that requires brings in. Scanned community plugins count as approved. unrequested-receiver and convention-collision are judged against this set: convention-collision is an error when two of its declarers are in the set, and a warning otherwise. Records are still checked against the schemas of every scanned plugin, so the data of a plugin the player turns on by hand is checked too.
The three cases of prompt-size. None of them stops the world from being used:
| Content | Warning line | Note |
|---|---|---|
WORLD.md | 8,000 tokens | This is how much the story prompt can hold. Longer lore is cut after the last full line that fits. |
Combined constant lorebook entries in one source | 2,000 tokens | This content enters the prompt on every turn |
| Dimension initial values | 8,000 tokens | The summary of dimensions in the prompt |
Length is estimated in tokens: one token per Chinese, Japanese or Korean character, and one token per four other characters. So the same world is measured the same way, whatever language it is written in. Keep the essential setting in WORLD.md. Write setting that is needed only in certain situations as selective lorebook entries.
The validation logic is in apps/server/src/world-data/validate-world-package.ts. It returns structured diagnostics, which other tools can reuse.
Developer checklist
- Give each source a short name, such as
cast,social-linksorportraits. - Put
pathunderdata/,characters/ormedia/. schemarefers to a versioned contract, or to a JSON Schema file in the world package.touses a public data contract or a kernel target (characters,lorebookand so on). Which plugin and which namespace the data goes to is decided by the plugin's receiving declaration. The world package does not write it.keypoints to a stable ID field in each data object.- Use markdown / text sources for large blocks of text, yaml / json sources for large amounts of structured data, and media sources for images and audio.
- The paths of a world package and of an override package must both stay inside their own root folders.
Current limits
- A source reads only local files. The types are
yaml,json,markdown,textandmedia. - A media source scans only one directory level. It accepts only fixed extensions (
.png,.jpg,.jpeg,.webp,.mp3,.wav,.mp4). A single file can be at most 20 MB, and one directory at most 100 MB in total. mergesupports onlyreplaceandskipExisting.keysupports only a simple field name, a fixed key for markdown / text, andfilenamefor media.- Remote sources, SQLite sources, CUE, RO-Crate and complex JSON Patch overrides are not supported at present.
Related pages:
- World time is not part of dimensions. It is plugin data. Its contract is
world.time-definition@1, the record is written as{ id: world, definition }, and it is placed indata/time.yaml. AI-generated records of this kind keep the same contract in files, in server storage and in browser-local worlds. A world without a folder requires the record'skeyto equalvalue.id. For fields, backward time and random rules, see World time. - For the world package directory on GitHub, choosing several worlds at once, proxy downloads and the update process, see world catalog and installation.