diff --git a/components/FlowCanvas.tsx b/components/FlowCanvas.tsx
index 0238fbb..128addc 100644
--- a/components/FlowCanvas.tsx
+++ b/components/FlowCanvas.tsx
@@ -23,6 +23,7 @@ import StreamEdge from './edges/StreamEdge'
import IntegrationPanel from './IntegrationPanel'
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types'
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
+import { simulateTick } from '@/lib/simulation'
const nodeTypes = {
funnel: FunnelNode,
@@ -523,41 +524,13 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
])
}, [setNodes, screenToFlowPosition])
- // Simulation
+ // Simulation — real flow logic (inflow → overflow → spending → outcomes)
useEffect(() => {
if (!isSimulating) return
const interval = setInterval(() => {
- setNodes((nds) =>
- nds.map((node) => {
- if (node.type === 'funnel') {
- const data = node.data as FunnelNodeData
- const change = (Math.random() - 0.45) * 300
- return {
- ...node,
- data: {
- ...data,
- currentValue: Math.max(0, Math.min(data.maxCapacity * 1.1, data.currentValue + change)),
- },
- }
- } else if (node.type === 'outcome') {
- const data = node.data as OutcomeNodeData
- const change = Math.random() * 80
- const newReceived = Math.min(data.fundingTarget * 1.05, data.fundingReceived + change)
- return {
- ...node,
- data: {
- ...data,
- fundingReceived: newReceived,
- status: newReceived >= data.fundingTarget ? 'completed' :
- data.status === 'not-started' && newReceived > 0 ? 'in-progress' : data.status,
- },
- }
- }
- return node
- })
- )
- }, 500)
+ setNodes((nds) => simulateTick(nds as FlowNode[]))
+ }, 1000)
return () => clearInterval(interval)
}, [isSimulating, setNodes])
diff --git a/components/nodes/OutcomeNode.tsx b/components/nodes/OutcomeNode.tsx
index fd25301..ace5cd6 100644
--- a/components/nodes/OutcomeNode.tsx
+++ b/components/nodes/OutcomeNode.tsx
@@ -45,11 +45,24 @@ function OutcomeNode({ data, selected }: NodeProps) {
diff --git a/lib/simulation.ts b/lib/simulation.ts
new file mode 100644
index 0000000..499ee06
--- /dev/null
+++ b/lib/simulation.ts
@@ -0,0 +1,128 @@
+/**
+ * Flow simulation engine — pure function, no React dependencies.
+ *
+ * Replaces the random-noise simulation with actual flow logic:
+ * inflow → overflow distribution → spending drain → outcome accumulation
+ */
+
+import type { FlowNode, FunnelNodeData, OutcomeNodeData } from './types'
+
+export interface SimulationConfig {
+ tickDivisor: number // inflowRate divided by this per tick
+ spendingRateHealthy: number // drain multiplier in healthy zone
+ spendingRateOverflow: number // drain multiplier above maxThreshold
+ spendingRateCritical: number // drain multiplier below minThreshold
+}
+
+export const DEFAULT_CONFIG: SimulationConfig = {
+ tickDivisor: 10,
+ spendingRateHealthy: 0.5,
+ spendingRateOverflow: 0.8,
+ spendingRateCritical: 0.1,
+}
+
+export function simulateTick(
+ nodes: FlowNode[],
+ config: SimulationConfig = DEFAULT_CONFIG,
+): FlowNode[] {
+ const { tickDivisor, spendingRateHealthy, spendingRateOverflow, spendingRateCritical } = config
+
+ // Separate funnels (sorted top-to-bottom) from outcomes
+ const funnelNodes = nodes
+ .filter((n) => n.type === 'funnel')
+ .sort((a, b) => a.position.y - b.position.y)
+
+ // Accumulators for inter-node transfers
+ const overflowIncoming = new Map()
+ const spendingIncoming = new Map()
+
+ // Store updated funnel data
+ const updatedFunnels = new Map()
+
+ for (const node of funnelNodes) {
+ const src = node.data as FunnelNodeData
+ const data: FunnelNodeData = { ...src }
+
+ // 1. Natural inflow
+ let value = data.currentValue + data.inflowRate / tickDivisor
+
+ // 2. Overflow received from upstream funnels
+ value += overflowIncoming.get(node.id) ?? 0
+
+ // 3. Cap at maxCapacity
+ value = Math.min(value, data.maxCapacity)
+
+ // 4. Distribute overflow when above maxThreshold
+ if (value > data.maxThreshold && data.overflowAllocations.length > 0) {
+ const excess = value - data.maxThreshold
+ for (const alloc of data.overflowAllocations) {
+ const share = excess * (alloc.percentage / 100)
+ overflowIncoming.set(
+ alloc.targetId,
+ (overflowIncoming.get(alloc.targetId) ?? 0) + share,
+ )
+ }
+ value = data.maxThreshold
+ }
+
+ // 5. Spending drain (gated by zone)
+ if (value > 0 && data.spendingAllocations.length > 0) {
+ let rateMultiplier: number
+ if (value > data.maxThreshold) {
+ rateMultiplier = spendingRateOverflow
+ } else if (value >= data.minThreshold) {
+ rateMultiplier = spendingRateHealthy
+ } else {
+ rateMultiplier = spendingRateCritical
+ }
+
+ let drain = (data.inflowRate / tickDivisor) * rateMultiplier
+ drain = Math.min(drain, value) // cannot drain below 0
+
+ value -= drain
+
+ for (const alloc of data.spendingAllocations) {
+ const share = drain * (alloc.percentage / 100)
+ spendingIncoming.set(
+ alloc.targetId,
+ (spendingIncoming.get(alloc.targetId) ?? 0) + share,
+ )
+ }
+ }
+
+ data.currentValue = Math.max(0, value)
+ updatedFunnels.set(node.id, data)
+ }
+
+ // Rebuild node array with updated data
+ return nodes.map((node) => {
+ if (node.type === 'funnel' && updatedFunnels.has(node.id)) {
+ return { ...node, data: updatedFunnels.get(node.id)! }
+ }
+
+ if (node.type === 'outcome') {
+ const data = node.data as OutcomeNodeData
+ const incoming = spendingIncoming.get(node.id) ?? 0
+ if (incoming <= 0) return node
+
+ const newReceived = Math.min(
+ data.fundingTarget > 0 ? data.fundingTarget * 1.05 : Infinity,
+ data.fundingReceived + incoming,
+ )
+
+ let newStatus = data.status
+ if (data.fundingTarget > 0 && newReceived >= data.fundingTarget && newStatus !== 'blocked') {
+ newStatus = 'completed'
+ } else if (newReceived > 0 && newStatus === 'not-started') {
+ newStatus = 'in-progress'
+ }
+
+ return {
+ ...node,
+ data: { ...data, fundingReceived: newReceived, status: newStatus },
+ }
+ }
+
+ return node
+ })
+}