What it is
A whole world in one file
A cartulary was the single bound volume a monastery kept to gather every charter, deed, and record that proved what it owned. One book, holding the documented existence of an entire estate. This is that, for a world you made up.
Download one HTML file, open it in a browser, and you have a worldbuilding database. Write your characters, cities, factions, gods, and wars as entries. Tie them together. Lay your history on a timeline, your bloodlines on a family tree, your continents on a map, your months on a calendar that runs by your own rules. Build a constructed language down to its sound changes if that's your kind of madness. All of it lives in that one file and your own browser, on your own machine.
There is no app to install, no account to make, no website you log into. The file is the program. Open it and it runs.
How it's built
The promises, and why they hold
Most software asks you to trust a promise: we won't raise the price, we won't sell your data, we'll still be here next year. Cartulary's promises are different because they aren't promises. They fall out of how the thing is made, and there's no lever to reverse them.
One file, offline, forever
Everything (the program, the fonts, the interface) is inlined into a single HTML document. Once it's on your drive it needs nothing else: no internet, no server, no dependency that can go dark. Run it from a hard drive, a USB stick, an email attachment you sent yourself. It'll open the same way in ten years, on a plane, in a bunker, after the company that made it is long gone.
Your data never leaves your machine
There is no cloud, no telemetry, no analytics, no account. Your world is saved in your browser's own IndexedDB and in the .json files you export. Nothing is uploaded, because there is nowhere to upload it to. There is no "us" sitting on a server with a copy of your world. We could not look at your data if we wanted to.
Free, and not as a pricing decision
Cartulary is MIT-licensed, and an MIT license can't be revoked on code that's already shipped. But the deeper reason it stays free is the architecture. There's no server to meter, no account to gate, no feature to lock behind a wall, because there's no backend at all. It isn't free because someone chose a price of zero and could choose otherwise tomorrow. It's free because the design leaves nothing to charge for and no way to charge for it.
Built with AI, and saying so
It would be dishonest not to tell you: Cartulary is written with heavy AI assistance. The design, the scope, every feature decision, and all of the testing are mine. The code itself is written by an AI model working under my direction; I decide what gets built and whether it's right, and the model does the typing. On the REAL Rating, a disclosure scale from the nonprofit Real Good AI that runs from REAL 0 (no AI) to REAL 5 (fully AI-made), that's a REAL 4: AI doing substantial production work under human direction. I'm telling you plainly because you're about to trust this file with work you care about, and you deserve to know how it was made. The source is right there inside the file, readable, if you'd rather check than take my word.
Get it
Download and open
Cartulary lives on itch.io. It's free. Grab the HTML file, put it somewhere you'll remember, and open it in any current browser.
There's no separate repository to clone. The HTML you download is the whole program, and a readable build ships the original code in plain sight inside it. If you want to read how a feature works, or audit what it does with your data, open the file in a text editor. What you run is what you can read.
Getting started
From empty file to first entry
- Open the file. Double-click the downloaded HTML, or drag it into a browser tab. It opens straight to an empty world. Nothing to set up.
- Name your world. It starts as Untitled World. Rename it to whatever you're building.
- Make an entry. Pick a template (Character, Location, Faction, and a couple dozen others) and create your first record. Fill in as much or as little as you like; an entry just needs a name, though a template can mark fields required (Location, for one, asks for a Type and a Subtype).
- Export to save for real. Cartulary autosaves to your browser as you work, but that copy is fragile: it's tied to one browser on one machine and a cache wipe can take it. Your true save is Export .json. A dated
.jsonfile is your backup and your version history at once. - Move it anywhere. Import that
.jsoninto Cartulary on another machine or browser and your world comes with it. The file travels; the tool is the same everywhere.
There is no undo across sessions and no cloud holding a copy. Export a .json often, and keep the dated ones. That single habit is the difference between a safe world and a lost one. Everything else you can learn as you go.
Core concepts
The whole model, in six words
Worlds hold entries. Entries are shaped by templates. Templates are made of fields. Tags and links connect everything. That's the entire mental model; the rest is detail.
- World
The top of the pile: one complete setting, with all its entries. You can keep several worlds and switch between them, but each is its own self-contained thing.
- Entry
A single record: one person, one city, one war, one god. The atom of your world. Everything you write is an entry of some kind.
- Template
The shape of an entry. Character is a template; every character you make shares its set of fields. Cartulary ships with a couple dozen built-in templates, and you can build your own when none of them fit.
- Field
One slot on a template: a name, a date, a paragraph, a dropdown, a link to another entry. Fields are what a template is made of, and you can rearrange or invent them.
- Tag
A free label you stick on any entry, regardless of its template. Tags cut across the whole world: mark every entry touched by a war, or everything set in one era, and pull them together later.
- Link
A field that points at another entry. Point a character at their home city and the two are joined; that connection then shows up on its own, in the relationship and map and timeline views, without you drawing it twice.
Once those six click, the rest of Cartulary is just tools that read them: views that draw your links as a graph or a family tree, exports that turn your entries into a document, a calendar that understands your dates. Each of those has its own page in this guide.
One limit worth naming up front: Cartulary is single-user. One world is open at a time, in one browser. Two people can't edit the same world simultaneously -- there's no real-time sync, no merge resolution, no comments workflow. The way to collaborate is to pass the .json file back and forth. If that's a dealbreaker for your use case, better to know now.
World settings
Your world, and how you keep it
Most of what you set here applies to the whole world, not a single entry, so I gathered the world-level controls in one place. The thing to hold onto is that your world lives in two places at once: in your browser while you work, and in the files you export. The browser copy is convenience. The file is the one you own. Only one world is open at a time, and you keep others as their own files. The sections below run in the same order as the panel on screen, so whatever you're looking at, you'll find it here in the same spot.
Appearance
Two cosmetic controls. Theme is either Default or High Contrast, where the high-contrast option swaps to a black, white, and yellow scheme for low-vision readability. Font Size runs from Small to Extra Large and resizes the whole interface. The one thing it leaves alone is the family tree, which has its own zoom. Set both to whatever you can read comfortably across a long session. Neither touches your data.
World
World Name and Description are labels for the world you're in. Name it something you'll recognize later, when you're staring at a folder of exported files and trying to remember which is which, and use the description for a line of context if you want one. Neither does anything mechanical.
Variant Filter
This is plumbing for constructed-language varieties, and only matters if you're using Glossopoeia's variety system. If that means nothing to you, leave it alone. It's covered on the Glossopoeia page.
Calendar
This section points at your world's calendars, which you actually build over in Kalendarium. What's worth knowing here is the current moment: the date your world treats as "now." You set it from a panel at the top of Kalendarium, and once it's set, a few features wake up. Living characters with a lifespan get their ages figured from it. Maps with time-bound pins can show the world as it stood on that date. Ephemeris can snap its sky view to the present. Leave it unset and those features just show everything regardless of when it happens. Building calendars and setting the moment are covered in full on the Kalendarium page.
Images
Images are the one thing that can make a save file balloon. By default, an image you add gets baked into the world as text. That's convenient, but heavy: the file grows by roughly the image's size plus a third, so a world full of art can swell from a couple hundred kilobytes into many megabytes.
Connecting an image folder fixes that. Click Connect Folder, point it at a directory on your drive, and from then on Cartulary writes your images into that folder and keeps only the filename inside the world. The save stays lean no matter how much art you add. Change repoints the folder, Disconnect ends the arrangement.
Browser support is the catch. Chrome and Edge handle this out of the box. Safari and Firefox don't offer the feature at all, so there the option falls back to baking images in and the file grows heavier. Brave is the in-between case: it has the feature but ships it switched off on purpose, for privacy. You can turn it on by going to brave://flags, searching for "File System Access API," setting it to Enabled, and relaunching. Whether you should is your call, not Cartulary's. Brave disabled it deliberately, so weigh that and decide for yourself. Either way, if you have connected a folder, keep your exported .json beside it, since the world stores those filenames and expects to find them there.
Access
Lock to view-only does what it says. It hides the edit, new, and delete controls and disables the calendar editor, so a world can be read but not changed. The lock travels with the file, so if you hand someone a locked world, they get the read-only version too. Unlocking asks for a confirmation, so you won't trip it by accident. It's the right setting for sharing a world you don't want anyone editing, yourself included, on a tired evening.
Save File
This is the part that matters most, because it's how you keep your work at all.
Export world hands you a .json file. That file is the real, canonical save: everything in your world, in a format Cartulary can load back later. Treat it as the thing you own and the thing you back up. If you've linked an image folder, keep the .json next to it.
Two other formats exist for when you want a readable copy rather than a working save. Export as Markdown gives you a .md document of your world, good for reading or pasting elsewhere. Export as Markdown + images bundles that same .md with all its referenced images into a .zip, a self-contained reference you can hand to someone who doesn't use Cartulary. Each comes in a plain version and a "with secrets" version. The secrets version includes any fields you've marked secret, so keep those for your own eyes or your GM notes, not for your players.
Import world loads a .json back in, and this is also how you switch worlds: importing replaces whatever is currently open. So the way you juggle several settings is to keep each as its own exported file and import the one you want. Export the current world first if you haven't, or you'll lose anything changed since your last save. Whole worlds aside, individual templates, packs, conlangs, stemmas, and calendars each export and import on their own; Import & Export maps the lot.
Reminders
Here's the thing I'd underline in red if I could: Cartulary has no single undo button, and nothing in the cloud holding a copy of your work. There's a Version History in Settings that can roll you back within a session, covered just below, but it lives in the same browser as your world and won't survive that storage being wiped. So the durable way back from a bad delete is always an earlier .json. That's the whole reason backups matter.
The Backup reminder is your safety net. With it on, a banner turns up once it's been more than two hours since your last JSON export and you've made changes since, nudging you to save. It tracks JSON exports specifically; a Markdown or zip export doesn't reset it, since neither can be re-imported. Hit Export now for a fresh .json, or Snooze 1h if you're mid-thought. Importing a file also clears the reminder, since the file you just loaded is by definition your most recent backup. Leave this on. The few seconds it costs are nothing next to losing an evening's work.
Version History
This is the net under the wire, and the reason I can talk about deleting things without total dread. Open Settings and Cartulary keeps a Version History of recent states you can roll back to. It works two ways at once. It takes automatic snapshots as you go, a few minutes apart, holding the last several and quietly dropping the oldest. And you can pin your own with Create restore point, which stay put until you remove them and can be labeled, so "Before the big rename" is sitting there waiting when the big rename goes sideways.
Each saved state gives you three choices. Restore swaps your current world for that snapshot, after a confirmation, and it takes a snapshot of the present state first, so even the rollback is reversible. Export downloads that snapshot as its own .json. Delete removes it.
The catch is the one that runs through this whole page: this history lives in the same browser storage as your working world. Anything that wipes that storage, clearing site data, a profile reset, a privacy extension, takes the history with it. Version History is real safety inside a session and worth nothing against losing the browser copy. It's the fast undo; the exported .json is the actual backup. Use both.
Danger Zone
One control sits here, and it throws work away: Start new world, whose button reads Reset. It replaces what's open with a blank world, so export first if you want to keep the current one. Before it wipes, it pins a "Before reset" snapshot into Version History, so a fresh start is reversible within the session; but it also clears the rest of that history, and none of it survives losing the browser copy. The durable way back is a .json you saved earlier, which, one more time, is why you keep them.
Templates
The shapes your entries take
A template is the shape of an entry: the set of fields a Character has, or a Faction, or a Location. Concepts has the one-line version of that idea; this page is the practical one. You get templates two ways. Cartulary ships a built-in set that covers the common ground, and when none of them fit what you're modeling, you build your own. Both kinds behave the same once they exist, and nothing here is precious: every built-in is yours to edit, rename, strip down, or throw away.
The built-in set
The starting set covers a couple dozen common worldbuilding subjects, grouped below by what they're for. I won't print an exact count, because it moves as the tool grows: when a subject gets big enough to deserve its own dedicated view, it graduates out of the plain template list. Calendars, maps, constructed languages, and the night sky all went that way, which is why you won't find a Map template in the sidebar (you work with maps over in Cartographia instead). The live, current list is always the Templates section of the sidebar. What's there is the truth; this is the map of it.
- People and peoples
Character, Race / Species / Ancestry, Culture / People, House / Dynasty / Clan / Bloodline, Creature.
- Powers and beings
Deity / Entity, Magic / Tech System.
- Places and cosmos
Location, Cosmology / Plane.
- Groups
Faction / Government / Organization, Religion / Belief System.
- Things
Artifact / Object / Currency, Weapon / Armor, Vehicle, Substance / Material, Book / Document / Text / Tome.
- Events and time
Historical Event, Festival / Holiday.
- Story scaffolding
Plot Thread, Theme / Motif.
Every one of these is a starting point, not a rule. Keeping the set small is a deliberate choice: a tight, strong default you can extend yourself beats a sprawling one nobody asked for. If a built-in is close but not quite right, edit it. If it's useless to you, delete it. If you need something that isn't here at all, build it. The next three sections are those three moves.
Making an entry
Getting started walks the basic flow: pick a template, make a record, fill in what you want. What's worth adding here is that everything you see on that form comes from the template. The fields, their order, which ones sit side by side, which only appear once another field is set a certain way: all of it is the template's doing, not a fixed Cartulary layout. A Character form looks the way it does because the Character template was built that way. Change the template and every Character changes with it.
One rule the forms quietly enforce for you: a link can't point at its own entry. A person can't be their own parent, a faction can't list itself as an ally. The dropdown for a link field leaves the current entry out of its own options, and if an older file somehow carries a self-link, it's cleaned up when the world loads.
Building your own
When the built-ins don't fit, click + New Template in the sidebar. You name it, pick an icon glyph and an accent color, and then you're in the Template Builder, adding the fields the thing actually needs.
Most of the work is just label plus type. A short text for a name, a long text for a description, a dropdown for a fixed set of options, a link to another entry, a date on your own calendar. The full catalog of field types, with what each is good for, lives on the Fields page. Add a field with + Add Field, remove one with the × on its row, and drag the handle to reorder. The order you set is the order entries show.
Past plain fields, the builder lets fields respond to each other. A field can be set to appear only when another holds a certain value, so a Cause of Death only surfaces once Status is Deceased. Two dropdowns can be chained, so picking a broad Type narrows the choices in a Subtype beneath it. A dropdown can offer an Other option that opens a free-text box when none of the canned answers fit. And eligible fields can pair two-to-a-row to keep a long form compact. You set these up once on the template, and every entry of that template inherits them. The full mechanics of each are on the Fields page; the point here is that they're the template author's to arrange.
The thing that makes all of this bearable to design is the preview. Click 👁 Template Preview at the top-left of the builder and a second window opens, showing your work-in-progress template as a real entry form. Type into it. Watch the conditional fields appear and vanish, the cascades narrow, the Other box open, the pairs line up, all live as you edit the template behind it. Whatever you type there is throwaway and never touches your world, and the window closes itself when you leave the builder. If you have a second monitor, drag it across and design with the form in full view beside you.
Editing a built-in
Editing a built-in works exactly like building one. Select it in the sidebar, click Edit Template, and the same builder opens, preview button and all. Change whatever you like. The only thing separating a built-in from one you made is where it came from, and that difference shows up in just two places.
The first is Reset to Default, which appears on built-ins only. It puts the template back to how it shipped: factory fields, name, icon, color. It's careful with your data. Fields you edited but kept hold onto their entries, matched by label, so your existing Descriptions survive the reset. Factory fields you'd deleted come back empty. Fields you added yourself are removed, and so is anything stored in them, and the confirmation modal lists exactly what you'll lose before you commit. If the template already matches the factory version, it tells you the reset would do nothing.
The second is Restore Built-ins. Delete a built-in outright and a Restore Built-ins button turns up under + New Template with a count of what's missing. Click it for a checklist of the deleted ones, restore any or all, and they slot back into their old place in the sidebar order. A template you built yourself has neither of these: there's no factory version to reset to, and once deleted it's gone unless you have a JSON backup.
Which is worth saying plainly, because deletion here is heavier than it looks. Deleting a template, built-in or your own, takes every entry made from it along with it, and the delete doesn't snapshot first the way a world reset or import does. You're not without a net: Settings keeps a Version History of recent automatic snapshots plus any restore points you've pinned yourself, so a recent one might still hold what you just deleted. But that history lives in the same browser storage as the world, so treat it as a cushion, not a guarantee. The real safety is a .json you exported beforehand, the same habit the rest of this guide keeps nagging about. Before a big delete, pin a restore point or export.
Sharing a template
A single template can travel between worlds on its own. The Export button on a template opens a small menu with two formats: .template.json, the portable file you hand to someone or carry to another world, and .template.md, a readable reference copy. Import (under + New Template in the sidebar) brings a .template.json back in. If the incoming name collides with a template already in the destination, Cartulary asks what you want: Skip it, Replace the existing one, or import it under a new name so both survive. It never overwrites without asking.
Packs
When you build a set of templates that belong together, a homebrew system, a genre kit, the furniture of one particular setting, you can group them into a pack. A pack is a labeled set that points at templates you've made; it doesn't copy them or wall them off. The templates still live in your one list. The pack just gathers them under a heading in the sidebar.
Built-ins can't be packed, since packs are for your own templates and ones you've imported. Create one with New pack, add templates to it, and a single template can sit in more than one pack at once if it honestly belongs to both. Export pack writes the whole set to a file; Import pack brings someone else's set into your world. Re-import the same pack later and Cartulary reconciles it template by template instead of duplicating, telling you what's new, what's unchanged, and what's changed since last time. Deleting a pack removes only the grouping. The templates and their entries stay put; they just go back to sitting loose in the list.
Templates and packs are two of the things Cartulary can move between worlds. For the whole set, worlds, conlangs, stemmas, calendars and all, see Import & Export.
Fields
The pieces an entry is made of
Every field is two decisions: a label and a type. The label is what you call it. The type decides everything else, what the input looks like, what counts as a valid value, how it stores in the file, and how it renders back on the entry and out across the other views. Templates sent you here for two things: the full catalog of types, and the behaviors that let fields react to each other. This is both. The catalog comes first; the behaviors are at the end.
Text and notes
- Short Text
A single line. Names, titles, epithets, a one-line summary. The workhorse, and the type most likely to pair into a side-by-side row with its neighbor (more on that at the end). If you find yourself typing a sentence or two, this is the wrong type. Reach for Long Text.
- Long Text
A multi-line box that understands Markdown and wiki-links. This is where descriptions, histories, and notes live. Markdown lets you use headings, bold, italics, and lists, and they render when you read the entry back. Wiki-links mean writing
[[Caer Duln]]becomes a live link to that entry, so your prose stitches itself into the rest of the world as you write. Long Text fields always take the full width of the form. - Longform Note
Looks like Long Text but behaves differently on purpose. It keeps your text literal instead of treating it as Markdown, and it gives you two buttons the others don't: Indent, which pushes the current line over by five spaces, and Divider, which drops a page-break line. Use it when the exact spacing matters and you don't want Markdown second-guessing you: a block of verse, a transcribed inscription, an indented outline, anything where the shape on the page is part of the content. The indents and dividers survive a Markdown export intact.
Choices
- Dropdown
A single pick from a fixed list of options you define. Status as Living or Deceased, an Allegiance, a Rarity, a Type. Reach for it whenever a field should only ever hold one of a known set of answers, because a clean list beats free text you have to keep spelling the same way. Two of the behaviors at the end of this page build on Dropdowns: the Other escape hatch, and cascades.
- Multi-select
A Dropdown that takes more than one answer. Same idea, a fixed set of options, but the field can hold any number of them at once: a creature with several Habitats, a character with multiple Roles. Multi-selects always render full-width.
- Tag List
A field that holds a loose set of short labels you type in, chip by chip, rather than picking from a fixed list. Good for ad-hoc keywords that belong to this one field on this one template. Worth a note: this is a field on a template, separate from the world-wide entry tags that sit at the top of every entry under its name. Those cut across every template and have their own home in the sidebar.
Numbers, marks, and media
- Number
A numeric value. Population, weight, a damage figure, a count. Stored as a number, so it behaves like one.
- Color
A color value with a swatch picker. For anything where a color is real data: a faction's livery, a banner, the hue a magic school is known by.
- URL
A web address that renders as a clickable link when you read the entry. For pointing out of the world: a reference, an inspiration board, a source you're drawing on. Not for linking between entries; that's what wiki-links and Entry Reference are for.
- Image
Attaches a picture to the field, either by filename if you've connected a local image folder, or embedded straight into the file if you haven't. The folder-versus-inline tradeoff (a small file with a folder dependency, or a self-contained file that balloons in size) is the Images section of World settings. The Image field is just how a template gets a picture slot in the first place.
Dates
First, the split that trips people up: there are two date worlds here, and they don't mix. A real-world Date is a date in our calendar, for out-of-world bookkeeping. An in-world Calendar Date is a date in your world's own calendar, the one you build in Kalendarium. Use the in-world types for anything that happens in the story; keep the real-world Date for metadata about the entry itself.
- Date (real-world)
A standard date picker on the actual Gregorian calendar. For things like when you drafted an entry, a session date, a real publication date for a source. It has nothing to do with your world's timeline.
- Calendar Date (in-world)
A single point in your world's time, entered as a year, a month picked from your calendar's own months, and a day bounded by that month's length. A small approximate marker (
~) flags dates you're unsure of. Anything carrying a Calendar Date shows up as a point on the Vertical timeline. This is the type for a birth, a founding, the day a war started. - Calendar Date Range (in-world)
A start and an optional end, each able to carry its own approximate flag. Leave the end open and the range reads as ongoing, with an open-ended arrow on the Chronometric timeline; fill both and it renders as a duration bar. A reign, a war, a character's lifespan, an age of the world: anything with extent rather than a single moment. Ranges always render full-width.
Entry Reference
A link from one entry to another. This is the type that turns a pile of entries into a connected world: a character's Liege points at another character, a location's Region points at a larger location, an artifact's Owner points at whoever holds it. Cartulary keeps these links honest in both directions, so a reference you make here turns up as a backlink over in Concordance without you doing a thing.
A few things you set on an entry-ref field when you build it:
- Single or multiple
A field can hold one link or many. One Capital city, but many Member factions. Single-value refs can pair side by side with a neighbor; multi-value refs always go full-width.
- Family role
You can tag a reference with a family role, Parent of this or Spouse of this, or leave it with none. That tag is what the family tree reads: mark Father, Mother, and the like as Parent refs and the tree assembles itself from them, with no separate tree to draw by hand. Spouse refs can also carry a marriage date and an end date, which the tree and the timelines pick up.
- What it can point at
You can scope a ref to one or more templates, so a Spouse field only offers Characters and a Located In field only offers Locations, instead of dumping every entry in the world into the picker. You can narrow further by field value when you need to: a ref to Characters where Status is Living, say.
- It can't point at itself
A link field never lists its own entry as an option. A person can't be their own parent, a place can't contain itself. If an older file somehow holds a self-link, it gets cleaned out when the world loads.
And the safety net: delete an entry that something else points at, or delete the whole template it belonged to, and Cartulary clears the dangling links automatically. You're never left with a reference pointing at nothing.
Name History
A record of what an entry used to be called, and when. Each row is a former name plus an optional From and To date; leave From blank for "since the beginning," leave To blank for "still current." New Amsterdam from 1626 to 1664, then New York from 1664 on. A maiden name with a To date at the wedding year. A faction that rebranded twice.
The entry's main Name, up at the top, is always whatever it's called now. Name History tracks everything it was called before, and that history does real work across the world. Wiki-links resolve through it, so [[New Amsterdam]] lands on the New York entry if that's one of its former names, and the link autocomplete suggests former names too, each marked with its current name so you know where you're really pointing. The map, when its time scrubber is on, labels a pin with the name that was current in the scrubbed year: set the year to 1640 and the pin reads New Amsterdam. Both timelines do the same, rendering each event under the name the entry carried in that event's year.
What doesn't shift: the entry's own title, the Concordance, and the family tree all show the current name. Those are "looking back from now" views by design.
A couple of editing notes. Add a row with + Add former name, remove one with its ×. Order doesn't matter, because the system picks the right name by date, not by list position. If two rows claim the same year, usually a mistake, the earlier row quietly wins, so if a timeline label looks wrong, come check here for overlaps. The field doesn't care why a name changed, only what it became and when; the story of the change belongs in a description or a Historical Event.
Repeating Group
A field that holds a list of structured sub-records, each with the same little set of sub-fields. When one value per entry isn't enough and a plain list won't hold the detail, this is the type. A ruler's titles, each with the land it covered and the years held. A ship's notable voyages, each with a date, a destination, and an outcome. You define the shape of one row, and the entry holds as many rows as it needs.
Sub-fields can be almost any type, so a row can mix text, dates, numbers, and links. Groups can even nest, a group inside a group, up to three levels deep, which is enough for org-chart-shaped data without turning the editor into a hall of mirrors. The one type a group can't contain is a Grid, which is heavy enough that it stays top-level only.
Grid / Table
A two-dimensional table, for when your data is genuinely a grid and not a list. You define rows and columns, each with its own label and an optional name for the axis, and every cell holds its own value. The thing that makes Cartulary's grid unusual is that each cell picks its own type, independently: a cell can be plain text, a number, a date or date range on your calendar, or a link to another entry. So one table can hold a label in the first column, a population number in the next, a founding date in the next, and a link to a ruling faction in the last. Cell links can point at anything, an entry or any registered node, with no scoping to set up.
Grids are top-level only: a template can carry as many as you like, but a grid can't sit inside a Repeating Group. Rename or reorder rows and columns freely; the cells stay bound to the right place because the binding is by stable id, not by label or position.
Field behaviors
The catalog is half the story. The other half is what fields can do beyond holding a value: react to each other, gate their own visibility, pair up, and carry a couple of useful flags. The first four below are what Templates pointed you here for; the last two are options you'll reach for less often but should know are there.
- Show only when
A field can be set to appear only when another field holds a particular value. A Manner of Death field that stays hidden until Status is set to Deceased; a Capital field that only shows once a Government Type is chosen. Conditions can chain, one field's visibility depending on another that's itself conditional. The form stays uncluttered, showing only what's relevant to what you've entered so far.
- Cascading dropdowns
Two Dropdowns can be chained so the second's options depend on the first's pick. Choose a broad Type and the Subtype list narrows to just the subtypes that belong under it: pick Settlement and the Subtype offers City, Town, Village, not Mountain Range. You define the branches once on the template; every entry of it inherits the filtered behavior.
- The Other option
A Dropdown can carry an Other choice that, when picked, opens a free-text box for the real value. It's the escape hatch for fields where you want a clean canonical list most of the time but can't swear the list is exhaustive. Canonical options for the common cases, a way out for the one you didn't foresee.
- Side-by-side fields
Short fields pair two-to-a-row automatically to keep forms compact. The eligible types are the narrow ones: Short Text, Number, Date, Calendar Date, Color, URL, Tag List, Dropdown, and single-value Entry Reference. The wide ones, Long Text, Longform Note, Multi-select, multi-value references, Name History, Repeating Groups, Grids, Calendar Date Ranges, and Images, always take a full row. There's no switch to flip. Two eligible fields placed next to each other in the field order pair up; an eligible field with no eligible neighbor stretches to full width on its own. Pairing happens after the show-when rules run, so a hidden field never leaves a gap beside its neighbor.
- Required
Mark a field Required and it shows a red asterisk, and trying to save an entry that leaves it empty is refused: you get a message naming what's missing, and the rest of your edits stay put while you fix it. Off by default, and most fields don't need it, but it's there for the one or two a template genuinely can't do without.
- Secret
Mark a field Secret and it carries a SECRET tag, drops out of any export you run without secrets, and hides when you switch secrets off while reading. It's how you keep a hidden truth, a twist, a real identity, in the same entry as the public-facing material without it leaking into a copy you hand to players. The export side of this is the Access section of World settings.
The toggle itself is the small dot icon in the top-right of the app -- ◎ when secrets are hidden, ◉ when they're showing. Its tooltip reads "Show secret fields" or "Hide secret fields." Switching it on and off is ephemeral: it resets to hidden when you reload the page.
The long-text editor
Long Text fields have a small toolbar above the text box. Left to right: B (bold) and I (italic); H and H₃ for a heading and a sub-heading; bullet-list, numbered-list, and quote buttons; [[ ]], which drops empty wiki-link brackets with the cursor inside, ready for autocomplete; ↗, which inserts an external-link template; an image-insert button; and an inline-code button. A Preview toggle on the right swaps the box for the rendered view. Longform Note fields are literal rather than Markdown, so they carry a different two-button toolbar, Indent and Divider, described above, not this one.
Keyboard shortcuts work when focus is in a Long Text field: Ctrl+B for bold, Ctrl+I for italic, Ctrl+K for an external link. Wiki-link autocomplete navigation: arrow keys move through suggestions, Enter or Tab accepts, Esc dismisses.
Search
The search input at the top of each template's entry list filters as you type. It looks at the entry's name, its tags, and the content of every field -- text, long-text, tag fields, dropdowns, all of it. It's scoped to the active template filter, so searching while a template is selected searches within that template only. Deselect the active template first to search across the whole world.
A few things search doesn't cover: template definitions and field labels (it searches entries, not templates), map pin labels (stored on mapData, not in fields), and former-name resolution (a search for "New Amsterdam" finds entries that have that text somewhere in a field, but doesn't apply the temporal name-mapping logic that wiki-links use). There's no regex, no advanced query syntax, no field-specific scoping.
Linea
The family tree, drawn for you
Linea is the family tree, and it draws itself. You don't place anyone on a canvas or route a single line. You set who a person's parents and spouse are, on their own entry, and the tree falls out of that. Point Linea at someone and it shows their ancestors climbing up and their descendants spreading down, with that person at the center.
Where it comes from
Linea reads exactly one thing: entry-ref fields whose family role is set to Parent of this or Spouse of this. That's the family-role setting on the Fields page; nothing else feeds the tree. The Character template ships with Father, Mother, and Spouse already wired, so for characters you get a tree for free the moment you fill those in. Set the same family roles on any other template and its entries join the tree too. If Linea looks empty, it's because nothing in view has a parent or spouse reference set yet, not because anything's broken.
Picking the center, and how far it reaches
A family tree only means anything relative to a person, so Linea always has a focal: the one at the center, with everyone else placed in relation to them. Pick the focal from the selector in the toolbar, and changing it recomputes the whole tree around the new center. Two number inputs, Generations up and Generations down, set how far the tree climbs and descends, each from 1 to 10, both starting at 5. Five each way covers most trees. Push them higher only if your genealogy genuinely runs that deep, because a fully populated ten-generation ancestor climb allocates over a thousand slots and gets very wide. A small count below the canvas reports how many ancestors, focal-row people, and descendants the current tree holds.
Reading a node
Each node is a person. The large line is their name, cut off with an ellipsis if it's too long for the box, with the full name on hover. The small italic line beneath is their lifespan, taken from the first Calendar Date Range field on their template. It reads 1903–2003 when both ends are set, 1980–Living when there's no end and the entry isn't marked Deceased, 1980–? when it's marked Deceased with no death year, and ~1742–1751 when either end is approximate, the ~ sitting on whichever end is uncertain. An entry with no lifespan field, or an empty one, shows its template name in that slot instead.
If your world runs more than one calendar, a calendar selector appears in the toolbar. The lifespan years project through whichever calendar you pick, so a tree that pulls together characters dated in different cultures' calendars still reads in one consistent reckoning instead of a jumble of mismatched years.
Status does one more thing. A character whose Status is Deceased renders dimmed, with the name in italics and a ✝ in front. Living and Unknown both look normal; only Deceased gets the mark, and it's independent of marriage status, so a dead former spouse shows both at once.
How it's laid out
Ancestors stack above the focal in Sosa-Stradonitz order, the ahnentafel numbering genealogists use: each generation up has twice the slots of the one below it, the father's line climbing to the left and the mother's to the right. Which parent counts as father and which as mother comes from their labels. A parent whose label includes "Father" or "Mother" takes that side; if neither label says, the sides are handed out by entry ID, which is consistent but arbitrary. If you care which side someone lands on, label them Father or Mother and the question settles itself.
Descendants hang below. When a child has two or more parents shown, the lines don't run independently from each one; they meet at a junction between the rows, drop to a shared horizontal at the parents' midpoint, and a single line continues down to a siblings bar above the children, then splits out to each child. It's the convention the large genealogy sites use, and it reads more cleanly than a tangle of separate lines. A single parent just gets a simple L-shaped line.
Spouses sit next to the focal on the same row. And if a person has more than two parents, a biological pair plus an adoptive one, say, the extra parents aren't dropped from the chart; they're placed in the same generation row beside the two the Sosa positions hold.
Spouses, status, and dates
A Spouse reference on a Character carries more than the link. It has a status, Active, Former, or Estranged, and two optional dates, Married and Ended. All of it shows up in the entry, in the Markdown export, and feeds the Marriages lane on the Chronometric timeline, so a marriage becomes a dated band on the world's timeline rather than just a pairing on the tree.
The two sides can't contradict each other. Declare A's spouse as B and save, and B gets the matching declaration automatically, same status, same dates. Edit one side and the other follows on save; remove one and the partner's side goes with it. You can't end up with a file where A was married in 1746 and B wasn't, because the pair gets reconciled every time you save.
One thing isn't mirrored: the override label. When you set a family reference you can type a label beside it, Adoptive Father, Stepmother, Estranged Wife, and that label is yours alone, because each side of a relationship names it from their own vantage. It shows on the entry's chips and in the Markdown export. Linea itself doesn't print the override on the canvas; the shape of the tree already says what the relationship is, so the label would only add clutter.
Getting around, and getting a tree out
The canvas zooms and pans: scroll to zoom, anywhere from a quarter size up to two and a half times, and drag empty space to move around. Clicking a node recenters the tree on that person, making them the new focal. To jump from a node to the entry behind it instead, hold Shift and click. And from the focal panel you can export the current tree as Markdown: the focal person plus everyone reachable from them, numbered by the same Sosa-Stradonitz scheme, ready to drop into notes or hand to someone else.
Stemma
Radial mind maps for tree-shaped ideas
Stemma is a mind-mapping canvas for tree-shaped thinking that doesn't belong inside a single entry: plot threads, theme webs, organizational charts, decision branches, classification systems. One word of warning, because the name invites it: this is not a family tree. Linea is the family tree, and it builds itself from your data. Stemma is the opposite kind of tool, a freeform canvas you arrange by hand, for structure that lives in your head rather than in your entries.
A stemma is a document
Each stemma is its own separate document, not a view onto your entries. Click Stemma in the sidebar and you land on the Stemmas index, a list of every stemma in the world, each shown with its name, its node count, the last updated date, and any description and tags it carries. + New Stemma starts one. Every row has its own Export and Delete, and Import at the top brings an outside stemma in. Deleting a stemma from this index is also the only way to remove its root node, which you'll see referenced again below.
The radial canvas
Open a stemma and you get a single focal node at the center, ringed by sixteen fixed slots arranged clockwise from twelve o'clock. Each slot holds one child or stays empty. Click a child to drill in: that child becomes the new center, with its own ring of children around it. A breadcrumb across the top tracks the path you've descended; click any segment to jump back up, or Back to root to return to the top. A child that has children of its own shows a short line radiating outward, so you can tell at a glance which nodes open into something and which are leaves.
The stemma's name sits top-left, just right of ← Stemmas, and you rename it by clicking it. To move around the canvas, hold Ctrl (Cmd on a Mac) and scroll to zoom, from half size up to two and a half times; plain scroll pans the view once you're zoomed in far enough to overflow the pane. It's the same zoom gesture the Chronometric timeline uses, and worth noting it's the reverse of Linea, where plain scroll zooms.
Two kinds of node
Every node is one of two kinds. A freeform node is yours to label, color, and give an icon, for ideas that have no entry to point at: "Act II climax", a morality theme, the missing letter the whole plot turns on. A linked node binds to an existing entry instead, and takes its look from that entry's template, the template's icon and color, with a small chain badge ⛓ marking it as linked. Its label is the entry's current name, so rename the entry and the node renames itself to match.
Adding, editing, and deleting nodes
Empty slots show a faint +. Click it for a smart input: type a name and matching entries from your world appear below as you go. Pick one, by mouse or with the arrow keys and Enter, and you get a linked node; ignore the suggestions and press Enter on your own text and you get a freeform node with that label; Esc backs out.
Click any node to open the inspector on the right. For a freeform node you edit its label, color, and icon there. For a linked node you get a preview card and a jump straight to the entry. The inspector footer converts a node between the two kinds, either direction, and converting a linked node to freeform keeps its current name as the new label so nothing is lost. The inspector is also where deletion lives: Delete node removes it, and if it has children it takes the whole subtree along with it, with the count spelled out in the confirmation. The root node is the one exception. It can't be deleted on its own; to clear an entire stemma, delete it from the index.
Orphans
A linked node depends on its entry continuing to exist. Delete that entry and the node becomes an orphan: desaturated, its label in italics, marked with a broken-link icon, and still showing the name it cached the moment you first linked it, so you remember what it stood for. From the inspector you can re-link it to a different entry, or convert it to freeform and keep that cached name as its label.
Import and export
Each stemma travels as a single .stemma.json file, exported from the Export button on the canvas or the per-row Export on the index, and brought back in through Import on the index. On the way in, every node id is regenerated so it can't collide with anything you already have, a name clash prompts the same Skip / Replace / Rename choice you've met elsewhere, and any linked node whose entry doesn't exist in the destination world arrives as an orphan, with a count in the import flash. Matching across worlds goes by entry id alone, with no fuzzy name fallback, so linked nodes usually need re-linking by hand after a move between worlds. The Import & Export page collects this alongside every other thing Cartulary can share.
What it doesn't do yet
A few things are deliberately outside this version, worth knowing before you lean on them. Siblings can't be reordered between slots; a node's slot is fixed at the moment you create it. There's no undo, in keeping with the rest of Cartulary, so export before a big rearrangement. Export is JSON only, with no Markdown version yet. And a stemma's tags show on the index but don't fold into the world's tag autocomplete, so they sit a little apart from the tags on your entries.
Concordance
The whole web, in one view
Concordance is the relationship graph of your entire world. Every entry is a node, every cross-reference between entries is an edge, and the graph arranges itself by how things connect, then hands the arrangement over to you. Where Linea and Stemma each show one shape, a bloodline, a single mind-map, Concordance shows everything at once: who points at whom, across every template you've got.
What becomes an edge
Anything that points from one entry to another turns into a line. The sources, all of them:
- Entry-ref fields
A plain reference draws an arrow from the entry holding the field to the one it points at. Family-role refs draw with meaning: a Parent ref points parent to child, matching the family tree, and a Spouse ref draws a line with an arrowhead on both ends.
- Wiki-links
Every
[[Name]]in any text field becomes an edge from the entry you wrote it in to the entry it resolves to. - Map pins
A pin on a map that links to an entry connects the map to whatever the pin points at, marked with a 📍 on the edge.
- References buried deeper
Entry-refs inside a repeating group's rows, and entry-ref cells inside a grid, both feed the graph as well, so a link counts no matter how deep in an entry's structure it sits.
Every edge is directed: the arrowhead tells you which entry references which.
Reading the graph
Each node is one entry, a small circle in its template's color with the entry's name beside it. Where an edge has a meaningful label, the field that made the connection (Father, Members, Affiliations), the label rides along the line, rotated to stay upright and sitting on the upper side wherever the line angles. When two entries connect through several fields in the same direction, the labels join into one with a // between them, like "Members // Council Members". When the link is reciprocal, each side declaring the other, the two labels split apart toward their own ends so they don't pile up in the middle. Long labels truncate. Labels from Description and Notes fields are dropped on purpose: a wiki-link buried in a description is a real connection, but its field name is just noise.
Hover a node and a panel opens in the corner with a quick summary of that entry: its name and template (icon and color), any tags, a lifespan block from its first date-range field (birth date if available, then the full year span), spouse relationships with marriage dates, and a row for each other populated field that isn't prose or a family-role reference. Below the facts, the first populated text field renders as a plain-text preview, markdown stripped, capped at 240 characters. A connection count at the bottom tells you how many edges touch this node in the current view. While the pane is open, everything unrelated dims so you can read that one entry's connections out of a crowded field. Secret fields stay hidden unless secrets are unlocked. Click a node to open that entry; the edges themselves aren't clickable.
Laying it out, and moving around
The first time the graph draws, a force simulation finds positions, related entries pulling together into clusters. But those positions aren't the point; they're a starting offer. Drag any node where you want it and it stays there. That drag freedom is the whole feature: lay your stars into an actual star map, your factions into a power diagram, your planes into a cosmology, any arrangement where the position of a thing carries meaning the force algorithm could never know. The layouts you build become part of the world file, surviving reload, export and re-import, and a move to another device.
New entries you haven't touched place themselves with a quick force pass around the pinned majority when they first appear, then their settled spots save too, so you're never made to lay out every new arrival by hand. Positions for deleted nodes get cleaned out when the world loads, so a delete leaves no ghosts in the saved layout.
To move around the canvas, scroll to zoom, from a fifth of normal size up to four times, and drag empty space to pan. Zoom buttons sit in the corner, with a reset that snaps the view back. Your zoom and position hold while you work, and a filter toggle doesn't throw them away.
The Templates filter
The side panel lists every template with a checkbox, and because each row carries that template's color, the panel doubles as the graph's legend, telling you what each color means. Each row also shows a count of how many nodes of that type are currently in the graph. Untick a template and its entries vanish from the graph along with every edge that touched them; tick it back and they return. All and None at the top flip everything at once. The filter is per-session, so a refresh brings every template back, and toggling it never disturbs the layout you've built: hidden nodes simply stop drawing, their saved positions waiting for them. A node and connection count runs along the bottom edge of the canvas so you can see the current totals at a glance.
The list includes more than your regular templates. Astronomical Bodies defined in Ephemeris appear as their own toggleable type, as do Glossopoeia entities -- ConLangs, Lexemes, Phonemes, Grammatical Values, Affixes, Word-Building Processes, Inflection Classes, and Writing Systems. Any of these that reference entries (or each other) produce edges just like a field on a regular template would. Toggle them off to reduce visual noise when you're not focusing on those parts of the world.
Focus mode
A whole-world graph can be a lot to look at. Focus mode narrows it to one neighborhood. Type a name into the Focus box at the top of the side panel, which autocompletes from your world and suggests only entries whose templates are currently visible, and once it matches an entry exactly the graph collapses to that entry and what surrounds it. The Hops input beside it sets how wide "surrounds" reaches: one hop is direct connections only, two is friends of friends, and so on up to fifteen, though in a well-connected world anything past three or four tends to pull in nearly everything. Hops count links as undirected, so a connection counts the same whichever way its arrow points. Click clear to return to the full graph. If the entry you focused is hidden by the Templates filter, the graph quietly falls back to showing everything rather than leaving you staring at a blank canvas.
When it gets big
The force pass that places everything on first render is where a large, densely linked world starts to feel slow, a few thousand entries with plenty of cross-references being the rough point where you notice it. The cost is paid once, though: with positions saved, later loads restore the arrangement without re-running the simulation. The Templates filter and Focus mode help here as well, since a narrower graph settles faster when new entries arrive.
Kalendarium
Your world's calendars, all in one place
Kalendarium holds every calendar in the world: the primary reckoning that everything else anchors to, and any number of additional calendars running alongside it. A world can have one calendar or a dozen, each with its own months, eras, week structure, seasons, and moons. You edit them here, compare them side by side, and navigate the year as a grid. Dates you enter anywhere in Cartulary draw from whatever you build in this view.
The primary calendar
Every world has exactly one primary calendar. It's the system that every date in the world stores against internally: when you write a date on a character's birth, an event, a map pin, it's always in primary's terms underneath. The primary also sets the reference frame that additional calendars anchor to. Rename its months freely; existing dates redisplay under the new names without moving. The default world ships with a twelve-month Gregorian-shaped calendar as the primary, which you can strip out and rebuild from scratch the moment you open the calendar editor.
Additional calendars
Click + New Calendar in the Kalendarium header to add a parallel calendar. Its structure is entirely independent: different months, different eras, a different week cycle, its own seasons and moons. Its only tie to the primary is one anchor date, set in the calendar's own editor, that says "Year 1, Month 1, Day 1 of this calendar falls on this date in the primary." Everything else derives from that single alignment. All the absolute-day arithmetic runs under the hood; you just see dates in whatever calendar you're working with.
To change the anchor, open the additional calendar's editor and click Re-pin Anchor. A date picker opens in primary's frame; click Save Anchor and every date in that secondary calendar shifts to match. The underlying dates don't move, only the displayed year-month-day changes. The primary's anchor is always absolute day zero by definition and has no Re-pin button.
To remove an additional calendar, click its Delete button on the index row. If any entries are bound to that calendar, the tool opens a conversion step first: pick a target calendar, and all those entries re-bind before the calendar is removed. The primary calendar has no Delete button.
What's inside the calendar editor
Each calendar's editor has these sections, in this order.
Months. Add months with + Add Month, drag the handle on any row to reorder, set the name and day count per month, and type a short form (up to three characters) in the Abbr field. The short form is what the abbreviated date formats use. Day counts can be any integer; nothing requires the year to total 365.
Reordering months is safe: dates track by month name, not by position, so moving a month from slot 1 to slot 6 takes all the dates in it along. Deletion is different. When you delete a month, dates that lived in it roll forward by name: the tool looks for the next month in the old order that still exists and can hold the original day number. If no later month qualifies, those dates shift to the first month of the following year. The editor tells you how many dates are affected before you confirm.
Date format. A dropdown with five display presets that control how dates render across the app for this calendar: 3 January, 2026 (default), 03 JAN 2026, 3 JAN 2026, January 3, 2026, and 2026 JAN 03. The three abbreviated forms use each month's short form from the Abbr field above.
Eras. Optional. Each era has a full name, a short name, and a start year. A year falls into the era with the highest start year that's still at or before it. Define "Before Binding" starting at a very low number and "After Binding" starting at year 0, and year 5 displays as "5 AB" while year -3 displays as "-3 BB." Eras are display labels only; they don't change the stored values or the math.
Week. Set how many days are in a week, and optionally name each day. Day names are entirely optional; leave them blank and the grid renders unlabeled columns. The week cycle is continuous across month boundaries, so Day 1 of any month falls in whichever column the math puts it, not necessarily the first one. Leading blank cells fill the gap before Day 1 in the grid view.
Celestials. Moons are defined once in Ephemeris, under the Astronomical Body workspace, and referenced here by id. Toggle which of the world's moons this calendar shows; the grid renders a phase glyph on each day cell for each selected moon, up to five per calendar (more would crowd the cells). Phase resolves in order: geometric first, from the moon's actual orbital elements if it has a full orbit chain; synodic cycle as a fallback if the moon has a cycle length but no orbit; no glyph if neither applies. A moon's color comes from its Ephemeris kind and can't be overridden per-calendar. If you don't have any moons yet, + New moon quick-creates one with placeholder values and adds it to the calendar immediately; flesh out its orbit in Ephemeris later.
Seasons. Bands by day-of-year that tint the calendar grid. Each season needs a name, a start day, an end day, and a color. Day-of-year numbering starts at 1. To make a season wrap through the year-end, like a winter running from day 335 through day 59 of the next year, set the end day lower than the start day; the editor labels it "wraps year-end" as confirmation. Seasons can overlap or leave gaps. A seasons legend strip appears in the grid controls once at least one is defined.
Notes. A free-form text field for anything about the calendar system that doesn't fit elsewhere.
The calendar grid
Each row in Kalendarium has a Show grid button. Click it to expand an inline year-at-a-glance view below the row, with every month laid out side by side and every day as a clickable cell. The button collapses it again.
Above the month grids, the controls have previous and next year buttons, a Jump to... input, and a Go button. If the world has a current moment set, an ↺ Current button also appears; click it to jump to the year containing the current moment, projected into this calendar's frame. The year you were viewing persists per-calendar, so reopening a grid drops you back where you left off.
Each day cell shows its day number. Cells with dated entries on them render with a gold accent border and a bold number. Click a cell to open a popover listing every entry dated on that day, each shown with its name and the field the date came from. Season tints shade each cell's background in its season color, and the seasons legend strip above the months shows which color means what. Moon phase glyphs sit below the day number on each cell; hovering shows a tooltip with each selected moon's phase name for that day.
Ctrl+scroll (Cmd+scroll on a Mac) over the grid zooms it, from half size down to two and a half times up. The zoom is ephemeral; closing and reopening the grid resets to 1x.
Current moment
A small panel at the top of the Kalendarium index lets you set the world's current moment: the story's "now." When set, character entries whose template has a Lifespan field display an auto-computed age suffix from current moment minus birth date. Deceased characters compute age from their death date instead, and show it regardless of whether a current moment is set. Click Set to pick a date in primary's frame, Edit to change it, and Clear to remove it. The Compute and Observe workspaces in Ephemeris also read the current moment to know where to put the sky.
Comparison strip and the reference calendar
When the world has additional calendars, each row in Kalendarium shows a strip: "Reference moment: [date in this calendar's terms]." The moment driving every strip comes from whichever calendar is currently pinned. The pin auto-moves to any calendar you edit, and you can set it explicitly with the Pin button on any row (it shows 📌 Pinned when active). Pin the primary to anchor comparisons against the world's spine; pin an additional calendar to see every other calendar expressed relative to that one instead.
Per-entry calendar binding
When the world has additional calendars, entry editors gain a Dates reckon in... selector. Pick a calendar there and every Calendar Date and Calendar Date Range field on that entry shows its values in the chosen calendar's terms. The underlying absolute day stays stable; only the displayed year-month-day changes. Leave it on the primary if you don't need per-entry reckoning.
Import and export
The Kalendarium header has Import and per-row Export buttons. Calendars travel as .calendar.json files, useful for moving a calendar between worlds or sharing one as a setting reference. If the imported name collides with an existing calendar, the tool asks before overwriting. This is one corner of a larger picture; see Import & Export for everything Cartulary can move between worlds.
Cartographia
Maps with meaning
Cartographia is the bound collection of maps. Each map is an entry that pairs an image with clickable, draggable pins, where each pin can link to any other entry in the world. They're cross-referenced, time-aware, and part of the same graph as everything else: a pin that links to a city links back from the city, shows up in Concordance, and filters itself off the map when the story hasn't reached it yet.
The index
Click Cartographia in the sidebar and you land on the map index: every map in the world, sorted by most recently updated, each row showing its name, template icon, pin count, whether an image is set, the last updated date, any tags on the map entry, and a description preview if the map entry has one. Click any row to open the map. Delete from the row's Delete button; the map and all its pins are removed, but entries the pins linked to are unaffected.
Creating a map
Click + New Map at the top of the index. If the world has more than one map-kind template, a dropdown appears next to the button so you can pick which to create from. The new map opens in its entry editor. Set the image with Set Image in the map canvas toolbar: if you have an image folder connected, the file stays on disk and only the filename is stored; without a folder, the image embeds as base64 directly in the save, and you'll get a warning for files over 1 MB. After setting an image, click anywhere on the canvas to place pins. Change Image replaces the image while keeping existing pins; Remove Image clears it and leaves the pins in place (invisible until a new image is set).
Pins
Click anywhere on the map image to drop a pin. Clicking a pin selects it and opens the pin editor below the canvas. In view mode (not editing), clicking a pin that's linked to an entry opens that entry. The editor has:
- Label
The text displayed on the pin. The first character of the label (uppercased) shows as the pin's marker initial. An empty label shows a bullet instead.
- Linked Entry
Any entry in the world, or any Astronomical Body defined in Ephemeris. The link surfaces in Concordance and on the linked entry's backlinks. If you later delete the linked entry, the pin shows "(missing entry)" instead of breaking silently.
- Always visible
A toggle that marks this pin permanent. Permanent pins show on the map regardless of the time scrubber. Use it for things that exist across the whole timeline: coastlines, mountain ranges, ancient ruins, anything the story predates.
- Color
Per-pin color override. Defaults to the world's accent gold. Click Reset to default to return to it.
- Notes
Freeform notes about this pin. Not the same as the linked entry's content; these are map-context notes.
Drag any pin to reposition it. Pins store their position as percentages of the image dimensions, so resizing the canvas or changing the display size doesn't drift them. Click outside a selected pin to deselect. Click Done in the editor or Delete Pin to remove it.
Canvas controls
Ctrl+scroll (Cmd+scroll on a Mac) zooms the map canvas, from half size up to four times. At high zoom the canvas scrolls in place. Drag the bottom-right corner of the canvas to resize the canvas width itself (from its full width down to 25%); double-click that handle to snap back to 100%. Neither the zoom level nor the canvas width persists when you navigate away from the map; both reset on navigation and on reload.
A small stats bar below the canvas shows the pin count, how many are linked to entries, and in time mode, counts of permanent and hidden-by-filter pins.
Time mode
When at least one pinned entry has a Calendar Date Range field with data, a Time button appears above the canvas. Toggle it on to add the year scrubber, and the map starts filtering pins by whether the current year falls within each pin's linked entry's range. Three pin states in time mode:
- Permanent pins
Marked "Always visible" in the pin editor. Always show, regardless of the scrubber. When time mode is on, a small ◇ marks them so you can tell them apart from temporal pins.
- Temporal pins
Linked to an entry with a date range. Visible only when the scrubber year falls within that range. In time mode, pin labels show the entry's name as it was at the scrubber year, so a pin linked to "New York" shows "New Amsterdam" when the year is 1640 if the entry's Former Names says so.
- Linked pins with no date data
Hidden when time mode is on. The interpretation is "you haven't filled in dates yet." If a pin should be visible across all time, mark it permanent explicitly.
Unlinked pins (no linked entry at all) are always visible; there's nothing to filter against. Toggle show faded to render hidden pins dimmed rather than invisible, so you can see "there was something here" without cluttering the map.
The scrubber's range comes from the actual data: the minimum and maximum years across all pinned entries' date ranges. Move through it with the range slider, the numeric year input, or the − and + step buttons beside it. Era buttons jump to that era's start year. If the world has multiple calendars, a calendar selector lets you drive the scrubber in any calendar's reckoning.
A few rules worth knowing. Only the first Calendar Date Range field on a linked entry's template counts; if an entry has multiple range fields, the others are ignored. Point-date fields (Calendar Date, not Calendar Date Range) don't affect pin visibility at all. Open-ended ranges work as you'd expect: a range with only a start year is visible from that year onward; a range with only an end year is visible up to it.
Custom map templates
The built-in Map template is a starting point. Any template can be flipped to map kind in the template editor's kind dropdown, useful when you want a map type with extra fields alongside the canvas: a Battle Map with tactical metadata, a Star Chart with navigation notes, a Floor Plan with room descriptions. All map-kind templates show up automatically in Cartographia's index and in the + New Map dropdown. Custom templates are edited the same way as any other: open a map of that template and click Edit Template, or reach the builder from the sidebar.
Map templates don't appear in the sidebar's Templates section; they live in Cartographia. To create a new map template, use + New Template in the sidebar and set its kind to "map" in the builder.
Limitations
No layers, no fog of war, no measurement tools, no vector drawing. Cartulary's map support is reference-grade: images with clickable, cross-referenced, time-aware pins. If you need a virtual tabletop experience, that's a different tool.
Ephemeris
Celestial mechanics for worldbuilders
Ephemeris is where you build a world’s sky: define its stars, planets, moons, and other bodies, then compute what the sky looks like on any date and what alignments those positions produce. The goal is a calendar of meaningful sky events you can hang lore on: conjunctions, eclipses, moon phases, festivals pinned to a real orbital moment. It is a worldbuilding tool, not a physics simulator. Bodies move on fixed Keplerian orbits around their parents. Mutual gravity, orbital decay, and collisions are out of scope by design.
The body tree
A panel on the left side of Ephemeris shows all the celestial bodies in the world, organized as a tree. Bodies nest by their parent: a moon under its planet, planets under their star, top-level bodies at the root. The tree has two sections, Computable and Narrative. Computable bodies participate in orbital math, phase calculations, and alignment queries. Narrative bodies exist for worldbuilding organization: galaxies, wormholes, megastructures, and similar things where Keplerian orbits don’t make sense. You can define them and put them in the tree without supplying any numbers.
The search box at the top of the tree filters by name or Other Names. The + button creates a new body. The body count and, in multi-select workspaces (Compute and Forced Alignment), the count of currently-selected bodies appears at the bottom of the panel.
Body selection behavior depends on the active workspace. In Astronomical Body and Observe, clicking a body selects it for the detail pane on the right. In Compute and Forced Alignment, clicking toggles the body’s membership in the current query; ineligible bodies are dimmed and unclickable.
Moons and calendars
A body of kind Moon is the same moon a calendar tracks for phase glyphs. Define the moon once in Ephemeris and reference it from any calendar’s Celestials section, or quick-create a new moon from the calendar editor and flesh out its orbit here later. Phase resolution follows a priority order: full geometric phase from orbital elements if the chain is complete, synodic-cycle fallback if not, no phase display if neither is available.
The current moment
Compute and Observe read the world’s current moment, the same anchor set in Kalendarium and World Settings. Move it and the sky moves with it. Observe can also step away from the current moment to any absolute day without changing the world’s stored moment.
Computable kinds
The nine computable kinds are Star, Planet, Moon, Dwarf Planet, Asteroid, Comet, Rogue Planet, Black Hole, and Meteor Shower. A body with one of these kinds can have orbital elements set, participates in Compute alignment queries, appears in the Observe diagram, and can be promoted to an Event entry. The narrative kinds (Galaxy, Nebula, Star System, and the rest) do none of those things.
What saves where
Body definitions are world data: they persist in the JSON save file under celestialBodies. Ephemeris workspace state (which mode is open, Compute query parameters, Observe zoom and display settings) persists in the save file under ephemeris but carries no world data. Dropping the ephemeris key from a JSON file loses only remembered UI positions, not any body.
Glossopoeia
A workbench for constructed languages
Glossopoeia is where you build a world’s invented languages, from the sound inventory up through morphology, grammar, the dictionary, the writing system, and glossed example texts. Each language is edited through an Overview container plus eleven workspaces. Pick a language from the selector at the top and every workspace shows that language’s data. A world holds as many languages as you like, and a language can be marked a dialect or a descendant of another.
It’s a view, not a template
Unlike characters or locations, languages aren’t entries built from a template. Glossopoeia is its own view with its own data, stored under glossopoeia in the save file. Each language is a ConLang container record; everything else (words, sounds, rules, affixes, and so on) is a separate record carrying a langId pointer back to its parent language. That structure is what lets the fork and sound-change tools copy a whole language at once.
An earlier build had a single “Language / Glossary” template. Worlds made with it migrate automatically: language-overview, dialect, and naming-convention entries become ConLang containers, and words, phrases, idioms, and every other entry become Lexemes. The five built-in templates that referenced the old template (Character, Location, Culture / People, Document, Race) re-point at ConLang. Any word whose language can’t be determined lands in the Unassigned bucket, described under Lexicon.
The language selector
The header runs across the top of every workspace. The Language dropdown lists every ConLang in the world, plus an Unassigned (N) option when orphaned words exist. Selecting a language opens its Overview; selecting Unassigned jumps straight to the Lexicon so you can re-home those words. To the right of the dropdown sit the language-level actions.
- + New Language
Creates a fresh, empty ConLang and opens its Overview.
- Delete
Removes the language and every record that belongs to it across its child arrays, after a confirm. It also scrubs any reference a surviving language held to one of the deleted sounds, so nothing dangles.
- Export
Writes the language and all its Glossopoeia data to a single
.conlang.jsonfile. - Grammar .md / Grammar .html
Export a readable reference grammar and dictionary, as Markdown or as a printable HTML page.
- Fork
Creates a descendant language. Covered below.
- Run sound changes
Appears only when the language has a Descended From link. Re-derives every word’s pronunciation from its parent word. Covered below.
- Import
Loads a
.conlang.jsonfile as a new, independent language. Cross-language links that can’t be resolved in the new world are dropped, and the count is reported.
The workspace rail
A 200-pixel rail on the left lists the workspaces, with two collapsible groups. The order is Overview, then Phonology (Inventory, Phonotactics), Morphology, Noun Number, Pronouns, Verbs, then Grammatica (Syntax, Complex Structures), Lexicon, Orthography, Corpus. Noun Number and Verbs are read-only digests that pull from Morphology; they own no data of their own.
Forking and sound change
Fork and Run sound changes are the diachronic engine. Fork copies a language’s lexicon and inventory only into a new descendant: the child gets fresh copies of every word and sound, its Descended From points at the parent, and each child word’s Descended From points at its parent etymon. Grammar, syntax, and orthography are left empty for you to build. Morphology annotations on the copied words (Inflection Class, Features, Built By) are dropped, since the child has no morphology yet. The child is phonetically identical to the parent until you add sound changes and run them.
You write those changes in Phonology: Phonotactics as Phonological Rules with the Diachronic field set to Diachronic. Run sound changes then re-derives every descended word’s pronunciation by applying those rules to its parent form. A preview modal shows each parent form mapped to its daughter form, with changed and unchanged counts, a list of any rules it couldn’t apply automatically (insertions with no input sound, or prose-only changes), and an optional checkbox to prune sounds the run leaves unused. Nothing is written until you confirm.
Two related fields are easy to confuse. Variety Of marks a dialect or register at the same time depth; it’s a name-pointer for the family tree only, with no live inheritance. Descended From marks evolution by sound change across time and is the link the engine reads. A forked language is self-contained after the fork: importing or editing it doesn’t reach back into the parent.
What registers in Concordance
Eight of the twenty-one data arrays register as node types, meaning their records appear in Concordance and are valid targets for entry-reference fields: ConLangs, Lexemes, Phonemes, Grammatical Values, Affixes, Word-Building Processes, Inflection Classes, and Writing Systems. A Lexeme’s etymology points at other Lexemes; an Affix’s Marks points at Grammatical Values; a Grapheme’s Belongs To points at a Writing System and its Spells at Phonemes. The remaining arrays are view-internal; nothing outside Glossopoeia references them.
What saves where
Everything you enter is world data and persists under glossopoeia in the save file: the ConLang containers in conlangs, and every other record in its own array (lexemes, phonemes, and so on). The ui key under glossopoeia remembers only which language and workspace were last open; dropping it loses no language data. Collapsible workspace notes live under workspaceNotes, keyed by language and workspace.
A language and its grammar can also leave the world entirely, as a .conlang.json or a readable grammar export; see Import & Export for how that sits beside everything else Cartulary can share.
Import & Export
Moving Cartulary data around
There's no account and no cloud here; your world is a file on your own disk. So sharing is just exporting a file and handing it over, and importing is the reverse. This page is the full map of what can travel and in what shape: a whole world, a single template, a pack of them, a constructed language, a family tree, a calendar. The JSON is always the real backup; everything else is a copy for reading, not a save.
The whole world
The world-level exports live in World settings, except the HTML one, which is on the home screen.
- Export .json
The canonical save format, and the only world file you can import back. This is your backup; keep it next to your image folder. Everything else on this list is a one-way copy.
- Export .md / .md (with secrets)
A human-readable Markdown copy with every reference resolved to names. Good as a player handout, a printed reference, or context for an AI assistant. Not a save file. The plain version leaves secret-marked fields out; the with-secrets version keeps them, for your own eyes.
- Export .zip (Markdown + images)
The same Markdown, bundled with every referenced image in an
images/folder so the reference is self-contained. Also has a with-secrets variant. - Export world as HTML
From the Export block on the home screen, a single self-contained HTML file that opens in any browser. Cross-references become anchor links, dates render through your calendar, and maps render as static images with their pins. Read-only, for sharing with people who don't run Cartulary.
- Import .json
Loads a previously exported world file. It replaces the current world, so export first if you want to keep what's open.
A single template
On any template, the Export button opens a menu with two formats: .template.json, the portable file another world can import, and .template.md, a readable reference copy. Import (the ↥ Import button under + New in the sidebar's Templates section) brings a .template.json into the current world. Built-in or custom, any template travels this way. Name collisions are handled below. The fuller treatment is on the Templates page.
A template pack
A pack is a named group of your own templates. Export pack writes the whole set to a .cartulary-pack.json; Import pack brings someone else's set in. Re-import the same pack later and Cartulary reconciles it template by template instead of duplicating, telling you what's new, what's unchanged, and what's changed since last time. Built-ins can't be packed, since packs are for templates you made or imported. See Templates for how packs sit in the sidebar.
A constructed language
In Glossopoeia, Export writes the selected language and all its data (sounds, words, rules, the lot) to a single .conlang.json. Import loads one as a new, independent language; any cross-language link that can't be resolved in the destination world is dropped, and the count is reported. Separately, Grammar .md and Grammar .html generate a readable reference grammar and dictionary. Those two are output only, not a re-importable save.
A family tree
A Stemma travels as a single .stemma.json, exported from the canvas or the per-row Export on the index and brought back through Import. Node ids are regenerated on the way in so nothing collides, and any node linked to an entry the destination world doesn't have arrives as an orphan to re-link, with a count in the import flash. Name collisions follow the rule below.
A calendar
A calendar exports to a .calendar.json for use in another world, and imports back. Entries bound to a calendar you replace will reckon against the new structure on the next load. Set them up in Kalendarium. Name collisions follow the rule below.
When a name collides
Import a template, stemma, or calendar whose name already exists in the destination and Cartulary never overwrites silently. It stops and asks:
- Skip
Keep what's there, import nothing.
- Replace
Overwrite the existing one with the incoming copy. For a template, entries keep their link and any field data that no longer fits the new shape stays in place but hidden; for a stemma or calendar, the old contents are replaced outright.
- Import as a new one named …
Bring the incoming copy in under a name you type, so both survive.
Packs are the exception: a re-imported pack reconciles per template rather than offering this three-way choice.
A read-only handout
Two ways to hand your world to someone who shouldn't edit it. Export the .json, open a fresh copy of the Cartulary .html file, import the .json there, and turn on Lock Mode: the reader browses everything but sees no edit controls. Lock Mode is friction, not security, since the toggle is two clicks away, so it stops honest readers rather than determined ones. For genuinely read-only sharing, the Markdown or HTML export carries no editing surface at all. Anything you marked secret drops out of every export you run without secrets.
JSON Structure
What's inside your .world.json
Every world you export lands in a single JSON file. The structure is designed to be human-readable and scriptable: if you want to bulk-edit entries, debug a corrupt save, or pipe your world data into another tool, this is the reference. Reverse-engineering from a known-good export works fine too, but this is the documented version.
Top-level shape
{
"schemaVersion": 1,
"world": { ...world metadata, including the primary calendar },
"templates": [ ...template definitions ],
"entries": [ ...entry records ],
"stemmas": [ ...stemma documents ],
"concordance": { "positions": { ... } },
"calendars": [ ...non-primary calendars ],
"celestialBodies":[ ...body definitions ],
"ephemeris": { ...Ephemeris view UI state },
"glossopoeia": { ...Glossopoeia data + UI state },
"packs": [ ...template packs ],
"viewPrefs": { ...per-view UI preferences }
}
schemaVersion is currently always 1. A higher number in a file means it was written by a newer build; loading is refused with a recovery prompt. A lower or missing number is treated as 1.
Everything except schemaVersion, world, templates, and entries is optional. Older worlds without stemmas, celestialBodies, glossopoeia, and so on load fine. Migration backfills each missing key to its empty default. New exports always include all keys.
Version-history snapshots are not part of the JSON file. They live in a separate IndexedDB store and don't survive clearing browser storage or moving the world to another machine. The exported file is the canonical save; snapshots are a session safety net only.
The world object
{
"name": string,
"description": string,
"imageFolder": string, // suggested folder name for FSAA images
"locked": boolean, // Lock Mode state
"calendar": { ...calendar }, // the primary calendar
"currentMoment": { "year": number, "monthIndex": number, "day": number } | null,
"createdAt": ISO-8601 timestamp,
"updatedAt": ISO-8601 timestamp,
"seededBuiltins": [ string, ... ],
"seededFields": { [tplName]: [fieldLabel, ...] },
"iconMigrations": { [migrationKey]: true },
"fieldRenames": { [migrationKey]: true },
"templateRenames": { [migrationKey]: true }
}
currentMoment is the world's "now" in primary calendar frame. It drives the time-cursor on the Kalendarium grid, the deceased-character obituary lines in Linea, and the time-aware pin filtering in Cartographia.
The migration-tracking maps (seededBuiltins, seededFields, etc.) prevent re-seeding work that's already been done: built-in templates the user deleted on purpose, fields renamed by upgrades, and so on. Safe to omit when constructing JSON from scratch; migration populates them on first load.
The calendar object
Used both for the primary calendar at world.calendar and for each entry in the top-level calendars array.
{
"name": string,
"months": [
{ "name": string, "days": number, "abbreviation": string },
...
],
"eras": [ { "name": string, "shortName": string, "startYear": number }, ... ],
"notes": string,
"week": {
"daysPerWeek": number, // positive integer
"dayNames": [ string, ... ] // either empty (no header row) or exactly daysPerWeek strings
},
"celestials": [ string, ... ], // moon-body id refs into top-level "celestialBodies"
"seasons": [
{
"id": string, // e.g. "sea-{4-char-suffix}"
"name": string,
"startDay": number, // 1-indexed day-of-year
"endDay": number, // 1-indexed; endDay < startDay = wraparound through year-end
"color": string // hex
},
...
],
"dateFormat": "name-day" | "abbr-day-pad" | "abbr-day" | "name-month" | "abbr-year-pad",
"anchor": { "absoluteDay": number }
}
A few things to know:
Month abbreviation is a v1.5 field, up to 3 characters. Used by the abbreviated date presets (abbr-day, abbr-day-pad, abbr-year-pad). If absent or empty, Cartulary derives a default from the first three letters of the month name.
dateFormat controls how calendar-date fields render across the world. The five preset IDs map to: name-day = "3 January, 2026"; abbr-day-pad = "03 JAN 2026"; abbr-day = "3 JAN 2026"; name-month = "January 3, 2026"; abbr-year-pad = "2026 JAN 03". Defaults to name-day if absent.
monthIndex in date values is 0-based into this array. Reordering or deleting a month invalidates existing dates that reference it. The in-app editor warns before doing this and walks all affected date surfaces.
celestials holds string id refs into celestialBodies (kind moon), not inline cycle data. Pre-v1.5 worlds stored inline objects here; migration moved those into celestialBodies and rewrote this field to refs. Cap is 5 per calendar.
Season wraparound: endDay < startDay means the season crosses year-end (e.g. a winter season with startDay 335 and endDay 59). Overlapping seasons are allowed; first-defined wins at a given day cell.
anchor is required on non-primary calendars. It's the absoluteDay (in primary frame) on which this calendar's year 1, month 1, day 1 falls. The primary calendar has no anchor; it defines the absolute frame. AbsoluteDay 0 = primary Y1M1D1.
The calendars array
Non-primary calendars. Empty array on single-calendar worlds. Each entry has the same shape as the calendar object above, plus a required anchor and a unique id:
[
{
"id": string, // e.g. "cal-{4-char-suffix}"
"anchor": { "absoluteDay": number },
...all other calendar fields
},
...
]
Entries bind to a non-primary calendar via a calendarId field. When set, all of that entry's date-bearing fields reckon in that calendar. When null or absent, dates reckon in primary.
Calendars export and import through a separate cartulary_calendar_export: 1 envelope. The collision modal on import allows Skip, Replace, or Rename; Replace is disabled when colliding with primary (a primary swap would re-frame every entry's stored dates).
The celestialBodies array
Stars, planets, moons, and other bodies defined in Ephemeris. Empty array on worlds with no Ephemeris data.
[
{
"id": string, // e.g. "cel-{suffix}"
"name": string,
"kind": "star" | "planet" | "moon" | "dwarf-planet" | "asteroid" |
"comet" | "rogue-planet" | "black-hole" | "meteor-shower" |
"narrative-star" | "narrative-planet" | "narrative-moon" |
"narrative-dwarf-planet" | "narrative-asteroid" | "narrative-comet" |
"narrative-rogue-planet" | "narrative-black-hole" | "narrative-meteor-shower" |
"galaxy" | "nebula" | "star-system" | "constellation" |
"sector" | "asteroid-belt" | "ring-system" | "supernova-remnant" |
"quasar" | "wormhole" | "megastructure" | "anomaly" | "structure",
"parentBodyId": string | null,
"orbitalElements": {
"semiMajorAxis": number | null, // AU
"eccentricity": number,
"inclination": number, // degrees
"ascendingNode": number, // degrees
"argumentOfPeriapsis": number, // degrees
"meanAnomalyAtEpoch": number // degrees
},
"physicalParams": {
"mass": number | null, // solar masses (for stars), earth masses otherwise
"radius": number | null,
"axialTilt": number | null
},
"synodicCycleDays": number | null, // fallback phase cycle when orbital math isn't used
"phaseAnchorDay": number | null, // absoluteDay on which this body was at New
"tags": string
},
...
]
Bodies form a tree through parentBodyId (moon → planet → star → null). Compute and Observe require a complete orbit-and-parent chain to the root; a body missing its parent's mass or with a broken chain is flagged rather than silently computed.
Kinds in the first group (star through meteor-shower) are computable: Cartulary runs Keplerian orbital math on them. The narrative-* kinds and everything below them are narrative-only: they exist for worldbuilding organization but don't participate in Compute, Observe, or Event.
A body of kind moon is what a calendar references in its celestials array. Define the moon once here, reference it from any calendar. Geometric phase uses the orbital elements when present; otherwise phase falls back to synodicCycleDays and phaseAnchorDay.
The orbitalElements block appears on all bodies for schema consistency but is only meaningful on computable kinds. Narrative-kind bodies ignore it entirely.
The ephemeris object
Ephemeris view UI state. No world data lives here; it's remembered workspace positions, zoom levels, and last-used query parameters. Safe to omit when constructing JSON from scratch; migration rebuilds it to the default shape.
{
"currentMode": "asbo" | "compute" | "force-align" | "observe" | "event",
"treeUI": { "searchText": string, "expandedNodeIds": [ string, ... ], "scrollY": number }
// ...plus per-mode substate (Compute range, Observe display toggles,
// Force-Align inputs, saved viewpoints, etc.)
}
Dropping this object loses only remembered UI positions, not world data.
The glossopoeia object
The constructed-language view's world data plus its UI state. All 21 per-language record arrays live here.
{
"conlangs": [ ...ConLang container records ],
"lexemes": [ ...dictionary words ],
"phonemes": [ ...sounds ],
"syllableStructures": [ ... ], "stressPatterns": [ ... ],
"phonotacticConstraints": [ ... ], "phonologicalRules": [ ... ],
"grammaticalValues": [ ... ], "affixes": [ ... ],
"wordBuildingProcesses": [ ... ], "inflectionClasses": [ ... ],
"pronounSets": [ ... ],
"phraseOrders": [ ... ], "sentenceMarkings": [ ... ],
"constructions": [ ... ],
"relativizations": [ ... ], "clauseLinkings": [ ... ],
"writingSystems": [ ... ], "graphemes": [ ... ],
"spellingRules": [ ... ],
"corpusEntries": [ ... ],
"workspaceNotes": { [langId]: { [workspaceId]: string } },
"ui": { "activeLangId": string | null, "workspace": string }
}
Every array is optional. Older worlds without Glossopoeia data, and worlds from an earlier build that predate a given workspace, load fine. Migration backfills each missing array to [].
Each record in the per-language arrays shares one base shape:
{
"id": string, // e.g. "syl-{suffix}", "afx-{suffix}"
"langId": string | null, // the ConLang this record belongs to
"name": string,
"tags": [ string, ... ],
"fields": { [fieldId]: value } // field value shapes per type, same as template entries
}
ConLang container records (conlangs) use the same shape minus langId. The ui key carries no world data; dropping it loses only which language and workspace were last open.
Eight of the arrays register as node types in Concordance and are valid entry-ref targets: conlangs, lexemes, phonemes, grammaticalValues, affixes, wordBuildingProcesses, inflectionClasses, writingSystems. The rest are view-internal and not referenceable from outside Glossopoeia.
The packs array
Named reference-sets over templates. Empty array if none. Added in v1.5.
[
{
"id": string,
"name": string,
"description": string,
"templateIds": [ string, ... ] // references templates[].id; built-ins are never members
},
...
]
A pack groups templates by id for display and sharing. It never owns or copies them. Packs export and import through a separate cartulary_pack_export: 1 envelope, which carries the full member template definitions. On import, each member template gets a world id namespaced under the pack id (form: packId__originalId), so same-named templates from different packs coexist without collision.
The templates array
[
{
"id": string, // e.g. "tpl-character-a3f2"
"name": string,
"icon": string, // single character, e.g. "◈"
"color": string, // hex
"kind": "standard" | "map",
"fields": [ ...field definitions ]
},
...
]
kind: "map" enables the map canvas in the entry editor and adds mapData to entries of that template.
Template IDs must be unique across the world. Field IDs must be unique within each template (including subfields recursively). Both are validated on load and import; a collision is rejected with a specific error rather than silently clobbered.
Field definitions
Each object in templates[].fields:
{
"id": string, // e.g. "fld-description-71b4"
"label": string,
"type": "text" | "textarea" | "tags" | "select" | "multiselect" |
"number" | "date" | "calendar-date" | "calendar-date-range" |
"url" | "color" | "image" | "entry-ref" | "name-history" |
"group" | "longform" | "grid",
"secret": boolean, // optional; default false
// type-specific:
"options": [ string, ... ], // type "select" / "multiselect"
"refTemplate": string, // type "entry-ref" -- template NAME (not id)
"multi": boolean, // type "entry-ref" -- single vs multi-select
"relationKind": "parent" | "spouse", // type "entry-ref" -- drives Linea / Chronometric Marriages lane
"subfields": [ ...field definitions ] // type "group" -- nestable up to 3 levels deep
}
refTemplate references a template by its name (case-sensitive), not by ID. The resolveTemplateRefs step at load time also writes a runtime refTemplateId, but the canonical reference in the JSON is the name.
Groups nest up to 3 levels deep (group→group→group); level-3 subfields cannot themselves be groups. Grid and Longform fields are top-level only and cannot appear as subfields inside a group.
The entries array
[
{
"id": string, // e.g. "ent-aria-a3f2"
"templateId": string, // references templates[].id
"name": string,
"tags": [ string, ... ],
"fields": { [fieldId]: value },
"mapData": { ...map data }, // ONLY on entries whose template kind is "map"
"calendarId": string | null, // bind to a non-primary calendar; null/absent = primary
"createdAt": ISO-8601 timestamp,
"updatedAt": ISO-8601 timestamp
},
...
]
calendarId binds all date-bearing fields in that entry (calendar-date, calendar-date-range, name-history rows, spouse marriedOn/endedOn, and group subfields recursively) to the named non-primary calendar. Per-field calendar overrides aren't supported; it's all-or-nothing at the entry level.
Entry IDs must be unique across the world. Every entry's templateId must reference an existing template. Both are hard validation rules on load and import.
Field values by type
The value at entries[].fields[fieldId] depends on the field's type:
| Type | Value shape |
|---|---|
| text, textarea, longform | string |
| tags | array of strings |
| select | string (one of the field's options) |
| multiselect | array of strings (subset of options) |
| number | number |
| date | ISO-8601 date string |
| url, color | string |
| image | string -- filename (folder mode) or data:image/...;base64,... (inline mode) |
| calendar-date | { year: number, monthIndex: number, day: number, approximate?: boolean } |
| calendar-date-range | { start: calendar-date | null, end: calendar-date | null } |
| name-history | array of { name: string, from: calendar-date | null, to: calendar-date | null } |
| entry-ref (no relationKind, single) | string -- the referenced entry's id |
| entry-ref (no relationKind, multi) | array of entry id strings |
| entry-ref (with relationKind, single) | { id: string, label?: string, status?: "active" | "former" | "estranged", marriedOn?: calendar-date, endedOn?: calendar-date } |
| entry-ref (with relationKind, multi) | array of the above objects |
| group | array of objects, one per instance -- each keyed by subfield IDs with values of the corresponding subfield types |
| grid | two-dimensional array of cell values -- grid[rowIndex][colIndex] is a string or entry-ref |
status, marriedOn, and endedOn apply only to relationKind: "spouse" refs. label is the user-typed per-side override. status, marriedOn, and endedOn mirror to the partner on save.
A note on tags: the field type named "tags" stores an array of strings. The top-level tags property on entries is also an array of strings. Neither is ever a comma-separated string, regardless of how the UI renders them.
Map entry data
Entries whose template has kind: "map" carry a mapData object alongside their regular fields:
{
"image": string | null, // filename or data URL, same shape as an image field
"pins": [
{
"id": string, // e.g. "pin-a3f2c1"
"x": number, // 0..1, fraction of image width
"y": number, // 0..1, fraction of image height
"label": string,
"entryId": string | null, // optional cross-reference to another entry
"color": string | null, // hex override; null = inherit default pin color
"notes": string,
"permanent": boolean // optional; default false. true = always visible in time mode
},
...
]
}
Pin coordinates are fractions of the image dimensions (0..1), so they stay positioned correctly if the canvas is resized. The canvas size shown in the UI (sizePct) is session UI state and not persisted.
Unlinked pins (no entryId) are always visible in time mode. Linked pins marked permanent are also always visible, regardless of whether the linked entry has date data. Linked pins without permanent and without date data on the linked entry are hidden in time mode.
The stemmas array
Each stemma is a labeled radial tree stored as a recursive node structure:
{
"id": string, // e.g. "stem-{4-char-suffix}"
"name": string,
"description": string,
"tags": [ string, ... ],
"root": { ...stemma node, recursive },
"createdAt": ISO-8601 timestamp,
"updatedAt": ISO-8601 timestamp
}
A stemma node is one of two types:
// Freeform node
{
"id": string, // e.g. "n-{4-char-suffix}"
"slot": number, // 0..15 (sixteen radial slots, clockwise from 12 o'clock)
"type": "freeform",
"label": string,
"color": string, // hex
"icon": string, // single character or symbol
"children": [ ...stemma nodes ]
}
// Linked node (references an existing entry)
{
"id": string,
"slot": number,
"type": "linked",
"entryId": string, // references entries[].id
"_cachedName": string, // fallback label if the entry is missing on load
"children": [ ...stemma nodes ]
}
The root node has slot: 0 by convention. Child slots are unique within their parent's children. Nesting is unbounded; the UI renders meaningful drill-down for the focal node and its immediate children.
The concordance object
{
"positions": {
[entryId]: { "x": number, "y": number },
...
}
}
Coordinates are in the force-directed graph's local space, not screen pixels. Zoom and pan are session UI state and not persisted. Entries without a saved position get auto-placed by force layout when first rendered, then their settled positions write back to this map on next save. Stale positions (whose entryId no longer exists) are scrubbed on load.
The viewPrefs object
{
"timelineZoomLevel": "year" | "month" | "day",
"mapCalendarId": string | null, // Cartographia
"timelineCalendarId": string | null, // Timelines · Vertical
"chronometricsCalendarId": string | null, // Timelines · Chronometric
"familyCalendarId": string | null, // Linea
"kalendariumGridYear": { // map, keyed by calendar rowKey
"__primary__": number, // the primary calendar
"<calendarId>": number // a non-primary calendar by id
}
}
viewPrefs collects per-world UI preferences that persist with the world file rather than living in browser storage. The four *CalendarId keys hold the per-view calendar selector, one each for Cartographia, the Vertical timeline, the Chronometric timeline, and Linea; null means the primary calendar. kalendariumGridYear is a map remembering the last-viewed year per calendar grid, keyed by __primary__ for the primary calendar or a non-primary calendar's id. Every key is safe to omit; individual views read with null defaults.
ID conventions
IDs are arbitrary strings, but Cartulary's own generators follow a consistent pattern: a type prefix, a slug derived from the name, and a randomized suffix to avoid collisions.
| Object | Format |
|---|---|
| Templates | tpl-{slug}-{4-char} |
| Entries | ent-{slug}-{4-char} |
| Fields | fld-{slug}-{4-char} |
| Pins | pin-{6-char} |
| Stemmas | stem-{4-char} |
| Stemma nodes | n-{4-char} |
| Calendars (non-primary) | cal-{4-char} |
| Celestial bodies | cel-{4-char} |
| Seasons | sea-{4-char} |
You can use any string format in hand-crafted JSON as long as IDs are unique within their required scope. Built-in template IDs follow the same pattern but are generated at seed time and don't change between versions.
Validation on load and import
Cartulary checks a set of hard rules before accepting a file. A file failing any of these is rejected with a specific error rather than partially loaded:
schemaVersionmust not exceed the current build's schema versionworld,templates(array), andentries(array) must all be present- Template IDs must be unique across the world
- Field IDs must be unique within each template, including subfields recursively
- Entry IDs must be unique across the world
- Every entry's
templateIdmust reference an existing template
A partial load on a failed validation would produce silent data corruption. Rejection with an error is intentional.
Migration
When Cartulary loads a file written by an older build, the migrate() function runs before any rendering. It:
- Backfills new built-in templates that didn't exist when the world was created, unless their name is in
seededBuiltins(meaning the user previously deleted them on purpose) - Backfills new fields on existing built-in templates via the same opted-out-deletion logic
- Renames built-in templates and fields when the rename tables specify it, keeping IDs stable so existing entries survive the rename
- Normalizes legacy
entry-refvalues from bare ID strings to the{ id, label?, status? }object shape - Strips self-references (an entry can't reference itself in an entry-ref field)
- Migrates any pre-v1.5 inline calendar moon data into
celestialBodiesand rewrites the calendar'scelestialsarray to id refs
Migration operates on a defensive clone of the input. Your original file is never written through during this step.
Minimal valid world
The smallest JSON that Cartulary will accept and render:
{
"schemaVersion": 1,
"world": {
"name": "Test World",
"description": "",
"imageFolder": "world-images",
"locked": false,
"calendar": {
"name": "Gregorian",
"months": [
{ "name": "January", "days": 31 },
{ "name": "February", "days": 28 }
]
},
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
},
"templates": [
{
"id": "tpl-character-0001",
"name": "Character",
"icon": "◈",
"color": "#c8a96e",
"kind": "standard",
"fields": [
{ "id": "fld-description-0001", "label": "Description", "type": "textarea" }
]
}
],
"entries": [
{
"id": "ent-aria-0001",
"templateId": "tpl-character-0001",
"name": "Aria",
"tags": ["warden"],
"fields": {
"fld-description-0001": "Captain of the Eastwatch."
},
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}
]
}
This loads, migrates (built-in templates backfill on the way in), validates, and renders. Everything else in the top-level shape is optional and will be initialized to its empty default.
Colophon
How this was made, and on whose terms
A colophon is the note at the back of a book that says how it was made: the type, the press, the hands. This is that note, for both the tool and this guide. None of it is marketing. It's the production record, plus the few commitments that aren't really commitments because they fall out of the build.
The build
Cartulary is one HTML file. The program, the interface, and the fonts are all inlined into that single document, so once it's on your drive it runs with nothing else: no server, no install, no network. The app is built with React, draws its graph and timeline views with d3, and keeps your working world in your browser's own IndexedDB. Exports are plain .json. There is no backend anywhere in the picture, because there's nothing for a backend to do.
This documentation site is hand-authored, separate from the app, and built the same way: a single file, fonts inlined, no external requests after it loads. It's a small client-side router that swaps panels by URL hash, so the back and forward buttons work the way you'd expect.
Built with AI, and saying so
I'll repeat here what the front of the guide says, because it belongs in the record. Cartulary is written with heavy AI assistance. The design, the scope, every feature decision, and the testing are mine. The code is typed by an AI model working under my direction; I decide what gets built and whether it's right. On the REAL Rating from the nonprofit Real Good AI, a disclosure scale running from REAL 0 (no AI) to REAL 5 (fully AI-made), that's a REAL 4: AI doing substantial production work under human direction. The source is inside the file, readable, if you'd rather check than take my word.
Free by construction
Cartulary is free, and it stays free for a reason sturdier than a promise. It's MIT-licensed, and an MIT license can't be revoked on code that already shipped. Underneath that, the architecture leaves nothing to charge for: no server to meter, no account to gate, no feature that could live behind a wall, because there is no backend at all. It isn't priced at zero by a decision someone could reverse tomorrow. The design forecloses the question. The longer version of this argument is on the Overview.
Accessibility, honestly
An honest statement, not a badge. A good amount of Cartulary works from the keyboard: Escape closes panels and pickers, Enter and clicking away commit edits, Ctrl or Cmd plus scroll zooms the timeline and graph views, and date fields have stepper buttons and typed inputs. Some controls carry ARIA labels, and images use whatever alt text you give them.
What doesn't work yet is worth stating plainly. The canvas-based views, the Concordance graph, the Chronometric chart, the Linea family tree, and the Cartographia map, are built for a sighted user with a mouse or trackpad; they aren't navigable by a screen reader. There's been no formal screen-reader pass and no WCAG audit. The interface is a single fixed dark theme: there's no light mode, and it doesn't follow your system color-scheme setting. The app doesn't gate its animations on a reduced-motion preference, though this documentation site does. If you depend on accessibility features to work at all, go in knowing those gaps are real.
Typefaces
Two families, nine faces, all inlined. Headings and the display text are set in Cormorant Garamond; body copy, labels, and interface text are DM Sans. Both are open-licensed and travel inside the file, so the type renders the same offline, on a machine that has never seen them installed, ten years from now.
License
Cartulary is released under the MIT License. In plain terms: do what you like with it. Use it, copy it, change it, share it, build something else on top of it, even sell that, as long as the copyright notice and this permission notice ride along. It comes with no warranty.
MIT License
Copyright (c) 2026 Shawn Fry
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
That's the whole record. One file, made in the open, free in a way that can't be taken back. Now go build a world.
Coming soon
Coming soon
This part of the guide hasn't been written yet. The tool already does it; the page is just on the way. Until it lands, the in-app manual covers the same ground, and the Discord can point you the right way.