Link
About
It started as a personal frustration project - I was trying to quickly balance statistics during another gamejam and got sick of trying to track formulas across Excel, Desmos, and WolframAlpha. The problem I personally needed solved is the functionality of spreadsheets not being readable enough to optimally tackle balancing of game systems.
Now it’s become a little simulation playground where you can connect variables, build graphs, and run experiments.
It includes: dark mode, multi-canvas workspace, hierarchical object management, json/csv/pdf export and import, accessibility features like ARIA labels and titles.
The application uses React for its user interface, Tailwind CSS for styling, and Zustand for global state management, ensuring a responsive experience.
Bidirectional Dependency System
The main and most powerful feature is the bidirectional relationship between connected variables. When X changes, Y automatically updates based on the formula. Conversely, when Y is manually changed, the system solves for X using inverse calculations.
updateStat: (objectId, statId, value) => set((state) => {
const updatedStats = new Set<string>();
const updateStatAndCascade = (
currentState: any,
targetObjectId: string | null,
targetStatId: string,
newValue: number,
depth: number = 0
): any => {
// Prevent infinite loops
if (depth > 10 || updatedStats.has(targetStatId)) {
return currentState;
}
updatedStats.add(targetStatId);
// Update the stat value
let newState = { ...currentState };
if (!targetObjectId) {
newState.globalStats = currentState.globalStats.map(stat =>
stat.id === targetStatId ? { ...stat, value: newValue } : stat
);
} else {
newState.objects = currentState.objects.map(obj =>
obj.id === targetObjectId
? {
...obj,
stats: obj.stats.map(stat =>
stat.id === targetStatId ? { ...stat, value: newValue } : stat
)
}
: obj
);
}
// Find and process bidirectional dependencies
const relevantDependencies = currentState.statDependencies.filter(dep =>
dep.bidirectional && (dep.sourceStatId === targetStatId || dep.targetStatId === targetStatId)
);
for (const dependency of relevantDependencies) {
try {
let calculatedValue: number;
let nextTargetStatId: string;
if (dependency.sourceStatId === targetStatId) {
// Forward: X changed, update Y
calculatedValue = evaluateFormula(dependency.formula, newValue);
nextTargetStatId = dependency.targetStatId;
} else {
// Reverse: Y changed, solve for X
calculatedValue = solveForX(dependency.formula, newValue);
nextTargetStatId = dependency.sourceStatId;
}
if (isFinite(calculatedValue)) {
// Recursively cascade to next dependency
newState = updateStatAndCascade(
newState,
nextTargetObjectId,
nextTargetStatId,
calculatedValue,
depth + 1
);
}
} catch (error) {
console.warn('Error evaluating bidirectional dependency:', error);
}
}
return newState;
};
return updateStatAndCascade(state, objectId, statId, value, 0);
}),
Data Persistence and Cleanup
Cleanup routines to prevent memory leaks.
cleanupOrphanedDependencies: (existingGraphNodeIds) => set((state) => {
console.log('Cleaning up orphaned dependencies...');
const orphanedDependencies = state.statDependencies.filter(dep =>
!existingGraphNodeIds.has(dep.graphNodeId)
);
if (orphanedDependencies.length > 0) {
const cleanedDependencies = state.statDependencies.filter(dep =>
existingGraphNodeIds.has(dep.graphNodeId)
);
return { statDependencies: cleanedDependencies };
}
return state;
}),