Documents and the file format
Description: How a Narratyr project is laid out on disk, and why it is shaped that way.
Prerelease. This describes what Narratyr writes today, at document schema 0.0.79. The
shape of the tree is settled; individual fields inside the files still move between builds,
and this page is a guide to the layout rather than a field-by-field schema reference.
A Narratyr project is a small JSON file next to a folder of more JSON files. There is no database, no server, no lock file, and no binary container — every authored thing is a text file you can open, grep, diff, and merge with the tools your team already runs.
That is a deliberate constraint rather than an implementation detail. The layout below is designed around one question: what happens when ten writers edit the same project at the same time?
A project on disk
A project is a .ntproj file plus a sibling folder with the same name and a .data suffix:
MyGame.ntproj project-level metadata
MyGame.ntproj.data/
├── dialogues/
│ └── supoh-suvam-basut-vivod.ngraph one file per dialogue graph
├── quests/
│ └── lakur-tusoz-rigav-hazos.ngraph one file per quest graph
├── entities/
│ ├── character/
│ │ └── nidoh-tabit-ropat-bodiv.nentity one file per entity, under its type
│ └── item/
│ └── default-item-1.nentity
├── variables/
│ ├── globals.nvars variables, bucketed by namespace
│ └── player.nvars
├── folders/
│ ├── graph/
│ └── entity/
│ └── performances-path.nstub one marker per *empty* editor folder
├── assets/
│ └── portraits/female-2.png binaries, laid out by asset path
├── docs/
│ └── lore/organizations/white_glove.md your own Markdown, verbatim
├── _index generated, git-ignored
└── .gitignore generated
The .ntproj file is the thing you open, and the data folder is derived from its path by
appending .data. The two travel together: moving, renaming, or copying a project means
moving both. Rename the .ntproj file on its own and it reopens with none of its graphs,
entities, or variables — nothing is lost, but nothing is found either.
What stays in the project file
The .ntproj file holds the project-wide schema — the definitions everything else refers
to. It is small and changes rarely:
| Key | What it is |
|---|---|
id, version |
Project id, and the document schema version it was made with |
entityTypes, propertySets |
The shape of your entities, and their reusable field groups |
enumerations, functionDefinitions |
Named value sets, and functions available to expressions |
assets, assetPaths |
Asset metadata — id, path, hash, size, MIME type |
propertySetPaths |
The property-set folder tree |
projectInfo |
Project name, AI prompts, rich-text settings |
The collections that grow with your story are not here. graphs, entities, variables,
graphPaths, and entityPaths all appear in the file as empty arrays — they are placeholders
for what the loader fills in from the data folder.
Asset metadata is the one entry that looks like it should have split out but didn't: Narratyr reads it in a lightweight parse before the editor window mounts, to reconcile the asset folder with what the project expects.
What lives in the data folder
Graphs — dialogues/<id>.ngraph and quests/<id>.ngraph, one file per graph, split by
kind. Each holds the whole graph: its name, title, description, notes, editor path,
and a graphData object with nodes and edges.
Entities — entities/<entityTypeId>/<id>.nentity, one file per entity, in a subfolder
named for its type. An entity's base type cannot change once it exists, so that partition is
stable — a character never has to move between folders.
Variables — variables/<namespace>.nvars, a JSON array of variables per bucket. Unlike
everything else, the filename here is derived from content: the namespace is the part of the
variable's name before the last underscore, lowercased.
| Variable name | Bucket |
|---|---|
Player_Level |
player.nvars |
Act1_JarlQuest_KilledJarl |
act1_jarlquest.nvars |
jarl_quest_started |
jarl_quest.nvars |
PlayerLevel |
globals.nvars |
Splitting on the last underscore keeps nested namespaces and long snake_case names grouped
sensibly instead of collapsing everything under a single leading token. A name with no usable
prefix lands in globals.nvars. Nothing enforces this — it is a storage convention driven by
how you name things, and the variable editor shows you which file a name will land in as you
type it. Variables are the highest-churn thing an author introduces, which is why they are
bucketed at all rather than left in the project file.
Empty folders — folders/graph/<id>.nstub and folders/entity/<id>.nstub. A folder that
contains something is implied by the path recorded inside the items in it, so it needs no
file of its own. A folder with nothing in it anywhere is the only case that carries
information nothing else can reconstruct, so it gets a one-line marker.
Assets — assets/, the binaries themselves, laid out to mirror the asset paths you see in
the editor. The metadata that describes them stays in the project file.
Docs — docs/, your own Markdown files, stored verbatim. This tree is the model, so an
external editor pointed at that folder edits the same documents Narratyr does.
Generated files, safe to ignore
Narratyr writes a .gitignore into the data folder listing the files it regenerates. If you
have customized it, subsequent saves leave it alone.
_index— a tab-separatedid → kind → path + namelisting of everything in the project, rewritten on every save. It exists purely so you can grep an id you found in a diff and learn what it is. Being ignored, it never conflicts..fs-log.json— a record of pending asset operations, used to undo asset edits that hit the disk before you saved..save-journal.json— present only mid-save, or after a crash during one.
Why it is split up
The obvious alternative is one big file, which is what Narratyr used to do. It works fine for one author and fails predictably for a team: every save rewrites the whole file, so two people editing unrelated conversations produce diffs in the same place, and one person's merge conflict is everybody's.
Splitting per item changes the unit of contention from the project to one graph. Two writers on two dialogues never touch the same file, so there is nothing to conflict.
This matters most on Perforce, where locking is per file and three-way merging is weaker: a single project file means the whole writing team serializes through it, and studios using exclusive checkout feel that immediately.
Entities are split for the same reason even though each one is tiny. The win is partitioning, not size — two authors editing two characters shouldn't collide any more than two authors editing two dialogues should.
Filenames are ids, and only ids
Every per-item file is named for its item's id and nothing else. The human-readable name lives inside the file. Nothing resolves a file by its filename — the loader reads every file in each directory and takes identity from the contents.
This looks unfriendly and buys something specific: exactly one file per id, always.
Consider a rename. If the filename carried the name, renaming a quest would be a file move — and if one author renames a quest while another edits its contents, the same id now exists at two paths. Git's rename detection usually re-pairs them, but degrades to a modify/delete conflict plus a stray file once the two sides diverge far enough. Perforce has no merge-time rename detection at all, so if the move wasn't reconciled you land in the two-files-one-id state with no help from the tool.
With id-only filenames that is structurally impossible, and the awkward operations become the easy ones:
- A rename is a plain content edit — one field changes, the path never moves.
- A rename racing an edit is two edits to different regions of one file, which merges cleanly.
- Duplicate names are fine. Two quests can each have an "Intro" fragment, because names were never load-bearing on disk.
Ids are proquints — 64 random bits encoded as four
pronounceable five-character chunks, like nidoh-tabit-ropat-bodiv. They are readable aloud
and distinctive in a diff. Items that ship with a new project keep readable ids instead
(character, default-item-1), and the format accepts both.
Reading a file
Everything is UTF-8 JSON, two-space indented, with object keys sorted alphabetically at every level. Sorting is what makes diffs meaningful: the same logical data serializes to the same bytes no matter what order the editor happened to build it in memory. Within a graph, nodes and edges are sorted by id for the same reason, since their array order carries no meaning.
An entity — entities/character/nidoh-tabit-ropat-bodiv.nentity:
{
"externalId": "MiraThorne",
"fieldValues": [
{ "value": "Mira Thorne", "varId": "full-name-field" },
{ "value": "Female", "varId": "character-gender-field" }
],
"id": "nidoh-tabit-ropat-bodiv",
"name": "Mira Thorne",
"path": "/characters/npcs/",
"profileImage": "gumur-rahab-vapor-jupub",
"typeId": "character"
}
Field values are stored against the id of the field they fill, not its label, so renaming a
field in the entity type doesn't rewrite every entity that uses it. profileImage is an
assetId from the project file's assets array, and typeId names an entry in
entityTypes. externalId is the stable handle your game code uses.
A dialogue node, from inside a .ngraph's graphData.nodes:
{
"data": {
"choiceSlots": [],
"shortText": "Fair enough",
"speakerEntityId": "nidoh-tabit-ropat-bodiv",
"text": "Fair enough. Fire's warm, ale's cheap, and I don't ask twice.",
"type": "dialogue"
},
"id": "bizog-nikov-vojul-ribug",
"position": { "x": 1377, "y": 166.82 },
"type": "dialogueNode"
}
The envelope — id, type, position, width, height, parentId — is canvas geometry;
data is the authored content and its shape depends on the node type. Edges carry source,
target, and a sourceHandle naming which output a connection leaves from, which is how a
branch stays bound to its specific choice.
An empty-folder marker — folders/entity/performances-path.nstub:
{
"id": "performances-path",
"path": "/performances/"
}
If one file is unparseable — a merge left conflict markers in a .ngraph, say — the open
fails and names that exact file. Narratyr deliberately does not skip it and carry on: a
skipped graph would be missing from the loaded project, and the next save would delete it for
real.
The folders you see are not folders on disk
The tree in the editor is virtual. Each graph and entity records its own path — a string
like /characters/npcs/ — and the tree is rebuilt from those paths on load. On disk, every
dialogue is a flat sibling of every other dialogue.
Keeping the two apart means reorganizing your project is cheap: dragging a quest into a different folder rewrites one field in one file, rather than moving a file and asking source control to notice.
What a save writes
A save is not "re-serialize the project." Narratyr serializes each item, hashes it, and
compares against what it last wrote — then writes only the files whose contents actually
changed, and deletes the files of items that no longer exist. Editing one line of dialogue
touches one .ngraph.
That matters more than it sounds. A writer that rewrote every file on every save would put the whole project in every diff and be worse than a single file.
Because a multi-file save is many operations rather than one, each one is staged to a temporary
file and recorded in .save-journal.json before anything is promoted into place. Individual
writes land via atomic rename. If the app dies mid-save, the next open finds the journal and
completes the save from what was staged.
Working in Git
Commit the .ntproj file and the whole .ntproj.data folder. The generated .gitignore
already excludes the transient files.
- Diffs are legible. A pull request shows which lines of dialogue changed.
- Unrelated work doesn't collide. Separate graphs are separate files.
- Conflicts are ordinary text conflicts. Because nodes carry stable ids, a merged graph that keeps both sides' nodes is still valid and reopens with both branches present.
There is one honest regression to know about. A single project file tended to surface a broken
cross-graph reference — a quest node pointing at a dialogue somebody deleted — as a merge
conflict, which is loud. With split files, source control will happily merge "delete
quests/<id>.ngraph" against an unrelated edit that still points at that id, and the breakage
is silent until something reads it. Narratyr validates on load and reports dangling references
prominently, which is the intended catch for this; treat the report after a large merge as
expected rather than alarming.
Working in Perforce
Narratyr writes project files directly and does not call p4 edit, so run the workspace in
external-tool mode:
- Set the client option
allwrite, so synced files stay writable. Under the default,noallwrite, synced files are read-only and a save fails with a permission error. - Run
p4 reconcile("Reconcile Offline Work" in P4V) before submitting, to open changed, added, and deleted files.
Id-only filenames pay off here: every edit is an in-place content change, so reconcile only has
to spot edits, adds, and deletes. There are no moves to detect and no p4 move to remember.
One file instead of many
The split format is the default, but the whole document also round-trips through a single JSON file. Save As offers both:
| Extension | Format |
|---|---|
.ntproj |
Split — the project file plus its data folder. |
.ntprojc |
Combined — the entire document in one JSON file. |
The extension you pick is what chooses the writer. A third extension, .rpgproj, is the
legacy combined format from before the rename; existing ones still open and still save in
place as combined, but nothing writes a new one.
The combined form is the same content the split files are made of, so nothing is lost in either direction. It is useful as a single-file backup, as something to hand a tool that wants one document, and as a way to read a project top to bottom. It is not a good format for a team to collaborate in — that is the problem the split layout exists to solve.
Binary assets and Markdown docs live in the .data folder in both cases; only the authored
document collapses.
Schema versions
The project file records the version of the document schema it was created against. The
schema grows by adding fields, and every added field carries a default, so a project written
by an older build loads in a newer one without a conversion step — anything missing is filled
in as the file is read, and written out on the next save.
Until 1.0 that schema is still moving. Keep projects in source control, and if you are writing a tool against these files, read defensively: expect fields you don't recognize, and don't assume one is present because you saw it in another project.
Next
Dialogues and Quests cover what lives inside these files, and
Variables and conditions covers the state they share — including the naming
convention that decides which .nvars bucket a variable lands in.