This guide covers the internal architecture, coding patterns, and development workflows for the Party Playground React application. It's designed to help new developers understand the codebase and contribute effectively.
- React 18: Component-based UI with hooks and modern patterns
- TypeScript: Full type safety with strict configuration
- Redux Toolkit: State management with modern Redux patterns
- Vite: Build tool and dev server for fast development
- CSS Modules: Scoped styling with PostCSS processing
- ESLint: Code linting with React and TypeScript rules
- Prettier: Code formatting with consistent style
- Vitest: Unit testing framework
- React DevTools: Redux DevTools integration
- @reduxjs/toolkit: Modern Redux with createSlice and RTK Query
- react-redux: React bindings for Redux
- lucide-react: Consistent icon library
- @cazala/party: Core physics engine
The playground follows a modular, layered architecture with clear separation of concerns:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ UI Components │ │ Hooks │ │ Redux Slices │
│ │ │ │ │ │
│ • Module UIs │◄──►│ • Module Hooks │◄──►│ • Module State │
│ • Tool Overlays │ │ • Tool Hooks │ │ • Actions │
│ • Common UI │ │ • Engine Hook │ │ • Selectors │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└───────────────────────┼───────────────────────┘
▼
┌─────────────────┐
│ Engine Context │
│ │
│ • Core Engine │
│ • Module Refs │
│ • Canvas Utils │
└─────────────────┘
packages/playground/src/
├── components/ # React components
│ ├── modules/ # Module-specific UI components
│ ├── modals/ # Modal dialogs
│ ├── ui/ # Reusable UI components
│ └── tools/ # Tool-related components
├── contexts/ # React contexts
├── hooks/ # Custom React hooks
│ ├── modules/ # Module-specific hooks
│ ├── tools/ # Tool system hooks
│ │ └── individual-tools/ # Individual tool implementations
│ └── utils/ # Utility hooks
├── slices/ # Redux Toolkit slices
│ ├── modules/ # Module state slices
│ └── utils/ # Utility slices
├── types/ # TypeScript type definitions
├── utils/ # Pure utility functions
└── styles/ # Global styles and CSS modules
- Components: PascalCase (
EnvironmentModule.tsx) - Hooks: camelCase with
useprefix (useEnvironment.ts) - Types: PascalCase (
ModuleState.ts) - Utils: camelCase (
sessionManager.ts) - CSS Modules: kebab-case (
.component-name)
Each physics module follows a three-layer pattern:
// slices/modules/environment.ts
export const environmentSlice = createSlice({
name: "environment",
initialState: {
gravityStrength: 0,
gravityDirection: "down" as const,
// ... other properties
},
reducers: {
setEnvironmentGravityStrength: (state, action: PayloadAction<number>) => {
state.gravityStrength = action.payload;
},
resetEnvironment: () => initialState,
importEnvironmentSettings: (state, action) => {
Object.assign(state, action.payload);
},
},
});// hooks/modules/useEnvironment.ts
export function useEnvironment() {
const dispatch = useAppDispatch();
const { environment } = useEngine();
const state = useAppSelector(selectEnvironmentState);
// Sync Redux state to engine when state changes
useEffect(() => {
if (environment) {
environment.setGravityStrength(state.gravityStrength);
// ... sync other properties
}
}, [environment, state]);
// Action creators with dual-write pattern
const setGravityStrength = useCallback(
(value: number) => {
dispatch(setEnvironmentGravityStrength(value)); // Redux update
environment?.setGravityStrength(value); // Immediate engine update
},
[dispatch, environment]
);
return {
// State properties (individual extractions)
gravityStrength: state.gravityStrength,
gravityDirection: state.gravityDirection,
// Action creators
setGravityStrength,
setGravityDirection,
// Utility actions
resetEnvironment: useCallback(
() => dispatch(resetEnvironment()),
[dispatch]
),
};
}// components/modules/EnvironmentModule.tsx
export function EnvironmentModule({ enabled = true }: { enabled?: boolean }) {
const {
gravityStrength,
setGravityStrength,
gravityDirection,
setGravityDirection,
} = useEnvironment();
return (
<>
<Slider
sliderId="environment.gravityStrength"
label="Gravity Strength"
value={gravityStrength}
onChange={setGravityStrength}
min={0}
max={2000}
disabled={!enabled}
/>
<Dropdown
label="Direction"
value={gravityDirection}
onChange={setGravityDirection}
options={[
{ value: "up", label: "Up" },
{ value: "down", label: "Down" },
// ... more options
]}
disabled={!enabled}
/>
</>
);
}Tools follow a hook-based pattern with standardized interfaces:
// types/tools.ts
export interface ToolHandlers {
onMouseDown?: (event: MouseEvent) => void | Promise<void>;
onMouseMove?: (event: MouseEvent) => void | Promise<void>;
onMouseUp?: (event: MouseEvent) => void | Promise<void>;
onKeyDown?: (event: KeyboardEvent) => void;
onKeyUp?: (event: KeyboardEvent) => void;
}
export type ToolRenderFunction = (
ctx: CanvasRenderingContext2D,
size: { width: number; height: number },
mouse: { x: number; y: number; isDown: boolean }
) => void;// hooks/tools/individual-tools/useSpawnTool.ts
export function useSpawnTool(isActive: boolean) {
const { addParticle } = useEngine();
const { appendToTransaction, beginTransaction } = useHistory();
const { spawnSettings } = useInit();
const handlers: ToolHandlers = {
onMouseDown: async (event) => {
if (!isActive) return;
beginTransaction("Spawn particles");
const particles = createParticlesAtPosition(event, spawnSettings);
for (const particle of particles) {
addParticle(particle);
appendToTransaction(new AddParticleCommand(particle));
}
},
};
const renderOverlay: ToolRenderFunction = useCallback(
(ctx, size, mouse) => {
if (!isActive) return;
// Draw spawn preview at cursor
drawSpawnPreview(ctx, mouse, spawnSettings);
},
[isActive, spawnSettings]
);
return { handlers, renderOverlay };
}❌ Never do this in components:
// DON'T: Direct Redux usage in components
const dispatch = useDispatch();
const state = useSelector(selectSomeState);✅ Always do this instead:
// DO: Use module hooks that wrap Redux
const { value, setValue, reset } = useModuleName();- Encapsulation: Hooks hide Redux complexity from components
- Dual-Write Pattern: Update both Redux state and engine immediately
- Memoization: Use
useCallbackfor all functions,useMemofor objects - Individual Exports: Export individual properties, not entire state objects
- Type Safety: Full TypeScript support with proper typing
export function useModuleName() {
// 1. Get dependencies
const dispatch = useAppDispatch();
const { moduleRef } = useEngine();
const state = useAppSelector(selectModuleState);
// 2. Sync state to engine
useEffect(() => {
if (moduleRef) {
moduleRef.updateFromState(state);
}
}, [moduleRef, state]);
// 3. Create action creators with useCallback
const setValue = useCallback(
(value: SomeType) => {
dispatch(setModuleValue(value));
moduleRef?.setValue(value);
},
[dispatch, moduleRef]
);
// 4. Return individual properties and actions
return {
// State (individual properties)
value: state.value,
otherValue: state.otherValue,
// Actions
setValue,
setOtherValue,
reset: useCallback(() => dispatch(resetModule()), [dispatch]),
};
}The playground implements a sophisticated undo/redo system using the Command pattern:
// types/history.ts
export interface Command {
id: string;
label: string;
timestamp: number;
do(ctx: HistoryContext): void | Promise<void>;
undo(ctx: HistoryContext): void | Promise<void>;
tryMergeWith?(next: Command): Command | null;
}
export interface HistoryContext {
engine: IEngine;
addParticle: (particle: IParticle) => Promise<void>;
removeParticle: (index: number) => Promise<void>;
// ... other utilities
}// commands/AddParticleCommand.ts
export class AddParticleCommand implements Command {
id = generateId();
label = "Add particle";
timestamp = Date.now();
constructor(private particle: IParticle, private index?: number) {}
async do(ctx: HistoryContext): Promise<void> {
const addedIndex = await ctx.addParticle(this.particle);
this.index = addedIndex; // Store for undo
}
async undo(ctx: HistoryContext): Promise<void> {
if (this.index !== undefined) {
await ctx.removeParticle(this.index);
}
}
}// In tool hooks
const { beginTransaction, appendToTransaction, commitTransaction } =
useHistory();
const handleMouseDown = async (event) => {
beginTransaction("Draw stroke");
const particle = await addParticle(particleData);
appendToTransaction(new AddParticleCommand(particle));
// ... more operations
commitTransaction(); // Groups all commands into single undo operation
};Each slice follows a consistent pattern:
export const moduleSlice = createSlice({
name: "moduleName",
initialState: {
// Primitive values for each module property
property1: defaultValue1,
property2: defaultValue2,
},
reducers: {
// Property setters: set[Module][Property]
setModuleProperty1: (state, action: PayloadAction<Type1>) => {
state.property1 = action.payload;
},
// Reset: reset[Module]
resetModule: () => initialState,
// Import: import[Module]Settings
importModuleSettings: (
state,
action: PayloadAction<Partial<ModuleState>>
) => {
Object.assign(state, action.payload);
},
},
});
// Export actions
export const { setModuleProperty1, resetModule, importModuleSettings } =
moduleSlice.actions;
// Export selectors
export const selectModuleState = (state: RootState) => state.modules.moduleName;
export const selectModuleProperty1 = (state: RootState) =>
state.modules.moduleName.property1;
// Export reducer
export default moduleSlice.reducer;// slices/modules/index.ts
import { combineReducers } from "@reduxjs/toolkit";
import environmentReducer from "./environment";
import fluidsReducer from "./fluids";
// ... other module reducers
export const modulesReducer = combineReducers({
environment: environmentReducer,
fluids: fluidsReducer,
// ... other modules
});
export type ModulesState = ReturnType<typeof modulesReducer>;- Single Responsibility: Components should have one clear purpose
- Prop Interfaces: Use TypeScript interfaces for all props
- Default Props: Use default parameters instead of defaultProps
- Conditional Rendering: Use logical operators for clean conditional rendering
- Event Handlers: Extract complex handlers to separate functions
interface ComponentProps {
enabled?: boolean;
className?: string;
onSomething?: (value: SomeType) => void;
}
export function Component({
enabled = true,
className,
onSomething,
}: ComponentProps) {
// 1. Hooks (state, effects, callbacks)
const { value, setValue } = useRelevantHook();
// 2. Event handlers
const handleClick = useCallback(
(event: MouseEvent) => {
// handler logic
onSomething?.(newValue);
},
[onSomething]
);
// 3. Render
return (
<div className={cn("component-class", className)}>
{/* Component content */}
</div>
);
}/* Component.module.css */
.container {
/* Container styles */
}
.enabled {
/* Enabled state */
}
.disabled {
/* Disabled state */
opacity: 0.6;
pointer-events: none;
}
.item {
/* Item styles */
}
.item:hover {
/* Hover effects */
}The EngineContext provides centralized access to the engine and utilities:
// contexts/EngineContext.tsx
export function useEngine() {
const context = useContext(EngineContext);
if (!context) {
throw new Error("useEngine must be used within an EngineProvider");
}
return context;
}
// Usage in hooks
export function useModuleName() {
const { moduleName } = useEngine(); // Get module reference
// Use module reference for direct engine calls
const setValue = useCallback(
(value) => {
dispatch(setModuleValue(value));
moduleName?.setValue(value); // Immediate engine update
},
[dispatch, moduleName]
);
}// Engine context provides coordinate utilities
const { screenToWorld, worldToScreen } = useEngine();
// Convert mouse coordinates for engine operations
const handleMouseClick = (event: MouseEvent) => {
const screenCoords = { x: event.clientX, y: event.clientY };
const worldCoords = screenToWorld(screenCoords);
// Use world coordinates for engine operations
addParticle({ x: worldCoords.x, y: worldCoords.y /* ... */ });
};-
Install dependencies:
npm run setup
-
Start development server:
npm run dev
-
Run tests:
npm test -
Type checking:
npm run type-check
Note: The project uses pnpm workspaces internally but all commands are available through npm scripts. The
setupcommand installs pnpm locally and sets up all workspace dependencies.
-
Create Redux slice:
// slices/modules/newModule.ts export const newModuleSlice = createSlice({ // Implementation });
-
Create module hook:
// hooks/modules/useNewModule.ts export function useNewModule() { // Implementation following standard pattern }
-
Create UI component:
// components/modules/NewModuleComponent.tsx export function NewModuleComponent({ enabled = true }) { // Implementation }
-
Integrate into main UI:
// Add to ModulesSidebar or appropriate location
-
Create tool hook:
// hooks/tools/individual-tools/useNewTool.ts export function useNewTool(isActive: boolean) { // Implement handlers and renderOverlay return { handlers, renderOverlay }; }
-
Register in tool system:
// Update tool registry and hotkey mappings -
Add UI controls:
// Add tool button to toolbar
// __tests__/hooks/useModule.test.ts
import { renderHook, act } from "@testing-library/react";
import { useModule } from "../hooks/useModule";
describe("useModule", () => {
it("should handle value updates correctly", () => {
const { result } = renderHook(() => useModule());
act(() => {
result.current.setValue(newValue);
});
expect(result.current.value).toBe(newValue);
});
});// Test Redux integration
import { configureStore } from "@reduxjs/toolkit";
import { Provider } from "react-redux";
const testStore = configureStore({
reducer: { modules: modulesReducer },
});
const wrapper = ({ children }) => (
<Provider store={testStore}>{children}</Provider>
);- Memoization: Use
useCallbackanduseMemoappropriately - Component Splitting: Break large components into smaller ones
- Conditional Rendering: Avoid expensive renders when not needed
- Event Handler Optimization: Debounce expensive operations
- Selector Memoization: Use reselect for complex selectors
- Normalized State: Keep state flat and normalized
- Minimal Updates: Update only necessary state slices
- Dual-Write Pattern: Immediate engine updates for responsive UI
- Batch Operations: Group multiple engine operations when possible
- Async Boundaries: Use async operations for expensive engine calls
// 1. Use module hooks instead of direct Redux
const { value, setValue } = useModule();
// 2. Memoize callbacks
const handleChange = useCallback(
(newValue) => {
setValue(newValue);
},
[setValue]
);
// 3. Individual state properties
return {
property1: state.property1,
property2: state.property2,
setProperty1,
setProperty2,
};
// 4. Proper TypeScript usage
interface Props {
value: number;
onChange: (value: number) => void;
}// 1. DON'T use Redux directly in components
const dispatch = useDispatch(); // ❌
const state = useSelector(selectState); // ❌
// 2. DON'T return entire state objects
return { state }; // ❌ Return individual properties instead
// 3. DON'T forget memoization
const handleClick = () => {
/* ... */
}; // ❌ Use useCallback
// 4. DON'T bypass the hook layer
engine.module.setValue(value); // ❌ Use module hooks instead- Use Redux DevTools browser extension
- Time-travel debugging for state changes
- Action inspection and replay
- Component hierarchy inspection
- Props and state debugging
- Performance profiling
- Use browser console for engine state inspection
- FPS monitoring in top bar
- WebGPU vs CPU runtime information
- Follow TypeScript strict mode
- Use Prettier for formatting
- Follow ESLint rules
- Write descriptive commit messages
- Create feature branch from main
- Implement changes following patterns
- Add tests for new functionality
- Update documentation if needed
- Ensure all checks pass
- Discuss major changes in issues first
- Follow existing patterns unless there's a compelling reason not to
- Consider performance implications
- Maintain backward compatibility when possible
This maintainer guide provides the foundation for understanding and contributing to the playground codebase. The consistent patterns and clear separation of concerns make the codebase maintainable and extensible while providing excellent developer experience.