Client SDK Reference
import { ... } from '@vibelands/mod-sdk/client';Use these hooks inside a component registered with defineModClient. The host mounts it in the game and supplies the mod context. For the usual React and Three.js dependencies, keep the Creator-generated build setup.
Start with surfaces, then choose hooks from the tables below. Exact signatures include optional arguments and related types. The API map groups APIs by task.
Physics (apiVersion 2)
import { DynamicBox, ReplicatedDynamicBox, StaticBox, KinematicBox, usePhysicsRaycast } from '@vibelands/mod-sdk/physics';The physics module requires physics.colliders. It exposes engine-neutral static, kinematic, and host-owned dynamic collider components (StaticBox, StaticBall, StaticMesh, Heightfield, KinematicBox, DynamicBox) and a closest-hit usePhysicsRaycast() hook. DynamicBox synchronizes its visual children to a client-only body without exposing the mutable body handle. For shared gameplay, spawn a server entity with physics.type: 'dynamic-box' and render it as <ReplicatedDynamicBox entityId={id}>…</ReplicatedDynamicBox>; the host integrates locally between authoritative 10 Hz snapshots and reconciles without per-snapshot React rendering. It deliberately does not expose the host world, mutable body handles, engine internals, or lifecycle methods.
Surfaces
export default defineModClient({
WorldLayer, // R3F component inside the world scene
HudPanel, // DOM component inside the HUD overlay (wrapped in <div data-mod-hud="<id>">)
objectRenderers: {
'my-item': MyItemRenderer, // one R3F component per manifest editorItems id
},
objectBatchRenderers: {
'my-item': MyItemBatchRenderer, // optional gameplay batch renderer for many visible objects
},
});At least one surface is required. HudPanel positions itself with absolute styles; use pointerEvents: 'none' unless you need input.
objectRenderers components render every placed world object with preset mod:<modId>:<itemId> and receive { itemId, properties, worldObject, renderMode }. renderMode is 'world' | 'ghost' | 'library' - skip gameplay side effects when it isn't 'world'. The host automatically applies ghost preview opacity/tint around mod object renderers, so simple renderers do not need to handle preview materials. All SDK hooks work inside renderers. See the manifest editorItems reference.
objectBatchRenderers are optional gameplay renderers for dense repeated objects. They receive { itemId, objects }, where objects are active visible world objects for that editor item. Use them for InstancedMesh batching; keep objectRenderers for editor previews, library thumbnails, ghost placement and fallback rendering.
World reads
| Hook | Returns |
|---|---|
useWorldInfo() | { worldId, worldName, terrain } (terrain: seed, size, seaLevel, biome, …) |
useTerrainSampler() | getGroundHeight(x, z) | null — check availability before sampling |
useAtmosphereFrame() | ref to the live atmosphere frame state - read .current inside frame callbacks: daylight, weatherType, weatherIntensity, timeOfDay, sun direction |
useWeather() | replicated { type, intensity } (re-renders on change) |
useTimeOfDayGetter() | () => number (0..1, 0.5 = noon) - a getter, frame-safe, never re-renders |
useGraphicsQuality() | 'low' | 'balanced' | 'high' - scale your content like core systems |
useRenderPipeline() | 'classic' | 'node' - branch materials when the manifest declares both rendering.pipelines |
useWorldEntityQuery(query, { pollMs? }) | bounded read-only EntityRef snapshots across core and mod entity kinds; requires world.entities.read |
useWorldEntity(ref, { pollMs? }) | one matching read-only projection or null; requires world.entities.read |
World entity queries filter by kinds, withComponents, withTags, and an optional near/radius. Player projections additionally require players.read. Mod components are advertised by qualified type such as mod:cargo-demo:cargo-state; their values remain available only on the owning mod's useModEntity* snapshots.
Players
| Hook | Returns |
|---|---|
useLocalPlayerGetter() | () => localPlayerState | null (server-echoed, ~20 Hz) |
usePlayersLite() | snapshot array { sessionId, userId, name, level, mode, dead, position } - re-renders only on join/leave; positions are frozen at render time |
usePlayerGetter() | (sessionId) => live player state | null - frame-safe peek for animations |
useLocalAvatarProximity(target, opts) | nearby result for a world-space point or XZ bounds; follows the player or driven vehicle |
useNearestWorldObject(preset, opts) | { object, player, distanceSq } | null for nearby-object prompts; opts supports range, yRange, requireMode, pollMs |
Position-following content
Subscribe structurally with usePlayersLite(), then read live positions each frame through usePlayerGetter() inside useModFrame - never re-render per movement.
Mod scope
| Hook | Returns |
|---|---|
useModConfig() | per-world config, already resolved against your configSchema |
useModPermissions() | the permission ids your manifest declared |
useDeterministicRng(salt?) | mulberry32 seeded from worldSeed ^ hash(modId + salt) - identical sequence on every client |
Replication (tier B)
| Hook | Returns |
|---|---|
useModState() | parsed KV blob set by ctx.state.set() on the server |
useModEntityList() | live array of this mod's entities: { id, kind, position, rotation, scale, velocity, properties, components, ownerSessionId } - narrow subscription, re-renders on add/remove/update |
useModEntityIds() | stable id array; re-renders only when entities are added/removed, suited to large physics structures |
useModEntity(id) | one live entity snapshot with an entity-scoped subscription |
useModMessage(type, handler) | subscribe to server→client messages |
useSendModMessage() | (type, data, { operationId? }) => operationId | void client→server (host rate-limits: 20/s, burst 40, ≤ 8 KB) |
useTypedModMessage<MessageMap, Type>(type, handler) | generated, type-safe server→client subscription |
useTypedSendModMessage<MessageMap>() | generated, type-safe client→server sender |
When the manifest declares messageSchemas, the SDK validates both directions at the client boundary too. Invalid outgoing intents never reach the bridge; invalid incoming events never reach mod handlers. TypeScript mods can import ClientToServerMessages and ServerToClientMessages from shared/mod-messages.generated.ts; runtime host validation remains the security boundary even when compile-time types are used.
For a message declared with delivery: "at-most-once", the sender creates and returns an operation id automatically. Retrying the same logical intent must reuse that id:
const send = useTypedSendModMessage<ClientToServerMessages>();
const operationId = send('openChest', { chestId });
// A transport retry uses the same operationId; a second user action uses a new one.
send('openChest', { chestId }, { operationId });Assets
useModGltf, useModTexture, and useModAudioBuffer are reference-counted per immutable asset URL. The last mounted owner clears the loader/audio cache and disposes GPU resources after a StrictMode-safe grace tick, so hot apply does not retain old build revisions indefinitely.
For custom browser/Three.js resources, use a component scope:
const resources = useModResourceScope('scanner');
useEffect(() => resources.defer(() => scanner.dispose(), 'scanner'), [resources, scanner]);The scope disposes in reverse order when the component genuinely unmounts and contains cleanup failures so one mod resource cannot stop the remainder.
| Hook | Returns |
|---|---|
useModAssetUrl(path) | resolved /mods/<id>/<version>/assets/<path> URL |
useModGltf(path) | loaded GLTF (suspends; cached per URL) |
useModTexture(path) | loaded THREE.Texture (suspends) |
useModAudioBuffer(path) | AudioBuffer | null, decoded with the host audio context - requires audio |
Audio
const audio = useModAudio(); // requires the 'audio' permission
audio.getAudioContext() // host AudioContext (null without permission)
audio.getMasterGain() // advanced routing; prefer useModAudioBus() below
audio.resumeAudioContext()
audio.isAudioEnabled() // user/perf toggle - respect itWithout the audio permission you get a disabled stub and a one-time console warning - your mod keeps working, silently.
Camera shot lease
Declare camera.control only for a reviewed cinematic. A mod never receives the host THREE.Camera; it returns a plain pose and the host validates and applies it while the lease is active.
const registerShot = useModCameraShotRegistration({
priority: 10,
durationMs: 4_000,
});
useEffect(() => registerShot({
getShot: () => ({
position: { x: 24, y: 12, z: 18 },
target: { x: 0, y: 2, z: 0 },
fov: 55,
}),
}), [registerShot]);Higher priority wins; equal priorities are stable by mod ID. Leases expire unless renewed and are released on unmount. Pause, editor, photo and view-mode camera states remain host-owned and override a mod shot.
Spatial audio attachment
For an emitter that belongs to one component, use a host-routed PannerNode. It requires audio; connect each temporary source to the node, not to the master gain.
const panner = useModSpatialAudio(npcPosition, {
refDistance: 3,
maxDistance: 96,
rolloffFactor: 1.2,
});
useEffect(() => {
if (!panner || !buffer) return;
const source = audio.getAudioContext().createBufferSource();
source.buffer = buffer;
source.connect(panner);
source.start();
return () => source.stop();
}, [audio, buffer, panner]);The node tracks the supplied position and disconnects from the mod audio bus on unmount.
HUD notifications
Use the host notification center instead of positioning routine hints or toast messages inside HudPanel.
const notifications = useModNotifications();
notifications.set('nearby-shop', {
tone: 'info',
title: 'Shop',
message: 'Browse the nearby store.',
actions: [{ key: 'E', label: 'open' }],
});
notifications.clear('nearby-shop');
notifications.notify({
tone: 'success',
title: 'Purchase complete',
message: 'The item was added to your inventory.',
}, { durationMs: 3000 });set(id, notice) creates or updates a persistent keyed notice. clear(id) removes it. notify(notice, options?) creates a temporary notice and returns an id that can be removed early with dismiss(id). The host owns placement, styling, timeout limits, and cleanup when the mod unmounts.
Declarative mission HUD
For a persistent objective card, send data to the host instead of building HUD DOM. This needs the same ui.hud permission as notifications.
const hud = useModHudSurfaces();
hud.set('delivery', {
title: 'Harbor delivery',
objective: 'Bring the crates to the warehouse.',
marker: 'Warehouse · 180 m',
detail: 'Pick crates up with F at the dock and carry them along the pier.',
progress: delivered / total,
progressLabel: `${delivered}/${total} crates`,
});
// cleanup on completion, or automatically when the mod HUD unmounts
hud.clear('delivery');The host clamps progress to 0..1 and limits text (title 100, objective 240, marker 160, progress label 80 characters), then owns placement, styling and teardown. Keep title/objective/marker to one short line each and put how-to explanations in detail (≤ 400 characters, wraps, \n breaks lines). Use a reviewed HudPanel only when this data-only surface cannot express the advanced UI.
Declarative dialogs and menus
useModDialogs() renders a bounded host dialog or menu. The callback gets only the declared action ID; validate any gameplay consequence on the server.
const dialogs = useModDialogs();
dialogs.open('confirm-travel', {
kind: 'dialog',
title: 'Travel to Harbor?',
message: 'Your vehicle will remain here.',
actions: [
{ id: 'confirm', label: 'Travel', tone: 'primary' },
{ id: 'cancel', label: 'Stay' },
],
}, (actionId) => {
if (actionId === 'confirm') send('requestTravel', {});
dialogs.close('confirm-travel');
});The host clips text, allows at most four actions, owns focus/placement and cleans all dialogs when the mod surface unmounts.
Host-validated interactions
const interactions = useModInteractions();
const execute = useExecuteModInteraction();
const nearby = interactions.find((entry) => entry.tags?.includes('terminal'));
if (nearby) execute(nearby.id); // auto-completes after declared holdDurationMsThe browser sends only begin/complete/cancel intents. It cannot decide that an interaction succeeded: the server rechecks target existence, player mode, distance, hold duration, cooldown, rate limits, and replay before invoking the owning mod. Pass { autoComplete: false } to receive an explicit { complete, cancel } handle for custom progress UI.
Message correlation IDs
Every send(type, data) call now returns a generated operation ID. It travels unchanged through the client envelope, authoritative server handler and an isolated worker, so include it in logs or an explicit result event when diagnosing a gameplay action. For an at-most-once message, reuse that exact ID only when retrying the same intent.
HUD key actions
useModKeyAction({
code: 'KeyF',
enabled: Boolean(nearby?.object),
onAction(event) {
event.preventDefault();
send('pressBox', { objectId: nearby.object.id });
},
});The helper listens on window, ignores repeated keydowns and text-entry targets by default, and cleans up when the HUD surface unmounts. Host gameplay handlers run first: if core code claims the key with preventDefault(), the mod action is skipped. This keeps vehicle and other core interactions above nearby mod actions when they share a key.
For new mods prefer a semantic action instead of binding a raw key:
useModInputAction({
actionId: 'open-terminal',
defaultCode: 'KeyF',
priority: 10,
enabled: Boolean(nearby?.object),
onAction() {
send('pressBox', { objectId: nearby.object.id });
},
});The host may rebind this action without changing mod code; current bindings are stored by the host as a device preference. If multiple mods resolve to the same key, exactly one runs: highest priority, then mod id, then action id. Core input still claims the event first.
Exclusive atmosphere surfaces
useCloudLayerRegistration() and useFogVolumeRegistration() claim the host's single postprocess slot through a lease instead of mount order. A higher priority wins; equal priorities are resolved by mod id, so two mods have the same outcome on every client. A lease can be temporary and renewed.
const registerCloudLayer = useCloudLayerRegistration({ priority: 10 });
useEffect(() => {
const release = registerCloudLayer(layer, { durationMs: 5_000 });
const renewal = setInterval(() => release.renew(5_000), 2_500);
return () => { clearInterval(renewal); release(); };
}, [layer, registerCloudLayer]);The host automatically falls back to the next valid lease when a higher one expires, unmounts, or is released. The provider remains responsible for its own GPU disposal.
Mod audio bus
useModAudioBus() returns a mod-owned GainNode, or null when audio is unavailable or the manifest lacks the audio permission. Connect sources to this node rather than the host master gain:
const bus = useModAudioBus();
// source.connect(bus); // the host routes it through environment filteringThe bus is shared by that mod's mounted surfaces and disconnects automatically after its final consumer unmounts. It cannot bypass host volume, underwater/interior filtering, or the global audio toggle.
Frame loop
useModFrame((state, delta, frame) => { ... }, priority?)useFrame with two host services: per-mod frame cost attribution (debug panel + frameMetrics()), and error containment (after 50 thrown errors your callback is disabled instead of breaking the render loop).
Utilities
| Export | Purpose |
|---|---|
useSignalValue(signal) | subscribe to a Preact signal (module-scope state shared between WorldLayer ↔ HudPanel) |
createMulberry32(seed), hashStringToSeed(str) | deterministic helpers |
MOD_LIMITS, MOD_API_VERSION | the contract constants |