A guide for AI coding agents working on the Teskooano N-Body simulation engine.
Teskooano is a 3D N-Body simulation engine that accurately simulates real physics and provides a multi-view experience in real time. It features collision detection, realistic orbital mechanics, and procedural generation to create unique star systems.
- Modular Monorepo: Managed with
moonandprotofor streamlined development - Plugin-Based UI: DockView for modular, dockable panels with custom plugin system
- Reactive State: Centralized RxJS-based state management
- 3D Rendering: Three.js with custom renderer packages and GLSL shaders
- Physics Engine: N-Body simulation with Verlet integrator and orbital mechanics
- Install moon and proto for task running and dependency management
- Node.js 24.2.0 (specified in package.json engines)
# Clone and setup
git clone https://github.com/tanepiper/teskooano.git
cd teskooano
# Install dependencies and start the main app
proto use
moon run teskooano:devThe application will be available at http://localhost:3000.
- Start dev server:
moon run teskooano:dev - Build:
moon run teskooano:build - Run tests:
moon run :test(runs all package tests) - Run specific package tests:
moon run <package-name>:test - Format code:
npm run format(prettier)
- Strict Mode: Always use strict TypeScript configuration
- Type Safety: Prefer explicit types over inference when clarity is needed
- Interfaces: Define dedicated TypeScript interfaces for constructor options instead of inline object types
- JSDoc: Include documentation but omit explicit type annotations (types are in TypeScript code)
- Indentation: Use 2-space indentation
- Naming:
PascalCasefor classes, interfaces, and typescamelCasefor variables, properties, and functionsUPPER_CASEfor constants
- File Size: Target max 300-400 lines per file
- Modularity: Prefer small, composable files with single responsibility
- Static Imports: Use ES import statements at the top of files exclusively
- Never use:
require()or dynamicimport() - Path Aliases: Use
@teskooano/*aliases defined in tsconfig.json
- Purpose: Application-agnostic business logic and data structures
- Dependencies: No UI-specific dependencies (no DockView, ThreeJS, etc.)
- Examples:
core-math,core-physics,core-state,core-debug
- Purpose: Three.js rendering modules with specific responsibilities
- Pattern: Compositional architecture with LOD management
- Examples:
renderer-threejs-core,renderer-threejs-celestial,renderer-threejs-orbits
- Purpose: Domain-specific logic for celestial systems
- Examples:
systems-procedural-generation,systems-solar-system
- Purpose: Application-specific functionality
- Examples:
app-simulation,app-ui-plugin,design-system
All UI plugins follow a strict Model-View-Controller (MVC) pattern:
plugin-name/
├── controller/
│ └── PluginName.controller.ts # Business logic
├── view/
│ ├── PluginName.view.ts # Custom element (dumb view)
│ └── PluginName.template.ts # HTML/CSS template
├── services/ # Reusable business logic
├── index.ts # Plugin registration
└── README.md # Architecture documentation
- Custom Elements: Define as
ComponentConfigobjects in plugin definition - No Manual Registration: Don't call
customElements.define()manually - Plugin Manager: Handles registration automatically during plugin loading
Plugins should follow a consistent directory structure based on component complexity:
Flat Structure (Most Common)
plugin-name/
├── controller/
│ └── PluginName.controller.ts
├── view/
│ ├── PluginName.view.ts
│ └── PluginName.template.ts
├── services/ # Optional
├── index.ts
└── README.md
Nested Structure (Complex Plugins)
plugin-name/
├── controller/
├── view/
├── components/ # For reusable sub-components
│ ├── component-name/
│ │ ├── ComponentName.ts
│ │ └── ComponentName.template.ts
│ └── another-component/
├── services/
├── index.ts
└── README.md
When to Use components/ Subdirectory:
- Component is reusable across multiple plugins or views
- Component has complex internal structure (own controller/view/template pattern)
- Component is not a standalone plugin (doesn't register with plugin manager)
- Example:
celestial-rowcomponent incelestial-hierarchyplugin,plugin-detail-cardinplugin-manager
When to Use Flat Structure:
- Single-purpose plugin with straightforward controller/view
- No reusable sub-components needed
- Simpler to navigate and maintain
- Example:
notifications,celestial-info,engine-settings
- View: "Dumb" custom elements that only manage DOM and delegate to controllers
- Controller: Contains all business logic, subscribes to state, handles events
- Services: Reusable, injectable classes for complex business logic
- Dependency Injection: Pass dependencies through constructors, not global state
All controllers and managers MUST use StateSubscriptionMixin from @teskooano/core-state for subscription management:
import { StateSubscriptionMixin } from "@teskooano/core-state";
export class MyController extends StateSubscriptionMixin {
constructor() {
super(); // Required for mixin initialization
}
public init(): void {
// ✅ Use subscribeToState for all RxJS subscriptions
this.subscribeToState(someObservable$, (value) => {
// Handle value
});
}
public dispose(): void {
// ✅ Automatic cleanup via StateSubscriptionMixin
super.dispose();
}
}Anti-patterns to avoid:
- ❌ Never use
private subscriptions = new Subscription() - ❌ Never manually call
.subscribe()and track subscriptions - ❌ Views should NOT manage subscriptions - delegate to controllers
Choose the appropriate event pattern based on the communication scope:
1. RxJS Streams (Internal Component Logic)
- Use for: Complex reactive logic within a controller or service
- Pattern: Operators like
combineLatest,merge,switchMap,debounceTime - Example: Combining multiple state streams, form validation, complex user interactions
const displayState$ = combineLatest([
celestialObjects$,
currentSeed$,
isGenerating$$,
]).pipe(
debounceTime(0),
tap(([objects, seed, isGenerating]) => {
this.updateDisplay(objects, seed, isGenerating);
}),
);
this.subscribeToState(displayState$, () => {});2. Custom DOM Events (Cross-System Communication)
- Use for: Communication between different systems/packages, plugin→core, or UI→renderer
- Pattern:
document.dispatchEvent(new CustomEvent(...)) - Example: Renderer events, state change notifications, system-level actions
// Dispatch
document.dispatchEvent(
new CustomEvent("renderer-focus-changed", {
detail: { objectId: "sun-001" },
}),
);
// Listen
document.addEventListener("renderer-focus-changed", (event) => {
const { objectId } = event.detail;
this.handleFocusChange(objectId);
});3. Direct Event Listeners (DOM-Native Events)
- Use for: ONLY native DOM events like clicks, inputs, resize, keyboard
- Pattern:
fromEvent(element, 'click')orelement.addEventListener() - Example: Button clicks, input changes, window resize
// RxJS approach (preferred for reactive pipelines)
const click$ = fromEvent(button, "click").pipe(
debounceTime(300),
map(() => this.getValue()),
);
// Direct listener (for simple cases)
button.addEventListener("click", () => this.handleClick());Decision Matrix:
| Scope | Pattern | When to Use |
|---|---|---|
| Within component | RxJS Streams | Complex reactive logic, multiple sources |
| Across systems | Custom DOM Events | Plugin communication, renderer events |
| DOM interaction | Direct Listeners | Simple button clicks, native events |
- File Convention: Test files (
<filename>.spec.ts) adjacent to source files - Unit Tests: Use Vitest for both backend and frontend
- Integration Tests: Use Playwright for complex UI features
- Test Data: Use fixed random values for deterministic tests
- All tests:
moon run :test - Specific package:
moon run <package-name>:test - Interactive mode: Tests run in interactive mode by default
- Browser tests: Use
@vitest/browserfor UI component testing
- MVC Testing: Test controllers independently of views
- State Testing: Test state management with RxJS operators
- Renderer Testing: Test renderer logic without ThreeJS context
- Plugin Testing: Test plugin registration and lifecycle
- Right-Handed: Use right-handed coordinate system for 3D axes
- Scene Units: 1000 scene units = 1 AU of distance (RENDER_SCALE_AU = 1000)
- Scaling: Use
AU_METERSconstant andMETERS_TO_SCENE_UNITSfor conversions - Vector Math: Use
OSVector3for physics, convert toTHREE.Vector3for rendering - Logarithmic Depth: Far plane at 200 AU (30 billion scene units) with NEAR=0.00001
- Compositional Pattern: Complex objects (planets with rings) use composition
- LOD Management: Level of Detail for performance optimization
- Shader Management: GLSL shaders in external files, not embedded strings
- Material Patterns: Separate material logic into
material.tsfiles
- Reactive Patterns: Use RxJS for data flow and state synchronization
- Centralized State: All state managed through
@teskooano/core-state - Unidirectional Flow: State flows down, events flow up
- No Direct DOM Manipulation: Use reactive patterns instead of manual DOM updates
- Create branch:
git checkout -b feature/your-feature-name - Follow patterns: Use established MVC patterns for UI components
- Write tests: Follow TDD approach with Vitest
- Run tests:
moon run :testbefore committing - Commit: Use conventional commits format
- PR: Create pull request with clear description
- Respect boundaries: Don't create tight dependencies between packages
- Use file: dependencies: Inter-package references use
file:paths - Build dependencies: Use
^:buildfor build task dependencies - Documentation: Each package needs README.md and ARCHITECTURE.md
- Dependency Injection: Pass dependencies through constructors
- Factory Functions: Use factories for complex object creation
- Composition over Inheritance: Prefer composition
- Immutable Data: Use immutable data structures where possible
- Reactive Programming: Use RxJS for data flow
- Global State: Avoid global variables and singletons
- Tight Coupling: Don't create tight dependencies between packages
- Premature Optimization: Don't optimize before measuring
- Magic Numbers: Use named constants instead of magic numbers
- Deep Nesting: Avoid deeply nested conditionals and loops
- Draw Call Reduction: Use instancing and batching
- LOD Management: Implement proper Level of Detail systems
- Memory Efficiency: Reuse objects and minimize allocations
- Caching: Cache expensive calculations and results
- Object Reuse: Pre-allocate vectors and matrices
- Garbage Collection: Minimize object creation in hot paths
- Algorithm Efficiency: Use appropriate data structures (Octrees, spatial hashing)
Teskooano uses a comprehensive event-driven architecture with three types of events for different communication patterns:
- RxJS Events - Type-safe observables for internal renderer communication
- DOM Events - Custom events for cross-system communication
- Pipeline Events - Stage-specific events for render pipeline coordination
Core State (DOM Events) → EventBridge → Renderer (RxJS Events) → Components
↓
Custom DOM Events ← UI Components
- Purpose: Bridge DOM events to RxJS for system and celestial operations
- Components:
SystemEventBridge– system-wide events (e.g., objects loaded/destroyed)CelestialEventBridge– celestial/UI events (e.g., clear trails/predictions)
- Usage: Initialized by
ModularSpaceRendererat startup
destruction$: Emits when celestial objects are destroyed- Purpose: Trigger visual effects like explosions or particle systems
- 10 stage-specific events: beforeUpdate, afterControlsUpdate, afterOrbitsUpdate, afterObjectsUpdate, afterBackgroundUpdate, afterGridUpdate, beforeRender, afterRender, afterOverlaysRender, afterUpdate
- Purpose: Allow components to react to specific rendering stages
- Payload:
{ deltaTime, elapsedTime, frameCount }
- DOM Events:
CELESTIAL_OBJECT_DESTROYED,CELESTIAL_OBJECTS_LOADED - Purpose: Cross-system communication for state changes
- Usage: Dispatched by state management functions and bridged to RxJS via
SystemEventBridge/CelestialEventBridge
// Subscribe to destruction events
import { rendererEvents } from "@teskooano/renderer-threejs";
rendererEvents.destruction$.subscribe((payload) => {
console.log(`Object ${payload.object.id} was destroyed`);
this.createExplosionEffect(payload.object.position);
});
// Subscribe to pipeline events
import { renderPipelineEvents } from "@teskooano/renderer-threejs";
renderPipelineEvents.afterObjectsUpdate$.subscribe((payload) => {
console.log(`Objects updated at frame ${payload.frameCount}`);
this.updateObjectUI();
});
// Dispatch custom DOM events
import { CustomEvents } from "@teskooano/data-types";
document.dispatchEvent(new CustomEvent("teskooano-clear-orbit-trails"));- Use StateSubscriptionMixin: Automatic subscription cleanup
- Validate payloads: Always check event data validity
- Throttle expensive operations: Use RxJS operators for performance
- Handle errors: Implement proper error handling in subscriptions
- Event documentation: See
packages/core/state/src/services/EVENT_SYSTEM.mdfor comprehensive documentation
This section documents the 10 core patterns that ensure consistency across the codebase. All new code MUST follow these patterns.
Rule: All controllers and managers MUST extend StateSubscriptionMixin from @teskooano/core-state.
import { StateSubscriptionMixin } from "@teskooano/core-state";
export class MyController extends StateSubscriptionMixin {
constructor() {
super(); // Required for mixin initialization
}
public init(): void {
// ✅ Use subscribeToState for all RxJS subscriptions
this.subscribeToState(someObservable$, (value) => {
// Handle value
});
}
public dispose(): void {
// ✅ Automatic cleanup via StateSubscriptionMixin
super.dispose();
// Manual cleanup (event listeners, etc.)
}
}Anti-patterns to avoid:
- ❌
private subscriptions = new Subscription() - ❌ Manually calling
.subscribe()and tracking subscriptions - ❌ Views managing subscriptions (delegate to controllers)
Rule: Use createFilteredStream$ and createFilteredMap from StoreFilters.ts for state filtering.
import {
createFilteredStream$,
createFilteredMap,
} from "@teskooano/core-state";
import { isActive, isDestroyed, isVisible } from "@teskooano/core-state";
// Filter to active, visible objects
const activeVisible$ = createFilteredStream$(
celestialStore.objects$,
(obj) => isActive(obj) && isVisible(obj),
);
// Map with composed predicates
const activeRenderables = createFilteredMap(
renderableStore.renderableObjects$,
isActive,
);Benefits: 60% reduction in code duplication, composable predicates, consistent filtering logic.
Rule: Use helpers from StreamCompositionHelpers.ts for state composition and performance optimization.
import {
withCelestialState,
withRenderableState,
atFrameRate,
} from "@teskooano/core-state";
// Combine with full state
const userAction$ = fromEvent(button, "click").pipe(
withCelestialState(),
map(({ value, celestials, renderables }) => {
return processAction(value, celestials, renderables);
}),
);
// Throttle to ~60fps
const mouseMove$ = fromEvent(document, "mousemove").pipe(
atFrameRate(),
map((event) => updateUIExpensively(event)),
);Available Helpers:
withCelestialState()- Combines with celestial + renderable statewithRenderableState()- Lighter-weight, renderable-onlyatFrameRate()- Throttles to ~60fpsatCustomFrameRate(fps)- Custom throttling
Rule: Subscribe to renderPipelineEvents for stage-specific rendering logic.
import { renderPipelineEvents } from "@teskooano/renderer-threejs";
// React to specific pipeline stages
renderPipelineEvents.afterObjectsUpdate$.subscribe((payload) => {
const { deltaTime, elapsedTime, frameCount } = payload;
this.updateObjectUI();
});
// Available events (all emit RenderPipelineStagePayload):
// - beforeUpdate$, afterControlsUpdate$, afterOrbitsUpdate$
// - afterObjectsUpdate$, afterBackgroundUpdate$, afterGridUpdate$
// - beforeRender$, afterRender$, afterOverlaysRender$, afterUpdate$Rule: Use factory functions from plugin-factory.ts to define plugins.
import { createPanelPlugin } from "@teskooano/app-ui-plugin";
export const plugin = createPanelPlugin({
id: "my-plugin",
name: "My Plugin",
description: "Description",
componentName: "my-plugin-panel",
panelClass: MyPluginPanel,
defaultTitle: "My Plugin",
iconSvg: "<svg>...</svg>",
order: 100,
target: "panel-toolbar",
});Available Factories:
createPanelPlugin- Panel-based plugins (most common)createComponentPlugin- Component-only pluginscreateControllerPlugin- Service pluginscreateInterfacePlugin- Function plugins with toolbarcreateFunctionPlugin- Lightweight function-onlycreateWidgetPlugin- Toolbar widget plugins
Rule: Use EventBridge classes for DOM→RxJS event bridging.
// System-level events
import { SystemEventBridge } from "@teskooano/core-state";
SystemEventBridge.getInstance().init();
// Celestial-specific events
import { CelestialEventBridge } from "@teskooano/core-state";
CelestialEventBridge.getInstance().init();
// Event flow: Core State (DOM) → EventBridge → Renderer (RxJS) → ComponentsRule: Always use CustomEvents constants for custom DOM events.
import { CustomEvents } from "@teskooano/data-types";
// ✅ Correct
document.dispatchEvent(
new CustomEvent(CustomEvents.CELESTIAL_OBJECT_DESTROYED, {
detail: { objectId },
}),
);
// ❌ Incorrect - string literals create typo risk
document.dispatchEvent(
new CustomEvent("celestial-object-destroyed", { detail: { objectId } }),
);Rule: Follow flat structure by default, nested only for reusable sub-components.
Flat Structure (Most Common):
plugin-name/
├── controller/
│ └── PluginName.controller.ts
├── view/
│ ├── PluginName.view.ts
│ └── PluginName.template.ts
├── services/ # Optional
├── index.ts
└── README.md
Nested Structure (Complex Plugins):
plugin-name/
├── controller/
├── view/
├── components/ # For reusable sub-components
│ ├── component-name/
│ │ ├── ComponentName.ts
│ │ └── ComponentName.template.ts
├── services/
├── index.ts
└── README.md
Rule: Choose the appropriate event pattern based on communication scope.
| Scope | Pattern | When to Use |
|---|---|---|
| Within component | RxJS Streams | Complex reactive logic, multiple sources |
| Across systems | Custom DOM Events | Plugin communication, renderer events |
| DOM interaction | Direct Listeners | Simple button clicks, native events |
Examples:
// Internal component logic - RxJS
const displayState$ = combineLatest([celestials$, renderables$]).pipe(
debounceTime(0),
tap(([celestials, renderables]) => this.updateDisplay()),
);
// Cross-system communication - Custom DOM Events
document.dispatchEvent(
new CustomEvent("renderer-focus-changed", {
detail: { objectId: "sun-001" },
}),
);
// Native DOM events - Direct listeners
button.addEventListener("click", () => this.handleClick());Rule: Use pre-allocated buffers and batch processing for performance-critical operations.
// Pre-allocate buffer pools
const trailDataPool = new TrailDataPool(100, 10000); // 100 objects × 10k points
// Batch processing with intervals
const BATCH_INTERVAL = 100; // Process every 100ms
// Distance filtering to skip redundant points
const MIN_DISTANCE_SQ_TO_ADD = 1e-10;
if (dx * dx + dy * dy + dz * dz < MIN_DISTANCE_SQ_TO_ADD) {
continue; // Skip duplicate point
}Benefits: Reduces GC pressure, improves frame rate stability, efficient memory usage.
- README.md: What, Why, Where, When, How for each package
- ARCHITECTURE.md: Detailed technical architecture with Mermaid diagrams
- CHANGELOG.md: Follow "Keep a Changelog" format
- JSDoc: Include functionality descriptions but omit type annotations
- Architecture Comments: Explain complex algorithms and design decisions
- Performance Notes: Document performance-critical sections
- Circular Dependencies: Use
@teskooano/data-typesfor shared types - Plugin Loading: Ensure plugin exports named
pluginconstant - State Synchronization: Use reactive patterns, not manual DOM updates
- Renderer Issues: Check LOD distances and material updates
- Core Debug: Use
@teskooano/core-debugfor debugging utilities - Plugin Manager: Use plugin-manager panel to inspect loaded plugins
- State Inspection: Use browser dev tools to inspect RxJS streams
- No Hardcoded Secrets: Use environment variables for sensitive data
- Input Validation: Validate all user inputs and external data
- XSS Prevention: Use proper DOM sanitization for dynamic content
- CSP Compliance: Follow Content Security Policy guidelines
- Use moon commands:
moon run <package>:<task>for specific operations - Path aliases: Use
@teskooano/*aliases for clean imports - Package boundaries: Respect package boundaries and dependencies
- Build order: Dependencies are handled automatically by moon
- Hot reloading: Development server supports HMR for plugins