24 KiB
Guildlib v2 — Complete Documentation
Table of contents
- How it works — overview
- Unity attributes — complete reference
- Creating your own shards and types
- Platform and tag system
- The sync cycle — step by step
- Server API reference
- Web editor guide
- Local testing (Windows / PowerShell)
- Docker deployment
- Changing things — common tasks
1. How it works
Unity C# classes
│
│ [ShardTarget], [GuildLabel], [GuildPlatforms] …
│ (attributes on your classes describe everything)
▼
SchemaExporter.Export()
│
│ produces schema.json
▼
POST /schema ──────────────────────────► Bun server
│
│ stores schema.json
│ auto-creates .sqlite per shard
▼
Web editor reads schema.json and renders type-safe forms
Users create / edit entries in the browser
Changes stored in weapons.sqlite, audio.sqlite, etc.
Unity Sync Panel clicks "Pull All Shards":
1. GET /manifest/weapons → [{id, version, checksum}] (lightweight!)
2. diff against local cache
3. POST /pull { ids: [only what changed] }
4. receive only changed rows
5. upsert into local weapons.sqlite
Key design decisions inherited from the Perforce paper:
- Manifest-first: only checksums cross the wire on the first pass. No data is transferred if nothing changed.
- Shard independence: each shard is a completely separate .sqlite file.
You can pull just
audiowithout touchingweapons. - Server-side filtering: platform and tag filters are applied on the server before rows are returned, so a Switch build only downloads Switch data.
2. Unity attributes
All attributes live in Guildlib.Runtime. Import the namespace at the top of
your C# files.
[ShardTarget("name")] ← REQUIRED
Declares which shard database this type belongs to.
[ShardTarget("characters")]
public class CharacterStats : DataEntry { ... }
[ShardTarget("ui")]
public class UIButtonConfig : DataEntry { ... }
[ShardTarget("vfx_particles")] // underscores OK, lowercase
public class ParticlePreset : DataEntry { ... }
Rules:
- Lowercase, no spaces, no special characters except underscore
- The server auto-creates a new
<name>.sqlitefile the first time an entry of that type is pushed — you do not need to pre-register shards anywhere - If two classes share a
[ShardTarget]they live in the same database file (the shard is a file, not a type)
[GuildLabel("Display Name")] ← optional
Sets the human-readable name shown in the web editor's type list. Defaults to the C# class name if omitted.
[ShardTarget("characters")]
[GuildLabel("Character Stats")]
public class CharacterStats : DataEntry { ... }
// Shows as "Character Stats" in the web editor, not "CharacterStats"
[GuildDescription("...")] ← optional
A one-sentence description shown in the web editor's form panel under the type name. Use it to explain what this entry type is for.
[GuildDescription("Defines base stats for all playable and enemy characters.")]
public class CharacterStats : DataEntry { ... }
[GuildPlatforms("p1", "p2", ...)] ← optional
Sets the default platform tags for newly created entries of this type. Can be overridden per-entry in the web editor.
// This entry type ships on all platforms by default
[GuildPlatforms("all")]
public class CharacterStats : DataEntry { ... }
// PC and console only — Switch gets different data
[GuildPlatforms("pc", "ps5", "xbox_series")]
public class HighResTextureConfig : DataEntry { ... }
// Switch-specific preset
[GuildPlatforms("switch", "mobile_ios", "mobile_android")]
public class LowResTextureConfig : DataEntry { ... }
If omitted, defaults to ["all"] — meaning every platform gets this data.
[GuildTags("t1", "t2", ...)] ← optional
Sets the default content/build tags for new entries of this type. Can be overridden per-entry in the web editor.
[GuildTags("base_game", "release")]
public class CharacterStats : DataEntry { ... }
[GuildTags("dlc_1")]
public class DLC1Character : CharacterStats { ... }
[GuildTags("debug")]
public class DebugTestCharacter : CharacterStats { ... }
[GuildDisplayName] ← optional, on a field
Marks one field as the "display name" for the entry list in the web editor. Without this, the entry ID is shown. Put it on whichever field is most useful as a human-readable label.
public class CharacterStats : DataEntry
{
[GuildDisplayName]
public string characterName; // web editor shows this in the list
public float maxHealth;
}
[GuildEnum("A", "B", "C")] ← optional, on a string field
Constrains a string field to a fixed set of options. The web editor renders a dropdown instead of a plain text input.
[GuildEnum("Warrior", "Mage", "Rogue", "Ranger")]
public string characterClass;
[GuildEnum("Common", "Uncommon", "Rare", "Epic", "Legendary")]
public string rarity;
[GuildEnum("Fire", "Ice", "Lightning", "Physical", "Poison")]
public string damageType;
[GuildRef("shard_name")] ← optional, on a string field
Marks a string field as a foreign key reference to an entry in another shard. The web editor shows an annotated hint (full picker UI is a planned enhancement). The actual stored value is still the entryId string.
public class CharacterStats : DataEntry
{
[GuildRef("audio")]
public string deathSoundId; // points to an entry in audio.sqlite
[GuildRef("models")]
public string modelId; // points to an entry in models.sqlite
}
[GuildMultiline] ← optional, on a string field
Makes the web editor render a textarea instead of a single-line input. Use for long descriptions or multi-line text content.
[GuildMultiline]
public string loreText;
[GuildMultiline]
public string debugNotes;
[GuildExclude] ← optional, on a field
Excludes a field from schema export and sync entirely. Use for Unity-only fields (Texture2D refs, EditorGUILayout state, etc.) that cannot be serialised to the database.
[GuildExclude]
public Texture2D previewTexture; // Unity asset ref — never synced
[GuildExclude]
public bool editorFoldout; // editor UI state — never synced
3. Creating shards and types
Step 1 — Write a C# class
Create a new .cs file anywhere under Assets/ in your Unity project.
using UnityEngine;
using Guildlib.Runtime;
// ── A brand new shard called "abilities" ─────────────────────────────────────
[ShardTarget("abilities")]
[GuildLabel("Ability Definition")]
[GuildDescription("Defines a single player or enemy ability.")]
[GuildPlatforms("all")]
[GuildTags("base_game", "release")]
[CreateAssetMenu(menuName = "Guildlib/Ability")]
public class AbilityDefinition : DataEntry
{
[GuildDisplayName]
public string abilityName;
[GuildMultiline]
public string description;
[GuildEnum("Active", "Passive", "Toggle")]
public string abilityType;
public float cooldown;
public float manaCost;
public int maxLevel;
[GuildRef("audio")]
public string castSoundId;
[GuildRef("vfx_particles")]
public string castVFXId;
}
// ── A child type — inherits all AbilityDefinition fields ─────────────────────
[ShardTarget("abilities")]
[GuildLabel("Area Ability")]
[GuildDescription("An ability that affects an area rather than a single target.")]
public class AreaAbility : AbilityDefinition
{
public float radius;
public bool falloffDamage;
[GuildEnum("Circle", "Cone", "Rectangle")]
public string areaShape;
}
Step 2 — Upload the schema
Open Window → Guildlib → Sync Panel, then click Upload Schema.
This sends all your C# type definitions to the server. The server stores
the schema as config/schema.json. The web editor reads this file to know
what types exist, what their fields are, and what options are available.
The abilities shard now exists on the server. Its .sqlite file is created
automatically the first time an entry is pushed.
Step 3 — Use in the web editor
Refresh the web editor. You will see the abilities shard appear in the left
column. Click it to see Ability Definition and Area Ability in the type tree.
Inheritance rules
- A child type appears nested under its parent in the web editor
- The form shows inherited fields first (marked "inherited"), then own fields
- Child types go into the same shard as their parent
- You can nest arbitrarily deep:
DataEntry → Ability → AreaAbility → AOEBomb - Multiple children of the same parent are all shown in the tree
4. Platform and tag system
What they are
Platforms describe which shipping targets an entry is relevant for. Tags describe which content group, build configuration, or DLC an entry belongs to.
Both are stored per-entry (not per-type) and can be edited in the web editor.
Type-level [GuildPlatforms] and [GuildTags] attributes only set the default
for newly created entries — users can change them freely in the web editor.
Defining your vocabulary
Both lists live in DataEntry.cs:
public static class GuildPlatformRegistry
{
public static readonly string[] All = new[]
{
"pc",
"ps5",
"ps4",
"xbox_series",
"xbox_one",
"switch",
"mobile_ios",
"mobile_android",
"vr",
};
}
public static class GuildTagRegistry
{
public static readonly string[] All = new[]
{
"release",
"debug",
"demo",
"base_game",
"dlc_1",
"dlc_2",
"high",
"medium",
"low",
"global",
"region_jp",
"region_eu",
"region_na",
};
}
To add a platform or tag: add a string to the appropriate array and run Upload Schema from the Sync Panel. The web editor will show the new option immediately.
To remove one: remove it from the array and re-upload the schema. Existing entries that had the old value keep it stored in the database — the web editor won't show the removed value as a selectable checkbox, but the data is preserved.
The special "all" platform
An entry tagged "all" is returned for every platform filter. This means if
you add a new platform later, entries tagged "all" are automatically included
without any data migration.
Using platform/tag filters in Unity
In the Sync Panel:
- Toggle on Filter by platform
- Enter comma-separated platforms:
pc,ps5 - Toggle on Filter by tag (optional)
- Enter tags:
release,base_game - Click Pull All Shards
Only rows matching the filters come down the wire.
Programmatically from C#:
var client = new ShardClient(config, "abilities");
// Pull only PC/PS5 release content
await client.PullAsync(
platformFilter: new List<string> { "pc", "ps5" },
tagFilter: new List<string> { "release", "base_game" }
);
// Pull everything (no filter)
await client.PullAsync();
Using filters on the server API directly
GET /entries/abilities?platforms=pc,ps5&tags=release,base_game
POST /pull { "shard":"abilities", "ids":[...], "filterPlatforms":["switch"], "filterTags":["base_game"] }
5. The sync cycle
Full cycle (first time or clean slate)
1. GET /manifest/weapons
← { shard:"weapons", rows: [] } ← empty, nothing on server yet
2. (nothing to diff — skip pull)
3. (user creates entries via web editor)
4. GET /manifest/weapons
← { rows: [{id:"abc", version:1, checksum:"d41d..."}, ...] }
5. Diff: local has nothing → need all IDs
6. POST /pull { shard:"weapons", ids:["abc", ...] }
← { rows: [{id:"abc", weaponType:"Sword", damage:45, ...}, ...] }
7. Upsert into local weapons.sqlite
8. Save manifest cache
Incremental sync (normal operation)
1. GET /manifest/weapons
← 10,000 rows of {id, version, checksum}
2. Compare with local manifest:
- 9,995 rows unchanged → skip
- 5 rows have newer version → add to needIds
3. POST /pull { ids: ["id1","id2","id3","id4","id5"] }
← only those 5 rows
4. Upsert 5 rows locally
5. Update manifest cache
This means a sync where nothing changed transfers only the manifest (a flat JSON list of id/version/checksum — a few KB even for large datasets) and then completes immediately.
6. Server API reference
All endpoints accept and return application/json.
If GUILDLIB_API_KEY is set, include X-Api-Key: <key> on every request
except /health.
| Method | Path | Description |
|---|---|---|
| GET | /health |
Server status, version, shard list |
| GET | /manifest/:shard |
Lightweight manifest for one shard |
| POST | /pull |
Fetch rows by ID list with optional filters |
| POST | /push |
Upsert one entry from Unity |
| POST | /schema |
Upload schema.json from Unity |
| GET | /schema |
Retrieve current schema.json |
| GET | /entries/:shard |
List entries (supports query params) |
| POST | /entries/:shard |
Create entry |
| GET | /entries/:shard/:id |
Single entry |
| PUT | /entries/:shard/:id |
Update entry |
| DELETE | /entries/:shard/:id |
Soft-delete entry |
/manifest/:shard
GET /manifest/weapons
→ {
"shard": "weapons",
"rows": [
{ "id": "abc123", "version": 3, "checksum": "d41d8cd9..." },
...
]
}
/pull
POST /pull
{
"shard": "weapons",
"ids": ["abc123", "def456"],
"filterPlatforms": ["pc", "ps5"], // optional
"filterTags": ["release"] // optional
}
→ {
"shard": "weapons",
"rows": [
{ "id":"abc123", "type_name":"...", "platforms":["all"],
"tags":["release","base_game"], "weaponType":"Sword", ... },
...
]
}
/entries/:shard (list)
Query params:
type— filter by C# type nameplatforms— comma-separated platform filtertags— comma-separated tag filterparent— filter by parent entry IDlimit— default 100offset— default 0
GET /entries/weapons?type=Guildlib.Runtime.SwordData&platforms=pc,ps5&tags=release
/entries/:shard (create)
POST /entries/weapons
{
"typeName": "MyGame.AbilityDefinition",
"platforms": ["all"],
"tags": ["base_game", "release"],
"data": {
"abilityName": "Fireball",
"cooldown": 3.5,
"manaCost": 25
}
}
→ { "id": "generated-uuid", "version": 1 }
/entries/:shard/:id (update)
PUT /entries/weapons/abc123
{
"platforms": ["pc", "ps5"],
"tags": ["release"],
"data": { "cooldown": 4.0 }
}
Only fields present in data are merged. Other fields are preserved.
7. Web editor guide
Open web/index.html in Chrome or Edge (no build step, no server needed for the file itself).
Connecting
Enter your server URL (default http://localhost:3000) and click Connect.
The editor fetches /health to verify, then loads /schema to build the UI.
Left column — shard and type tree
Lists all shards the schema describes. Each shard has a colour dot. Click a shard to expand its type tree. Types are shown as root + children:
weapons
├─ WeaponData
│ └─ SwordData [child]
│ └─ DaggerData [child]
└─ ...
abilities
├─ AbilityDefinition
│ └─ AreaAbility [child]
Click any type to load its entries.
Middle column — entry list
Shows all entries of the selected type. Each row shows:
- The display name (from
[GuildDisplayName]field, or entry ID) - A short ID chip
- Row version
- Up to 3 platform tags (blue)
- Up to 2 content tags (yellow)
Use the search box to filter by display name.
Right column — entry form
Platform tags: checkbox grid of all defined platforms. Click to toggle. Click "all" to mark as platform-agnostic.
Content tags: checkbox grid of all defined tags. Click to toggle.
Fields: rendered based on the type's field schema:
[GuildEnum]→ dropdownbool→ toggle switchint/float→ number inputstring[]→ textarea (one value per line)[GuildMultiline]→ textarea[GuildRef]→ text input with shard hint- Everything else → text input
Inherited fields appear first with an "inherited" badge.
Click Create entry or Save changes to persist. Click Delete to soft-delete
(the row is marked deleted=1 and version-bumped so Unity clients know to remove it).
8. Local testing (Windows / PowerShell)
Install Bun (once)
# Run as Administrator
powershell -c "irm bun.sh/install.ps1 | iex"
# Close and reopen PowerShell, then verify:
bun --version
Start the server
cd C:\path\to\guildlib-v2\server
bun install # first time only
New-Item -ItemType Directory -Force -Path ".\shards",".\config"
$env:GUILDLIB_API_KEY = "" # blank = auth disabled for local dev
$env:SHARD_DIR = ".\shards"
$env:SCHEMA_PATH = ".\config\schema.json"
bun run dev
Expected output:
[Guildlib] :3000 shards: auth: OFF
[ShardManager] Opened: weapons.sqlite
[ShardManager] Opened: audio.sqlite
...
Verify it works
Invoke-RestMethod http://localhost:3000/health
Push a test schema (without Unity)
$schema = Get-Content -Raw "C:\path\to\guildlib-v2\docs\example-schema.json"
Invoke-RestMethod -Method POST `
-Uri http://localhost:3000/schema `
-ContentType "application/json" `
-Body $schema
Or use the example at the bottom of this document.
Create an entry via PowerShell
$body = '{"typeName":"MyGame.AbilityDefinition","platforms":["all"],"tags":["base_game"],"data":{"abilityName":"Fireball","cooldown":3.5}}'
Invoke-RestMethod -Method POST `
-Uri http://localhost:3000/entries/abilities `
-ContentType "application/json" `
-Body $body
Test the delta sync
# 1. Get manifest
Invoke-RestMethod http://localhost:3000/manifest/abilities
# 2. Pull specific IDs
$pull = '{"shard":"abilities","ids":["<paste-id-here>"]}'
Invoke-RestMethod -Method POST `
-Uri http://localhost:3000/pull `
-ContentType "application/json" `
-Body $pull
# 3. Test platform filter
$pull = '{"shard":"abilities","ids":["<id>"],"filterPlatforms":["switch"]}'
Invoke-RestMethod -Method POST `
-Uri http://localhost:3000/pull `
-ContentType "application/json" `
-Body $pull
9. Docker deployment
cd guildlib-v2/docker
# 1. Copy env template
cp .env.example .env
# Edit .env — set GUILDLIB_API_KEY to a real secret
# 2. Build and start
docker compose up -d
# 3. Check it's running
curl http://localhost:3000/health
The guildlib-shards and guildlib-config Docker volumes persist your data
across container restarts and updates.
To update the server:
docker compose pull
docker compose up -d
For HTTPS in production, uncomment the caddy block in docker-compose.yml
and set DOMAIN=yourdomain.com in .env.
10. Changing things — common tasks
Add a new shard
Just write a class with [ShardTarget("new_shard_name")] and upload the schema.
No server config changes needed. The .sqlite file is created automatically
on first push.
Add a new platform
- Open
DataEntry.cs - Add your platform string to
GuildPlatformRegistry.All - Click Upload Schema in the Sync Panel
- The web editor now shows the new platform as a checkbox
Add a new tag
Same as adding a platform, but edit GuildTagRegistry.All.
Add a field to an existing type
- Add the
publicfield to your C# class - Click Upload Schema
- Existing entries in the database keep all their old data — the new field will be empty/default until entries are edited and re-saved
Rename a field
Rename it in C# and re-upload the schema. The old field name remains in existing database rows (the data is stored as JSON internally). Use the web editor to open and re-save affected entries so they write the new field name.
Remove a field
Remove it from C# and re-upload. Existing data with the old field name is preserved in the database JSON blob but won't appear in the web editor forms. It won't be synced to new Unity clients.
Change a shard name
This is a breaking change. Rename the [ShardTarget] value, re-upload schema,
then manually copy the old .sqlite file on the server to the new name.
The server will open it automatically on restart.
Add a child type
Subclass an existing DataEntry subclass, give it the same [ShardTarget],
add its own fields, and upload the schema. It appears nested in the web editor
under its parent type immediately.
Example schema JSON (for testing without Unity)
Save this as example-schema.json and POST it to /schema:
{
"version": "2",
"exportedAt": 1710000000,
"unityProjectId": "test",
"shards": ["abilities", "characters"],
"platformConfig": {
"platforms": ["pc","ps5","xbox_series","switch","mobile_ios"],
"tags": ["release","debug","demo","base_game","dlc_1","high","low"]
},
"types": [
{
"typeName": "MyGame.AbilityDefinition",
"displayName": "Ability Definition",
"description": "Defines a single player or enemy ability.",
"shard": "abilities",
"parentType": null,
"childTypes": ["MyGame.AreaAbility"],
"defaultPlatforms": ["all"],
"defaultTags": ["base_game", "release"],
"fields": [
{"name":"abilityName","type":"string","isInherited":false,"isDisplayName":true},
{"name":"description","type":"string","isInherited":false,"isMultiline":true},
{"name":"abilityType","type":"string","isInherited":false,"enumOptions":["Active","Passive","Toggle"]},
{"name":"cooldown","type":"float","isInherited":false},
{"name":"manaCost","type":"float","isInherited":false},
{"name":"maxLevel","type":"int","isInherited":false},
{"name":"castSoundId","type":"string","isInherited":false,"refShard":"audio"}
]
},
{
"typeName": "MyGame.AreaAbility",
"displayName": "Area Ability",
"description": "An ability that affects an area.",
"shard": "abilities",
"parentType": "MyGame.AbilityDefinition",
"childTypes": [],
"defaultPlatforms": ["all"],
"defaultTags": ["base_game", "release"],
"fields": [
{"name":"abilityName","type":"string","isInherited":true,"isDisplayName":true},
{"name":"cooldown","type":"float","isInherited":true},
{"name":"radius","type":"float","isInherited":false},
{"name":"falloffDamage","type":"bool","isInherited":false},
{"name":"areaShape","type":"string","isInherited":false,"enumOptions":["Circle","Cone","Rectangle"]}
]
},
{
"typeName": "MyGame.CharacterStats",
"displayName": "Character Stats",
"description": "Base stats for all characters.",
"shard": "characters",
"parentType": null,
"childTypes": [],
"defaultPlatforms": ["all"],
"defaultTags": ["base_game", "release"],
"fields": [
{"name":"characterName","type":"string","isInherited":false,"isDisplayName":true},
{"name":"maxHealth","type":"float","isInherited":false},
{"name":"moveSpeed","type":"float","isInherited":false},
{"name":"characterClass","type":"string","isInherited":false,"enumOptions":["Warrior","Mage","Rogue","Ranger"]},
{"name":"loreText","type":"string","isInherited":false,"isMultiline":true}
]
}
]
}