Skip to content
SheetForge

Sheet Syntax

A SheetForge sheet is self-describing: column A is reserved for markers, and actual data starts at column B. Rows are identified by their marker, not their position, so you can insert comment rows anywhere and nothing breaks.

To adapt an existing spreadsheet, insert one marker column in front of your data and add the three marker rows. Existing data columns stay as they are.

Markers (column A)

Column AMeaning
#Comment row — ignored entirely, preserved verbatim on round-trip.
@nameField-name row (one name per column).
@typeField-type row.
@descDescription row — codegen bakes it into XML doc comments and inspector tooltips.
@overlap(optional) Per-column duplicate policy — true (allow, the default) / false (enforce value uniqueness).
@style(optional) Sheet display metadata — a group label and a color for this sheet. See below.
@enum(optional) Marks the whole sheet as enum definitions rather than a data table. See below.
@loc(optional) Marks the sheet as a localization sheet — its cells name each column's locale code. See below.
@yourMarker(optional, plugin-registered) A custom structural marker — see below.
(empty)Data row.
  • @name, @type, @desc are required; @overlap, @style and any custom markers are optional.
  • Marker rows may appear in any order, as long as they are above the data rows.
  • An unknown @marker is an error, with a nearest-match suggestion ("did you mean @desc?"). Registered custom markers join the suggestion pool.
  • Data in a column that has no @name/@type header is an error (orphan-data guard — silent data loss is never allowed).

Example (columns shown A | B | C | D):

#        | Item definitions — hand-edited by design team
@name    | codeName      | displayName | price
@type    | RecordId      | string      | int=10
@desc    | unique key    | shown in UI | shop price (gold)
         | item.sword    | Sword       | 120
         | item.potion   | Potion      |

