Goal
Stop allocating an array copy and a fresh state object on every event commit. Cuts GC churn during normal use and dominates wins during stress bursts (500-event scenario).
Today
web/hooks/use-agent-simulation.ts:257-267 — every event commit does eventLog.concat(newEvents) then slice(-MAX_EVENT_LOG) and spreads into a fresh currentState. Two array allocations + a state spread per commit.
scripts/relay.ts:94-97 — relay-side has the same shape: buf.slice(buf.length - MAX_EVENT_BUFFER) per event.
processEvent returns a new SimulationState per event with shallow-copied internal Maps; a 500-event burst processed in one frame ⇒ ~500 Map allocations.
scripts/relay.ts:514-519 — SSE replay sends the entire 5 000-event buffer in a single JSON.stringify on every new client connect.
Plan
- Add
web/lib/ring-buffer.ts — RingBuffer<T>(capacity) with O(1) push, indexed access, chunk-aware iterator.
- Replace
SimulationState.eventLog: SimulationEvent[] with the ring buffer. Update consumers (timeline rebuild, seek, restart, snapshot/restore).
- Allow
processEvent to mutate frameRef.current in place during ingestion. Snapshot to React state only at the 4 Hz commit boundary (already throttled). Add an explicit commitSnapshot() that produces an immutable structural copy on demand.
- Time-slice ingestion: cap per-frame processing to ~5 ms; defer remaining queued events to the next rAF.
- Mirror the relay-side buffer (
scripts/relay.ts:80).
- Sub-task: stream SSE replay in chunks of ~100 events with
setImmediate between chunks.
Acceptance
pnpm sim stress shows no frame drops in ?perf overlay; P95 frame time < 20 ms.
- DevTools heap-snapshot diff: no new state objects allocated per event in steady state.
- All 24 existing tests pass.
Parallelism
Independent of #6 (renderer) and #3 (stats store). Conflicts with #5 and #4 because all three edit use-agent-simulation.ts. Land this first in Wave A.
Goal
Stop allocating an array copy and a fresh state object on every event commit. Cuts GC churn during normal use and dominates wins during stress bursts (500-event scenario).
Today
web/hooks/use-agent-simulation.ts:257-267— every event commit doeseventLog.concat(newEvents)thenslice(-MAX_EVENT_LOG)and spreads into a freshcurrentState. Two array allocations + a state spread per commit.scripts/relay.ts:94-97— relay-side has the same shape:buf.slice(buf.length - MAX_EVENT_BUFFER)per event.processEventreturns a newSimulationStateper event with shallow-copied internal Maps; a 500-event burst processed in one frame ⇒ ~500 Map allocations.scripts/relay.ts:514-519— SSE replay sends the entire 5 000-event buffer in a singleJSON.stringifyon every new client connect.Plan
web/lib/ring-buffer.ts—RingBuffer<T>(capacity)with O(1)push, indexed access, chunk-aware iterator.SimulationState.eventLog: SimulationEvent[]with the ring buffer. Update consumers (timeline rebuild, seek, restart, snapshot/restore).processEventto mutateframeRef.currentin place during ingestion. Snapshot to React state only at the 4 Hz commit boundary (already throttled). Add an explicitcommitSnapshot()that produces an immutable structural copy on demand.scripts/relay.ts:80).setImmediatebetween chunks.Acceptance
pnpm sim stressshows no frame drops in?perfoverlay; P95 frame time < 20 ms.Parallelism
Independent of #6 (renderer) and #3 (stats store). Conflicts with #5 and #4 because all three edit
use-agent-simulation.ts. Land this first in Wave A.