···11+# Dependencies
22+node_modules/
33+44+# Build output
55+dist/
66+*.wasm
77+88+# Environment
99+.env.local
1010+.env.*.local
1111+1212+# Editor
1313+.vscode/
1414+.idea/
1515+*.swp
1616+*.swo
1717+*~
1818+1919+# OS
2020+.DS_Store
2121+Thumbs.db
2222+2323+# SpacetimeDB generated bindings (regenerated from module)
2424+client/src/module_bindings/
2525+2626+# SpacetimeDB local server
2727+.spacetime.pid
2828+.spacetime.log
2929+3030+# Logs
3131+*.log
3232+3333+# Test coverage
3434+coverage/
+766
AGENTS.md
···11+# SpacetimeDB Rules (All Languages)
22+33+## Migrating from 1.0 to 2.0?
44+55+**If you are migrating existing SpacetimeDB 1.0 code to 2.0, apply `spacetimedb-migration-2.0.mdc` first.** It documents breaking changes (reducer callbacks → event tables, `name`→`accessor`, `sender()` method, etc.) and should be considered before other rules.
66+77+---
88+99+## Language-Specific Rules
1010+1111+| Language | Rule File |
1212+|----------|-----------|
1313+| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) |
1414+| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) |
1515+| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) |
1616+| **Migrating 1.0 → 2.0** | `spacetimedb-migration-2.0.mdc` |
1717+1818+---
1919+2020+## Core Concepts
2121+2222+1. **Reducers are transactional** — they do not return data to callers
2323+2. **Reducers must be deterministic** — no filesystem, network, timers, or random
2424+3. **Read data via tables/subscriptions** — not reducer return values
2525+4. **Auto-increment IDs are not sequential** — gaps are normal, don't use for ordering
2626+5. **`ctx.sender` is the authenticated principal** — never trust identity args
2727+2828+---
2929+3030+## Feature Implementation Checklist
3131+3232+When implementing a feature that spans backend and client:
3333+3434+1. **Backend:** Define table(s) to store the data
3535+2. **Backend:** Define reducer(s) to mutate the data
3636+3. **Client:** Subscribe to the table(s)
3737+4. **Client:** Call the reducer(s) from UI — **don't forget this step!**
3838+5. **Client:** Render the data from the table(s)
3939+4040+**Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them.
4141+4242+---
4343+4444+## Index System
4545+4646+SpacetimeDB automatically creates indexes for:
4747+- Primary key columns
4848+- Columns marked as unique
4949+5050+You can add explicit indexes on non-unique columns for query performance.
5151+5252+**Index names must be unique across your entire module (all tables).** If two tables have indexes with the same declared name → conflict error.
5353+5454+**Schema ↔ Code coupling:**
5555+- Your query code references indexes by name
5656+- If you add/remove/rename an index in the schema, update all code that uses it
5757+- Removing an index without updating queries causes runtime errors
5858+5959+---
6060+6161+## Commands
6262+6363+```bash
6464+# Login to allow remote database deployment e.g. to maincloud
6565+spacetime login
6666+6767+# Start local SpacetimeDB
6868+spacetime start
6969+7070+# Publish module
7171+spacetime publish <db-name> --module-path <module-path>
7272+7373+# Clear and republish
7474+spacetime publish <db-name> --clear-database -y --module-path <module-path>
7575+7676+# Generate client bindings
7777+spacetime generate --lang <lang> --out-dir <out> --module-path <module-path>
7878+7979+# View logs
8080+spacetime logs <db-name>
8181+```
8282+8383+---
8484+8585+## Deployment
8686+8787+- Maincloud is the spacetimedb hosted cloud and the default location for module publishing
8888+- The default server marked by *** in `spacetime server list` should be used when publishing
8989+- If the default server is maincloud you should publish to maincloud
9090+- Publishing to maincloud is free of charge
9191+- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@<username>/<database-name>
9292+- The database owner can view utilization and performance metrics on the dashboard
9393+9494+---
9595+9696+## Debugging Checklist
9797+9898+1. Is SpacetimeDB server running? (`spacetime start`)
9999+2. Is the module published? (`spacetime publish`)
100100+3. Are client bindings generated? (`spacetime generate`)
101101+4. Check server logs for errors (`spacetime logs <db-name>`)
102102+5. **Is the reducer actually being called from the client?**
103103+104104+---
105105+106106+## Editing Behavior
107107+108108+- Make the smallest change necessary
109109+- Do NOT touch unrelated files, configs, or dependencies
110110+- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
111111+- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users
112112+113113+114114+# SpacetimeDB TypeScript SDK
115115+116116+## ⛔ HALLUCINATED APIs — DO NOT USE
117117+118118+**These APIs DO NOT EXIST. LLMs frequently hallucinate them.**
119119+120120+```typescript
121121+// ❌ WRONG PACKAGE — does not exist
122122+import { SpacetimeDBClient } from "@clockworklabs/spacetimedb-sdk";
123123+124124+// ❌ WRONG — these methods don't exist
125125+SpacetimeDBClient.connect(...);
126126+SpacetimeDBClient.call("reducer_name", [...]);
127127+connection.call("reducer_name", [arg1, arg2]);
128128+129129+// ❌ WRONG — positional reducer arguments
130130+conn.reducers.doSomething("value"); // WRONG!
131131+132132+// ❌ WRONG — static methods on generated types don't exist
133133+User.filterByName('alice');
134134+Message.findById(123n);
135135+tables.user.filter(u => u.name === 'alice'); // No .filter() on tables object!
136136+```
137137+138138+### ✅ CORRECT PATTERNS:
139139+140140+```typescript
141141+// ✅ CORRECT IMPORTS
142142+import { DbConnection, tables } from './module_bindings'; // Generated!
143143+import { SpacetimeDBProvider, useTable, Identity } from 'spacetimedb/react';
144144+145145+// ✅ CORRECT REDUCER CALLS — object syntax, not positional!
146146+conn.reducers.doSomething({ value: 'test' });
147147+conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
148148+149149+// ✅ CORRECT DATA ACCESS — useTable returns [rows, isLoading]
150150+const [items, isLoading] = useTable(tables.item);
151151+```
152152+153153+### ⛔ DO NOT:
154154+- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)`
155155+- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings`
156156+157157+---
158158+159159+## 1) Common Mistakes Table
160160+161161+### Server-side errors
162162+163163+| Wrong | Right | Error |
164164+|-------|-------|-------|
165165+| Missing `package.json` | Create `package.json` | "could not detect language" |
166166+| Missing `tsconfig.json` | Create `tsconfig.json` | "TsconfigNotFound" |
167167+| Entrypoint not at `src/index.ts` | Use `src/index.ts` | Module won't bundle |
168168+| `indexes` in COLUMNS (2nd arg) | `indexes` in OPTIONS (1st arg) | "reading 'tag'" error |
169169+| Index without `algorithm` | `algorithm: 'btree'` | "reading 'tag'" error |
170170+| `filter({ ownerId })` | `filter(ownerId)` | "does not exist in type 'Range'" |
171171+| `.filter()` on unique column | `.find()` on unique column | TypeError |
172172+| `insert({ ...without id })` | `insert({ id: 0n, ... })` | "Property 'id' is missing" |
173173+| `const id = table.insert(...)` | `const row = table.insert(...)` | `.insert()` returns ROW, not ID |
174174+| `.unique()` + explicit index | Just use `.unique()` | "name is used for multiple entities" |
175175+| Index on `.primaryKey()` column | Don't — already indexed | "name is used for multiple entities" |
176176+| Same index name in multiple tables | Prefix with table name | "name is used for multiple entities" |
177177+| `.indexName.filter()` after removing index | Use `.iter()` + manual filter | "Cannot read properties of undefined" |
178178+| Import spacetimedb from index.ts | Import from schema.ts | "Cannot access before initialization" |
179179+| Multi-column index `.filter()` | **⚠️ BROKEN** — use single-column | PANIC or silent empty results |
180180+| `JSON.stringify({ id: row.id })` | Convert BigInt first: `{ id: row.id.toString() }` | "Do not know how to serialize a BigInt" |
181181+| `ScheduleAt.Time(timestamp)` | `ScheduleAt.time(timestamp)` (lowercase) | "ScheduleAt.Time is not a function" |
182182+| `ctx.db.foo.myIndexName.filter()` | Use exact name: `ctx.db.foo.my_index_name.filter()` | "Cannot read properties of undefined" |
183183+| `.iter()` in views | Use index lookups | Severe performance issues (re-evaluates on any change) |
184184+| `ctx.db` in procedures | `ctx.withTx(tx => tx.db...)` | Procedures need explicit transactions |
185185+| `ctx.myTable` in procedure tx | `tx.db.myTable` | Wrong context variable |
186186+187187+### Client-side errors
188188+189189+| Wrong | Right | Error |
190190+|-------|-------|-------|
191191+| `@spacetimedb/sdk` | `spacetimedb` | 404 / missing subpath |
192192+| `conn.reducers.foo("val")` | `conn.reducers.foo({ param: "val" })` | Wrong reducer syntax |
193193+| Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render |
194194+| `const rows = useTable(table)` | `const [rows, isLoading] = useTable(table)` | Tuple destructuring |
195195+| Optimistic UI updates | Let subscriptions drive state | Desync issues |
196196+| `<SpacetimeDBProvider builder={...}>` | `connectionBuilder={...}` | Wrong prop name |
197197+198198+---
199199+200200+## 2) Table Definition (CRITICAL)
201201+202202+**`table()` takes TWO arguments: `table(OPTIONS, COLUMNS)`**
203203+204204+```typescript
205205+import { schema, table, t } from 'spacetimedb/server';
206206+207207+// ❌ WRONG — indexes in COLUMNS causes "reading 'tag'" error
208208+export const Task = table({ name: 'task' }, {
209209+ id: t.u64().primaryKey().autoInc(),
210210+ ownerId: t.identity(),
211211+ indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }] // ❌ WRONG!
212212+});
213213+214214+// ✅ RIGHT — indexes in OPTIONS (first argument)
215215+export const Task = table({
216216+ name: 'task',
217217+ public: true,
218218+ indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }]
219219+}, {
220220+ id: t.u64().primaryKey().autoInc(),
221221+ ownerId: t.identity(),
222222+ title: t.string(),
223223+ createdAt: t.timestamp(),
224224+});
225225+```
226226+227227+### Column types
228228+```typescript
229229+t.identity() // User identity (primary key for per-user tables)
230230+t.u64() // Unsigned 64-bit integer (use for IDs)
231231+t.string() // Text
232232+t.bool() // Boolean
233233+t.timestamp() // Timestamp (use ctx.timestamp for current time)
234234+t.scheduleAt() // For scheduled tables only
235235+236236+// Product types (nested objects) — use t.object, NOT t.struct
237237+const Point = t.object('Point', { x: t.i32(), y: t.i32() });
238238+239239+// Sum types (tagged unions) — use t.enum, NOT t.sum
240240+const Shape = t.enum('Shape', { circle: t.i32(), rectangle: Point });
241241+// Values use { tag: 'circle', value: 10 } or { tag: 'rectangle', value: { x: 1, y: 2 } }
242242+243243+// Modifiers
244244+t.string().optional() // Nullable
245245+t.u64().primaryKey() // Primary key
246246+t.u64().primaryKey().autoInc() // Auto-increment primary key
247247+```
248248+249249+> ⚠️ **BIGINT SYNTAX:** All `u64`, `i64`, and ID fields use JavaScript BigInt.
250250+> - Literals: `0n`, `1n`, `100n` (NOT `0`, `1`, `100`)
251251+> - Comparisons: `row.id === 5n` (NOT `row.id === 5`)
252252+> - Arithmetic: `row.count + 1n` (NOT `row.count + 1`)
253253+254254+### Auto-increment placeholder
255255+```typescript
256256+// ✅ MUST provide 0n placeholder for auto-inc fields
257257+ctx.db.task.insert({ id: 0n, ownerId: ctx.sender, title: 'New', createdAt: ctx.timestamp });
258258+```
259259+260260+### Insert returns ROW, not ID
261261+```typescript
262262+// ❌ WRONG
263263+const id = ctx.db.task.insert({ ... });
264264+265265+// ✅ RIGHT
266266+const row = ctx.db.task.insert({ ... });
267267+const newId = row.id; // Extract .id from returned row
268268+```
269269+270270+### Schema export (CRITICAL)
271271+```typescript
272272+// At end of schema.ts — schema() takes exactly ONE argument: an object
273273+const spacetimedb = schema({ table1, table2, table3 });
274274+export default spacetimedb;
275275+276276+// ❌ WRONG — never pass tables directly or as multiple args
277277+schema(myTable); // WRONG!
278278+schema(t1, t2, t3); // WRONG!
279279+```
280280+281281+---
282282+283283+## 3) Index Access
284284+285285+### TypeScript Query Patterns
286286+287287+```typescript
288288+// 1. PRIMARY KEY — use .pkColumn.find()
289289+const user = ctx.db.user.identity.find(ctx.sender);
290290+const msg = ctx.db.message.id.find(messageId);
291291+292292+// 2. EXPLICIT INDEX — use .indexName.filter(value)
293293+const msgs = [...ctx.db.message.message_room_id.filter(roomId)];
294294+295295+// 3. NO INDEX — use .iter() + manual filter
296296+for (const m of ctx.db.roomMember.iter()) {
297297+ if (m.roomId === roomId) { /* ... */ }
298298+}
299299+```
300300+301301+### Index Definition Syntax
302302+303303+```typescript
304304+// In table OPTIONS (first argument), not columns
305305+export const Message = table({
306306+ name: 'message',
307307+ public: true,
308308+ indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }]
309309+}, {
310310+ id: t.u64().primaryKey().autoInc(),
311311+ roomId: t.u64(),
312312+ // ...
313313+});
314314+```
315315+316316+### Naming conventions
317317+318318+**Table names — automatic transformation:**
319319+- Schema: `table({ name: 'my_messages' })`
320320+- Access: `ctx.db.myMessages` (automatic snake_case → camelCase)
321321+322322+**Index names — NO transformation, use EXACTLY as defined:**
323323+```typescript
324324+// Schema definition
325325+indexes: [{ name: 'canvas_member_canvas_id', algorithm: 'btree', columns: ['canvasId'] }]
326326+327327+// ❌ WRONG — don't assume camelCase transformation
328328+ctx.db.canvasMember.canvasMember_canvas_id.filter(...) // WRONG!
329329+ctx.db.canvasMember.canvasMemberCanvasId.filter(...) // WRONG!
330330+331331+// ✅ RIGHT — use exact name from schema
332332+ctx.db.canvasMember.canvas_member_canvas_id.filter(...)
333333+```
334334+335335+> ⚠️ **Index names are used VERBATIM** — pick a convention (snake_case or camelCase) and stick with it.
336336+337337+**Index naming pattern — use `{tableName}_{columnName}`:**
338338+```typescript
339339+// ✅ GOOD — unique names across entire module
340340+indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }]
341341+indexes: [{ name: 'reaction_message_id', algorithm: 'btree', columns: ['messageId'] }]
342342+343343+// ❌ BAD — will collide if multiple tables use same index name
344344+indexes: [{ name: 'by_owner', ... }] // in Task table
345345+indexes: [{ name: 'by_owner', ... }] // in Note table — CONFLICT!
346346+```
347347+348348+**Client-side table names:**
349349+- Check generated `module_bindings/index.ts` for exact export names
350350+- Usage: `useTable(tables.MyMessages)` or `tables.myMessages` (varies by SDK version)
351351+352352+### Filter vs Find
353353+```typescript
354354+// Filter takes VALUE directly, not object — returns iterator
355355+const rows = [...ctx.db.task.by_owner.filter(ownerId)];
356356+357357+// Unique columns use .find() — returns single row or undefined
358358+const row = ctx.db.player.identity.find(ctx.sender);
359359+```
360360+361361+### ⚠️ Multi-column indexes are BROKEN
362362+```typescript
363363+// ❌ DON'T — causes PANIC
364364+ctx.db.scores.by_player_level.filter(playerId);
365365+366366+// ✅ DO — use single-column index + manual filter
367367+for (const row of ctx.db.scores.by_player.filter(playerId)) {
368368+ if (row.level === targetLevel) { /* ... */ }
369369+}
370370+```
371371+372372+---
373373+374374+## 4) Reducers
375375+376376+### Definition syntax (CRITICAL)
377377+**Reducer name comes from the export — NOT from a string argument.** Use `reducer(params, fn)` or `reducer(fn)`.
378378+379379+```typescript
380380+import spacetimedb from './schema';
381381+import { t, SenderError } from 'spacetimedb/server';
382382+383383+// ✅ CORRECT — export const name = spacetimedb.reducer(params, fn)
384384+export const reducer_name = spacetimedb.reducer({ param1: t.string(), param2: t.u64() }, (ctx, { param1, param2 }) => {
385385+ // Validation
386386+ if (!param1) throw new SenderError('param1 required');
387387+388388+ // Access tables via ctx.db
389389+ const row = ctx.db.myTable.primaryKey.find(param2);
390390+391391+ // Mutations
392392+ ctx.db.myTable.insert({ ... });
393393+ ctx.db.myTable.primaryKey.update({ ...row, newField: value });
394394+ ctx.db.myTable.primaryKey.delete(param2);
395395+});
396396+397397+// No params: export const init = spacetimedb.reducer((ctx) => { ... });
398398+```
399399+400400+```typescript
401401+// ❌ WRONG — reducer('name', params, fn) does NOT exist
402402+spacetimedb.reducer('reducer_name', { param1: t.string() }, (ctx, { param1 }) => { ... });
403403+```
404404+405405+### Update pattern (CRITICAL)
406406+```typescript
407407+// ✅ CORRECT — spread existing row, override specific fields
408408+const existing = ctx.db.task.id.find(taskId);
409409+if (!existing) throw new SenderError('Task not found');
410410+ctx.db.task.id.update({ ...existing, title: newTitle, updatedAt: ctx.timestamp });
411411+412412+// ❌ WRONG — partial update nulls out other fields!
413413+ctx.db.task.id.update({ id: taskId, title: newTitle });
414414+```
415415+416416+### Delete pattern
417417+```typescript
418418+// Delete by primary key VALUE (not row object)
419419+ctx.db.task.id.delete(taskId); // taskId is the u64 value
420420+ctx.db.player.identity.delete(ctx.sender); // delete by identity
421421+```
422422+423423+### Lifecycle hooks
424424+```typescript
425425+spacetimedb.clientConnected((ctx) => {
426426+ // ctx.sender is the connecting identity
427427+ // Create/update user record, set online status, etc.
428428+});
429429+430430+spacetimedb.clientDisconnected((ctx) => {
431431+ // Clean up: set offline status, remove ephemeral data, etc.
432432+});
433433+```
434434+435435+### Snake_case to camelCase conversion
436436+- Server: `export const do_something = spacetimedb.reducer(...)` — name from export
437437+- Client: `conn.reducers.doSomething({ ... })`
438438+439439+### Object syntax required
440440+```typescript
441441+// ❌ WRONG - positional
442442+conn.reducers.doSomething('value');
443443+444444+// ✅ RIGHT - object
445445+conn.reducers.doSomething({ param: 'value' });
446446+```
447447+448448+---
449449+450450+## 5) Scheduled Tables
451451+452452+```typescript
453453+// 1. Define table first (scheduled: () => reducer — pass the exported reducer)
454454+export const CleanupJob = table({
455455+ name: 'cleanup_job',
456456+ scheduled: () => run_cleanup // reducer defined below
457457+}, {
458458+ scheduledId: t.u64().primaryKey().autoInc(),
459459+ scheduledAt: t.scheduleAt(),
460460+ targetId: t.u64(), // Your custom data
461461+});
462462+463463+// 2. Define scheduled reducer (receives full row as arg)
464464+export const run_cleanup = spacetimedb.reducer({ arg: CleanupJob.rowType }, (ctx, { arg }) => {
465465+ // arg.scheduledId, arg.targetId available
466466+ // Row is auto-deleted after reducer completes
467467+});
468468+469469+// Schedule a job
470470+import { ScheduleAt } from 'spacetimedb';
471471+const futureTime = ctx.timestamp.microsSinceUnixEpoch + 60_000_000n; // 60 seconds
472472+ctx.db.cleanupJob.insert({
473473+ scheduledId: 0n,
474474+ scheduledAt: ScheduleAt.time(futureTime),
475475+ targetId: someId
476476+});
477477+478478+// Cancel a job by deleting the row
479479+ctx.db.cleanupJob.scheduledId.delete(jobId);
480480+```
481481+482482+---
483483+484484+## 6) Timestamps
485485+486486+### Server-side
487487+```typescript
488488+import { Timestamp, ScheduleAt } from 'spacetimedb';
489489+490490+// Current time
491491+ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
492492+493493+// Future time (add microseconds)
494494+const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n; // 5 minutes
495495+```
496496+497497+### Client-side (CRITICAL)
498498+**Timestamps are objects, not numbers:**
499499+```typescript
500500+// ❌ WRONG
501501+const date = new Date(row.createdAt);
502502+const date = new Date(Number(row.createdAt / 1000n));
503503+504504+// ✅ RIGHT
505505+const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
506506+```
507507+508508+### ScheduleAt on client
509509+```typescript
510510+// ScheduleAt is a tagged union
511511+if (scheduleAt.tag === 'Time') {
512512+ const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n));
513513+}
514514+```
515515+516516+---
517517+518518+## 7) Data Visibility & Subscriptions
519519+520520+**`public: true` exposes ALL rows to ALL clients.**
521521+522522+| Scenario | Pattern |
523523+|----------|---------|
524524+| Everyone sees all rows | `public: true` |
525525+| Users see only their data | Private table + filtered subscription |
526526+527527+### Subscription patterns (client-side)
528528+```typescript
529529+// Subscribe to ALL public tables (simplest)
530530+conn.subscriptionBuilder().subscribeToAll();
531531+532532+// Subscribe to specific tables with SQL
533533+conn.subscriptionBuilder().subscribe([
534534+ 'SELECT * FROM message',
535535+ 'SELECT * FROM room WHERE is_public = true',
536536+]);
537537+538538+// Handle subscription lifecycle
539539+conn.subscriptionBuilder()
540540+ .onApplied(() => console.log('Initial data loaded'))
541541+ .onError((e) => console.error('Subscription failed:', e))
542542+ .subscribeToAll();
543543+```
544544+545545+### Private table + view pattern (RECOMMENDED)
546546+547547+**Views are the recommended approach** for controlling data visibility. They provide:
548548+- Server-side filtering (reduces network traffic)
549549+- Real-time updates when underlying data changes
550550+- Full control over what data clients can access
551551+552552+> ⚠️ **Do NOT use Row Level Security (RLS)** — it is deprecated.
553553+554554+> ⚠️ **CRITICAL:** Procedural views (views that compute results in code) can ONLY access data via index lookups, NOT `.iter()`.
555555+> If you need a view that scans/filters across many rows (including the entire table), return a **query** built with the query builder (`ctx.from...`).
556556+557557+```typescript
558558+// Private table with index on ownerId
559559+export const PrivateData = table(
560560+ { name: 'private_data',
561561+ indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }]
562562+ },
563563+ {
564564+ id: t.u64().primaryKey().autoInc(),
565565+ ownerId: t.identity(),
566566+ secret: t.string()
567567+ }
568568+);
569569+570570+// ❌ BAD — .iter() causes performance issues (re-evaluates on ANY row change)
571571+spacetimedb.view(
572572+ { name: 'my_data_slow', public: true },
573573+ t.array(PrivateData.rowType),
574574+ (ctx) => [...ctx.db.privateData.iter()] // Works but VERY slow at scale
575575+);
576576+577577+// ✅ GOOD — index lookup enables targeted invalidation
578578+spacetimedb.view(
579579+ { name: 'my_data', public: true },
580580+ t.array(PrivateData.rowType),
581581+ (ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)]
582582+);
583583+```
584584+585585+### Query builder view pattern (can scan)
586586+587587+```typescript
588588+// Query-builder views return a query; the SQL engine maintains the result incrementally.
589589+// This can scan the whole table if needed (e.g. leaderboard-style queries).
590590+spacetimedb.anonymousView(
591591+ { name: 'top_players', public: true },
592592+ t.array(Player.rowType),
593593+ (ctx) =>
594594+ ctx.from.player
595595+ .where(p => p.score.gt(1000))
596596+);
597597+```
598598+599599+### ViewContext vs AnonymousViewContext
600600+```typescript
601601+// ViewContext — has ctx.sender, result varies per user (computed per-subscriber)
602602+spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
603603+ return [...ctx.db.item.by_owner.filter(ctx.sender)];
604604+});
605605+606606+// AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf)
607607+spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(LeaderboardRow), (ctx) => {
608608+ return [...ctx.db.player.by_score.filter(/* top scores */)];
609609+});
610610+```
611611+612612+**Views require explicit subscription:**
613613+```typescript
614614+conn.subscriptionBuilder().subscribe([
615615+ 'SELECT * FROM public_table',
616616+ 'SELECT * FROM my_data', // Views need explicit SQL!
617617+]);
618618+```
619619+620620+---
621621+622622+## 8) React Integration
623623+624624+### Key patterns
625625+```typescript
626626+// Memoize connectionBuilder to prevent reconnects on re-render
627627+const builder = useMemo(() =>
628628+ DbConnection.builder()
629629+ .withUri(SPACETIMEDB_URI)
630630+ .withDatabaseName(MODULE_NAME)
631631+ .withToken(localStorage.getItem('auth_token') || undefined)
632632+ .onConnect(onConnect)
633633+ .onConnectError(onConnectError),
634634+ [] // Empty deps - only create once
635635+);
636636+637637+// useTable returns tuple [rows, isLoading]
638638+const [rows, isLoading] = useTable(tables.myTable);
639639+640640+// Compare identities using toHexString()
641641+const isOwner = row.ownerId.toHexString() === myIdentity.toHexString();
642642+```
643643+644644+---
645645+646646+## 9) Procedures (Beta)
647647+648648+**Procedures are for side effects (HTTP requests, etc.) that reducers can't do.**
649649+650650+⚠️ Procedures are currently in beta. API may change.
651651+652652+### Defining a procedure
653653+**Procedure name comes from the export — NOT from a string argument.** Use `procedure(params, ret, fn)` or `procedure(ret, fn)`.
654654+655655+```typescript
656656+// ✅ CORRECT — export const name = spacetimedb.procedure(params, ret, fn)
657657+export const fetch_external_data = spacetimedb.procedure(
658658+ { url: t.string() },
659659+ t.string(), // return type
660660+ (ctx, { url }) => {
661661+ const response = ctx.http.fetch(url);
662662+ return response.text();
663663+ }
664664+);
665665+```
666666+667667+### Database access in procedures
668668+669669+⚠️ **CRITICAL: Procedures don't have `ctx.db`. Use `ctx.withTx()` for database access.**
670670+671671+```typescript
672672+spacetimedb.procedure({ url: t.string() }, t.unit(), (ctx, { url }) => {
673673+ // Fetch external data (outside transaction)
674674+ const response = ctx.http.fetch(url);
675675+ const data = response.text();
676676+677677+ // ❌ WRONG — ctx.db doesn't exist in procedures
678678+ ctx.db.myTable.insert({ ... });
679679+680680+ // ✅ RIGHT — use ctx.withTx() for database access
681681+ ctx.withTx(tx => {
682682+ tx.db.myTable.insert({
683683+ id: 0n,
684684+ content: data,
685685+ fetchedAt: tx.timestamp,
686686+ fetchedBy: tx.sender,
687687+ });
688688+ });
689689+690690+ return {};
691691+});
692692+```
693693+694694+### Key differences from reducers
695695+| Reducers | Procedures |
696696+|----------|------------|
697697+| `ctx.db` available directly | Must use `ctx.withTx(tx => tx.db...)` |
698698+| Automatic transaction | Manual transaction management |
699699+| No HTTP/network | `ctx.http.fetch()` available |
700700+| No return values to caller | Can return data to caller |
701701+702702+---
703703+704704+## 10) Project Structure
705705+706706+### Server (`backend/spacetimedb/`)
707707+```
708708+src/schema.ts → Tables, export spacetimedb
709709+src/index.ts → Reducers, lifecycle, import schema
710710+package.json → { "type": "module", "dependencies": { "spacetimedb": "^1.11.0" } }
711711+tsconfig.json → Standard config
712712+```
713713+714714+### Avoiding circular imports
715715+```
716716+schema.ts → defines tables AND exports spacetimedb
717717+index.ts → imports spacetimedb from ./schema, defines reducers
718718+```
719719+720720+### Client (`client/`)
721721+```
722722+src/module_bindings/ → Generated (spacetime generate)
723723+src/main.tsx → Provider, connection setup
724724+src/App.tsx → UI components
725725+src/config.ts → MODULE_NAME, SPACETIMEDB_URI
726726+```
727727+728728+---
729729+730730+## 11) Commands
731731+732732+```bash
733733+# Start local server
734734+spacetime start
735735+736736+# Publish module
737737+spacetime publish <module-name> --module-path <backend-dir>
738738+739739+# Clear database and republish
740740+spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
741741+742742+# Generate bindings
743743+spacetime generate --lang typescript --out-dir <client>/src/module_bindings --module-path <backend-dir>
744744+745745+# View logs
746746+spacetime logs <module-name>
747747+```
748748+749749+---
750750+751751+## 12) Hard Requirements
752752+753753+**TypeScript-specific:**
754754+755755+1. **`schema({ table })`** — takes exactly one object; never `schema(table)` or `schema(t1, t2, t3)`
756756+2. **Reducer/procedure names from exports** — `export const name = spacetimedb.reducer(params, fn)`; never `reducer('name', ...)`
757757+3. **Reducer calls use object syntax** — `{ param: 'value' }` not positional args
758758+4. **Import `DbConnection` from `./module_bindings`** — not from `spacetimedb`
759759+5. **DO NOT edit generated bindings** — regenerate with `spacetime generate`
760760+6. **Indexes go in OPTIONS (1st arg)** — not in COLUMNS (2nd arg) of `table()`
761761+7. **Use BigInt for u64/i64 fields** — `0n`, `1n`, not `0`, `1`
762762+8. **Reducers are transactional** — they do not return data
763763+9. **Reducers must be deterministic** — no filesystem, network, timers, random
764764+10. **Views should use index lookups** — `.iter()` causes severe performance issues
765765+11. **Procedures need `ctx.withTx()`** — `ctx.db` doesn't exist in procedures
766766+12. **Sum type values** — use `{ tag: 'variant', value: payload }` not `{ variant: payload }`
+89
CLAUDE.md
···11+# Checkmate
22+33+Real-time chess on the AT Protocol, powered by SpacetimeDB.
44+55+## Architecture
66+77+- **`server/`** — SpacetimeDB TypeScript module (tables + reducers)
88+- **`client/`** — React 19 + Vite frontend
99+1010+SpacetimeDB replaces the traditional API server entirely. The browser connects via WebSocket, subscribes to game state tables, and calls reducers to mutate state. Both players see moves instantly via real-time subscription updates.
1111+1212+## Development
1313+1414+### Prerequisites
1515+1616+- [SpacetimeDB CLI](https://spacetimedb.com/docs) (`spacetime` v2.1+)
1717+- [Bun](https://bun.sh/) (package manager)
1818+- Node.js 22+
1919+2020+### Quick Start
2121+2222+```bash
2323+# Install dependencies
2424+cd server && bun install && cd ..
2525+cd client && bun install && cd ..
2626+2727+# Start SpacetimeDB locally
2828+spacetime start
2929+3030+# In another terminal — publish the module
3131+spacetime publish checkmate --module-path ./server --server local --delete-data
3232+3333+# Generate client bindings
3434+spacetime generate --lang typescript --out-dir client/src/module_bindings --module-path server
3535+3636+# Start the client dev server
3737+cd client && bun run dev
3838+```
3939+4040+### Running with `spacetime dev` (recommended)
4141+4242+```bash
4343+spacetime dev
4444+```
4545+4646+This starts SpacetimeDB + Vite with hot reload and auto-regenerates bindings on server changes.
4747+4848+### Testing
4949+5050+```bash
5151+cd client && bun run test # Vitest watch mode
5252+cd client && bun run test:ci # Single run, CI mode
5353+```
5454+5555+### Type Checking
5656+5757+```bash
5858+cd client && npx tsc --noEmit
5959+```
6060+6161+## Key Files
6262+6363+| File | Purpose |
6464+|------|---------|
6565+| `server/src/index.ts` | SpacetimeDB module — all tables and reducers |
6666+| `client/src/main.tsx` | Entry point — providers setup |
6767+| `client/src/App.tsx` | State-based routing |
6868+| `client/src/hooks/useGame.ts` | Game state hook (SpacetimeDB subscriptions) |
6969+| `client/src/hooks/useAuth.ts` | atproto OAuth hook |
7070+| `client/src/components/game/ChessBoard.tsx` | Chess board wrapper (react-chessboard) |
7171+| `client/src/lib/oauth.ts` | atproto OAuth client config |
7272+| `client/src/lib/spacetime.ts` | SpacetimeDB connection config |
7373+7474+## SpacetimeDB Conventions
7575+7676+Follow the patterns in the template's built-in CLAUDE.md (copied to `.cursor/` and `AGENTS.md`):
7777+7878+- Tables: `table(OPTIONS, COLUMNS)` — indexes go in OPTIONS
7979+- Reducers: `export const name = spacetimedb.reducer(params, fn)` — name from export
8080+- Client reducer calls: `conn.reducers.foo({ param: 'value' })` — always object syntax
8181+- Use `BigInt` (`0n`, `1n`) for all `u64`/`i64` fields
8282+- `useTable(tables.foo)` returns `[rows, isLoading]` tuple
8383+- Never edit files in `client/src/module_bindings/` — they are auto-generated
8484+8585+## Domain
8686+8787+Production: `checkmate.social`
8888+OAuth client metadata: `https://checkmate.social/client-metadata.json`
8989+Local dev: `http://127.0.0.1:5173`
+202
LICENSE
···11+ Apache License
22+ Version 2.0, January 2004
33+ http://www.apache.org/licenses/
44+55+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
66+77+ 1. Definitions.
88+99+ "License" shall mean the terms and conditions for use, reproduction,
1010+ and distribution as defined by Sections 1 through 9 of this document.
1111+1212+ "Licensor" shall mean the copyright owner or entity authorized by
1313+ the copyright owner that is granting the License.
1414+1515+ "Legal Entity" shall mean the union of the acting entity and all
1616+ other entities that control, are controlled by, or are under common
1717+ control with that entity. For the purposes of this definition,
1818+ "control" means (i) the power, direct or indirect, to cause the
1919+ direction or management of such entity, whether by contract or
2020+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
2121+ outstanding shares, or (iii) beneficial ownership of such entity.
2222+2323+ "You" (or "Your") shall mean an individual or Legal Entity
2424+ exercising permissions granted by this License.
2525+2626+ "Source" form shall mean the preferred form for making modifications,
2727+ including but not limited to software source code, documentation
2828+ source, and configuration files.
2929+3030+ "Object" form shall mean any form resulting from mechanical
3131+ transformation or translation of a Source form, including but
3232+ not limited to compiled object code, generated documentation,
3333+ and conversions to other media types.
3434+3535+ "Work" shall mean the work of authorship, whether in Source or
3636+ Object form, made available under the License, as indicated by a
3737+ copyright notice that is included in or attached to the work
3838+ (an example is provided in the Appendix below).
3939+4040+ "Derivative Works" shall mean any work, whether in Source or Object
4141+ form, that is based on (or derived from) the Work and for which the
4242+ editorial revisions, annotations, elaborations, or other modifications
4343+ represent, as a whole, an original work of authorship. For the purposes
4444+ of this License, Derivative Works shall not include works that remain
4545+ separable from, or merely link (or bind by name) to the interfaces of,
4646+ the Work and Derivative Works thereof.
4747+4848+ "Contribution" shall mean any work of authorship, including
4949+ the original version of the Work and any modifications or additions
5050+ to that Work or Derivative Works thereof, that is intentionally
5151+ submitted to Licensor for inclusion in the Work by the copyright owner
5252+ or by an individual or Legal Entity authorized to submit on behalf of
5353+ the copyright owner. For the purposes of this definition, "submitted"
5454+ means any form of electronic, verbal, or written communication sent
5555+ to the Licensor or its representatives, including but not limited to
5656+ communication on electronic mailing lists, source code control systems,
5757+ and issue tracking systems that are managed by, or on behalf of, the
5858+ Licensor for the purpose of discussing and improving the Work, but
5959+ excluding communication that is conspicuously marked or otherwise
6060+ designated in writing by the copyright owner as "Not a Contribution."
6161+6262+ "Contributor" shall mean Licensor and any individual or Legal Entity
6363+ on behalf of whom a Contribution has been received by Licensor and
6464+ subsequently incorporated within the Work.
6565+6666+ 2. Grant of Copyright License. Subject to the terms and conditions of
6767+ this License, each Contributor hereby grants to You a perpetual,
6868+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
6969+ copyright license to reproduce, prepare Derivative Works of,
7070+ publicly display, publicly perform, sublicense, and distribute the
7171+ Work and such Derivative Works in Source or Object form.
7272+7373+ 3. Grant of Patent License. Subject to the terms and conditions of
7474+ this License, each Contributor hereby grants to You a perpetual,
7575+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
7676+ (except as stated in this section) patent license to make, have made,
7777+ use, offer to sell, sell, import, and otherwise transfer the Work,
7878+ where such license applies only to those patent claims licensable
7979+ by such Contributor that are necessarily infringed by their
8080+ Contribution(s) alone or by combination of their Contribution(s)
8181+ with the Work to which such Contribution(s) was submitted. If You
8282+ institute patent litigation against any entity (including a
8383+ cross-claim or counterclaim in a lawsuit) alleging that the Work
8484+ or a Contribution incorporated within the Work constitutes direct
8585+ or contributory patent infringement, then any patent licenses
8686+ granted to You under this License for that Work shall terminate
8787+ as of the date such litigation is filed.
8888+8989+ 4. Redistribution. You may reproduce and distribute copies of the
9090+ Work or Derivative Works thereof in any medium, with or without
9191+ modifications, and in Source or Object form, provided that You
9292+ meet the following conditions:
9393+9494+ (a) You must give any other recipients of the Work or
9595+ Derivative Works a copy of this License; and
9696+9797+ (b) You must cause any modified files to carry prominent notices
9898+ stating that You changed the files; and
9999+100100+ (c) You must retain, in the Source form of any Derivative Works
101101+ that You distribute, all copyright, patent, trademark, and
102102+ attribution notices from the Source form of the Work,
103103+ excluding those notices that do not pertain to any part of
104104+ the Derivative Works; and
105105+106106+ (d) If the Work includes a "NOTICE" text file as part of its
107107+ distribution, then any Derivative Works that You distribute must
108108+ include a readable copy of the attribution notices contained
109109+ within such NOTICE file, excluding those notices that do not
110110+ pertain to any part of the Derivative Works, in at least one
111111+ of the following places: within a NOTICE text file distributed
112112+ as part of the Derivative Works; within the Source form or
113113+ documentation, if provided along with the Derivative Works; or,
114114+ within a display generated by the Derivative Works, if and
115115+ wherever such third-party notices normally appear. The contents
116116+ of the NOTICE file are for informational purposes only and
117117+ do not modify the License. You may add Your own attribution
118118+ notices within Derivative Works that You distribute, alongside
119119+ or as an addendum to the NOTICE text from the Work, provided
120120+ that such additional attribution notices cannot be construed
121121+ as modifying the License.
122122+123123+ You may add Your own copyright statement to Your modifications and
124124+ may provide additional or different license terms and conditions
125125+ for use, reproduction, or distribution of Your modifications, or
126126+ for any such Derivative Works as a whole, provided Your use,
127127+ reproduction, and distribution of the Work otherwise complies with
128128+ the conditions stated in this License.
129129+130130+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131131+ any Contribution intentionally submitted for inclusion in the Work
132132+ by You to the Licensor shall be under the terms and conditions of
133133+ this License, without any additional terms or conditions.
134134+ Notwithstanding the above, nothing herein shall supersede or modify
135135+ the terms of any separate license agreement you may have executed
136136+ with Licensor regarding such Contributions.
137137+138138+ 6. Trademarks. This License does not grant permission to use the trade
139139+ names, trademarks, service marks, or product names of the Licensor,
140140+ except as required for reasonable and customary use in describing the
141141+ origin of the Work and reproducing the content of the NOTICE file.
142142+143143+ 7. Disclaimer of Warranty. Unless required by applicable law or
144144+ agreed to in writing, Licensor provides the Work (and each
145145+ Contributor provides its Contributions) on an "AS IS" BASIS,
146146+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147147+ implied, including, without limitation, any warranties or conditions
148148+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149149+ PARTICULAR PURPOSE. You are solely responsible for determining the
150150+ appropriateness of using or redistributing the Work and assume any
151151+ risks associated with Your exercise of permissions under this License.
152152+153153+ 8. Limitation of Liability. In no event and under no legal theory,
154154+ whether in tort (including negligence), contract, or otherwise,
155155+ unless required by applicable law (such as deliberate and grossly
156156+ negligent acts) or agreed to in writing, shall any Contributor be
157157+ liable to You for damages, including any direct, indirect, special,
158158+ incidental, or consequential damages of any character arising as a
159159+ result of this License or out of the use or inability to use the
160160+ Work (including but not limited to damages for loss of goodwill,
161161+ work stoppage, computer failure or malfunction, or any and all
162162+ other commercial damages or losses), even if such Contributor
163163+ has been advised of the possibility of such damages.
164164+165165+ 9. Accepting Warranty or Additional Liability. While redistributing
166166+ the Work or Derivative Works thereof, You may choose to offer,
167167+ and charge a fee for, acceptance of support, warranty, indemnity,
168168+ or other liability obligations and/or rights consistent with this
169169+ License. However, in accepting such obligations, You may act only
170170+ on Your own behalf and on Your sole responsibility, not on behalf
171171+ of any other Contributor, and only if You agree to indemnify,
172172+ defend, and hold each Contributor harmless for any liability
173173+ incurred by, or claims asserted against, such Contributor by reason
174174+ of your accepting any such warranty or additional liability.
175175+176176+ END OF TERMS AND CONDITIONS
177177+178178+ APPENDIX: How to apply the Apache License to your work.
179179+180180+ To apply the Apache License to your work, attach the following
181181+ boilerplate notice, with the fields enclosed by brackets "[]"
182182+ replaced with your own identifying information. (Don't include
183183+ the brackets!) The text should be enclosed in the appropriate
184184+ comment syntax for the file format. We also recommend that a
185185+ file or class name and description of purpose be included on the
186186+ same "printed page" as the copyright notice for easier
187187+ identification within third-party archives.
188188+189189+ Copyright 2025 Clockwork Labs, Inc
190190+191191+ Licensed under the Apache License, Version 2.0 (the "License");
192192+ you may not use this file except in compliance with the License.
193193+ You may obtain a copy of the License at
194194+195195+ http://www.apache.org/licenses/LICENSE-2.0
196196+197197+ Unless required by applicable law or agreed to in writing, software
198198+ distributed under the License is distributed on an "AS IS" BASIS,
199199+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200200+ See the License for the specific language governing permissions and
201201+ limitations under the License.
202202+
···11+/**
22+ * LobbyScreen — matchmaking interface.
33+ *
44+ * Shows a "Find Game" button for real matchmaking and a "Play vs Self"
55+ * button for solo play. While searching, displays an animated indicator
66+ * with a cancel option. Auto-transitions to GameScreen when a match is
77+ * found (handled by the parent App routing via activeGame state).
88+ */
99+1010+import { useGame } from '../../hooks/useGame';
1111+1212+export function LobbyScreen() {
1313+ const { connected, isQueued, joinQueue, leaveQueue, createSoloGame } = useGame();
1414+1515+ if (isQueued) {
1616+ return (
1717+ <div className="flex flex-1 flex-col items-center justify-center gap-8">
1818+ {/* Animated searching indicator */}
1919+ <div className="relative flex items-center justify-center">
2020+ <div className="animate-pulse-ring h-32 w-32 rounded-full border-2 border-violet-500/30" />
2121+ <div className="absolute text-4xl">♟</div>
2222+ </div>
2323+2424+ <div className="text-center">
2525+ <p className="text-lg font-medium text-white">
2626+ Searching for opponent...
2727+ </p>
2828+ <p className="mt-1 text-sm text-neutral-400">
2929+ Waiting for another player to join
3030+ </p>
3131+ </div>
3232+3333+ <button
3434+ onClick={leaveQueue}
3535+ className="rounded-lg border border-neutral-700 px-6 py-2 text-sm text-neutral-300 transition-colors hover:border-neutral-500 hover:text-white"
3636+ >
3737+ Cancel
3838+ </button>
3939+ </div>
4040+ );
4141+ }
4242+4343+ return (
4444+ <div className="flex flex-1 flex-col items-center justify-center gap-8">
4545+ <div className="text-center">
4646+ <div className="text-6xl mb-4">♟</div>
4747+ <h2 className="text-2xl font-bold text-white">Ready to play?</h2>
4848+ <p className="mt-2 text-neutral-400">
4949+ Find an opponent or play both sides yourself
5050+ </p>
5151+ </div>
5252+5353+ <div className="flex flex-col gap-3">
5454+ <button
5555+ onClick={joinQueue}
5656+ disabled={!connected}
5757+ className="rounded-lg bg-violet-600 px-8 py-3 text-lg font-medium text-white transition-colors hover:bg-violet-700 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-neutral-950 disabled:cursor-not-allowed disabled:opacity-50"
5858+ >
5959+ Find Game
6060+ </button>
6161+6262+ <button
6363+ onClick={createSoloGame}
6464+ disabled={!connected}
6565+ className="rounded-lg border border-neutral-700 px-8 py-3 text-sm font-medium text-neutral-300 transition-colors hover:border-neutral-500 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
6666+ >
6767+ Play vs Self
6868+ </button>
6969+ </div>
7070+7171+ {!connected && (
7272+ <p className="text-sm text-yellow-500">
7373+ Connecting to server...
7474+ </p>
7575+ )}
7676+ </div>
7777+ );
7878+}
+13
client/src/hooks/useAuth.ts
···11+import { useContext } from 'react';
22+import { AuthContext, type AuthState } from '../components/auth/AuthProvider';
33+44+/**
55+ * Access the atproto OAuth auth state from any component.
66+ */
77+export function useAuth(): AuthState {
88+ const context = useContext(AuthContext);
99+ if (!context) {
1010+ throw new Error('useAuth must be used within an AuthProvider');
1111+ }
1212+ return context;
1313+}
···11+# Checkmate — real-time chess on atproto + SpacetimeDB
22+33+pid_file := ".spacetime.pid"
44+log_file := ".spacetime.log"
55+66+# Install all dependencies
77+install:
88+ cd server && bun install
99+ cd client && bun install
1010+1111+# Build the SpacetimeDB server module
1212+build:
1313+ spacetime build --module-path ./server
1414+1515+# Generate TypeScript client bindings from the server module
1616+generate:
1717+ spacetime generate --lang typescript --out-dir client/src/module_bindings --module-path server
1818+1919+# Publish the module to local SpacetimeDB (clears existing data)
2020+publish:
2121+ spacetime publish checkmate --module-path ./server --server local --delete-data -y
2222+2323+# Start SpacetimeDB in the background
2424+up:
2525+ #!/usr/bin/env bash
2626+ set -euo pipefail
2727+ if [ -f {{pid_file}} ] && kill -0 "$(cat {{pid_file}})" 2>/dev/null; then
2828+ echo "SpacetimeDB already running (pid $(cat {{pid_file}}))"
2929+ exit 0
3030+ fi
3131+ spacetime start --non-interactive > {{log_file}} 2>&1 &
3232+ echo $! > {{pid_file}}
3333+ echo "SpacetimeDB started (pid $(cat {{pid_file}}))"
3434+ # Wait for the server to accept TCP connections
3535+ for i in $(seq 1 30); do
3636+ if curl -so /dev/null http://127.0.0.1:3000/ 2>/dev/null; then
3737+ echo "SpacetimeDB ready"
3838+ exit 0
3939+ fi
4040+ sleep 0.2
4141+ done
4242+ echo "Warning: SpacetimeDB may not be ready yet — check 'just logs'"
4343+4444+# Stop SpacetimeDB
4545+down:
4646+ #!/usr/bin/env bash
4747+ set -euo pipefail
4848+ if [ ! -f {{pid_file}} ]; then
4949+ echo "No PID file found — SpacetimeDB not running"
5050+ exit 0
5151+ fi
5252+ pid=$(cat {{pid_file}})
5353+ if kill -0 "$pid" 2>/dev/null; then
5454+ kill "$pid"
5555+ echo "SpacetimeDB stopped (pid $pid)"
5656+ else
5757+ echo "SpacetimeDB was not running (stale PID file)"
5858+ fi
5959+ rm -f {{pid_file}}
6060+6161+# Start the Vite dev server
6262+dev:
6363+ cd client && bun run dev
6464+6565+# Run all tests
6666+test:
6767+ cd client && bun run test:ci
6868+6969+# Run tests in watch mode
7070+test-watch:
7171+ cd client && bun run test
7272+7373+# Type-check the client
7474+check:
7575+ cd client && npx tsc --noEmit
7676+7777+# Full build + type-check + test
7878+ci: build check test
7979+8080+# View SpacetimeDB server logs
8181+logs:
8282+ spacetime logs checkmate
8383+8484+# Query the database
8585+sql query:
8686+ spacetime sql checkmate "{{query}}"
8787+8888+# First-time setup: install deps, start db, build, publish, generate bindings
8989+setup: install up build publish generate
+42
lexicons/social.checkmate.game.json
···11+{
22+ "lexicon": 1,
33+ "id": "social.checkmate.game",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "description": "A completed chess game, stored as PGN.",
88+ "key": "tid",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["pgn", "result", "color", "opponent", "createdAt"],
1212+ "properties": {
1313+ "pgn": {
1414+ "type": "string",
1515+ "maxLength": 100000,
1616+ "description": "Full PGN text including Seven Tag Roster and movetext."
1717+ },
1818+ "result": {
1919+ "type": "string",
2020+ "knownValues": ["1-0", "0-1", "1/2-1/2"],
2121+ "description": "Game result in standard PGN notation."
2222+ },
2323+ "color": {
2424+ "type": "string",
2525+ "knownValues": ["white", "black"],
2626+ "description": "The color this player played."
2727+ },
2828+ "opponent": {
2929+ "type": "string",
3030+ "format": "did",
3131+ "description": "The opponent's atproto DID."
3232+ },
3333+ "createdAt": {
3434+ "type": "string",
3535+ "format": "datetime",
3636+ "description": "Timestamp when the game was completed."
3737+ }
3838+ }
3939+ }
4040+ }
4141+ }
4242+}