This guide covers the complete pipeline for adding new components to the Vibe Coder 3D engine, from initial definition to inspector panel visualization.
The component system uses a scalable component registry that provides:
- BitECS integration for high-performance data storage
- Zod schema validation for type safety and runtime validation
- Automatic serialization/deserialization between formats
- Dependency management and conflict detection
- Inspector panel integration with automatic UI generation
src/core/lib/ecs/components/
├── definitions/
│ ├── TransformComponent.ts # ✅ Core component
│ ├── RigidBodyComponent.ts # ✅ Physics component
│ ├── CameraComponent.ts # ✅ Rendering component
│ ├── YourNewComponent.ts # 🆕 Your component goes here
│ └── index.ts # Export aggregator
├── ComponentDefinitions.ts # Registration logic
└── ...
src/editor/components/
├── inspector/adapters/
│ ├── TransformAdapter.tsx # ✅ Transform UI adapter
│ ├── RigidBodyAdapter.tsx # ✅ Physics UI adapter
│ └── YourNewAdapter.tsx # 🆕 Your UI adapter
├── panels/InspectorPanel/
│ ├── Transform/ # ✅ Transform UI sections
│ ├── RigidBody/ # ✅ Physics UI sections
│ └── YourComponent/ # 🆕 Your UI sections
└── ...
Create a new file src/core/lib/ecs/components/definitions/HealthComponent.ts:
/**
* Health Component Definition
* Handles entity health, damage, and regeneration
*/
import { Types } from 'bitecs';
import { z } from 'zod';
import { ComponentCategory, ComponentFactory } from '../../ComponentRegistry';
import { EntityId } from '../../types';
// 1. Define Zod schema for validation and TypeScript types
const HealthSchema = z.object({
current: z.number().min(0),
maximum: z.number().min(1),
regenerationRate: z.number().min(0),
isInvulnerable: z.boolean(),
lastDamageTime: z.number().optional(),
});
// 2. Create component using ComponentFactory
export const healthComponent = ComponentFactory.create({
id: 'Health', // Unique identifier
name: 'Health', // Display name
category: ComponentCategory.Gameplay, // Category for organization
schema: HealthSchema, // Zod schema for validation
// 3. Define BitECS field mappings (performance-optimized storage)
fields: {
current: Types.f32,
maximum: Types.f32,
regenerationRate: Types.f32,
isInvulnerable: Types.ui8, // Boolean as uint8
lastDamageTime: Types.f32,
},
// 4. Serialization: BitECS → JavaScript object
serialize: (eid: EntityId, component: any) => ({
current: component.current[eid],
maximum: component.maximum[eid],
regenerationRate: component.regenerationRate[eid],
isInvulnerable: Boolean(component.isInvulnerable[eid]),
lastDamageTime: component.lastDamageTime[eid],
}),
// 5. Deserialization: JavaScript object → BitECS
deserialize: (eid: EntityId, data, component: any) => {
component.current[eid] = data.current ?? data.maximum ?? 100;
component.maximum[eid] = data.maximum ?? 100;
component.regenerationRate[eid] = data.regenerationRate ?? 0;
component.isInvulnerable[eid] = data.isInvulnerable ? 1 : 0;
component.lastDamageTime[eid] = data.lastDamageTime ?? 0;
},
// 6. Lifecycle hooks (optional)
onAdd: (eid: EntityId, data) => {
console.log(`Health component added to entity ${eid} with ${data.current}/${data.maximum} HP`);
},
onRemove: (eid: EntityId) => {
console.log(`Health component removed from entity ${eid}`);
},
// 7. Component relationships (optional)
dependencies: [], // Required components
conflicts: [], // Incompatible components
// 8. Metadata (optional)
metadata: {
description: 'Health and damage system for gameplay entities',
version: '1.0.0',
author: 'Your Name',
tags: ['gameplay', 'health', 'damage'],
},
});
// 9. Export TypeScript type
export type HealthData = z.infer<typeof HealthSchema>;Update src/core/lib/ecs/components/definitions/index.ts:
// Add your new component to the exports
export { healthComponent, type HealthData } from './HealthComponent';
// Existing exports...
export { cameraComponent, type CameraData } from './CameraComponent';
export { meshColliderComponent, type MeshColliderData } from './MeshColliderComponent';
export { meshRendererComponent, type MeshRendererData } from './MeshRendererComponent';
export { rigidBodyComponent, type RigidBodyData } from './RigidBodyComponent';
export { transformComponent, type TransformData } from './TransformComponent';Update src/core/lib/ecs/components/ComponentDefinitions.ts:
// Import your new component
import {
healthComponent, // 🆕 Add this
cameraComponent,
meshColliderComponent,
meshRendererComponent,
rigidBodyComponent,
transformComponent,
type HealthData, // 🆕 Add this
type CameraData,
// ... other types
} from './definitions';
export function registerCoreComponents(): void {
componentRegistry.register(transformComponent);
componentRegistry.register(meshRendererComponent);
componentRegistry.register(rigidBodyComponent);
componentRegistry.register(meshColliderComponent);
componentRegistry.register(cameraComponent);
componentRegistry.register(healthComponent); // 🆕 Add this
console.log('Core components registered successfully');
}
// Export the new type
export type { HealthData }; // 🆕 Add thisUpdate src/core/lib/ecs/IComponent.ts:
export enum KnownComponentTypes {
TRANSFORM = 'Transform',
MESH_RENDERER = 'MeshRenderer',
RIGID_BODY = 'RigidBody',
MESH_COLLIDER = 'MeshCollider',
CAMERA = 'Camera',
HEALTH = 'Health', // 🆕 Add this
}Create src/editor/components/panels/InspectorPanel/Health/HealthSection.tsx:
import React from 'react';
import { FiHeart } from 'react-icons/fi';
import { KnownComponentTypes } from '@/core/lib/ecs/IComponent';
import { HealthData } from '@/core/lib/ecs/components/definitions/HealthComponent';
import { ComponentField } from '@/editor/components/shared/ComponentField';
import { FieldGroup } from '@/editor/components/shared/FieldGroup';
import { GenericComponentSection } from '@/editor/components/shared/GenericComponentSection';
import { SingleAxisField } from '@/editor/components/shared/SingleAxisField';
import { ToggleField } from '@/editor/components/shared/ToggleField';
export interface IHealthSectionProps {
healthData: HealthData;
onUpdate: (data: Partial<HealthData>) => void;
onRemove?: () => void;
isPlaying?: boolean;
}
export const HealthSection: React.FC<IHealthSectionProps> = ({
healthData,
onUpdate,
onRemove,
isPlaying = false,
}) => {
const handleRemoveHealth = () => {
onRemove?.();
};
const updateHealth = (updates: Partial<HealthData>) => {
onUpdate({ ...healthData, ...updates });
};
// Calculate health percentage for visual feedback
const healthPercentage = (healthData.current / healthData.maximum) * 100;
const isLowHealth = healthPercentage < 25;
const isCriticalHealth = healthPercentage < 10;
return (
<GenericComponentSection
title="Health"
icon={<FiHeart />}
headerColor={isCriticalHealth ? 'red' : isLowHealth ? 'orange' : 'green'}
componentId={KnownComponentTypes.HEALTH}
onRemove={handleRemoveHealth}
>
{/* Health Status Bar */}
<div className="mb-3">
<div className="flex justify-between items-center mb-1">
<span className="text-xs text-gray-300">Health</span>
<span className="text-xs text-gray-300">
{healthData.current.toFixed(1)} / {healthData.maximum.toFixed(1)}
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${
isCriticalHealth
? 'bg-red-500'
: isLowHealth
? 'bg-orange-500'
: 'bg-green-500'
}`}
style={{ width: `${Math.max(0, healthPercentage)}%` }}
/>
</div>
</div>
<FieldGroup label="Health Values">
<SingleAxisField
label="Current Health"
value={healthData.current}
onChange={(value: number) => updateHealth({ current: Math.max(0, Math.min(value, healthData.maximum)) })}
step={1}
min={0}
max={healthData.maximum}
disabled={isPlaying}
/>
<SingleAxisField
label="Maximum Health"
value={healthData.maximum}
onChange={(value: number) => updateHealth({
maximum: Math.max(1, value),
current: Math.min(healthData.current, Math.max(1, value))
})}
step={1}
min={1}
disabled={isPlaying}
/>
<SingleAxisField
label="Regeneration Rate"
value={healthData.regenerationRate}
onChange={(value: number) => updateHealth({ regenerationRate: Math.max(0, value) })}
step={0.1}
min={0}
placeholder="HP per second"
/>
</FieldGroup>
<FieldGroup label="Status">
<ToggleField
label="Invulnerable"
value={healthData.isInvulnerable}
onChange={(value: boolean) => updateHealth({ isInvulnerable: value })}
resetValue={false}
color="purple"
/>
</FieldGroup>
{/* Debug Information */}
{healthData.lastDamageTime && healthData.lastDamageTime > 0 && (
<div className="mt-2 p-2 bg-gray-800/50 rounded text-xs text-gray-400">
Last damage: {new Date(healthData.lastDamageTime).toLocaleTimeString()}
</div>
)}
</GenericComponentSection>
);
};Create src/editor/components/inspector/adapters/HealthAdapter.tsx:
import React from 'react';
import { KnownComponentTypes } from '@/core/lib/ecs/IComponent';
import { HealthSection } from '@/editor/components/panels/InspectorPanel/Health/HealthSection';
interface IHealthAdapterProps {
healthComponent: any;
updateComponent: (type: string, data: any) => boolean;
removeComponent: (type: string) => boolean;
isPlaying: boolean;
}
export const HealthAdapter: React.FC<IHealthAdapterProps> = ({
healthComponent,
updateComponent,
removeComponent,
isPlaying,
}) => {
const data = healthComponent?.data;
if (!data) return null;
// Convert ECS data to the format expected by HealthSection
const healthData = {
current: data.current ?? 100,
maximum: data.maximum ?? 100,
regenerationRate: data.regenerationRate ?? 0,
isInvulnerable: data.isInvulnerable ?? false,
lastDamageTime: data.lastDamageTime ?? 0,
};
const handleHealthUpdate = (newData: any) => {
if (newData === null) {
// Remove health component
removeComponent(KnownComponentTypes.HEALTH);
} else {
// Update health component
updateComponent(KnownComponentTypes.HEALTH, newData);
}
};
return (
<HealthSection
healthData={healthData}
onUpdate={handleHealthUpdate}
onRemove={() => handleHealthUpdate(null)}
isPlaying={isPlaying}
/>
);
};Update src/editor/hooks/useEntityComponents.ts:
// Add convenience computed value
const hasHealth = useMemo(
() => components.some((c) => c.type === KnownComponentTypes.HEALTH),
[components],
);
// Add getter method
const getHealth = useCallback(() => {
return getComponent(KnownComponentTypes.HEALTH);
}, [getComponent]);
// Add to return object
return {
// ... existing properties
hasHealth, // 🆕 Add this
getHealth, // 🆕 Add this
// ... rest of properties
};Update src/editor/hooks/useInspectorData.ts:
export const useInspectorData = () => {
const selectedEntity = useEditorStore((s) => s.selectedId);
const isPlaying = useEditorStore((s) => s.isPlaying);
const entityComponentsData = useEntityComponents(selectedEntity);
return {
selectedEntity,
isPlaying,
...entityComponentsData, // This now includes hasHealth and getHealth
};
};Update src/editor/components/inspector/sections/ComponentList.tsx:
// Import new adapter
import { HealthAdapter } from '@/editor/components/inspector/adapters/HealthAdapter';
// Add to interface
interface IComponentListProps {
// ... existing props
hasHealth: boolean; // 🆕 Add this
getHealth: () => any; // 🆕 Add this
}
// Add to component props
export const ComponentList: React.FC<IComponentListProps> = ({
// ... existing props
hasHealth, // 🆕 Add this
getHealth, // 🆕 Add this
}) => {
return (
<>
{/* Existing components... */}
{/* Health Component */}
{hasHealth && (
<HealthAdapter
healthComponent={getHealth()}
updateComponent={updateComponent}
removeComponent={removeComponent}
isPlaying={isPlaying}
/>
)}
</>
);
};Update src/editor/components/panels/InspectorPanel/InspectorPanelContent/InspectorPanelContent.tsx:
export const InspectorPanelContent: React.FC = React.memo(() => {
const {
selectedEntity,
isPlaying,
components,
hasTransform,
hasMeshRenderer,
hasRigidBody,
hasMeshCollider,
hasCamera,
hasHealth, // 🆕 Add this
getTransform,
getMeshRenderer,
getRigidBody,
getMeshCollider,
getCamera,
getHealth, // 🆕 Add this
addComponent,
updateComponent,
removeComponent,
} = useInspectorData();
// ... rest of component
return (
<div className="space-y-2 p-2 pb-4">
<ComponentList
selectedEntity={selectedEntity}
isPlaying={isPlaying}
hasTransform={hasTransform}
hasMeshRenderer={hasMeshRenderer}
hasRigidBody={hasRigidBody}
hasMeshCollider={hasMeshCollider}
hasCamera={hasCamera}
hasHealth={hasHealth} // 🆕 Add this
getTransform={getTransform}
getMeshRenderer={getMeshRenderer}
getRigidBody={getRigidBody}
getMeshCollider={getMeshCollider}
getCamera={getCamera}
getHealth={getHealth} // 🆕 Add this
addComponent={addComponent}
updateComponent={updateComponent}
removeComponent={removeComponent}
/>
<DebugSection selectedEntity={selectedEntity} components={components} />
</div>
);
});Update src/editor/components/menus/AddComponentMenu.tsx:
const COMPONENT_DEFINITIONS: IComponentDefinition[] = [
// ... existing components
{
id: KnownComponentTypes.HEALTH,
name: 'Health',
description: 'Health and damage system',
icon: <FiHeart className="w-4 h-4" />,
category: 'Gameplay',
},
];Systems process component data each frame. Create src/core/systems/healthSystem.ts:
import { defineQuery } from 'bitecs';
import { componentRegistry } from '@core/lib/ecs/ComponentRegistry';
import { ECSWorld } from '@core/lib/ecs/World';
import { HealthData } from '@core/lib/ecs/components/definitions/HealthComponent';
// Get world instance
const world = ECSWorld.getInstance().getWorld();
// Lazy-initialize the query
let healthQuery: ReturnType<typeof defineQuery> | null = null;
function getHealthQuery() {
if (!healthQuery) {
const healthComponent = componentRegistry.getBitECSComponent('Health');
if (!healthComponent) {
console.warn('[healthSystem] Health component not yet registered, skipping update');
return null;
}
healthQuery = defineQuery([healthComponent]);
}
return healthQuery;
}
/**
* Health System - Processes health regeneration and status updates
*/
export function healthSystem(deltaTime: number): number {
const query = getHealthQuery();
if (!query) return 0;
const entities = query(world);
let updatedCount = 0;
entities.forEach((eid: number) => {
const healthData = componentRegistry.getComponentData<HealthData>(eid, 'Health');
if (!healthData) return;
// Skip if invulnerable or already at max health
if (healthData.isInvulnerable || healthData.current >= healthData.maximum) {
return;
}
// Apply health regeneration
if (healthData.regenerationRate > 0) {
const newHealth = Math.min(
healthData.maximum,
healthData.current + healthData.regenerationRate * deltaTime,
);
if (newHealth !== healthData.current) {
componentRegistry.updateComponent(eid, 'Health', { current: newHealth });
updatedCount++;
}
}
});
return updatedCount;
}Update src/core/components/EngineLoop.tsx:
// Import your system
import { healthSystem } from '../systems/healthSystem';
// In the useFrame callback, add:
useFrame((state, deltaTime) => {
// ... existing systems
// Run health system
healthSystem(deltaTime);
// ... rest of systems
});IMPORTANT: The guide above creates component data and UI, but to make changes actually reflect in the viewport/game, you need to create systems that read component data and apply it to the rendering/physics/game systems.
Here's how the Camera component integrates with the Three.js scene:
src/core/hooks/useCameraBackground.ts:
import { useEffect, useRef } from 'react';
import { useThree } from '@react-three/fiber';
import { Color } from 'three';
export function useCameraBackground(
clearFlags: string = 'skybox',
backgroundColor?: { r: number; g: number; b: number; a: number },
) {
const { scene } = useThree();
const currentClearFlagsRef = useRef<string | null>(null);
const currentBgColorRef = useRef<string | null>(null);
useEffect(() => {
const bgColorKey = backgroundColor
? `${backgroundColor.r}-${backgroundColor.g}-${backgroundColor.b}-${backgroundColor.a}`
: null;
// Check if anything has changed
const clearFlagsChanged = currentClearFlagsRef.current !== clearFlags;
const bgColorChanged = currentBgColorRef.current !== bgColorKey;
if (!clearFlagsChanged && !bgColorChanged) return;
// Apply the appropriate background based on clear flags
switch (clearFlags) {
case 'solidColor':
if (backgroundColor) {
scene.background = new Color(backgroundColor.r, backgroundColor.g, backgroundColor.b);
} else {
scene.background = new Color(0, 0, 0); // Black fallback
}
break;
case 'skybox':
scene.background = new Color('#87CEEB'); // Sky blue
break;
case 'depthOnly':
case 'dontClear':
scene.background = null;
break;
}
// Update refs
currentClearFlagsRef.current = clearFlags;
currentBgColorRef.current = bgColorKey;
}, [clearFlags, backgroundColor, scene]);
}src/core/components/cameras/CameraBackgroundManager.tsx:
import React, { useEffect, useState } from 'react';
import { defineQuery } from 'bitecs';
import { componentRegistry } from '@core/lib/ecs/ComponentRegistry';
import { ECSWorld } from '@core/lib/ecs/World';
import { useCameraBackground } from '@core/hooks/useCameraBackground';
export const CameraBackgroundManager: React.FC = () => {
const [clearFlags, setClearFlags] = useState<string>('skybox');
const [backgroundColor, setBackgroundColor] = useState<
{ r: number; g: number; b: number; a: number } | undefined
>();
const world = ECSWorld.getInstance().getWorld();
useEffect(() => {
const updateFromMainCamera = () => {
const cameraComponent = componentRegistry.getBitECSComponent('Camera');
if (!cameraComponent) return;
const query = defineQuery([cameraComponent]);
const entities = query(world);
// Find the main camera
let mainCameraEntity: number | null = null;
for (const eid of entities) {
const cameraData = componentRegistry.getComponentData<CameraData>(eid, 'Camera');
console.log(`Entity ${eid} - Camera data:`, cameraData);
if (cameraData?.isMain) {
mainCameraEntity = eid;
console.log(`Found main camera: entity ${eid}`);
break;
}
}
if (mainCameraEntity === null && entities.length > 0) {
mainCameraEntity = entities[0];
console.log('No main camera found, using first camera:', mainCameraEntity);
}
if (mainCameraEntity) {
const cameraData = componentRegistry.getComponentData(mainCameraEntity, 'Camera');
if (cameraData) {
setClearFlags(cameraData.clearFlags);
setBackgroundColor(cameraData.backgroundColor);
}
}
};
// Update immediately and then poll for changes
updateFromMainCamera();
const interval = setInterval(updateFromMainCamera, 100);
return () => clearInterval(interval);
}, [world]);
// Use the hook to actually apply the changes to the scene
useCameraBackground(clearFlags, backgroundColor);
return null; // This component doesn't render anything
};Add the manager to your Canvas in ViewportPanel.tsx:
import { CameraBackgroundManager } from '@/core/components/cameras/CameraBackgroundManager';
// Inside your Canvas:
<Canvas>
{/* Other components */}
<CameraBackgroundManager />
{/* Rest of your scene */}
</Canvas>For any component that affects the 3D scene or game state:
-
Create a Hook (
useComponentName.ts) that:- Takes component data as parameters
- Uses Three.js hooks (
useThree,useFrame) to apply changes - Optimizes with refs to avoid unnecessary updates
-
Create a Manager Component (
ComponentNameManager.tsx) that:- Queries ECS for entities with your component
- Extracts the relevant data
- Passes it to your hook
-
Add Manager to Scene in
ViewportPanel.tsxorEngineLoop.tsx -
Remove Hardcoded Values that might override your component data
This pattern ensures that:
- ✅ UI changes immediately reflect in the viewport
- ✅ Component data drives the actual game/rendering behavior
- ✅ Multiple entities can have the same component type
- ✅ Main/primary entities can be prioritized
- ✅ Performance is optimized with change detection
import { useComponentRegistry } from '@/core/hooks/useComponentRegistry';
function MyGameComponent() {
const { addComponent, getComponentData, updateComponent } = useComponentRegistry();
const handleDamage = (entityId: number, damage: number) => {
const healthData = getComponentData<HealthData>(entityId, 'Health');
if (healthData && !healthData.isInvulnerable) {
const newHealth = Math.max(0, healthData.current - damage);
updateComponent(entityId, 'Health', {
current: newHealth,
lastDamageTime: Date.now()
});
}
};
return (
// Your component JSX
);
}import { useEntityCreation } from '@/editor/hooks/useEntityCreation';
import { useComponentManager } from '@/editor/hooks/useComponentManager';
function CreateEnemyButton() {
const { createCube } = useEntityCreation();
const componentManager = useComponentManager();
const createEnemy = () => {
// Create cube entity (comes with Transform and MeshRenderer)
const enemy = createCube('Enemy');
// Add health component
componentManager.addComponent(enemy.id, 'Health', {
current: 100,
maximum: 100,
regenerationRate: 1,
isInvulnerable: false,
});
// Add physics components
componentManager.addComponent(enemy.id, 'RigidBody', {
enabled: true,
bodyType: 'dynamic',
mass: 1,
gravityScale: 1,
canSleep: true,
material: { friction: 0.7, restitution: 0.3, density: 1 }
});
};
return <button onClick={createEnemy}>Create Enemy</button>;
}// In your game code or tests
import { useComponentRegistry } from '@/core/hooks/useComponentRegistry';
const { addComponent, getComponentData, updateComponent } = useComponentRegistry();
// Add health component to an entity
addComponent(entityId, 'Health', {
current: 80,
maximum: 100,
regenerationRate: 2.5,
isInvulnerable: false,
});
// Read component data
const healthData = getComponentData(entityId, 'Health');
console.log(healthData); // { current: 80, maximum: 100, ... }
// Update component
updateComponent(entityId, 'Health', { current: 50 });- Run the editor:
yarn dev - Create an entity in the scene
- Select the entity in the hierarchy
- Click "Add Component" in the inspector
- Choose your new "Health" component
- Verify the UI renders correctly
- Test field updates and validation
- Single Responsibility: Each component should have one clear purpose
- Data-Oriented: Store only data, not behavior (use systems for logic)
- Immutable Updates: Always create new objects when updating
- Validation: Use Zod schemas for runtime validation
- BitECS Fields: Use appropriate data types (
Types.f32for floats,Types.ui8for booleans) - Serialization: Keep serialize/deserialize functions simple and fast
- Memory: Avoid storing large objects directly in components
- Consistent Styling: Follow existing component UI patterns
- Responsive Fields: Use appropriate input types for different data
- Visual Feedback: Provide immediate feedback for user interactions
- Error Handling: Handle invalid input gracefully
graph TD
A[Component Definition] --> B[Register in System]
B --> C[Add to Entity]
C --> D[Serialize to BitECS]
D --> E[System Processing]
E --> F[UI Updates]
F --> G[User Interaction]
G --> H[Deserialize from BitECS]
H --> I[Component Update]
I --> E
I --> J[Remove Component]
J --> K[Cleanup]
export const myComponent = ComponentFactory.create({
// ... other config
dependencies: ['Transform', 'MeshRenderer'], // Required components
conflicts: ['Camera'], // Incompatible components
});// Listen for component events
useEvent('component:added', (event) => {
if (event.componentId === 'Health') {
console.log(`Health component added to entity ${event.entityId}`);
}
});// Runtime component registration
const dynamicComponent = ComponentFactory.create({
// ... component definition
});
componentRegistry.register(dynamicComponent);After implementing your component:
- Write Tests: Add unit tests for your component logic
- Create Systems: Build systems that process your component data
- Add Documentation: Document your component's purpose and usage
- Performance Profile: Test with many entities to ensure performance
- Share: Consider contributing useful components back to the core
- Core System:
src/core/lib/ecs/ComponentRegistry.ts - Component Definitions:
src/core/lib/ecs/components/definitions/ - Inspector System:
src/editor/components/inspector/ - Example Components:
src/core/lib/ecs/components/ComponentDefinitions.ts
Use this checklist to ensure you haven't missed any steps:
- Created component definition file in
src/core/lib/ecs/components/definitions/ - Defined Zod schema for validation
- Used
ComponentFactory.create()with proper BitECS field mappings - Implemented serialize/deserialize functions
- Added component to
definitions/index.tsexports - Registered component in
ComponentDefinitions.ts - Added component ID to
KnownComponentTypesenum
- Created UI section component in
src/editor/components/panels/InspectorPanel/YourComponent/ - Created adapter component in
src/editor/components/inspector/adapters/ - Added component props to
useEntityComponentshook - Updated
ComponentList.tsxto render your component - Updated inspector panel content to pass component data
- Added component to
AddComponentMenu.tsx
- Created system file in
src/core/systems/ - Integrated system into
EngineLoop.tsx - Tested system performance with many entities
- Component appears in Add Component menu
- Component UI renders correctly in inspector
- Component data updates correctly
- Component validation works (try invalid data)
- Component removal works properly
- No console errors during normal usage
// Problem: Component not registered
console.error('Component Health not found');
// Solution: Check registration in ComponentDefinitions.ts
export function registerCoreComponents(): void {
// ... other components
componentRegistry.register(healthComponent); // Make sure this is present
}// Problem: Missing in ComponentList or useEntityComponents
// Solution: Check these files for your component:
// 1. useEntityComponents.ts - Add computed value
const hasHealth = useMemo(
() => components.some((c) => c.type === KnownComponentTypes.HEALTH),
[components],
);
// 2. ComponentList.tsx - Add rendering
{hasHealth && (
<HealthAdapter
healthComponent={getHealth()}
updateComponent={updateComponent}
removeComponent={removeComponent}
isPlaying={isPlaying}
/>
)}// Problem: Data not persisting correctly
// Solution: Check field mappings match schema
// Schema defines:
const HealthSchema = z.object({
current: z.number(),
maximum: z.number(),
});
// Fields must match:
fields: {
current: Types.f32, // ✅ Matches
maximum: Types.f32, // ✅ Matches
// missing: Types.f32 // ❌ Would cause issues
}// Problem: System running too often
// Solution: Add performance optimizations
export function healthSystem(deltaTime: number): number {
// Use throttling for expensive operations
const now = performance.now();
if (now - lastUpdateTime < 16) return 0; // 60fps max
// Only update entities that need updates
entities.forEach((eid: number) => {
if (shouldSkipEntity(eid)) return;
// ... process entity
});
}// Problem: Type mismatches
// Solution: Ensure consistent types
// Component definition
export type HealthData = z.infer<typeof HealthSchema>;
// Usage in systems/hooks
const healthData = getComponentData<HealthData>(eid, 'Health');
// ^^^^^^^^^^^ Use the exported type// Create compound components that depend on others
export const characterComponent = ComponentFactory.create({
dependencies: ['Transform', 'Health', 'MeshRenderer'],
// ... rest of definition
});The Vibe Coder 3D engine includes a powerful event system that allows components to react to changes efficiently without polling. This is the preferred approach for real-time component synchronization.
import { useEvent } from '@/core/hooks/useEvent';
// Component lifecycle events
useEvent('component:added', (event) => {
console.log(`Component ${event.componentId} added to entity ${event.entityId}`);
console.log('Component data:', event.data);
});
useEvent('component:updated', (event) => {
console.log(`Component ${event.componentId} updated on entity ${event.entityId}`);
console.log('New data:', event.data);
});
useEvent('component:removed', (event) => {
console.log(`Component ${event.componentId} removed from entity ${event.entityId}`);
});Instead of using polling (setInterval), use events for responsive component managers:
export const CameraBackgroundManager: React.FC = () => {
const [clearFlags, setClearFlags] = useState<string>('skybox');
const [backgroundColor, setBackgroundColor] = useState<any>();
const world = ECSWorld.getInstance().getWorld();
const lastUpdateRef = useRef<any>({});
const updateFromMainCamera = useCallback(() => {
try {
const cameraComponent = componentRegistry.getBitECSComponent('Camera');
if (!cameraComponent) return;
const query = defineQuery([cameraComponent]);
const entities = query(world);
// Find main camera
let mainCameraEntity: number | null = null;
for (const eid of entities) {
const cameraData = componentRegistry.getComponentData<CameraData>(eid, 'Camera');
if (cameraData?.isMain) {
mainCameraEntity = eid;
break;
}
}
if (mainCameraEntity !== null) {
const cameraData = componentRegistry.getComponentData<CameraData>(
mainCameraEntity,
'Camera',
);
if (cameraData) {
const newClearFlags = cameraData.clearFlags || 'skybox';
const newBackgroundColor = cameraData.backgroundColor;
// Check for changes to avoid unnecessary updates
const clearFlagsChanged = lastUpdateRef.current.clearFlags !== newClearFlags;
const backgroundColorChanged =
JSON.stringify(lastUpdateRef.current.backgroundColor) !==
JSON.stringify(newBackgroundColor);
if (clearFlagsChanged || backgroundColorChanged) {
console.log('[CameraBackgroundManager] Camera background updated:', {
clearFlags: newClearFlags,
backgroundColor: newBackgroundColor,
});
setClearFlags(newClearFlags);
setBackgroundColor(newBackgroundColor);
// Update ref for next comparison
lastUpdateRef.current = {
clearFlags: newClearFlags,
backgroundColor: newBackgroundColor,
};
}
}
}
} catch (error) {
console.error('[CameraBackgroundManager] Error updating from main camera:', error);
}
}, [world]);
// Initial load
useEffect(() => {
updateFromMainCamera();
}, [updateFromMainCamera]);
// Event-driven updates (NO POLLING!)
useEvent('component:updated', (event) => {
if (event.componentId === 'Camera') {
updateFromMainCamera();
}
});
useEvent('component:added', (event) => {
if (event.componentId === 'Camera') {
updateFromMainCamera();
}
});
useEvent('component:removed', (event) => {
if (event.componentId === 'Camera') {
updateFromMainCamera();
}
});
// Apply changes to Three.js scene
useCameraBackground(clearFlags, backgroundColor);
return null;
};✅ Performance: No wasted CPU cycles on constant polling
✅ Responsiveness: Immediate updates when data changes
✅ Debugging: Clear event logs for tracking data flow
✅ Scalability: Works efficiently with many components
// ✅ DO: Use events for real-time synchronization
useEvent('component:updated', (event) => {
if (event.componentId === 'YourComponent') {
updateFromECS();
}
});
// ❌ DON'T: Use excessive polling
useEffect(() => {
const interval = setInterval(updateFromECS, 100); // Wasteful!
return () => clearInterval(interval);
}, []);
// ✅ DO: Add change detection to prevent unnecessary updates
const updateFromECS = useCallback(() => {
const newData = getComponentData();
// Only update if data actually changed
if (!isEqual(lastDataRef.current, newData)) {
setComponentState(newData);
lastDataRef.current = newData;
}
}, []);
// ✅ DO: Use setTimeout for event handling to ensure proper timing
useEvent('component:updated', (event) => {
if (event.componentId === 'YourComponent') {
// Ensure event processing completes before updating
setTimeout(() => updateFromECS(), 0);
}
});// Listen to multiple component types
const WATCHED_COMPONENTS = ['Transform', 'MeshRenderer', 'Camera'];
useEvent('component:updated', (event) => {
if (WATCHED_COMPONENTS.includes(event.componentId)) {
console.log(`Watched component updated: ${event.componentId}`);
updateRelatedSystems(event);
}
});
// Batch updates for performance
const pendingUpdates = useRef(new Set<string>());
useEvent('component:updated', (event) => {
pendingUpdates.current.add(event.componentId);
// Debounce batch updates
setTimeout(() => {
if (pendingUpdates.current.size > 0) {
processBatchUpdates(Array.from(pendingUpdates.current));
pendingUpdates.current.clear();
}
}, 0);
});// Enable comprehensive event logging for debugging
const DEBUG_COMPONENT_EVENTS = true;
if (DEBUG_COMPONENT_EVENTS) {
useEvent('component:added', (event) => {
console.log(`[EVENT] ➕ Component added:`, {
component: event.componentId,
entity: event.entityId,
data: event.data,
timestamp: Date.now(),
});
});
useEvent('component:updated', (event) => {
console.log(`[EVENT] 🔄 Component updated:`, {
component: event.componentId,
entity: event.entityId,
data: event.data,
timestamp: Date.now(),
});
});
useEvent('component:removed', (event) => {
console.log(`[EVENT] ➖ Component removed:`, {
component: event.componentId,
entity: event.entityId,
timestamp: Date.now(),
});
});
}// Register components at runtime
const dynamicComponent = ComponentFactory.create({
id: 'DynamicComponent',
// ... definition
});
componentRegistry.register(dynamicComponent);- Use appropriate BitECS types (
Types.ui8for booleans, notTypes.f32) - Avoid storing large objects directly in components
- Use entity IDs to reference other entities instead of storing references
- Use queries efficiently (cache them, don't recreate each frame)
- Process only entities that need updates
- Consider using different update frequencies for different systems
- Use
React.memo()for component UI sections - Debounce user input for expensive updates
- Only re-render inspector sections when data actually changes
When creating systems that process component data, follow these proven patterns:
// ❌ WRONG: Only update when target moves
const targetMoved = followState.lastTargetPosition.distanceTo(targetPosition) > 0.001;
const needsUpdate = followState.needsInstantUpdate || targetMoved;
// ✅ CORRECT: Update when camera OR target position changes
const targetMoved = followState.lastTargetPosition.distanceTo(targetPosition) > 0.001;
const cameraNotAtTarget = currentCameraPosition.distanceTo(desiredCameraPosition) > 0.001;
const needsUpdate = followState.needsInstantUpdate || targetMoved || cameraNotAtTarget;Why this matters: Systems need to continue processing until the desired state is reached, not just when inputs change. For camera following, the camera must keep moving toward the target even if the target is stationary.
// System that processes component data and marks entities for rendering updates
export function yourSystem(deltaTime: number): number {
const query = getQuery();
if (!query) return 0;
const entities = query(world);
let updatedCount = 0;
entities.forEach((eid: number) => {
const data = componentRegistry.getComponentData(eid, 'ComponentName');
if (!data || !shouldUpdate(data)) return;
// Process your component logic
const newTransform = calculateNewTransform(data, deltaTime);
// Update the transform component
componentRegistry.updateComponent(eid, 'Transform', newTransform);
// CRITICAL: Mark related systems for update
const relatedComponent = componentRegistry.getBitECSComponent('Camera');
if (relatedComponent?.needsUpdate) {
relatedComponent.needsUpdate[eid] = 1;
}
updatedCount++;
});
return updatedCount;
}// Prevents crashes when components aren't registered yet
let componentQuery: ReturnType<typeof defineQuery> | null = null;
function getComponentQuery() {
if (!componentQuery) {
const component = componentRegistry.getBitECSComponent('ComponentName');
if (!component) return null; // Component not ready yet
componentQuery = defineQuery([component]);
}
return componentQuery;
}
export function systemFunction(deltaTime: number): number {
const query = getComponentQuery();
if (!query) return 0; // Graceful early return
// ... rest of system logic
}// Critical: Systems only run when EngineLoop is present in the scene
// In ViewportPanel.tsx or similar:
<Canvas>
<EngineLoop autoStart={true} debug={false} /> {/* Essential for system execution */}
{/* Other scene components */}
</Canvas>
// In EngineLoop.tsx, add your system:
useFrame((state, deltaTime) => {
// Existing systems...
cameraSystem(deltaTime);
cameraFollowSystem(deltaTime); // Your new system
// Other systems...
});When adding new fields to existing components, follow this pattern:
// 1. Update the Zod schema
const ComponentSchema = z.object({
// ... existing fields
newField: z.string().optional(), // Always make new fields optional for backward compatibility
newComplexField: z.object({
subField1: z.number(),
subField2: z.boolean(),
}).optional(),
});
// 2. Update BitECS fields
fields: {
// ... existing fields
newField: Types.ui8, // Choose appropriate type
newComplexSubField1: Types.f32, // Break down complex objects into primitives
newComplexSubField2: Types.ui8,
},
// 3. Update serialization (BitECS → JS object)
serialize: (eid: EntityId, component: any) => ({
// ... existing fields
newField: component.newField[eid] ?? 'defaultValue',
newComplexField: {
subField1: component.newComplexSubField1[eid] ?? 0,
subField2: Boolean(component.newComplexSubField2[eid] ?? 0),
},
}),
// 4. Update deserialization (JS object → BitECS)
deserialize: (eid: EntityId, data, component: any) => {
// ... existing fields
component.newField[eid] = data.newField ?? 'defaultValue';
component.newComplexSubField1[eid] = data.newComplexField?.subField1 ?? 0;
component.newComplexSubField2[eid] = data.newComplexField?.subField2 ? 1 : 0;
},For components that need to affect external systems (Three.js, physics, audio, etc.), use this manager pattern:
// 1. Create a system hook that applies component data to the external system
export function useSystemIntegration(
componentData: ComponentData,
// Pass specific data, not the entire component
) {
const { externalSystem } = useExternalSystemContext();
const lastDataRef = useRef<ComponentData | null>(null);
useEffect(() => {
// Change detection to avoid unnecessary updates
const hasChanged = !isEqual(lastDataRef.current, componentData);
if (!hasChanged) return;
console.log('[useSystemIntegration] Applying data to external system:', componentData);
// Apply data to external system
externalSystem.updateFromData(componentData);
// Update ref for next comparison
lastDataRef.current = componentData;
}, [componentData, externalSystem]);
}
// 2. Create a manager component that bridges ECS to the system hook
export const SystemIntegrationManager: React.FC = () => {
const [componentData, setComponentData] = useState<ComponentData | null>(null);
const world = ECSWorld.getInstance().getWorld();
const updateFromECS = useCallback(() => {
try {
const component = componentRegistry.getBitECSComponent('YourComponent');
if (!component) {
console.log('[Manager] Component not registered yet');
return;
}
const query = defineQuery([component]);
const entities = query(world);
// Find the target entity (main, active, or first)
const targetEntity = findTargetEntity(entities);
if (!targetEntity) {
setComponentData(null);
return;
}
const data = componentRegistry.getComponentData<ComponentData>(targetEntity, 'YourComponent');
setComponentData(data || null);
} catch (error) {
console.error('[Manager] Error updating from ECS:', error);
}
}, [world]);
// Event-driven updates for real-time synchronization
useEvent('component:updated', (event) => {
if (event.componentId === 'YourComponent') {
setTimeout(() => updateFromECS(), 0);
}
});
useEvent('component:added', (event) => {
if (event.componentId === 'YourComponent') {
setTimeout(() => updateFromECS(), 0);
}
});
useEvent('component:removed', (event) => {
if (event.componentId === 'YourComponent') {
setTimeout(() => updateFromECS(), 0);
}
});
// Initial update and fallback polling
useEffect(() => {
updateFromECS();
const interval = setInterval(updateFromECS, 2000); // Fallback only
return () => clearInterval(interval);
}, [updateFromECS]);
// Apply data to external system
useSystemIntegration(componentData);
return null; // Manager components don't render UI
};
// 3. Add manager to your main scene/viewport
<Canvas>
<SystemIntegrationManager />
{/* Rest of scene */}
</Canvas>For components that need to trigger system updates, use the needsUpdate flag pattern:
// 1. Add needsUpdate field to your component
fields: {
// ... other fields
needsUpdate: Types.ui8, // Flag to mark when component needs processing
},
// 2. Set flag in deserialize when data changes
deserialize: (eid: EntityId, data, component: any) => {
// ... set other fields
component.needsUpdate[eid] = 1; // Mark for update
},
// 3. Add special handling in ComponentRegistry for your component
updateComponent<TData>(entityId: EntityId, componentId: string, data: Partial<TData>): boolean {
// ... existing update logic
// Special handling for components that need immediate system updates
if (componentId === 'YourComponent') {
const bitECSComponent = this.bitECSComponents.get('YourComponent');
if (bitECSComponent?.needsUpdate) {
bitECSComponent.needsUpdate[entityId] = 1;
}
}
// ... rest of update logic
}
// 4. Check and reset flag in your system
export function yourComponentSystem(): number {
const query = getComponentQuery();
if (!query) return 0;
const entities = query(world);
let updatedCount = 0;
entities.forEach((eid: number) => {
const component = componentRegistry.getBitECSComponent('YourComponent');
// Only process entities that need updates
if (!component?.needsUpdate?.[eid]) return;
const data = componentRegistry.getComponentData(eid, 'YourComponent');
if (!data) return;
// Process the component data
processComponentData(eid, data);
updatedCount++;
// Reset the needsUpdate flag
component.needsUpdate[eid] = 0;
});
return updatedCount;
}// Add comprehensive logging to verify system execution
export function yourSystem(deltaTime: number): number {
console.log(`[YourSystem] === CALLED WITH DELTATIME: ${deltaTime} ===`);
const query = getQuery();
if (!query) {
console.warn('[YourSystem] Query not available - component not registered');
return 0;
}
const entities = query(world);
console.log(`[YourSystem] Processing ${entities.length} entities`);
if (entities.length === 0) {
console.log('[YourSystem] No entities found with required components');
return 0;
}
let updatedCount = 0;
entities.forEach((eid: number) => {
console.log(`[YourSystem] Processing entity ${eid}`);
// ... system logic
if (wasUpdated) {
updatedCount++;
console.log(`[YourSystem] Updated entity ${eid}`);
}
});
console.log(`[YourSystem] Updated ${updatedCount} entities`);
return updatedCount;
}// Track the complete update flow from UI to system
// 1. In UI component (e.g., inspector)
const handleUpdate = (newData: ComponentData) => {
console.log('[UI] Updating component with data:', newData);
updateComponent('ComponentName', newData);
};
// 2. In ComponentRegistry.updateComponent
updateComponent<TData>(entityId: EntityId, componentId: string, data: Partial<TData>): boolean {
console.log(`[Registry] Updating ${componentId} on entity ${entityId} with:`, data);
// ... update logic
// Verify the update
const updatedData = this.getComponentData(entityId, componentId);
console.log(`[Registry] Component now has data:`, updatedData);
return success;
}
// 3. In your system
export function yourSystem(deltaTime: number): number {
entities.forEach((eid: number) => {
const data = componentRegistry.getComponentData(eid, 'ComponentName');
console.log(`[System] Processing entity ${eid} with data:`, data);
if (needsUpdate(data)) {
console.log(`[System] Entity ${eid} needs update`);
// ... process update
} else {
console.log(`[System] Entity ${eid} does not need update`);
}
});
}// Problem: Systems not running at all
// Check: Is EngineLoop component present in the scene?
// In ViewportPanel.tsx, verify this exists:
<Canvas>
<EngineLoop autoStart={true} debug={false} />
{/* If this is missing, no systems will run! */}
</Canvas>
// In EngineLoop.tsx, verify your system is called:
useFrame((state, deltaTime) => {
// Log to verify frame loop is running
console.log('[EngineLoop] Frame update, deltaTime:', deltaTime);
// Verify each system is called
const cameraUpdates = cameraSystem(deltaTime);
console.log('[EngineLoop] Camera system updated:', cameraUpdates);
const followUpdates = cameraFollowSystem(deltaTime);
console.log('[EngineLoop] Follow system updated:', followUpdates);
});// Specific pattern for camera/transform systems integration
export function cameraFollowSystem(deltaTime: number): number {
// ... process follow logic
entities.forEach((eid: number) => {
if (needsUpdate) {
// Update transform component
const success = componentRegistry.updateComponent(eid, 'Transform', newTransform);
console.log(`[FollowSystem] Transform update success: ${success}`);
// CRITICAL: Mark camera for rendering update
const cameraComponent = componentRegistry.getBitECSComponent('Camera');
if (cameraComponent?.needsUpdate) {
cameraComponent.needsUpdate[eid] = 1;
console.log(`[FollowSystem] Marked camera ${eid} for rendering update`);
} else {
console.warn(`[FollowSystem] Could not mark camera ${eid} for update`);
}
}
});
}
// In cameraSystem.ts, verify the needsUpdate check:
export function cameraSystem(deltaTime: number): number {
entities.forEach((eid: number) => {
const needsUpdate = cameraComponent.needsUpdate?.[eid];
console.log(`[CameraSystem] Entity ${eid} needsUpdate flag:`, needsUpdate);
if (needsUpdate) {
// Apply updates to Three.js camera
updateThreeJSCamera(eid);
// Reset flag
cameraComponent.needsUpdate[eid] = 0;
}
});
}When component changes don't reflect in your systems, add comprehensive logging:
// 1. In your Manager component
const updateFromECS = useCallback(() => {
try {
console.log('[Manager] === Update Cycle Start ===');
// Log component registration
const component = componentRegistry.getBitECSComponent('YourComponent');
if (!component) {
console.log('[Manager] ❌ Component not registered yet');
return;
}
console.log('[Manager] ✅ Component registered');
// Log entity query
const entities = query(world);
console.log('[Manager] 📊 Found entities:', entities.length);
// Log target entity selection
const targetEntity = findTargetEntity(entities);
if (!targetEntity) {
console.log('[Manager] ❌ No target entity found');
return;
}
console.log('[Manager] 🎯 Target entity:', targetEntity);
// Log raw BitECS data (for debugging data issues)
if (component.someField) {
const rawValue = component.someField[targetEntity];
console.log('[Manager] 🔧 Raw BitECS value:', rawValue);
}
// Log processed data
const data = componentRegistry.getComponentData(targetEntity, 'YourComponent');
console.log('[Manager] 📦 Processed data:', data);
// Log change detection
const hasChanged = !isEqual(lastDataRef.current, data);
console.log('[Manager] 🔄 Data changed:', hasChanged);
if (hasChanged) {
setComponentData(data);
console.log('[Manager] ✅ State updated');
}
console.log('[Manager] === Update Cycle End ===');
} catch (error) {
console.error('[Manager] ❌ Error in update cycle:', error);
}
}, [world]);
// 2. In your system hook
export function useSystemIntegration(data: ComponentData) {
const { externalSystem } = useExternalSystemContext();
useEffect(() => {
console.log('[SystemHook] Hook triggered with data:', data);
if (!data) {
console.log('[SystemHook] No data, skipping');
return;
}
// Log what's being applied
console.log('[SystemHook] Applying to external system:', {
field1: data.field1,
field2: data.field2,
});
try {
externalSystem.update(data);
console.log('[SystemHook] ✅ Successfully applied to external system');
} catch (error) {
console.error('[SystemHook] ❌ Failed to apply to external system:', error);
}
}, [data, externalSystem]);
}| Issue | Symptoms | Solution |
|---|---|---|
| Manager not finding entities | "No entities found" in logs | Check component registration timing, ensure component is added to entities |
| Data not updating | Old values persist in system | Check event handlers, ensure component:updated events are firing |
| External system not responding | Component data changes but no visual/system effect | Add invalidation calls, check external system API usage |
| Performance issues | Excessive logging, frame drops | Add change detection, reduce polling frequency, optimize queries |
| Event timing issues | Inconsistent updates, race conditions | Use setTimeout(0) for event handling, ensure proper async patterns |
| System not executing | No system logs, no entity processing | Check EngineLoop is present in Canvas, verify system is called in useFrame |
| needsUpdate flag not set | System processes but no visual changes | Ensure system sets needsUpdate[eid] = 1 on related components |
| Smooth following not working | Camera snaps or doesn't move continuously | Check update condition includes camera distance from target, not just target movement |
// Pattern: Find main/active entity
function findMainEntity(entities: number[]): number | null {
for (const eid of entities) {
const data = componentRegistry.getComponentData(eid, 'ComponentName');
if (data?.isMain || data?.isActive) {
return eid;
}
}
return entities.length > 0 ? entities[0] : null;
}
// Pattern: Find entity by priority
function findPriorityEntity(entities: number[]): number | null {
let highestPriority = -1;
let targetEntity: number | null = null;
for (const eid of entities) {
const data = componentRegistry.getComponentData(eid, 'ComponentName');
const priority = data?.priority ?? 0;
if (priority > highestPriority) {
highestPriority = priority;
targetEntity = eid;
}
}
return targetEntity;
}
// Pattern: Find entity by selection state
function findSelectedEntity(entities: number[], selectedEntityId?: number): number | null {
if (selectedEntityId && entities.includes(selectedEntityId)) {
return selectedEntityId;
}
return entities.length > 0 ? entities[0] : null;
}Based on real implementation experience, here's the complete pattern for camera follow systems:
// 1. Camera Follow System Structure
export function cameraFollowSystem(deltaTime: number): number {
const cameraQuery = getCameraQuery();
const transformQuery = getTransformQuery();
if (!cameraQuery || !transformQuery) {
return 0; // Components not ready
}
const cameraEntities = cameraQuery(world);
let updatedCount = 0;
cameraEntities.forEach((cameraEntityId: EntityId) => {
const cameraData = componentRegistry.getComponentData(cameraEntityId, 'Camera');
const cameraTransform = componentRegistry.getComponentData(cameraEntityId, 'Transform');
// Skip if follow not enabled
if (!cameraData?.enableSmoothing || !cameraData.followTarget) {
return;
}
// Get target transform
const targetTransform = componentRegistry.getComponentData(
cameraData.followTarget,
'Transform',
);
if (!targetTransform) return;
// Calculate positions
const targetPosition = new THREE.Vector3(...(targetTransform.position || [0, 0, 0]));
const currentCameraPos = new THREE.Vector3(...(cameraTransform.position || [0, 0, 0]));
const followOffset = cameraData.followOffset || { x: 0, y: 5, z: -10 };
const desiredCameraPos = targetPosition
.clone()
.add(new THREE.Vector3(followOffset.x, followOffset.y, followOffset.z));
// CRITICAL: Update condition must check camera distance, not just target movement
const cameraNotAtTarget = currentCameraPos.distanceTo(desiredCameraPos) > 0.001;
const needsInstantUpdate = checkInstantUpdateCondition(cameraEntityId);
if (needsInstantUpdate || cameraNotAtTarget) {
// Calculate new position (instant or lerped)
const newPosition = needsInstantUpdate
? desiredCameraPos
: currentCameraPos.lerp(desiredCameraPos, (cameraData.smoothingSpeed || 2.0) * deltaTime);
// Update transform component
componentRegistry.updateComponent(cameraEntityId, 'Transform', {
position: [newPosition.x, newPosition.y, newPosition.z],
// Optional: Add lookAt rotation
});
// CRITICAL: Mark camera for rendering update
const cameraComponent = componentRegistry.getBitECSComponent('Camera');
if (cameraComponent?.needsUpdate) {
cameraComponent.needsUpdate[cameraEntityId] = 1;
}
updatedCount++;
}
});
return updatedCount;
}// 1. Unit test your component data transformations
describe('ComponentDataFlow', () => {
it('should serialize and deserialize correctly', () => {
const testData = { field1: 'value', field2: 123 };
const eid = 1;
// Simulate deserialize
component.deserialize(eid, testData);
// Simulate serialize
const result = component.serialize(eid);
expect(result).toEqual(testData);
});
});
// 2. Integration test your manager
describe('SystemIntegrationManager', () => {
it('should update external system when component data changes', async () => {
const mockExternalSystem = jest.fn();
// Add component data
componentRegistry.addComponent(1, 'TestComponent', testData);
// Wait for manager to process
await waitForNextTick();
expect(mockExternalSystem).toHaveBeenCalledWith(testData);
});
});
// 3. Manual testing checklist
/*
□ Component appears in Add Component menu
□ Component data shows correctly in inspector
□ Inspector changes trigger component:updated events
□ Manager receives and processes events
□ External system reflects component changes
□ Performance is acceptable with many entities
*/Happy Component Building! 🎉
This guide covers the complete end-to-end pipeline from component definition to inspector visualization, including systems integration and practical usage patterns. The modular architecture makes it easy to add new components while maintaining type safety and performance.
// Problem: Main camera detection logic error
// Original buggy code:
let mainCameraEntity = entities.find((eid) => {
const cameraData = componentRegistry.getComponentData<CameraData>(eid, 'Camera');
const isMain = cameraData?.isMain;
console.log(`Entity ${eid} isMain:`, isMain); // Shows true
return isMain;
});
if (!mainCameraEntity && entities.length > 0) {
mainCameraEntity = entities[0];
console.log('No main camera found, using first camera:', mainCameraEntity);
// This runs even when isMain is true! Logic bug!
}
// Solution: Proper null checking and explicit iteration
let mainCameraEntity: number | null = null;
for (const eid of entities) {
const cameraData = componentRegistry.getComponentData<CameraData>(eid, 'Camera');
console.log(`Entity ${eid} - Camera data:`, cameraData);
if (cameraData?.isMain) {
mainCameraEntity = eid;
console.log(`Found main camera: entity ${eid}`);
break;
}
}
if (mainCameraEntity === null && entities.length > 0) {
mainCameraEntity = entities[0];
console.log('No main camera found, using first camera:', mainCameraEntity);
}// Problem: needsUpdate flag not set for camera components
// Solution: Add special handling in ComponentRegistry.ts
updateComponent<TData>(entityId: EntityId, componentId: string, data: Partial<TData>): boolean {
// ... existing code ...
// Special handling for camera components - ensure needsUpdate flag is set
if (componentId === 'Camera') {
const bitECSCamera = this.bitECSComponents.get('Camera');
if (bitECSCamera?.needsUpdate) {
bitECSCamera.needsUpdate[entityId] = 1;
}
}
// ... rest of code ...
}// Problem: Insufficient logging makes debugging impossible
// Solution: Add comprehensive logging at each step
const updateFromMainCamera = useCallback(() => {
try {
// Log component registration
const cameraComponent = componentRegistry.getBitECSComponent('Camera');
if (!cameraComponent) {
console.log('[CameraBackgroundManager] Camera component not registered yet');
return;
}
// Log entity query results
const entities = query(world);
console.log('[CameraBackgroundManager] Found camera entities:', entities.length);
// Log raw BitECS data for debugging
if (cameraComponent) {
const rawClearFlags = cameraComponent.clearFlags?.[mainCameraEntity];
const rawBgR = cameraComponent.backgroundR?.[mainCameraEntity];
console.log('[CameraBackgroundManager] Raw BitECS data:', {
clearFlags: rawClearFlags,
backgroundR: rawBgR,
// ... other fields
});
}
// Log processed data
const cameraData = componentRegistry.getComponentData<CameraData>(mainCameraEntity, 'Camera');
console.log('[CameraBackgroundManager] Retrieved camera data:', cameraData);
// Log change detection
console.log('[CameraBackgroundManager] Change detection:', {
clearFlagsChanged,
backgroundColorChanged,
currentClearFlags: lastUpdateRef.current.clearFlags,
newClearFlags,
});
} catch (error) {
console.error('[CameraBackgroundManager] Error updating from main camera:', error);
}
}, [world]);// Problem: Three.js scene not updating when background changes
// Solution: Proper scene invalidation in useCameraBackground
export function useCameraBackground(clearFlags: string, backgroundColor?: RGBA) {
const { scene, invalidate } = useThree();
useEffect(() => {
// Log hook calls for debugging
console.log('[useCameraBackground] Hook called with params:', { clearFlags, backgroundColor });
// Apply background changes
switch (clearFlags) {
case 'solidColor':
if (backgroundColor) {
const appliedColor = new Color(backgroundColor.r, backgroundColor.g, backgroundColor.b);
scene.background = appliedColor;
console.log('[useCameraBackground] Applied solid color:', appliedColor.getHexString());
}
break;
// ... other cases
}
// Critical: Invalidate frame to trigger re-render
invalidate();
console.log('[useCameraBackground] Frame invalidated');
}, [clearFlags, backgroundColor, scene, invalidate]);
}// Problem: Component events fire but data doesn't flow properly
// Solution: Immediate event handling with proper timing
useEvent('component:updated', (event) => {
if (event.componentId === 'Camera') {
console.log('[CameraBackgroundManager] Camera component updated for entity:', event.entityId);
// Use setTimeout to ensure event processing completes first
setTimeout(() => updateFromMainCamera(), 0);
}
});// Essential pattern for system integration managers:
export const SystemIntegrationManager: React.FC = () => {
const [systemState, setSystemState] = useState(defaultState);
const lastUpdateRef = useRef(defaultState);
const updateFromECS = useCallback(() => {
try {
// 1. Check component registration
const component = componentRegistry.getBitECSComponent('ComponentName');
if (!component) {
console.log('[Manager] Component not registered yet');
return;
}
// 2. Query entities
const entities = query(world);
console.log('[Manager] Found entities:', entities.length);
// 3. Process main/priority entity
const targetEntity = findTargetEntity(entities);
if (!targetEntity) {
console.log('[Manager] No target entity found');
return;
}
// 4. Get processed data
const data = componentRegistry.getComponentData(targetEntity, 'ComponentName');
console.log('[Manager] Retrieved data:', data);
// 5. Change detection
const hasChanges = detectChanges(data, lastUpdateRef.current);
if (!hasChanges) {
console.log('[Manager] No changes detected');
return;
}
// 6. Update state
setSystemState(data);
lastUpdateRef.current = data;
console.log('[Manager] State updated');
} catch (error) {
console.error('[Manager] Error:', error);
}
}, [world]);
// Event-driven updates
useEvent('component:updated', (event) => {
if (event.componentId === 'ComponentName') {
setTimeout(() => updateFromECS(), 0);
}
});
// Hook integration
useSystemHook(systemState);
return null;
};