Documentation menu

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

Graphsdialogues/<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.

Entitiesentities/<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.

Variablesvariables/<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 foldersfolders/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.

Assetsassets/, 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.

Docsdocs/, 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-separated id → kind → path + name listing 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:

  1. 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.
  2. 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.