Architecture
Skein has four deployable parts and one Unity package:
Unity Editor (package) Browser
┌──────────────────────────┐ ┌───────────────┐
│ Skein window / Scene view│ │ Next.js site │
│ SceneTracker → mutations │ │ + dashboard │
│ CollabClient (protocol) │ └──────┬────────┘
└───────┬───────────┬──────┘ │ same-origin /api/* (rewrite)
HTTPS │ │ WSS (skein.v1) │
▼ ▼ ▼
┌──────────────┐ ┌───────────────────────┐ ┌──────────────┐
│ API │ │ Realtime nodes (N) │ │ web (Next) │
│ Fastify │ │ ws + Lua-in-Redis │ └──────────────┘
│ REST + auth │ │ presence/locks/sync │
└──┬───────┬───┘ └──┬──────────────┬─────┘
│ │ ctl:realtime pub/sub │
│ └────────────► Redis ◄──┘ session state, ordering, pub/sub, replay log, rate limits
│ │
▼ ▼ (persister: Redis stream → Postgres)
PostgreSQL ◄────────────────┘ users, tenants, projects, sessions, changes, snapshots, billing, audit
Object storage (S3/R2/MinIO or local disk) — snapshot scene files
| Component | Path | Responsibility |
|---|---|---|
| Protocol | packages/protocol |
Versioned wire contract: message schemas (zod), limits, close codes, error messages. Shared by API, realtime and tests; mirrored in C#. |
| Shared | packages/shared |
Plans/limits, roles/permission matrix, participant colors, Redis key layout, control-plane message types. |
| API | apps/api |
Accounts, auth (web sessions, device flow, OAuth), tenancy, projects, members, sessions + realtime tickets, history, snapshots, API keys, billing (Stripe), audit logs, OpenAPI. |
| Realtime | apps/realtime |
WebSocket server. Authenticates tickets, relays presence, grants locks, sequences mutations, replays on reconnect, persists changes. Stateless per node — all session state lives in Redis. |
| Web | apps/web |
Marketing site, docs, auth pages, device-approval page, dashboard. Talks to the API through a same-origin /api rewrite. |
| Unity package | unity/Packages/com.skeinlabs.collab |
Runtime/Core (engine-agnostic: JSON, protocol, transport, client state machine, presence math, REST client) and Editor (identity, change tracking, applying remote changes, Scene view drawing, UI). |
Technology decisions
| Decision | Choice | Why |
|---|---|---|
| Language (server) | TypeScript (strict) on Node 22 | One language for API, realtime, web and the protocol definitions; zod schemas give runtime validation and static types from one source. |
| HTTP framework | Fastify 5 | Fast, small, first-class hooks for auth/rate limiting, good OpenAPI support. NestJS's DI container was unnecessary at this size — dependencies are passed explicitly through an AppContext. |
| Realtime transport | Raw WebSockets (ws), JSON frames, sub-protocol skein.v1 |
Works through every corporate proxy and with .NET's ClientWebSocket in Unity without native plugins. WebRTC adds NAT traversal and a signaling tier for no benefit in a server-authoritative design. |
| Encoding | JSON with quantized numbers | Debuggable, schema-validated, ~330 bytes per camera frame. A binary codec can be negotiated later via the sub-protocol (skein.v2) without changing semantics. |
| Session state | Redis (Lua scripts, pub/sub, streams) | Every mutation is one atomic script: dedup → lock check → INCR seq → state update → replay-log append → publish. That single step is what gives a global, gap-free order across any number of realtime nodes. |
| Durable store | PostgreSQL 16 | Relational tenancy model, foreign keys, transactional plan-limit checks, JSONB for change payloads. |
| Persistence of changes | Redis stream + consumer group → Postgres | Keeps Postgres off the hot path; consumer groups survive node loss; inserts are idempotent on (session_id, seq). |
| Unity UI | UI Toolkit (window) + IMGUI/Handles (Scene view) | UI Toolkit is the modern editor UI; Handles is the right tool for drawing in the Scene view. |
| Unity identity | GlobalObjectId + session-assigned ids |
Stable across machines for committed scenes, and requires no components added to user scenes. |
| Website | Next.js 16 (App Router) + Tailwind 4 | Static marketing/docs pages, server-side auth gate for the dashboard, one deployable. |
| Passwords | argon2id (OWASP parameters) | Memory-hard; @node-rs/argon2 ships prebuilt binaries (no compiler needed in containers). |
The hardest technical risks (and how they're handled)
- Ordering and exactly-once delivery across multiple realtime nodes. Solved by doing all ordering inside Redis Lua scripts (atomic), publishing in the same script, assigning a per-session
seq, and de-duplicating client event ids for 5 minutes. Clients detect gaps and request replays. Tested with racing clients and two nodes (apps/realtime/test). - Stable object identity in Unity without polluting scenes.
GlobalObjectIdis derived from the scene GUID + local file id, so it matches on every machine with the same committed scene. Objects created during a session get server-sharedn:ids. Requirement: collaborators open the same committed scene (documented). - Detecting local edits without polling or GC churn.
ObjectChangeEvents+Undo.postprocessModificationsmark touched objects; each is diffed against a cached baseline once the gesture ends. Drags stream previews at ≤20 Hz. - Conflicts. There is no meaningful merge of two transforms. Skein uses server-granted leases (locks) and reverts edits to objects someone else owns. See conflicts.md.
- Unity domain reloads. Every script compile destroys all managed state including sockets. The client detaches without leaving, persists a resume token in
SessionState, and resumes with the same connection id — keeping its locks. - Not trusting the client. Every frame is schema-validated; identity comes from the signed ticket, never from the message; permissions are rechecked server-side (viewer = read-only, lock ownership, admin-only force unlock).
Data flow of an edit
- User drags
Door_Main(lock already held — it was requested on selection). - While dragging:
OBJECT_TRANSFORM_PREVIEWat ≤20 Hz → realtime node checks the lock in its bus-fed lock cache → publishes to the session channel → other clients apply it without touching Undo. - Mouse up:
OBJECT_TRANSFORMwithprevand a label. The node runsCOMMIT_MUTATIONin Redis: dedup, verify lock owner + lease,seq = INCR, update object state hash, append to replay stream (<seq>-0), append to the global change stream, publish relay. - Sender receives
ACK {seq}; everyone (sender included) receives the sequenced relay in order. - A persister (any node, consumer group) inserts the change into
changeswith before/after.
Database schema
Defined in apps/api/migrations/0001_init.sql. Key tables:
users,oauth_accounts,auth_sessions(browser),refresh_tokens(editor, rotating families),device_codes,email_tokensorganizations,organization_members— tenants; every user gets a personal organization on sign-upprojects,project_members,invitationscollab_sessions,session_participantschanges(unique(session_id, seq)),snapshotssubscriptions,stripe_events(webhook idempotency),usage_countersapi_keys,audit_logs
Every tenant-owned row carries org_id/project_id; all project access goes through one function (requireProjectAccess) that resolves the effective role with a single join and returns 404 for both "doesn't exist" and "no access".
Repository layout
apps/api REST API (Fastify) apps/api/migrations SQL migrations
apps/realtime WebSocket server apps/web Next.js site + dashboard
packages/protocol wire protocol (TS) packages/shared plans, roles, keys
unity/Packages/com.skeinlabs.collab the Unity package (UPM)
unity/SampleProject Unity project using the package via file: reference
unity/Tooling .NET projects: compile checks + C# tests
tests/e2e cross-language end-to-end tests tests/load load generator
infrastructure Dockerfiles, Kubernetes-ready manifests, Caddy config
docs this documentation (also rendered at /docs on the website)