HoloScript Trait Extension Guide
Create custom traits for spatial computing, robotics, XR, and AI scenarios.
Overview
HoloScript's trait system is designed for extensibility. You can create custom traits that integrate seamlessly with the runtime, physics engine, networking, and AI systems.
Quick Start
import { registerTrait } from '@holoscript/core';
registerTrait({
name: '@pulse_glow',
defaultConfig: { color: 'blue', speed: 1.0 },
onAttach(node, config, context) {
node.userData.pulseTime = 0;
},
onUpdate(node, config, context, delta) {
node.userData.pulseTime += delta * config.speed;
const intensity = (Math.sin(node.userData.pulseTime) + 1) / 2;
context.emit('glow_intensity', { nodeId: node.id, intensity });
},
});Use in HoloScript:
sphere {
@pulse_glow(color=blue, speed=2.0)
}Trait Anatomy
interface TraitHandler<TConfig = unknown> {
// Unique trait identifier (must start with @)
name: VRTraitName;
// Default values for all config properties
defaultConfig: TConfig;
// Lifecycle hooks (all optional)
onAttach?: (node: HSPlusNode, config: TConfig, context: TraitContext) => void;
onDetach?: (node: HSPlusNode, config: TConfig, context: TraitContext) => void;
onUpdate?: (node: HSPlusNode, config: TConfig, context: TraitContext, delta: number) => void;
onEvent?: (node: HSPlusNode, config: TConfig, context: TraitContext, event: TraitEvent) => void;
}Lifecycle Hooks
onAttach(node, config, context) 🟢
Called once when the trait is added to a node.
Use for: initialization, setup, resource allocation.
onAttach(node, config, context) {
// Create resources
const sound = context.audio.createSource(config.soundUrl);
node.userData.sound = sound;
// Subscribe to events
context.emit('trait_ready', { traitName: '@my_trait', nodeId: node.id });
}onDetach(node, config, context) 🔴
Called once when the trait is removed.
Use for: cleanup, resource disposal, event unsubscription.
onDetach(node, config, context) {
// Cleanup resources
if (node.userData.sound) {
node.userData.sound.stop();
node.userData.sound = null;
}
}onUpdate(node, config, context, delta) 🔵
Called every frame during active simulation.
Use for: animations, physics, AI ticks, state updates.
onUpdate(node, config, context, delta) {
// delta = seconds since last frame (typically ~0.016 for 60fps)
node.userData.angle = (node.userData.angle || 0) + config.speed * delta;
const angle = node.userData.angle;
// Apply rotation...
}⚡ Performance Tip: Keep
onUpdateextremely lightweight. It runs every frame for every trait on every object. Avoid allocations and complex computations.
onEvent(node, config, context, event) 🟡
Called when the node receives an event.
Use for: user input, collision, network messages.
onEvent(node, config, context, event) {
switch (event.type) {
case 'grab':
config.isGrabbed = true;
context.emit('grabbed', { nodeId: node.id });
break;
case 'release':
config.isGrabbed = false;
break;
case 'collision':
context.audio.playSound(config.impactSound, {
position: event.payload.point,
volume: Math.min(1, event.payload.impulse / 10)
});
break;
}
}The TraitContext API
The context parameter provides access to all runtime systems:
VR/XR Context
context.vr.hands.left; // Left hand tracking data
context.vr.hands.right; // Right hand tracking data
context.vr.headset.position; // Head position [x, y, z]
// Get pointer ray from controller
const ray = context.vr.getPointerRay('right');
if (ray) {
const { origin, direction } = ray;
}Physics Context
// Apply forces
context.physics.applyVelocity(node, { x: 0, y: 5, z: 0 }); // Launch upward
context.physics.applyAngularVelocity(node, { x: 0, y: 1, z: 0 }); // Spin
// Raycasting
const hit = context.physics.raycast(origin, direction, 10.0);
if (hit) {
console.log('Hit:', hit.nodeId, 'Distance:', hit.distance);
}
// Query body state
const vel = context.physics.getBodyVelocity(node.id);
const pos = context.physics.getBodyPosition(node.id);Audio Context
// Spatial audio
context.audio.playSound('/sounds/whoosh.ogg', {
position: { x: 0, y: 1, z: 0 },
volume: 0.8,
spatial: true, // 3D positional audio
});State & Events
// Read/write reactive state
const health = context.getState().health as number;
context.setState({ health: health - 10 });
// Emit events to other traits/systems
context.emit('health_changed', { nodeId: node.id, health });
context.emit('death', { nodeId: node.id });Accessibility Context
// Screen reader support
context.accessibility?.announce('Item picked up');
context.accessibility?.setAltText(node.id, 'A red cube (grabbable)');Configuration Schema
Define strongly-typed configuration with TypeScript:
interface ParticleConfig {
count: number;
color: string;
speed: number;
lifetime: number;
spread: number;
loop: boolean;
}
const ParticleTrait: TraitHandler<ParticleConfig> = {
name: '@particles' as any,
defaultConfig: {
count: 100,
color: 'white',
speed: 2.0,
lifetime: 2.0,
spread: 45,
loop: true,
},
// ...
};HoloScript syntax:
sphere {
@particles(count=200, color=red, speed=3.0)
}Complete Examples
1. Health Bar Trait
Shows a floating health bar above objects:
interface HealthBarConfig {
maxHealth: number;
height: number; // Offset above object
width: number; // Bar width in meters
showText: boolean; // Show numeric value
}
registerTrait<HealthBarConfig>({
name: '@health_bar',
defaultConfig: { maxHealth: 100, height: 1.5, width: 0.5, showText: true },
onAttach(node, config, context) {
// Create UI panel above object
node.userData.healthBar = {
currentHealth: config.maxHealth,
barObject: createBarMesh(config.width),
};
context.emit('health_bar_created', { nodeId: node.id });
},
onUpdate(node, config, context, delta) {
const bar = node.userData.healthBar;
if (!bar) return;
// Update bar fill based on health
const percent = bar.currentHealth / config.maxHealth;
bar.barObject.scale.x = percent;
bar.barObject.material.color = percent > 0.5 ? 'green' : percent > 0.25 ? 'yellow' : 'red';
},
onEvent(node, config, context, event) {
if (event.type === 'take_damage') {
const damage = event.payload as number;
node.userData.healthBar.currentHealth = Math.max(
0,
node.userData.healthBar.currentHealth - damage
);
if (node.userData.healthBar.currentHealth <= 0) {
context.emit('death', { nodeId: node.id });
}
}
if (event.type === 'heal') {
const amount = event.payload as number;
node.userData.healthBar.currentHealth = Math.min(
config.maxHealth,
node.userData.healthBar.currentHealth + amount
);
}
},
onDetach(node, config, context) {
node.userData.healthBar = null;
},
});Usage:
enemy_robot {
@health_bar(maxHealth=200, showText=true)
@character
@networked
}2. MQTT Sensor Trait
Streams real-time IoT data into the scene:
interface MQTTSensorConfig {
brokerUrl: string;
topic: string;
updateRate: number; // Hz
visualize: boolean; // Show sensor readings as color
}
registerTrait<MQTTSensorConfig>({
name: '@mqtt_sensor',
defaultConfig: {
brokerUrl: 'mqtt://localhost:1883',
topic: 'sensors/#',
updateRate: 10,
visualize: true,
},
onAttach(node, config, context) {
// Connect to MQTT broker
const client = connectMQTT(config.brokerUrl);
client.subscribe(config.topic);
client.on('message', (topic, payload) => {
const data = JSON.parse(payload.toString());
node.userData.sensorData = data;
context.emit('sensor_update', { nodeId: node.id, data });
});
node.userData.mqttClient = client;
},
onUpdate(node, config, context, delta) {
if (!config.visualize || !node.userData.sensorData) return;
// Visualize sensor value as color
const value = node.userData.sensorData.value || 0;
const normalized = Math.min(1, Math.max(0, value / 100));
// Interpolate green → yellow → red based on value
const color = lerpColor([0, 255, 0], [255, 0, 0], normalized);
// Apply color to material...
},
onDetach(node, config, context) {
node.userData.mqttClient?.end();
},
});3. LLM Behavior Trait
Drives object behavior with an LLM:
interface LLMBehaviorConfig {
model: string;
systemPrompt: string;
updateInterval: number; // Seconds between LLM calls
responseTimeout: number; // Max wait time
}
registerTrait<LLMBehaviorConfig>({
name: '@llm_behavior',
defaultConfig: {
model: 'claude-3-opus',
systemPrompt: 'You control a game character. Respond with JSON actions.',
updateInterval: 2.0,
responseTimeout: 5.0,
},
onAttach(node, config, context) {
node.userData.llmTimer = 0;
node.userData.context = [];
},
onUpdate(node, config, context, delta) {
node.userData.llmTimer += delta;
if (node.userData.llmTimer < config.updateInterval) return;
node.userData.llmTimer = 0;
// Gather scene context
const sceneContext = {
position: context.physics.getBodyPosition(node.id),
nearbyObjects: getNearbyObjects(node.id),
playerDistance: getPlayerDistance(node.id),
};
// Async LLM call (non-blocking)
queryLLM(config.model, config.systemPrompt, sceneContext)
.then((response) => {
const action = JSON.parse(response);
context.emit('llm_action', { nodeId: node.id, action });
})
.catch((err) => {
console.warn('LLM query failed:', err);
});
},
});Registering Traits
import { registerTrait, TraitRegistry } from '@holoscript/core';
// Register a single trait
registerTrait(MyCustomTrait);
// Register multiple traits
TraitRegistry.registerBatch([HealthBarTrait, MQTTSensorTrait, LLMBehaviorTrait]);
// Plugin pattern - register with namespace
function installGamePlugin(registry: TraitRegistry) {
registry.register('@game/health_bar', HealthBarTrait);
registry.register('@game/inventory', InventoryTrait);
registry.register('@game/respawn', RespawnTrait);
}Testing Traits
import { describe, it, expect, vi } from 'vitest';
import { createMockContext, createMockNode } from '@holoscript/core/testing';
import { MyTrait } from './MyTrait';
describe('MyTrait', () => {
it('initializes correctly on attach', () => {
const node = createMockNode();
const config = { ...MyTrait.defaultConfig };
const context = createMockContext();
MyTrait.onAttach?.(node, config, context);
expect(node.userData.initialized).toBe(true);
});
it('emits event on update', () => {
const node = createMockNode();
const config = { ...MyTrait.defaultConfig };
const context = createMockContext();
const emitSpy = vi.spyOn(context, 'emit');
MyTrait.onUpdate?.(node, config, context, 0.016);
expect(emitSpy).toHaveBeenCalledWith('trait_updated', expect.any(Object));
});
it('handles events correctly', () => {
const node = createMockNode();
const config = { ...MyTrait.defaultConfig };
const context = createMockContext();
MyTrait.onEvent?.(node, config, context, {
type: 'grab',
payload: {},
});
expect(config.isGrabbed).toBe(true);
});
});Trait Best Practices
1. Minimal onUpdate
// ✅ Good - O(1) operation
onUpdate(node, config, context, delta) {
node.userData.time += delta;
}
// ❌ Bad - allocates every frame
onUpdate(node, config, context, delta) {
const newPos = new Vector3(...); // Avoid allocations!
}2. Use userData for State
// ✅ Good - persists across frames
node.userData.pulsePhase = 0;
// ❌ Bad - local variable lost
let pulsePhase = 0;3. Cleanup in onDetach
onAttach(node, config, context) {
node.userData.interval = setInterval(..., 1000);
},
onDetach(node, config, context) {
clearInterval(node.userData.interval); // Always cleanup!
}4. Emit Events for Cross-Trait Communication
// Trait A emits
context.emit('item_collected', { itemId: node.id, type: config.type });
// Trait B listens via onEvent
onEvent(node, config, context, event) {
if (event.type === 'item_collected') {
updateInventory(event.payload.itemId);
}
}5. TypeScript Types
// ✅ Good - fully typed config
interface GlowConfig {
color: `#${string}`; // Hex color string
intensity: number; // 0.0 - 1.0
pulsate: boolean;
}
// ❌ Bad - any type
defaultConfig: { color: 'blue' as any, intensity: 0.5 }Publishing Custom Traits
1. Package Structure
my-holoscript-traits/
├── src/
│ ├── index.ts # Export all traits
│ ├── HealthBarTrait.ts
│ └── MQTTTrait.ts
├── package.json
│ # "name": "@myorg/holoscript-traits"
│ # "keywords": ["holoscript", "trait"]
└── README.md2. Export Pattern
// src/index.ts
export { HealthBarTrait } from './HealthBarTrait';
export { MQTTTrait } from './MQTTTrait';
// Auto-register on import
import { registerTrait } from '@holoscript/core';
registerTrait(HealthBarTrait);
registerTrait(MQTTTrait);3. HoloScript Trait Registry
Submit your traits to the HoloScript Trait Registry to:
- Make traits discoverable by the community
- Get featured in MCP server suggestions
- Receive compatibility testing
- Access the trait documentation generator
API Reference
Full TypeDoc-generated API reference: API Reference
Key interfaces: TraitHandler<T>, TraitContext, VRTraitSystem, VRTraitRegistry
Run
pnpm docs:apito generate full TypeDoc reference indocs/api/
License: MIT | Version: 3.4.0 | GitHub