Spaceplate is a boilerplate for real-time 3D web apps. It combines:
- Svelte 5 (runes:
$state,$derived,$effect,.svelte.tsreactive modules) - Threlte (Three.js for Svelte —
@threlte/core,@threlte/extras) - SpacetimeDB (real-time relational DB with server-side reducers)
src/
App.svelte — Root: Canvas + SceneHud + Loader siblings; loads Studio extensions
Root.svelte — SpacetimeDB provider wrapper (wraps App)
main.ts — Entry point
Scene.svelte — 3D scene router (inside Canvas, Threlte context)
SceneHud.svelte — HTML overlay router (sibling to Canvas)
module_bindings/ — Generated SpacetimeDB client bindings (do not edit)
core/
Camera.svelte — PerspectiveCamera + AudioListener
GlobalAudio.svelte — All Audio components; re-exports from globalAudio.svelte.ts
globalAudio.svelte.ts — soundTriggers + soundActions singleton (import from here in .ts files)
Keymapper.svelte — Global keyboard/mouse event listeners; routes into input extension
Loader.svelte — Asset loading screen (useProgress, shown until finishedOnce)
Renderer.svelte — Post-processing (25+ effects, quality-gated)
Skybox.svelte — Sky + dual-layer stars (state-driven)
tasks.ts — Task pipeline: physicsStage, renderStage, uiStage, audioStage
scenes/
SettingsHud.svelte — Settings overlay (tabbed: General, Audio, Controls/keybindings)
MainMenu/
MainMenu.svelte — Example 3D scene 1 (inside Canvas)
MainMenuHud.svelte — HTML overlay for main menu (SpacetimeDB example)
DemoScene/
DemoScene.svelte — Example 3D scene 2 (inside Canvas)
DemoSceneHud.svelte — HTML overlay for demo scene
DemoFloor.svelte — Floor plane with collider
DemoPhysicsBodies.svelte — Spawned physics bodies renderer
extensions/
input/
input.svelte.ts — Action-based input state, bindings, queries, persistence
types.ts — InputAction, InputAxisAction, binding types, state shape
useInput.ts — Hook returning { state, actions, queries }
scene/
scene.svelte.ts — Scene state machine + SCENES config array
bundledPresets.ts — Code-committed preset assignments per scene + global
SceneExtension.svelte — Studio toolbar: scene switching + preset manager
types.ts
settings/
settings.svelte.ts — Persistent settings state (audio, graphics, general)
types.ts
sound/
soundState.svelte.ts — Positional audio state
SoundExtension.svelte — Studio extension
useSound.ts
types.ts
skybox/
skybox.svelte.ts — Sky/stars presets + animated transitions
bundledPresets.ts — Built-in skybox presets (not stored in localStorage)
envTextures.ts — HDR/cubemap environment texture registry
SkyboxExtension.svelte — Studio extension
types.ts
postprocessing/
postprocessing.svelte.ts — 25+ effects state + preset management
bundledPresets.ts — Built-in PP presets (not stored in localStorage)
PostProcessingExtension.svelte — Studio extension
types.ts
usePostProcessing.ts
logger/
logger.svelte.ts — Multi-channel styled logging
LoggerExtension.svelte — Studio extension (auto-generates checkboxes from channelStyles)
types.ts
physics/
physics.svelte.ts — Rapier world state, attractor, spawn defaults, spawn actions
PhysicsExtension.svelte — Studio extension: world tuning + body spawning
PhysicsController.svelte — Applies per-body forces and attractor logic
PhysicsWorldLogger.svelte — Logs Rapier world lifecycle/debug info
types.ts
gltf-viewer/
gltfViewer.svelte.ts — GLTF/GLB model state + actions (dev-only, DemoScene)
GltfViewerExtension.svelte — Studio extension: load, transform, animate models, toggle colliders
GltfViewerInstance.svelte — Per-model component: useGltf + animation mixer
GltfViewerScene.svelte — Renders all loaded models inside DemoScene
types.ts
- 3D content (meshes, lights, cameras) belongs inside
<Canvas>— useScene.svelte→ scene components - HTML overlays (buttons, panels, forms) cannot live inside Canvas — use
SceneHud.svelte→ HUD components - HUD components are siblings to Canvas in a
position: relativewrapper div
core/GlobalAudio.svelteowns all<Audio>Threlte components — never unmounts (no race conditions)soundTriggersandsoundActionslive incore/globalAudio.svelte.ts— imported from there in.tsfiles- Always import from
.tsfile, not the.sveltefile — named exports from<script module>in.svelteare not visible to TypeScript in.tsimports - Import:
import { soundActions } from '$core/globalAudio.svelte' soundActions.playSwoosh()— polyphonic (clone per call → overlapping instances)soundActions.playClick()— one-shot (stop+restart)$state.raw<ThreeAudio>()— prevents Svelte 5 Proxy wrapping THREE.js class instances
- Scenes defined in
SCENES: SceneConfig[]— each entry hasid,label,icon - Adding a new scene = one new entry in
SCENES(+ add itsidtoSceneType) sceneActions.setScene(scene)transitions scene (plays swoosh, logs)- Convenience:
sceneActions.goToMainMenu()/goToDemoScene()/goBack() - Read current scene via
sceneState.currentScene sceneState.isTransitioning— set during animated transitions (sceneActions.transitionTo)
- Single source of truth for which PP/skybox presets load per scene — all in code, not localStorage
BUNDLED_SCENE_PRESETS: Partial<Record<SceneType, ScenePresets>>— per-scene assignmentsBUNDLED_GLOBAL_PRESETS: ScenePresets— baseline applied to all scenes (scene-specific wins on conflict)resolveScenePreset(sceneId, type)— localStorage studio override → bundledPresets → nullresolveGlobalPreset(type)— localStorage studio override → bundledPresets → null- Studio SceneExtension lets you set/override assignments at runtime and copy the code to paste into
bundledPresets.ts - Conflict detection: PP extension warns if global and scene presets share the same enabled effect
- Deletion guard: PP/Skybox extensions block deleting presets that are in use by the scene manager
- Four ordered stages per frame:
physicsStage(BEFORE render) →renderStage(default) →uiStage(AFTER) →audioStage(AFTER ui) useGameTasks()returns{ stages, createPhysicsTask, createUiTask, createAudioTask }- Boilerplate convention: physics-style game tasks usually run in DemoScene;
uiStagepauses during transitions;audioStagealways runs - Use tasks instead of raw
useTaskto ensure correct execution order
- Sky presets: 11 time-of-day options (dawn, day, dusk, night, sunset, sunrise, cloudy, overcast, aurora, vacuum)
- Star presets: 5 configurations (dense, sparse, twinkle, nebula, milkyway) — each sky preset embeds a star preset
skyboxActions.applyPreset(id)— instant or animated transition viarequestAnimationFrame- Individual setters:
setTurbidity,setAzimuth,setElevation, etc. skyboxState/starsState/transitionState— all reactive, drivecore/Skybox.svelteENV_TEXTURES/CUBE_TEXTURESfromenvTextures.tsprovide image-based environment options in addition to procedural sky- User presets:
skyboxActions.savePreset(name)/loadUserPreset(id)/deletePreset(id)— persisted to localStorage - Bundled presets: add to
extensions/skybox/bundledPresets.ts— always available, not stored in localStorage - Scene preset assignment: owned by the scene manager — use
resolveScenePreset/resolveGlobalPresetfrom$extensions/scene/scene.svelte;core/Skybox.sveltereads these reactively
- 25+ effects: SMAA, FXAA, Bloom, Tone Mapping, God Rays, SSAO, Chromatic Aberration, Lens Distortion, Glitch, ASCII, Pixelation, Outline, Depth of Field, and more
- All effects disabled when
graphics.quality === 'low'(render pass only) postprocessingActions.savePreset(name)/loadPreset(id)/deletePreset(id)/updatePreset(id)— persisted to localStoragepostprocessingActions.resetAll()/resetEffect(name)— restore defaults;resetEffectpreservesenabledstate- Bundled presets: add to
extensions/postprocessing/bundledPresets.ts— always available, not stored in localStorage - Scene preset assignment: owned by the scene manager —
core/Renderer.sveltecallsresolveScenePreset/resolveGlobalPresetto merge active presets - Studio UI: active preset shows ✓, enabled effects auto-expand on load, warning banner shown when a preset overrides manual changes
Core principle: State in .svelte.ts modules is always reactive and works everywhere — in production, in components, in hooks. Threlte Studio is a dev-only editor (VITE_GAME_ENGINE=true) that provides a UI panel to tweak that same state at runtime. Never put logic in *Extension.svelte files — only UI.
extensions/my-feature/
types.ts — extensionScope constant + all types
myFeature.svelte.ts — $state + actions (always active, works without Studio)
MyFeatureExtension.svelte — Studio toolbar UI only (dev mode)
useMyFeature.ts — (optional) Studio-aware hook with fallback
export const extensionScope = 'my-feature';
export type MyFeatureState = { enabled: boolean; value: number };
export type MyFeatureActions = { setEnabled(v: boolean): void; setValue(v: number): void };import { logSettings } from '$extensions/logger/logger.svelte';
import type { MyFeatureState, MyFeatureActions } from './types';
export type { MyFeatureState, MyFeatureActions } from './types';
export const myFeatureState = $state<MyFeatureState>({ enabled: true, value: 0.5 });
export const myFeatureActions: MyFeatureActions = {
setEnabled(v) { myFeatureState.enabled = v; logSettings.info('Enabled:', v); },
setValue(v) { myFeatureState.value = v; },
};<script lang="ts">
import { useStudio, ToolbarItem, DropDownPane } from '@threlte/studio/extend';
import { Folder, Slider, Checkbox } from 'svelte-tweakpane-ui';
import { myFeatureState, myFeatureActions } from './myFeature.svelte';
import { extensionScope } from './types';
import type { Snippet } from 'svelte';
interface Props { children?: Snippet; }
let { children }: Props = $props();
const { createExtension } = useStudio();
createExtension({ scope: extensionScope, state: () => ({}), actions: {} });
</script>
<ToolbarItem position="left">
<DropDownPane icon="mdiStar" title="My Feature">
<Folder title="Settings" expanded={true}>
<Checkbox label="Enabled" value={myFeatureState.enabled}
on:change={() => myFeatureActions.setEnabled(!myFeatureState.enabled)} />
<Slider label="Value" value={myFeatureState.value} min={0} max={1} step={0.01}
on:change={(e) => myFeatureActions.setValue(e.detail.value)} />
</Folder>
</DropDownPane>
</ToolbarItem>
{@render children?.()}ToolbarButton uses onclick prop (Svelte 5), NOT on:click — using on:click silently does nothing.
import { useStudio } from '@threlte/studio/extend';
import { myFeatureState, myFeatureActions } from './myFeature.svelte';
import { extensionScope } from './types';
export const useMyFeature = () => {
try {
const { useExtension } = useStudio();
return useExtension(extensionScope);
} catch {
return { state: myFeatureState, ...myFeatureActions };
}
};{#await import('@threlte/studio') then { Studio }}
<Studio extensions={[SceneExtension, PostProcessingExtension, SkyboxExtension, SoundExtension, LoggerExtension, GltfViewerExtension, PhysicsExtension]}>
<!-- app content -->
</Studio>
{/await}| Extension | State export | Actions export | Has Studio UI |
|---|---|---|---|
scene |
sceneState |
sceneActions, resolveScenePreset, resolveGlobalPreset |
SceneExtension.svelte |
settings |
settingsState |
audioActions, graphicsActions, generalActions |
none (state-only) |
logger |
loggerState |
loggerActions.toggleChannel(ch) |
LoggerExtension.svelte |
postprocessing |
postprocessingState, postprocessingPresetsState |
postprocessingActions |
PostProcessingExtension.svelte |
skybox |
skyboxState, starsState, transitionState |
skyboxActions |
SkyboxExtension.svelte |
sound |
soundState |
(via settingsState.audio) |
SoundExtension.svelte |
physics |
physicsState |
physicsActions |
PhysicsExtension.svelte |
gltf-viewer |
gltfViewerState |
gltfViewerActions |
GltfViewerExtension.svelte (dev only) |
input |
inputState |
inputActions, inputQueries, advanceInputFrame |
none (runtime only) |
Logger named exports: logEngine, logSettings, logSound, logPostprocessing, logSkybox, logCache, logGltf, logPhysics, logInput
import { logEngine, logSettings, logGltf, logPhysics, logInput } from '$extensions/logger/logger.svelte';
logEngine.info('Scene:', scene); // console.log
logSettings.warn('Bad value'); // console.warn
logGltf.error('Failed:', err); // console.error
logPhysics.info('Spawned body');
logInput.info('Binding captured'); // input channel (off by default)localStorage persistence — write inside actions, not $effect:
const MY_KEY = 'my-key';
export const myState = $state({ value: parseFloat(localStorage.getItem(MY_KEY) ?? '0.5') });
export const myActions = {
setValue(v: number) { myState.value = v; localStorage.setItem(MY_KEY, String(v)); }
};Audio defaults must be false — browser autoplay policy requires audio to start disabled:
musicEnabled: false, // Always off by default — never true
sfxEnabled: false,
ambienceEnabled: false,Use on:change not bind: for toggles — bind: bypasses actions:
<!-- ❌ Bypasses actions -->
<Checkbox bind:value={state.enabled} />
<!-- ✅ Triggers action -->
<Checkbox value={state.enabled} on:change={() => actions.toggleEnabled()} />Cross-extension state access — import directly, no wrappers needed:
import { settingsState } from '$extensions/settings/settings.svelte';
// Read or mutate directly — runes are reactive across modules
settingsState.audio.sfxVolume = 0.8;Post-processing effect disposal — track isUpdatingEffects to prevent render mid-rebuild:
let isUpdatingEffects = false;
$effect(() => {
isUpdatingEffects = true;
disposeAllEffects();
// rebuild...
isUpdatingEffects = false;
});
useTask((delta) => { if (composer && !isUpdatingEffects) composer.render(delta); });| Component | Use case |
|---|---|
Checkbox |
Boolean toggles — use on:change |
Slider |
Numeric values — min/max/step props |
Button |
Actions — on:click |
Folder |
Group related controls — expanded={true} |
DropDownPane |
Main extension panel in toolbar |
List |
Select from options — options={[{value, text}]} |
Separator |
Visual divider |
- All settings persist to localStorage automatically
- Audio:
musicVolume/musicEnabled,ambienceVolume/ambienceEnabled,sfxVolume/sfxEnabled,effectsVolume - Graphics:
quality("low"|"high") — affects DPR and whether post-processing runs - General:
uiVisible(toggled withCtrl+H) - Actions:
audioActions.toggleMusic/Ambience/Sfx(),setMusicVolume(v),graphicsActions.setQuality(q),generalActions.toggleUiVisible() BASE_URL— always import and use this for static asset paths; never hardcode/or relative pathsimport { BASE_URL } from '$extensions/settings/settings.svelte'; const src = `${BASE_URL}sounds/click.mp3`;
- Connection is set up in
Root.svelteviaDbConnection.builder()+createSpacetimeDBProvider - Module bindings are in
src/module_bindings/— regenerate withnpm run spacetime:generate - Use
useTable(tables.x)fromspacetimedb/svelte— returns[rows, isLoading] - SpacetimeDB UI lives in HUD components (HTML), not 3D scene components
$state.raw<T>()for Three.js class instances (avoids Proxy breakage)- All extension state lives in
.svelte.tsmodules — exported asfooState/fooActionssingletons transition:flyon each HUD component's root element —transition:fadeon the uiVisible wrapper- Separate
{#if}blocks (not{:else if}) for scene HUD routing — ensures transitions fire on switch
Styled multi-channel logging with timestamp + color-coded channel prefix.
Channels: engine (blue), settings (green), sound (purple), postprocessing (yellow), skybox (cyan), cache (orange), gltf (teal), physics (orange)
import { logEngine, logSound, logGltf } from '$extensions/logger/logger.svelte';
logEngine.info('Scene:', scene); // console.log — general info
logSound.warn('Missing asset'); // console.warn — recoverable issues
logGltf.error('Failed:', err); // console.error — failuresAdding a new channel — two files only; the Studio UI picks it up automatically via channelStyles:
// 1. types.ts — add to the union and state type
export type LoggerChannel = 'engine' | ... | 'game';
export type LoggerState = { ...; game: boolean };
// 2. logger.svelte.ts — add state entry, channelStyle, and export
export const loggerState = $state<LoggerState>({ ..., game: true });
export const channelStyles = {
...,
game: { color: '#ff6b6b', bg: 'background:#4a2020', text: '🎮', label: 'Game' }
};
export const logGame = createLogger('game', 'game');Where logs are used in the boilerplate:
Root.svelte— SpacetimeDB connect / disconnect / errorRenderer.svelte— graphics quality appliedcore/GlobalAudio.svelte— each audio file loadedLoader.svelte— all assets finished loadingextensions/scene/scene.svelte.ts— every scene transition (mainMenu → demoScene)extensions/settings/settings.svelte.ts— quality changes, volume changes, HUD toggleextensions/skybox/skybox.svelte.ts— preset appliedextensions/physics/physics.svelte.ts— gravity changes, spawns, reset/clear actionsextensions/gltf-viewer/gltfViewer.svelte.ts— model load, remove, animation changesextensions/gltf-viewer/GltfViewerInstance.svelte— GLTF scene loaded, clips discovered
Rapier-backed sandbox controls exposed through both reactive state and a Studio panel.
State: physicsState
- World:
gravityX/Y/Z,framerate,debug - Spawn defaults: restitution, friction, damping, gravity scale, CCD, sleep, random spawn
- Attractor: enabled flag, strength, range, gravity type, position
- Bodies:
PhysicsBody[]withball/box, color, spawn position, and per-body material values
Key actions:
import { physicsActions } from '$extensions/physics/physics.svelte';
physicsActions.setGravityY(-9.8);
physicsActions.spawnBall();
physicsActions.spawnBox();
physicsActions.clearBodies();
physicsActions.toggleAttractor();
physicsActions.setAttractorGravityType('newtonian');Behavior notes:
- Spawning a body auto-switches to
demoScene - Leaving
demoSceneclears spawned bodies inScene.svelte PhysicsExtension.svelteis editor UI only; runtime logic stays inphysics.svelte.tsand controller components
Dev-only (VITE_GAME_ENGINE=true) extension for loading and inspecting GLTF/GLB files. Always targets DemoScene — loading a model auto-switches to it.
State: gltfViewerState.models: GltfViewerModel[], selectedId: string | null
Each model has:
position/rotation/scale— transform (rotation in degrees)animationClips: string[]— populated after GLTF loadsactiveAnimations: string[]— multiple clips can play simultaneously (Three.js blending)playState: 'playing' | 'paused' | 'stopped'— paused keeps current frame; stopped resets to frame 0animationSpeed,crossfadeDuration,loop,visible
Key actions:
import { gltfViewerActions } from '$extensions/gltf-viewer/gltfViewer.svelte';
gltfViewerActions.loadFromFile(file); // Blob URL, auto-switches to DemoScene
gltfViewerActions.loadFromPath('/models/x.glb'); // Public asset path
gltfViewerActions.toggleAnimation(id, clipName); // Enable/disable a clip
gltfViewerActions.setPlayState(id, 'playing'); // 'playing' | 'paused' | 'stopped'
gltfViewerActions.setCrossfadeDuration(id, 0.3); // Seconds; 0 = instant cutsCrossfade / animation blending:
- Enabling a clip →
action.fadeIn(crossfadeDuration)— weight ramps 0→1 - Disabling a clip →
action.fadeOut(crossfadeDuration)— weight ramps 1→0 naturally - Re-enabling during fade-out →
action.fadeIn()reverses the ongoing fade crossfadeDuration = 0→ hard cuts (same as before)
GltfViewerScene.svelte — drop inside DemoScene (dev-only); renders one GltfViewerInstance per model. Each instance owns its own useGltf + useGltfAnimations lifecycle, preventing mixer conflicts between models.
Action-based input mapping with keyboard, mouse, and gamepad support. Persists to localStorage. Works in production without Studio.
core/Keymapper.svelte — mounted once in App.svelte, owns all <svelte:window> event listeners:
keydown/keyup→ updatesinputState.runtime.keyboardPressedmousedown/mouseup→ updatesinputState.runtime.mousePressed; skips UI elements (buttons, inputs, labels)blur→ clears all pressed state to avoid stuck keysCtrl+His intercepted here as a global engine shortcut before input routing
State: inputState
players: Record<PlayerId, PlayerInputMap>— per-player bindings (player1–player4)capture— transient rebinding UI state (active, target action, started time)runtime— transient pressed state (keyboardPressed, mousePressed, connectedGamepads)
Player binding map:
actions: Record<InputAction, AnyBinding[]>— each action can have multiple bindingsaxes: Record<InputAxisAction, GamepadAxisBinding | null>— analog axis assignmentsgamepad: { enabled, index, deadzoneLeftStick, deadzoneRightStick }
InputAction values: moveForward moveBackward moveLeft moveRight jump sprint interact primaryAction secondaryAction reload use crouch drop prone emote slot1–slot4 pause toggleUi openSettings
InputAxisAction values: moveX moveY lookX lookY
Binding types: KeyboardBinding (code) | MouseBinding (left/right/middle) | GamepadButtonBinding | GamepadAxisBinding
Default player1 keyboard/mouse bindings:
W/A/S/D + Arrows → movement Space → jump Shift → sprint
E → interact Q + RMB → secondary LMB → primary
R → reload F → use C → crouch
X → drop Z → prone T → emote
1–4 → slot1–slot4 Esc → pause , → openSettings
Key actions:
import { inputActions, inputQueries, inputState } from '$extensions/input/input.svelte';
// Gameplay queries
inputQueries.isPressed('player1', 'jump') // boolean — current frame
inputQueries.wasPressed('player1', 'primaryAction') // boolean — edge detect (needs advanceInputFrame)
inputQueries.getMoveVector('player1') // { x: number; y: number }
inputQueries.getAxis('player1', 'lookX') // number
// Rebinding
inputActions.startCapture('player1', 'jump', 'action') // enter capture mode
inputActions.bindKeyboard('player1', 'jump', 'Space')
inputActions.bindMouse('player1', 'primaryAction', 'left')
inputActions.removeBinding('player1', 'jump', bindingId)
inputActions.resetAction('player1', 'jump')
inputActions.resetPlayerBindings('player1')
inputActions.resetAllInputSettings()advanceInputFrame() — call once per frame task to enable wasPressed edge detection:
import { advanceInputFrame } from '$extensions/input/input.svelte';
useTask(() => { advanceInputFrame(); });Persistence: spaceplate-input-settings in localStorage — only player bindings and gamepad config, never transient pressed state.
SettingsHud.svelte — tabbed UI: General (graphics quality) | Audio (SFX/music/ambient) | Controls (full keybinding editor per action group with add/remove/reset per binding).
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.
| Language | Rule File |
|---|---|
| TypeScript/React | spacetimedb-typescript.mdc (MANDATORY) |
| Rust | spacetimedb-rust.mdc (MANDATORY) |
| C# | spacetimedb-csharp.mdc (MANDATORY) |
| Migrating 1.0 → 2.0 | spacetimedb-migration-2.0.mdc |
- Reducers are transactional — they do not return data to callers
- Reducers must be deterministic — no filesystem, network, timers, or random
- Read data via tables/subscriptions — not reducer return values
- Auto-increment IDs are not sequential — gaps are normal, don't use for ordering
ctx.senderis the authenticated principal — never trust identity args
When implementing a feature that spans backend and client:
- Backend: Define table(s) to store the data
- Backend: Define reducer(s) to mutate the data
- Client: Subscribe to the table(s)
- Client: Call the reducer(s) from UI — don't forget this step!
- Client: Render the data from the table(s)
Common mistake: Building backend tables/reducers but forgetting to wire up the client to call them.
SpacetimeDB automatically creates indexes for:
- Primary key columns
- Columns marked as unique
You can add explicit indexes on non-unique columns for query performance.
Index names must be unique across your entire module (all tables). If two tables have indexes with the same declared name → conflict error.
Schema ↔ Code coupling:
- Your query code references indexes by name
- If you add/remove/rename an index in the schema, update all code that uses it
- Removing an index without updating queries causes runtime errors
# Login to allow remote database deployment e.g. to maincloud
spacetime login
# Start local SpacetimeDB
spacetime start
# Publish module
spacetime publish <db-name> --module-path <module-path>
# Clear and republish
spacetime publish <db-name> --clear-database -y --module-path <module-path>
# Generate client bindings
spacetime generate --lang <lang> --out-dir <out> --module-path <module-path>
# View logs
spacetime logs <db-name>- Maincloud is the spacetimedb hosted cloud and the default location for module publishing
- The default server marked by *** in
spacetime server listshould be used when publishing - If the default server is maincloud you should publish to maincloud
- Publishing to maincloud is free of charge
- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@/
- The database owner can view utilization and performance metrics on the dashboard
- Is SpacetimeDB server running? (
spacetime start) - Is the module published? (
spacetime publish) - Are client bindings generated? (
spacetime generate) - Check server logs for errors (
spacetime logs <db-name>) - Is the reducer actually being called from the client?
- Make the smallest change necessary
- Do NOT touch unrelated files, configs, or dependencies
- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users
These APIs DO NOT EXIST. LLMs frequently hallucinate them.
// ❌ WRONG PACKAGE — does not exist
import { SpacetimeDBClient } from "@clockworklabs/spacetimedb-sdk";
// ❌ WRONG — these methods don't exist
SpacetimeDBClient.connect(...);
SpacetimeDBClient.call("reducer_name", [...]);
connection.call("reducer_name", [arg1, arg2]);
// ❌ WRONG — positional reducer arguments
conn.reducers.doSomething("value"); // WRONG!
// ❌ WRONG — static methods on generated types don't exist
User.filterByName('alice');
Message.findById(123n);
tables.user.filter(u => u.name === 'alice'); // No .filter() on tables object!// ✅ CORRECT IMPORTS
import { DbConnection, tables } from './module_bindings'; // Generated!
import { SpacetimeDBProvider, useTable, Identity } from 'spacetimedb/react';
// ✅ CORRECT REDUCER CALLS — object syntax, not positional!
conn.reducers.doSomething({ value: 'test' });
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
// ✅ CORRECT DATA ACCESS — useTable returns [rows, isLoading]
const [items, isLoading] = useTable(tables.item);- Invent hooks like
useItems(),useData()— useuseTable(tables.tableName) - Import from fake packages — only
spacetimedb,spacetimedb/react,./module_bindings
| Wrong | Right | Error |
|---|---|---|
Missing package.json |
Create package.json |
"could not detect language" |
Missing tsconfig.json |
Create tsconfig.json |
"TsconfigNotFound" |
Entrypoint not at src/index.ts |
Use src/index.ts |
Module won't bundle |
indexes in COLUMNS (2nd arg) |
indexes in OPTIONS (1st arg) |
"reading 'tag'" error |
Index without algorithm |
algorithm: 'btree' |
"reading 'tag'" error |
filter({ ownerId }) |
filter(ownerId) |
"does not exist in type 'Range'" |
.filter() on unique column |
.find() on unique column |
TypeError |
insert({ ...without id }) |
insert({ id: 0n, ... }) |
"Property 'id' is missing" |
const id = table.insert(...) |
const row = table.insert(...) |
.insert() returns ROW, not ID |
.unique() + explicit index |
Just use .unique() |
"name is used for multiple entities" |
Index on .primaryKey() column |
Don't — already indexed | "name is used for multiple entities" |
| Same index name in multiple tables | Prefix with table name | "name is used for multiple entities" |
.indexName.filter() after removing index |
Use .iter() + manual filter |
"Cannot read properties of undefined" |
| Import spacetimedb from index.ts | Import from schema.ts | "Cannot access before initialization" |
Multi-column index .filter() |
PANIC or silent empty results | |
JSON.stringify({ id: row.id }) |
Convert BigInt first: { id: row.id.toString() } |
"Do not know how to serialize a BigInt" |
ScheduleAt.Time(timestamp) |
ScheduleAt.time(timestamp) (lowercase) |
"ScheduleAt.Time is not a function" |
ctx.db.foo.myIndexName.filter() |
Use exact name: ctx.db.foo.my_index_name.filter() |
"Cannot read properties of undefined" |
.iter() in views |
Use index lookups | Severe performance issues (re-evaluates on any change) |
ctx.db in procedures |
ctx.withTx(tx => tx.db...) |
Procedures need explicit transactions |
ctx.myTable in procedure tx |
tx.db.myTable |
Wrong context variable |
| Wrong | Right | Error |
|---|---|---|
@spacetimedb/sdk |
spacetimedb |
404 / missing subpath |
conn.reducers.foo("val") |
conn.reducers.foo({ param: "val" }) |
Wrong reducer syntax |
Inline connectionBuilder |
useMemo(() => ..., []) |
Reconnects every render |
const rows = useTable(table) |
const [rows, isLoading] = useTable(table) |
Tuple destructuring |
| Optimistic UI updates | Let subscriptions drive state | Desync issues |
<SpacetimeDBProvider builder={...}> |
connectionBuilder={...} |
Wrong prop name |
table() takes TWO arguments: table(OPTIONS, COLUMNS)
import { schema, table, t } from 'spacetimedb/server';
// ❌ WRONG — indexes in COLUMNS causes "reading 'tag'" error
export const Task = table({ name: 'task' }, {
id: t.u64().primaryKey().autoInc(),
ownerId: t.identity(),
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }] // ❌ WRONG!
});
// ✅ RIGHT — indexes in OPTIONS (first argument)
export const Task = table({
name: 'task',
public: true,
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }]
}, {
id: t.u64().primaryKey().autoInc(),
ownerId: t.identity(),
title: t.string(),
createdAt: t.timestamp(),
});t.identity() // User identity (primary key for per-user tables)
t.u64() // Unsigned 64-bit integer (use for IDs)
t.string() // Text
t.bool() // Boolean
t.timestamp() // Timestamp (use ctx.timestamp for current time)
t.scheduleAt() // For scheduled tables only
// Product types (nested objects) — use t.object, NOT t.struct
const Point = t.object('Point', { x: t.i32(), y: t.i32() });
// Sum types (tagged unions) — use t.enum, NOT t.sum
const Shape = t.enum('Shape', { circle: t.i32(), rectangle: Point });
// Values use { tag: 'circle', value: 10 } or { tag: 'rectangle', value: { x: 1, y: 2 } }
// Modifiers
t.string().optional() // Nullable
t.u64().primaryKey() // Primary key
t.u64().primaryKey().autoInc() // Auto-increment primary key
⚠️ BIGINT SYNTAX: Allu64,i64, and ID fields use JavaScript BigInt.
- Literals:
0n,1n,100n(NOT0,1,100)- Comparisons:
row.id === 5n(NOTrow.id === 5)- Arithmetic:
row.count + 1n(NOTrow.count + 1)
// ✅ MUST provide 0n placeholder for auto-inc fields
ctx.db.task.insert({ id: 0n, ownerId: ctx.sender, title: 'New', createdAt: ctx.timestamp });// ❌ WRONG
const id = ctx.db.task.insert({ ... });
// ✅ RIGHT
const row = ctx.db.task.insert({ ... });
const newId = row.id; // Extract .id from returned row// At end of schema.ts — schema() takes exactly ONE argument: an object
const spacetimedb = schema({ table1, table2, table3 });
export default spacetimedb;
// ❌ WRONG — never pass tables directly or as multiple args
schema(myTable); // WRONG!
schema(t1, t2, t3); // WRONG!// 1. PRIMARY KEY — use .pkColumn.find()
const user = ctx.db.user.identity.find(ctx.sender);
const msg = ctx.db.message.id.find(messageId);
// 2. EXPLICIT INDEX — use .indexName.filter(value)
const msgs = [...ctx.db.message.message_room_id.filter(roomId)];
// 3. NO INDEX — use .iter() + manual filter
for (const m of ctx.db.roomMember.iter()) {
if (m.roomId === roomId) { /* ... */ }
}// In table OPTIONS (first argument), not columns
export const Message = table({
name: 'message',
public: true,
indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }]
}, {
id: t.u64().primaryKey().autoInc(),
roomId: t.u64(),
// ...
});Table names — automatic transformation:
- Schema:
table({ name: 'my_messages' }) - Access:
ctx.db.myMessages(automatic snake_case → camelCase)
Index names — NO transformation, use EXACTLY as defined:
// Schema definition
indexes: [{ name: 'canvas_member_canvas_id', algorithm: 'btree', columns: ['canvasId'] }]
// ❌ WRONG — don't assume camelCase transformation
ctx.db.canvasMember.canvasMember_canvas_id.filter(...) // WRONG!
ctx.db.canvasMember.canvasMemberCanvasId.filter(...) // WRONG!
// ✅ RIGHT — use exact name from schema
ctx.db.canvasMember.canvas_member_canvas_id.filter(...)
⚠️ Index names are used VERBATIM — pick a convention (snake_case or camelCase) and stick with it.
Index naming pattern — use {tableName}_{columnName}:
// ✅ GOOD — unique names across entire module
indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }]
indexes: [{ name: 'reaction_message_id', algorithm: 'btree', columns: ['messageId'] }]
// ❌ BAD — will collide if multiple tables use same index name
indexes: [{ name: 'by_owner', ... }] // in Task table
indexes: [{ name: 'by_owner', ... }] // in Note table — CONFLICT!Client-side table names:
- Check generated
module_bindings/index.tsfor exact export names - Usage:
useTable(tables.MyMessages)ortables.myMessages(varies by SDK version)
// Filter takes VALUE directly, not object — returns iterator
const rows = [...ctx.db.task.by_owner.filter(ownerId)];
// Unique columns use .find() — returns single row or undefined
const row = ctx.db.player.identity.find(ctx.sender);// ❌ DON'T — causes PANIC
ctx.db.scores.by_player_level.filter(playerId);
// ✅ DO — use single-column index + manual filter
for (const row of ctx.db.scores.by_player.filter(playerId)) {
if (row.level === targetLevel) { /* ... */ }
}Reducer name comes from the export — NOT from a string argument. Use reducer(params, fn) or reducer(fn).
import spacetimedb from './schema';
import { t, SenderError } from 'spacetimedb/server';
// ✅ CORRECT — export const name = spacetimedb.reducer(params, fn)
export const reducer_name = spacetimedb.reducer({ param1: t.string(), param2: t.u64() }, (ctx, { param1, param2 }) => {
// Validation
if (!param1) throw new SenderError('param1 required');
// Access tables via ctx.db
const row = ctx.db.myTable.primaryKey.find(param2);
// Mutations
ctx.db.myTable.insert({ ... });
ctx.db.myTable.primaryKey.update({ ...row, newField: value });
ctx.db.myTable.primaryKey.delete(param2);
});
// No params: export const init = spacetimedb.reducer((ctx) => { ... });// ❌ WRONG — reducer('name', params, fn) does NOT exist
spacetimedb.reducer('reducer_name', { param1: t.string() }, (ctx, { param1 }) => { ... });// ✅ CORRECT — spread existing row, override specific fields
const existing = ctx.db.task.id.find(taskId);
if (!existing) throw new SenderError('Task not found');
ctx.db.task.id.update({ ...existing, title: newTitle, updatedAt: ctx.timestamp });
// ❌ WRONG — partial update nulls out other fields!
ctx.db.task.id.update({ id: taskId, title: newTitle });// Delete by primary key VALUE (not row object)
ctx.db.task.id.delete(taskId); // taskId is the u64 value
ctx.db.player.identity.delete(ctx.sender); // delete by identityspacetimedb.clientConnected((ctx) => {
// ctx.sender is the connecting identity
// Create/update user record, set online status, etc.
});
spacetimedb.clientDisconnected((ctx) => {
// Clean up: set offline status, remove ephemeral data, etc.
});- Server:
export const do_something = spacetimedb.reducer(...)— name from export - Client:
conn.reducers.doSomething({ ... })
// ❌ WRONG - positional
conn.reducers.doSomething('value');
// ✅ RIGHT - object
conn.reducers.doSomething({ param: 'value' });// 1. Define table first (scheduled: () => reducer — pass the exported reducer)
export const CleanupJob = table({
name: 'cleanup_job',
scheduled: () => run_cleanup // reducer defined below
}, {
scheduledId: t.u64().primaryKey().autoInc(),
scheduledAt: t.scheduleAt(),
targetId: t.u64(), // Your custom data
});
// 2. Define scheduled reducer (receives full row as arg)
export const run_cleanup = spacetimedb.reducer({ arg: CleanupJob.rowType }, (ctx, { arg }) => {
// arg.scheduledId, arg.targetId available
// Row is auto-deleted after reducer completes
});
// Schedule a job
import { ScheduleAt } from 'spacetimedb';
const futureTime = ctx.timestamp.microsSinceUnixEpoch + 60_000_000n; // 60 seconds
ctx.db.cleanupJob.insert({
scheduledId: 0n,
scheduledAt: ScheduleAt.time(futureTime),
targetId: someId
});
// Cancel a job by deleting the row
ctx.db.cleanupJob.scheduledId.delete(jobId);import { Timestamp, ScheduleAt } from 'spacetimedb';
// Current time
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
// Future time (add microseconds)
const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n; // 5 minutesTimestamps are objects, not numbers:
// ❌ WRONG
const date = new Date(row.createdAt);
const date = new Date(Number(row.createdAt / 1000n));
// ✅ RIGHT
const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));// ScheduleAt is a tagged union
if (scheduleAt.tag === 'Time') {
const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n));
}public: true exposes ALL rows to ALL clients.
| Scenario | Pattern |
|---|---|
| Everyone sees all rows | public: true |
| Users see only their data | Private table + filtered subscription |
// Subscribe to ALL public tables (simplest)
conn.subscriptionBuilder().subscribeToAll();
// Subscribe to specific tables with SQL
conn.subscriptionBuilder().subscribe([
'SELECT * FROM message',
'SELECT * FROM room WHERE is_public = true',
]);
// Handle subscription lifecycle
conn.subscriptionBuilder()
.onApplied(() => console.log('Initial data loaded'))
.onError((e) => console.error('Subscription failed:', e))
.subscribeToAll();Views are the recommended approach for controlling data visibility. They provide:
- Server-side filtering (reduces network traffic)
- Real-time updates when underlying data changes
- Full control over what data clients can access
⚠️ Do NOT use Row Level Security (RLS) — it is deprecated.
⚠️ CRITICAL: Procedural views (views that compute results in code) can ONLY access data via index lookups, NOT.iter(). 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...).
// Private table with index on ownerId
export const PrivateData = table(
{ name: 'private_data',
indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }]
},
{
id: t.u64().primaryKey().autoInc(),
ownerId: t.identity(),
secret: t.string()
}
);
// ❌ BAD — .iter() causes performance issues (re-evaluates on ANY row change)
spacetimedb.view(
{ name: 'my_data_slow', public: true },
t.array(PrivateData.rowType),
(ctx) => [...ctx.db.privateData.iter()] // Works but VERY slow at scale
);
// ✅ GOOD — index lookup enables targeted invalidation
spacetimedb.view(
{ name: 'my_data', public: true },
t.array(PrivateData.rowType),
(ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)]
);// Query-builder views return a query; the SQL engine maintains the result incrementally.
// This can scan the whole table if needed (e.g. leaderboard-style queries).
spacetimedb.anonymousView(
{ name: 'top_players', public: true },
t.array(Player.rowType),
(ctx) =>
ctx.from.player
.where(p => p.score.gt(1000))
);// ViewContext — has ctx.sender, result varies per user (computed per-subscriber)
spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
return [...ctx.db.item.by_owner.filter(ctx.sender)];
});
// AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf)
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(LeaderboardRow), (ctx) => {
return [...ctx.db.player.by_score.filter(/* top scores */)];
});Views require explicit subscription:
conn.subscriptionBuilder().subscribe([
'SELECT * FROM public_table',
'SELECT * FROM my_data', // Views need explicit SQL!
]);// Memoize connectionBuilder to prevent reconnects on re-render
const builder = useMemo(() =>
DbConnection.builder()
.withUri(SPACETIMEDB_URI)
.withDatabaseName(MODULE_NAME)
.withToken(localStorage.getItem('auth_token') || undefined)
.onConnect(onConnect)
.onConnectError(onConnectError),
[] // Empty deps - only create once
);
// useTable returns tuple [rows, isLoading]
const [rows, isLoading] = useTable(tables.myTable);
// Compare identities using toHexString()
const isOwner = row.ownerId.toHexString() === myIdentity.toHexString();Procedures are for side effects (HTTP requests, etc.) that reducers can't do.
Procedure name comes from the export — NOT from a string argument. Use procedure(params, ret, fn) or procedure(ret, fn).
// ✅ CORRECT — export const name = spacetimedb.procedure(params, ret, fn)
export const fetch_external_data = spacetimedb.procedure(
{ url: t.string() },
t.string(), // return type
(ctx, { url }) => {
const response = ctx.http.fetch(url);
return response.text();
}
);ctx.db. Use ctx.withTx() for database access.
spacetimedb.procedure({ url: t.string() }, t.unit(), (ctx, { url }) => {
// Fetch external data (outside transaction)
const response = ctx.http.fetch(url);
const data = response.text();
// ❌ WRONG — ctx.db doesn't exist in procedures
ctx.db.myTable.insert({ ... });
// ✅ RIGHT — use ctx.withTx() for database access
ctx.withTx(tx => {
tx.db.myTable.insert({
id: 0n,
content: data,
fetchedAt: tx.timestamp,
fetchedBy: tx.sender,
});
});
return {};
});| Reducers | Procedures |
|---|---|
ctx.db available directly |
Must use ctx.withTx(tx => tx.db...) |
| Automatic transaction | Manual transaction management |
| No HTTP/network | ctx.http.fetch() available |
| No return values to caller | Can return data to caller |
src/schema.ts → Tables, export spacetimedb
src/index.ts → Reducers, lifecycle, import schema
package.json → { "type": "module", "dependencies": { "spacetimedb": "^1.11.0" } }
tsconfig.json → Standard config
schema.ts → defines tables AND exports spacetimedb
index.ts → imports spacetimedb from ./schema, defines reducers
src/module_bindings/ → Generated (spacetime generate)
src/main.tsx → Provider, connection setup
src/App.tsx → UI components
src/config.ts → MODULE_NAME, SPACETIMEDB_URI
# Start local server
spacetime start
# Publish module
spacetime publish <module-name> --module-path <backend-dir>
# Clear database and republish
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
# Generate bindings
spacetime generate --lang typescript --out-dir <client>/src/module_bindings --module-path <backend-dir>
# View logs
spacetime logs <module-name>TypeScript-specific:
schema({ table })— takes exactly one object; neverschema(table)orschema(t1, t2, t3)- Reducer/procedure names from exports —
export const name = spacetimedb.reducer(params, fn); neverreducer('name', ...) - Reducer calls use object syntax —
{ param: 'value' }not positional args - Import
DbConnectionfrom./module_bindings— not fromspacetimedb - DO NOT edit generated bindings — regenerate with
spacetime generate - Indexes go in OPTIONS (1st arg) — not in COLUMNS (2nd arg) of
table() - Use BigInt for u64/i64 fields —
0n,1n, not0,1 - Reducers are transactional — they do not return data
- Reducers must be deterministic — no filesystem, network, timers, random
- Views should use index lookups —
.iter()causes severe performance issues - Procedures need
ctx.withTx()—ctx.dbdoesn't exist in procedures - Sum type values — use
{ tag: 'variant', value: payload }not{ variant: payload }