(item.potion's empty price cell materializes the explicit default 10.)

Type system

Every type is self-describing — a reader sees what a column holds from the @type cell alone.

NotationMeaning
int float bool stringBuilt-in scalars.
Enum<DamageType>A C# enum — either defined in an enum sheet (see below, no code needed) or registered by a plugin (EnumRegistry). Member names validated, typos get nearest-match suggestions.
List<T>A list — element separator ;, elements trimmed, an empty element is an error, an empty cell is an empty list.
RecordIdThis tab's key column — a string self-identifier (e.g. item.sword). Always a required scalar. Recommended column name: codeName.
IntIdThis tab's secondary integer key — at most one per tab, required scalar, for runtime/save/backend ids. Recommended column name: id. A tab may key on RecordId, IntId, or both.
RecordId@EffectsA reference to a record in the Effects tab by its string key — integrity-validated (target tab exists, has a key column, the id resolves; typos get suggestions).
IntId@EffectsA reference to a record in the Effects tab by its integer key — full parity with RecordId@Effects: integrity-validated the same way (target tab exists, has an IntId column, the id resolves), with a nearest-integer suggestion on a miss. Values are canonicalized with int.ToString, so a hand-typed 007 resolves as 7.
AssetRef@IconsA reference to an asset in the Addressables group Icons — existence-validated against the catalog. A sub-asset (a sprite inside a texture, a material inside a font) is addressed as parent[sub] — the address Addressables gives a sub-object entry, e.g. atlas[sword] — and is validated, baked (SubObjectName) and exported by that key.
AssetRef@Icons<Sprite>The same reference restricted to one asset type: an address passes only when the asset — or one of its sub-assets — can be loaded as Sprite. The name is any UnityEngine.Object-derived asset type the project knows (engine or your own): the short name when exactly one type matches, otherwise the full name (MyGame.ItemData). Codegen emits AssetReferenceT<Sprite>; AssetRef@Icons without <…> stays unrestricted; AssetRef<Sprite>@Icons is rejected with the correct spelling suggested. See Typed asset references below.
LocRef@StringsA reference to a localization key in the localization sheet Strings — integrity-validated like RecordId@Tab (existence, nearest-match suggestions, rename propagation, picker, dropdowns), previewing the entry's source-locale text inline. The target tab must carry @loc (LocRefTargetNotLocalizationSheet otherwise), and a bare LocRef without @Target is rejected. List<LocRef@Strings> and LocRef@Strings? compose as usual. Codegen emits a plain LocRef struct — see Localization Sheets.
Color · AnimationCurve · GradientBuilt-in visual value types. Each has a compact text form (below) that the Data Studio and the web app edit with a native colour, curve or gradient editor instead of raw text; codegen emits UnityEngine.Color / AnimationCurve / Gradient fields.
Modifier (example)A plugin-registered custom cell type (see Plugin Authoring) — e.g. the sample's stat:op:value mini-grammar. CustomType@Target also works by registration alone. When the plugin opts into IReferencingCellType, that column behaves exactly like RecordId@Target — validated, suggested, renamed, drawn and picked the same way.
Pair<T> (example)A plugin-registered wrapper type — a generic value shape MyWrapper<T> that packs several inner T values into one cell (e.g. Pair<int> = 1~2). The inner type is resolved recursively, so Pair<RecordId@Effects>, Pair<Enum<DamageType>>, and nested Box<Pair<int>> all work. See Plugin Authoring.

<> and @ mean different things and coexist: <> = kind/wrapper (built-in List, or a plugin MyWrapper<T>), @ = target. So List<RecordId@Effects> is a list of references, and Pair<RecordId@Effects> packs two references — both into the Effects tab. The integer key composes the same way: List<IntId@Effects> is a list of integer-key references.

Wrapper types (MyWrapper<T>)

A plugin can register a wrapper — a generic value shape that owns an outer syntax (delimiter, arity) and delegates the inner type to the Core. The wrapper composes with any inner type. Any references inside it are still validated, propagated on key rename, and rewritten on tab rename (full pass-through).

Rejection rules (consistent with List):

NotationAllowed?Why
Pair<RecordId@Effects> · Pair<Enum<E>> · Box<Pair<int>>YesWrapper over a scalar, reference, enum, or another wrapper.
List<Pair<int>>YesA list of composites. The wrapper's own delimiter must differ from ; (the list separator) — a plugin-authoring responsibility.
Pair<List<int>>NoA list may not sit inside a wrapper (List stays flat and outermost, same rule as List<List<T>>).
Pair<int>@EffectsNoA wrapper is a value shape; put the @ on the inner leaf instead (Pair<RecordId@Effects>).
Pair<int?> · Pair<int=1>NoOptionality/defaults are a field-level notation, not part of the inner type.

Required / optional / defaults

NotationMeaning
float (unmarked)Required — an empty cell is an error (silent contamination is blocked at the entrance).
float?Optional — an empty cell materializes the type default (0), flagged IsDefaulted. Applies to the four scalars (int / float / bool / string) and to the three visual types: Color? → transparent black #00000000, AnimationCurve? → a curve with no keys, Gradient? → the white gradient `#FFFFFF@0,#FFFFFF@1
RecordId@Effects? · IntId@Effects? · AssetRef@Icons?Optional reference — an empty cell materializes an empty reference: "points at nothing", with the target tab/group preserved and the cell flagged IsDefaulted. This is not a broken reference — reference-integrity and asset-key validation skip it, the canvas draws no wire for it, and @overlap does not count two empty references as duplicates. A cell that does carry a value is validated exactly as before, so a typo in an optional column is still caught.
RecordId@Effects=The same thing written explicitly: an empty explicit default is equivalent to the bare ? above. A non-empty default (RecordId@Effects=fire) still resolves and is still integrity-checked.
int=1Optional with an explicit default — an empty cell materializes 1.
List<T>An empty cell is always allowed (empty list).

Where ? is not accepted, the reason is always the same: the Core cannot invent a value out of nothing, so those types need an explicit =default. That covers:

  • Enum<T>?
  • a plugin custom type — Modifier?, including Modifier@Tab?
  • a wrapper — Pair<int>?

The key columns are excluded for a different reason: an empty key would breed duplicates. So RecordId? (the keyless self-identifier form) and IntId? are rejected too.

Other deliberately rejected notations:

  • int?=1 and RecordId@Effects?=fire? and = both say "optional", so pick one.
  • List<T>? — a list already allows empty.
  • List<List<T>> — no nested lists.
  • Pair<int?> — optionality is field-level, not part of an inner type.

Value rules

  • bool: true / false only, case-insensitive on input; canonical form is lowercase.
  • Numbers: always . as the decimal separator (locale-independent). Comma decimals, NaN, and Infinity are rejected at the entrance.
  • Floats round-trip: export renders the shortest round-trip format, so 1.0 may come back as 1 — the value is preserved exactly (semantic round-trip).
  • Marker and enum comparisons are Ordinal (no locale surprises).

Typed asset references (AssetRef@Group<Type>)

AssetRef@Icons accepts any address in the group. AssetRef@Icons<Sprite> narrows it to one asset type, and the narrowing is checked at three points: validation, code generation and the authoring surface.

  • Which names resolve. The type is any asset type derived from UnityEngine.Object that the project can load — engine types (Sprite, Texture2D, AudioClip, an abstract base such as Texture) and your own ScriptableObjects alike; there is no allow-list. Components and editor-only types are not candidates. Write the short name when exactly one type carries it, otherwise the full name including the namespace. Ambiguous names (AmbiguousAssetType, with every candidate listed) and unknown names (UnknownAssetType, with a nearest-match suggestion) are reported once per column, on the @type row.
  • What passes. An address satisfies the restriction when the asset at that address, or any of its sub-assets, is loadable as that type — so a texture imported in Sprite mode passes <Sprite>, and a plain texture is reported per cell as AssetTypeMismatch. The sub-asset itself is addressable as parent[sub], and that key is checked against its own type only.
  • A type the generated code cannot reference is refused. A type that lives in a predefined assembly (Assembly-CSharp and its siblings — any script folder without an assembly definition) is found but reported as AssetTypeNotReferenceable, because the generated companion assembly cannot reference those assemblies and AssetReferenceT<T> would not compile. Move the type into an assembly definition, or drop the <…>.
  • What codegen emits. AssetReferenceT<global::UnityEngine.Sprite> for a resolved type, AssetReference for an unrestricted column. The companion assembly definition automatically references the assembly the type lives in, and the resolved full name is part of the schema fingerprint, so re-mapping the name regenerates the code.
  • Composes like any other type: AssetRef@Icons<Sprite>?, List<AssetRef@Icons<Sprite>> and a wrapper such as Pair<AssetRef@Icons<Sprite>> all work; AssetRef@Icons<> (empty), AssetRef@Ic<ons (an angle bracket in the group name) and RecordId@Skills<X> (the restriction is for AssetRef only) are syntax errors.
  • The Data Studio's column form has a Type… button that lists the candidate types and rewrites the @type cell for you — see Data Studio.

Visual value types (Color, AnimationCurve, Gradient)

Three built-in types carry values that are unreadable as raw text. Their text form is designed so that a person can type a short version by hand while every tool — the editors, Export, Push, the web app — always writes the canonical, complete form, and a value survives sheet → Unity → sheet without loss.

The separators are shared by all three and sit one level below the list separator: within a value, items are separated by ,, fields inside an item by :, sections by |, and a key's time is attached with @. Elements of a List<> are still separated by ;, and none of the three notations ever contains a ; — so List<AnimationCurve> = 0:0,1:1;0:1,1:0 splits cleanly. Numbers use . as the decimal point everywhere (a locale comma shows up as a wrong field count, never as a silently wrong value), whitespace around separators is trimmed, and the round trip parse(render(parse(x))) == parse(x) holds for every accepted input.

TypeAccepted inputCanonical form
Color#RGB, #RGBA, #RRGGBB, #RRGGBBAA (case-insensitive, # required)Upper-case #RRGGBB when the colour is opaque, #RRGGBBAA otherwise — #FF8800, #FF880080
AnimationCurve`key,key,…[pre:post], where a key is t:v, t:v:in:out, t:v:in:out:inW:outW:wmort:v:in:out:inW:outW:wm:tm` (2, 4, 7 or 8 fields — 3, 5 and 6 are errors)
Gradient`colorKeys[alphaKeys[

Colour: the value is stored as four bytes. HDR (channels above 1) is not supported — a baked colour is clamped to 0…1 on Export. The type default is transparent black, #00000000.

Curve: wm is the weighted-tangent flag (0 none · 1 in · 2 out · 3 both) and tm the tangent mode pair Left/Right, optionally followed by /broken — each side is one of Free, Auto, Linear, Constant, ClampedAuto, the same names Unity's curve editor uses. Shorter forms fill in the rest: a 2-field key takes the slope to its neighbours as tangents (Linear/Linear), weights of 0.33333334 and no weighting; a 4-field key keeps your tangents (Free/Free); a 7-field key adds weights. Tangent fields may be Infinity or -Infinity (a Constant step); time, value and weight must be finite, key times must be distinct (keys are sorted by time on import, so the order you type them in does not matter), and there is no limit on the number of keys. The mode wins over the number: for any side that is not Free, the tangent value is recomputed from the mode at import time — the same computation Unity performs — so a hand-typed number that contradicts its mode is replaced, and the sheet, the editors and the game all show one curve. Wrap modes are ClampForever, Loop, PingPong and Default; Once is accepted as an alias for ClampForever (Unity normalises it) and is never written back. A curve with no keys has no text form: it exists only as the empty cell of an optional column, and Export renders it as an empty cell.

Gradient: colour keys carry no alpha (#RRGGBBAA in the colour section is an error — alpha has its own section); within a section the @t times are either all present or all absent, and when absent the keys are spread evenly (n = 10, n ≥ 2i/(n−1)); a missing alpha section means 1@0,1@1, a missing mode means Blend. Modes are Blend, Fixed (steps) and PerceptualBlend; the optional colour space (Gamma or Linear) only changes how PerceptualBlend interpolates. Times and alphas are 0…1; times are quantised to 16 bits on import, exactly as Unity stores them, so the value you see is the value the engine holds. A gradient with a single key round-trips through Unity as two identical keys — the picture is unchanged, only the key count grows.

Lists: List<Color> = #F00;#0F0, List<Gradient> = #F00,#00F;#0F0,#000 — the list separator is unchanged.

The Data Studio shows these cells as native colour, curve and gradient fields and the web app as previews with full editors — see Data Studio and SheetForge Web. Both write the canonical form; the minimal forms are for people.

Keys and uniqueness

  • RecordId (no @) is the key column: at most one per tab.
    • Zero key columns is valid — until another tab references this tab (TargetTabHasNoKey).
    • Two or more is an error (MultipleKeyColumns).
    • Duplicate key values (DuplicateRecordId) and empty key cells are errors.
  • IntId is a secondary integer key: uniqueness is enforced independently, and other tabs can reference it via IntId@Tab.
    • Integer-key references get the same integrity validation, nearest-match suggestions, rename propagation, and graph/canvas support as RecordId@Tab.
  • A tab may key on RecordId only, IntId only, or both, and the three cases behave symmetrically everywhere.
    • When a tab carries both, RecordId is the display / identity value and the integer is shown alongside it.
    • Another tab can point at the same record either way: RecordId@ThisTab by its string key, or IntId@ThisTab by its integer key.
  • @overlap: ordinary columns allow duplicate values by default. Put false in a column's @overlap cell to enforce value-based uniqueness.
    • 1.0 and 1 count as the same value; two lists are duplicates when all elements and their order match.
    • Two empty references are never duplicates of each other (an empty scalar default still is an ordinary value).
    • Key columns are always unique; writing @overlap true on a key column is a contradiction error.

Sheet display metadata (@style)

@style lets a sheet say what group it belongs to and what color it is, so that grouping and coloring live in the sheet rather than only in the editor. It is the one marker that describes the sheet rather than its columns. Its cells are therefore not aligned to columns — they are a free list of key=value pairs starting at column B.

@style   | title=Combat  | color=#4D8FF0
@name    | codeName      | displayName | power
@type    | RecordId      | string      | int
@desc    | unique key    | shown in UI | attack power
         | skill.fire    | Fireball    | 12
KeyValueEffect
titleAny textSheets sharing a title are bundled under that heading in the Data Studio sidebar. Sections appear in first-appearance order and sheets keep their own order inside a section; sheets with no title stay in the default section.
color#RRGGBB (six hex digits)Tints this sheet wherever it appears: the sidebar dot, the node border on the canvas, and every port and wire that points at this sheet.
  • Both keys are optional and order does not matter; write one, both, or neither. An empty cell is ignored (padding cells are fine).
  • Validation errors all arrive as MarkerCellInvalid, with the cell coordinate and a concrete fix:
    • an unknown key (with a nearest-match suggestion),
    • a repeated key,
    • a missing value,
    • a color that is not #RRGGBB.
  • Three-digit shorthand (#4AF) and named colors are rejected on purpose, so the value round-trips as one notation.
  • Display only: codegen, baking and the schema fingerprint never read @style. Changing a sheet's color does not regenerate code or re-bake ScriptableObjects.
  • Round-trip safe: the @style row is preserved like a comment row. Adding, deleting, moving and renaming columns leave it untouched, because its cells do not belong to columns. Editing it goes through the Group & color form (right-click a sheet in the Data Studio sidebar), which rewrites the row in canonical form.
  • A sheet that only has @style (plus comments) counts as "no table yet": the import skips it with a warning instead of failing on the three missing required markers. As soon as you add @name/@type/@desc, it is parsed normally. See Capabilities & Limits.
  • style is a reserved marker name — a plugin that tries to register it is rejected, and a typo like @styl gets @style as a suggestion.

Enum definition sheets (@enum)

An Enum<T> column needs a T. You can register one from plugin C# (EnumRegistry), but you can also just write it in the sheet — no code, no plugin. A sheet is read as enum definitions when either is true:

  • it carries an @enum marker row (the tab can be named anything), or
  • the tab is named exactly Enum (case-sensitive) and has no @type row.

The second rule requires @type to be absent on purpose: a data table always has it, so an existing table that happens to be called Enum keeps being a table. A sheet holding both @enum and @type is contradictory and is reported as EnumSheetMarkerConflict rather than guessed at.

@desc takes no part in the decision — it is legal on both kinds of sheet, and on an enum sheet it describes the enum in that column (see below).

An enum sheet has no table — no schema, no key column, no records. One column is one enum: the @name cell holds the enum's name, and every row below it (blank column A) is one member.

@enum    | byte       |
@desc    | Damage kind| Elemental affinity
@name    | DamageType | Element
         | Physical   | Fire
         | Magical=10 | Ice
         | True       | Lightning

That sheet defines two enums, and Enum<DamageType> / Enum<Element> now resolve in any @type cell — the column syntax is unchanged. All three extras shown above are optional; a bare @name row plus members is still a complete enum sheet.

You don't have to type that skeleton yourself: Create sheet ships an Enum definitions template that lays the sheet out for you, one of the two built-in templates (see Data Studio ▸ Sheet create / delete).

  • Order is the value, and Name=value pins it. A member cell is either a plain name or Name=value with an explicit integer — exactly C#'s enum rules: an unnumbered member is the previous value plus one, the first one is 0.
    • Normal / Rare=10 / Epic compiles to 0 / 10 / 11. Codegen emits the = value only where you wrote one.
    • Data cells and dropdowns always use the name (Rare, never Rare=10).
    • A value that is not a plain integer, or that falls outside the underlying type's range (including by auto-increment), is InvalidEnumMemberValue.
    • That is also why the Data Studio never rearranges members and never back-fills a gap: shifting a member would silently change values already baked into assets and stored in save files.
  • @desc describes the enum. The @desc cell of a column becomes that enum's XML <summary> in the generated code (tooltips in the IDE), same spirit as a data table's field @desc. Empty cell = no description; the marker row itself is optional.
  • @enum cells pick the underlying type. The @enum row's cell in a column may name the C# underlying type for that enum — one of byte, sbyte, short, ushort, int, uint, long, ulong.
    • Empty cell (or no @enum row at all, on a tab named Enum) means int. Anything else is InvalidEnumUnderlyingType.
    • Codegen emits public enum Grade : byte { … }.
    • For ulong, explicit values above long.MaxValue are not supported from a sheet — register such an enum from plugin C# instead.
  • Blank cells are skipped, not read as members, so columns may have different lengths and a hole in the middle is simply passed over.
  • Comment rows (#) are ignored anywhere in the sheet. Many enums per sheet and many enum sheets are both fine. Names must be unique across all of them, and a name a plugin already registered from C# wins — the sheet definition is rejected with DuplicateEnumName.
  • Names and members must be usable as C# identifiers: ASCII letters, digits and _, not starting with a digit, and not a reserved keyword (InvalidEnumIdentifier).
    • Non-ASCII is rejected deliberately, because look-alike Unicode identifiers would produce a type nobody can tell apart from another.
    • A declared name with no members below it is EnumSheetEmptyColumn.
    • If any member of one column fails, that whole enum is dropped rather than half-registered.
  • What the import generates. One SheetForgeEnums.cs for the whole project — enums are a project-level output, not a per-tab one. It is written to the generated-code folder in the settings, in the same namespace as the generated tab types. The first import creates the type, compiles it and finishes the bake after the domain reload, with no extra click.
  • Adding a member without opening the sheet: an Enum<T> cell's dropdown in the Data Studio carries "Add a new member…", which stages the member onto the enum sheet as one undo step. An enum registered from plugin C# does not offer that row — code owns it.
  • Enum sheets have no records, so they are never baked into a ScriptableObject and Export/Push leave their text alone; the import reports them separately from skipped tabs.
  • See Capabilities & Limits for the two boundaries: plugin-registered enums cannot be extended from a sheet, and the generated enum file always lands in the settings folder.

Localization sheets (@loc)

An @loc marker row turns the sheet into a localization sheet: rows are keys, columns are locales, and each locale column's @loc cell names its locale code.

  • The RecordId key column is required — the key value is the localization key.
  • The first locale column is the source locale.
  • Locale columns are string columns. string? is the recommended form: an empty cell is then a coverage gap, not an error.
  • Two optional columns are reserved by name: smart (bool) and comment (string).
@loc     |            | en          | ko    |
@name    | codeName   | en          | ko    | comment
@type    | RecordId   | string?     | string? | string?
@desc    | key        | source text |       |
         | ui.ok      | OK          | 확인  | Confirm button

The sheet stays an ordinary table for editing, Export, Push, xlsx and the web app. What changes is the output: no record class and no Database SO, but per-tab key constants and — when the Unity Localization package is installed — StringTable synchronization. @enum and @loc on the same sheet is a conflict error.

The full story — LocRef references, minting, the bridge, translation workflows — is on Localization Sheets.

Custom structural markers (plugin-registered)

@overlap is the built-in example of a per-column marker: a marker row whose cells carry one value per column, validated column by column. A plugin can register its own markers the same way — for example a @curve marker that annotates how each numeric column interpolates.

The value is stored as domain-agnostic metadata (FieldSchema.MarkerValues) that validators, edge contributors, and the authoring window column-header tooltip can read. The Core never interprets the value itself — validation is delegated to the marker definition.

  • Registered custom markers are accepted exactly like @overlap: any order above the data, duplicates rejected, a marker below the data is an error.
  • Each marker owns only its per-column value validation (including what an empty cell means) — it does not take over parsing the whole row. Data "shapes" stay in normalization (references, List<T>, type columns).
  • Custom markers are for column-level metadata, not new data shapes. See Plugin Authoring §4.5 for a registration example.
  • @style is the one built-in marker that is not per-column (it describes the sheet), so it is not the model to copy — @overlap is.

Composing complex data: normalization first

The recommended way to express complex structures is reference assembly ("assemble, don't script"):

  • Atoms live as rows in their own tab.
  • Combinations are reference lists: List<RecordId@Effects>.
  • A type column (an enum) links a data row to a code atom — your runtime switches on it to dispatch behavior. No embedded scripting language needed.

Mini-grammars (custom cell types like attack:add:10) are for small tuples — the Core provides ; and : conventions; don't overuse them.

For genuinely procedural one-off logic, reference a script asset the same way you reference an image: List<AssetRef@Scripts>. SheetForge validates the reference and bakes the addressable; executing the script is your game's job.

Special data "shapes": even tricky-looking data (level curves, etc.) normalizes cleanly (List<float>, reference assembly). A custom structural marker adds column-level metadata (validated per column), not a new data shape — normalize the data first, and reach for a custom marker only for genuinely verbose per-column annotations. See Plugin Authoring.