Make models, not spreadsheets.
The Sim turns a tab-zoo of Excel scenarios into one chart, three layers of inputs, and a math layer you can read — so you argue about assumptions, not formulas.
How to read the chart.
One picture, three layers, two colours of truth. The chart paints a cost mountain with revenue riding on top. The gap between them is your business.
- Cost mountain. Stacked bands, floor up: agency cost first, then product-derived media cost, then each active channel as its own band, then each active initiative's own cost. The top of the stack is total cost for that month.
- Revenue line. One green line — the sum of all revenue curves. Above the stack means profit; below means loss.
- Brutal green gap = profit (revenue above stack-top). Brutal red gap = loss (stack-top above revenue). The fill is bounded by the same monotone revenue and stack-top curves, and sign changes split exactly where those two curves cross — not stair-stepped. The crossing is your break-even.
- Net-profit reference line. A thin dark line for
revenue − total_cost, riding on a dashed €0 baseline. The real signal is the green/red gap; this line is just the subtraction made explicit. - TODAY divider. A vertical dashed line at the history/projection boundary. The history side is tinted. X-axis labels read
M-Non the past andM+Non the future. There is noM0— labels skip fromM-1toM+1with TODAY in between. A faint vertical gridline marks every month, while the labels stay sparse so the grid reads calm, not busy. - HISTORY / FUTURE / BOTH toggle. A segmented control in the chart header frames the chart on the past only, the future only, or all of it — it's a view window, not a data filter (every band, line, overlay and pin stays intact; the y-axis just auto-fits to whatever's in view). BOTH is the full timeline; HISTORY puts TODAY at the right edge; FUTURE puts it at the left. Your choice sticks across reloads and works the same in fullscreen. (With no history set, the HISTORY chip is greyed out.)
- Honest curves — no invented values. Every line (revenue, net-profit, the band tops, and each overlay) is drawn with monotone (non-overshooting) interpolation. The curve stays smooth but it can never swing past the two months it connects: between a month at 217 and the next at 225 the line stays inside 217–225 — no phantom peak or dip. A step (e.g. a channel turning on: 0 → 217) renders as a clean ramp that never overshoots below 0 or above 217. The green/red P/L fill follows the exact same curves, so the shading always matches the lines.
- FIRST PROFIT · M+N chip. The earliest month where revenue first ≥ total cost. Reads PROFIT SINCE M-N when you were already profitable before TODAY, or Never in horizon when you never cross.
- WOM · N% badge. Appears next to the profit chip when word-of-mouth contributes ≥ 10% of revenue at TODAY+1. A flag, not a brag — if the model leans hard on virality, go re-check those assumptions.
- Costs group-chip. When the stack gets crowded the cost bands collapse under one
COSTSchip with a vertical mini-swatch. Expand it to mute individual bands. Muting is purely visual — the PnL math never changes.
Layout, focus & fullscreen.
The chart is the hero. There are two ways to work with it, and they coexist: the normal webpage (scroll, everything inline) and a true fullscreen immersive mode (a trading-terminal cockpit). Use whichever fits the moment — switching between them never changes your model or loses anything.
- Every panel collapses (webpage mode). Each panel below the chart — Historic data, Cost & revenue curves (including the horizon / history / unit / currency controls), Channels, Products, Initiatives — has a slim header bar with a ▾ chevron. Click to fold it to just that bar, which then shows a one-line summary (e.g.
3 curves · 24mo · €120k cost @ M+24,2 channels · 1 active · €8k/mo spend,3 initiatives · 2 active). Click again to reopen. The bars are your tools — one click brings back exactly the panel you need. - The initiatives rail collapses too. The rail beside the chart has a › button that folds it to a thin spine on the chart's edge, handing that width to the curve. Click the spine to bring it back.
- Focus mode (in-page). The Focus button in the top bar — or the F key — collapses everything at once (all panels and the rail), leaving just the chart filling the page. You're still on the webpage; toggle it off to restore exactly the layout you had. This is the lightweight “enjoy the curve” mode.
- Your layout sticks. Collapsed panels, rail state, and focus mode persist across reloads as a personal preference — it's about how you like to work, not saved per simulation.
- Tools stay close. The simulation switcher, New / Duplicate / Delete, the WIKI link, and the chart's own controls (overlay picker, cost legend, first-profit chip) live in the top bar and the chart card. They never collapse, so the things you reach for most are always one click away.
Fullscreen — the immersive cockpit.
For a distraction-free, trading-dashboard experience, click ⛶ Fullscreen in the top bar (or press Shift + F). The browser goes true fullscreen — no webpage chrome, no scrolling — and the chart fills the entire screen like Koyfin or Kraken. Every control is still there, just tucked into edge drawers you slide in when you need them.
- Left instrument rail. A thin icon rail docks on the left edge with one button per control group: Time (horizon / history / unit / currency), Curves, Channels, Initiatives, Products, Historic, and Overlays. Hover an icon for its label.
- Slide-in drawers. Click an icon and that panel slides in smoothly from the left over the chart edge — the same panel you use on the webpage, with every control intact (per-row Apply, anchor pickers, WOM, lifecycle, the channel↔product connector, the historic table + paste, the initiative rail, chart overlays). Click the same icon again, click the dimmed backdrop, or press Esc once to slide it back out. Only one drawer is open at a time, so the chart always breathes.
- Visual complexity, live. The chart legend stays floating on the chart, so you can add / remove overlay lines and mute cost bands without leaving fullscreen — the Overlays rail button flashes the legend to point you to it. Dial the chart up or down to exactly what you want to see.
- Top bar stays. A slim floating bar across the top keeps the simulation switcher, the live KPI strip, New / Duplicate / Delete, the save-state indicator (see below) — plus the ⟳ exit button.
- Exit any time. Press Esc (when no drawer is open), click the exit button, or use your browser's fullscreen gesture. You drop straight back to the normal webpage in exactly the layout you left it — lossless. (Browsers only allow entering fullscreen from a click or key press, so it can't auto-resume after a reload, but your last-opened drawer is remembered for next time.)
- Two modes, one model. Fullscreen and the webpage share the same data and the same controls — Fullscreen is purely a different way to see and steer, never a different simulation.
Save state — one clear signal.
A single save-state indicator sits in the top bar — in both the normal webpage and fullscreen (it's the same component), so you never have to guess whether a change took. It reflects the whole simulation's persistence state, not one panel:
- ✓ All changes saved (or Saved · just now) — everything is persisted; nothing pending.
- • Unsaved changes (amber) — you've edited something that hasn't been written yet (a row awaiting its Apply, or an auto-save still debouncing).
- Saving… (spinner) — a save is in flight.
- ✓ Saved (green flash) — the save landed. This shows only once the server confirms and the chart re-renders from the saved state, so “Saved” genuinely means persisted and applied to the chart — not merely “request sent”.
- Save failed — retry (red) — a network/server error; click the pill to retry the exact failed save. Errors are never swallowed silently.
The per-row Apply buttons and the small in-panel “all saved / unsaved” footers still tell you which row needs attention; the top-bar signal is the one-glance answer for the whole model.
Overlay lines — measure anything.
The defaults — cost mountain, revenue, P/L gap, net-profit reference — tell the headline story. To inspect anything else, overlay it as a thin line.
- + ADD METRIC. The button in the chart legend strip opens a categorised picker: Channels, Products, Curves, Global. Each leaf is one (entity, field) pair; already-added items show a check.
- Smart multi-axis. Overlays self-route by unit so they never drown each other:
- € on the default left axis (money-shaped metrics).
- % on the right axis (CTR, conversion rates, WOM share). Appears automatically when a percentage overlay is active.
- # on a far-right axis (impressions, clicks, orders). Appears automatically when a count overlay is active.
- Lines, not bands. Overlays draw as thin 1.6px lines with no fill and no stacking. They sit above the model — they never touch the cost mountain or the P/L gap.
- Per-chip controls. Each active overlay gets a chip in the “Active overlays” row with a colour swatch, label, and an axis-tag (
€/%/#). Click the body to mute / unmute; click×to remove. Past ~5 active overlays a small warning glyph appears — mute a few.
Initiatives on the chart.
Initiative impact is visible directly on the chart — no “did my CTR boost actually move anything?” guesswork.
- Marker per initiative. Each active initiative draws a vertical dashed line at its
t_startin its own colour, plus a small pin chip at the top of the chart with its name. When it has at_end, a paired pin plus a faint horizontal bracket shows the active window. - Inactive initiatives stay visible as faded dashed lines (no pin) so you can see what's available to toggle on.
- Pin tooltip. Hover a pin for the full name, target (e.g.
CHANNEL Google Ads · CTR %), op (× 1.5), impact chain, active window, and cost summary. - Side rail. A compact menu beside the chart lists every initiative with an on/off toggle and a compressed impact chain. Click a row name to scroll to the matching Initiatives panel row (it flashes). Hover a row to pulse the matching chart marker. Toggling in the rail mirrors the panel toggle — same switch, two places. You edit in the panel; you monitor in either.
Impact chains — honest by design.
Every initiative carries an auto-generated impact chain: the direct change to the target field, then the confident causal consequences, each marked with a direction arrow (↑ up / ↓ down). Direction is inferred from operator and value — × 0.5 or + negative is a decrease, × 1.5 or + positive is an increase, a bare set shows a neutral →.
The operator verb describes only what the initiative does to the target (halves CPC, sets CPC to €0.50). Downstream nodes carry a direction arrow and a metric name — never the op verb. We deliberately don't write “Multiplies SPEND”: halving CPC does not halve spend, and the operator never propagates downstream.
Chains are anchor-aware and stay short (≤ 3 nodes). Honest examples:
- CPC ↓ on a spend-anchored channel →
CPC ↓ → CLICKS ↑ → ORDERS ↑— a fixed budget buys more clicks. - CPC ↓ on a count-anchored channel →
CPC ↓ → CAC ↓— cheaper clicks, cheaper acquisition. - CTR ↑ (better creative) →
CTR ↑ → CPC ↓ → CAC ↓— the canonical “better ads” story. - Product AOV ↑ →
AOV ↑ → REVENUE ↑ → PROFIT ↑ - WOM decay ↑ →
DECAY ↑ → VIRAL K ↓ → WOM ORDERS ↓ - Cost-kind curve value ↑ →
COST ↑ → PROFIT ↓; revenue-kind curve →REVENUE ↑ → PROFIT ↑.
Arrows tint green when the change moves toward profit, red when away, and grey when unsure. The tool shows less when the downstream is uncertain: a field with no confident causal story renders just the target node and stops — no invented downstream. Even “CTR ↑ → CPC ↓ → CAC ↓” stops at CAC, never promising orders — because you can lift CTR with clickbait that never converts. Conservative best-case, always.
Inputs you control.
Three layers, built bottom-up. Products are the atom — what you sell. Channels feed them traffic. Curves carry cost and revenue across time. Everything on the chart is a consequence of these.
Products — what you sell.
Each product row has an anchor mode. CRR (cost-revenue ratio — media spend as a share of revenue) is always your input; it's a media-buying judgement, not derivable. The anchor picks which one of orders, media_budget, profit_target is the number you type; the other two derive from AOV, margin %, and CRR %.
“I plan to sell X units/month at €Y AOV. What media spend does that take at Z% CRR?”
“I have €X/month for media. How many orders does that buy at Z% CRR?”
“I want €X profit/month. What spend + orders does that imply?”
“Orders + media come from connected channels, not a CRR assumption.” (See connector.)
Every product also carries word-of-mouth mechanics — recommend %, friend purchase %, friend AOV ratio %, lag months, decay %. The viral coefficient K = recommend% × friend_purchase% rides on top of direct acquisition and shows live next to the row, with a steady-state multiplier chip and an · UNSTABLE warning when the math blows up. Full mechanics under WOM.
Lifecycle — start & (optional) end month.
Each product has a lifecycle window: a start month (when sales begin) and an optional end month (when they stop). By default products run forever — set a start, leave the end at ∞. The end is for limited availability: a cohort course, a seasonal SKU. Open the Timing sub-section on any row to edit; a compact hint like ● M+3 → ∞ or ● M+3 → M+12 shows on the row whenever the lifecycle is non-default, so time-boxed products read at a glance.
Both fields use the same offset-from-TODAY M+N convention as initiatives. M+0 = the first projection month (now); M+6 = six months out; a negative offset (M-3) means the product was already selling three months before TODAY. Internally these are absolute timeline months, so changing history_months later never moves a product's real launch date.
While a product is inactive (before its start or after its end) it contributes zero orders, MRR, media spend, and gross profit for that month. The revenue and media-cost curves therefore step as products switch on and off — a course starting M+3 steps revenue up at M+3; one ending M+12 steps it down at M+13. Validation keeps end ≥ start (an end before the start collapses to a single month).
WOM tail-off. Because the cascade is driven by direct orders, WOM is naturally zero before start + lag. After the end month direct orders stop, but the last active cohort still rings out — final customers keep referring friends for a few months, tapering by the lag/decay cycle. That trailing referral tail is real and intentional.
Price schedule — change the price over time.
A product's price (its AOV) can change over time on a schedule. Open the Price schedule sub-section on any row and add one or more segments: each runs From M+X To M+Y — or toggle ∞ to run indefinitely — at a set price. A segment overrides the base AOV for every month it covers; in any month no segment covers, the base price applies (so an empty schedule = base price everywhere, exactly as before). Both From and To use the same offset-from-TODAY M+N convention as the lifecycle and initiatives.
When two segments overlap, the one with the later start wins for the overlapping months (last-defined wins). The effective price drives everything for that month — MRR, the media spend implied by CRR, the budget- and profit-anchor maths, word-of-mouth revenue, and channel-driven revenue — so the revenue curve steps at each price change. A compact hint like ● €37 → €49 @M+6 → €59 @M+12 shows on the row whenever a schedule exists.
Worked example. Base price €37; add one segment €49 from M+6, indefinite. Months M+0…M+5 price at €37; from M+6 onward they price at €49. With a constant 100 orders/month, MRR is €3,700/mo through M+5 and steps up to €4,900/mo at M+6. A temporary promo works the same way: €29 from M+3 to M+5 dips the price (and revenue) for those three months, then it returns to the base €37 at M+6. A price segment that falls entirely outside the product's active lifecycle window is moot — an inactive month sells nothing regardless of price.
Channels — how you generate orders.
Each channel is one paid acquisition source with its own funnel and anchor. Pick a model first:
- b2c —
impressions → CTR → clicks → click_to_order → orders. - b2b —
impressions → CTR → clicks → click_to_lead → leads → lead_to_order → orders.
Then pick the anchor metric — the number you can measure, not the one you wish for: SPEND, IMPRESSIONS, CLICKS, LEADS (b2b only), or ORDERS. The funnel solves bidirectionally from there: it walks backward from the anchor to impressions, then forward to fill any remaining step.
Cost rates — CPM, CPC, CPL (b2b only), CPO — fill whichever you have data for. When the anchor is spend, the most upstream non-zero rate drives the funnel (preference CPM > CPC > CPL > CPO). When the anchor is a count, spend is computed forward using the same preference. Every active channel resolves to per-month impressions / clicks / leads / orders / spend / CAC, and its spend joins the cost stack as its own band.
LP CR % is informational on the row — in b2c it usually mirrors click_to_order, in b2b click_to_lead. It documents how a landing-page experiment moved but doesn't itself drive the funnel.
Connecting channels to products.
A channel can fund a specific product. The classic case: you sell Video Course 1 and Video Course 2, and your Meta Ads cost and performance differ per course. Model that as two channels — Meta Ads VC1 and Meta Ads VC2 — each wired to its own product.
Two steps to wire them:
- On the channel — set its “Drives product” dropdown from
— Brand / unattributed —to the product it funds. A small→ Productindicator confirms the link. - On the product — switch its anchor mode to the
CHANNELSchip. Now the product's orders and media spend are the sum of its connected, active channels per month — not its own anchor or CRR.
In CHANNELS mode the orders / budget / profit cells go read-only (channel-driven), the Media CRR field disappears (it's inert — media comes from the channels), and a summary strip shows N channels connected · ~X orders/mo · €Y media/mo. AOV, margin %, WOM, and the lifecycle window stay editable — they apply no matter where the orders come from. Select CHANNELS with no channel pointing at the product and the row reminds you to wire one.
Anti-double-count rule. Channel spend is always in the cost stack (every channel, linked or not). A CHANNELS-anchored product takes its media from those channels, so it adds no CRR media of its own — otherwise the same euros would count twice. Products on the other three anchor modes still self-derive media from CRR. Net: total media = (sum of all channel spend) + (CRR media for non-CHANNELS products only).
Lifecycle gates everything. A CHANNELS product that starts M+6 stays at zero orders and zero media until M+6 even if its channels spend earlier — the product simply isn't selling yet. The channels are the magnitude during the active window; the lifecycle is the gate.
For now the model is one channel → one product (a product can have several channels; a channel funds at most one product). Product clustering / categories for large catalogues is on the roadmap.
Cost & revenue curves — time-shape.
Three curves are seeded automatically: agency cost (orange, flat), media cost (violet, derived from product CRR and/or summed channel spend), revenue (green, derived from products). Add your own custom curves on top.
Growth models:
- flat — same value every month.
- linear — ramp from start to end at a fixed slope.
- exponential — multiplicative growth per month.
- manual — per-month values (data model + API exist; the editor UI is on the roadmap).
- derived — computed from products (the revenue + media-cost curves).
Each row has its own Apply button + dirty-state pill — edit freely; values persist only on Apply. Horizon, time-unit, history-months, and currency sit in the panel header with their own Apply that re-fits the whole sim. Resizing the horizon or history re-computes parametric curves at the new length and re-derives revenue + media-cost from products; history values are preserved where the index still fits, new slots default to 0.
Historic data — actuals before TODAY.
Set history-months > 0 in the Curves panel header and a dedicated Historic Data panel appears above the curves. It's a matrix: one row per metric, one column per past month (oldest M-N left → newest M-1 right), a sticky row-total column, and a footer of column totals + grand total. Type past actuals into the cells; saves debounce 600 ms.
- Curve rows. Every cost-kind curve and the revenue curve get a row. Derived curves (revenue + media-cost driven by products) render their computed historic value read-only — for a different past actual, switch that curve to manual in the Curves panel and the row turns editable.
- Channel rows — full historic funnel. Each channel is a group: a header row (name + colour + model tag) and indented metric sub-rows. By default only Spend shows. The + metric picker adds CTR %, CPC, CPM, Clicks, Impressions, Orders (plus Leads / CPL on b2b). The × on a metric row removes it. So you can feed last quarter's real Meta Ads CTR and CPC, not just spend.
- Robustness — enter whatever you have. Partial data is the norm. What's not there isn't there. Per past month the model uses every metric you provided verbatim, derives the missing ones from the present ones where a funnel relationship makes it confident (
spend + CPC → clicks;impressions + CTR → clicks;clicks + CTR → impressions;spend + CPM → impressions;clicks + click→order → orders[b2c];clicks + click→lead → leadsthenleads + lead→order → orders[b2b];spend + CPL → leads[b2b];spend + CPO → orders), and leaves the rest blank — never fabricated. Once any actual is present for a month, the projection formula does not fill the gaps. A channel with no historic data behaves exactly as the formula does today. - Spreadsheet paste. Copy a tab- or newline-separated chunk from Excel, Sheets, or Numbers and paste into a cell — values spread from the focused cell. The killer entry path for retrofitting last year's numbers. Pasted separators are parsed leniently (see Conventions) and redisplayed in international format.
- Money totals only. The footer grand total sums the money rows (spend, cost-kind curves, revenue). A metric row's own total adapts to its unit — a sum for counts, an average (⌀) for rate-shaped CTR / CPC / CPM, since summing a percentage column is meaningless.
- Keyboard. Tab and Enter move to the next cell; shift-Tab steps back. Click a cell to select, type to replace. Empty cells = 0, rendered as a muted dash.
This is also where you tell the chart “we were already profitable since M-3”. Populate the revenue + cost rows and the FIRST PROFIT chip rewrites itself to PROFIT SINCE M-N.
Overlays — what changes over time.
Two layers ride on top of the base model. Initiatives are open-loop changes you toggle on and off. WOM is a closed-loop feedback layer where past orders compound future ones. (A third overlay — chart overlay lines — is measurement only; see above.)
Initiatives — what if we did X starting M+N.
An initiative is a typed change to one field on one entity for one window, with a cost. Five inputs and a switch:
- Target. Pick a
kind(GLOBAL/CHANNEL/PRODUCT/CURVE), then an entity (e.g. “Google Ads”), then a field (e.g.ctr_pct).GLOBALtargets per-sim multipliers and skips the entity step. - Field. Channels expose
Ad CTR %,Ad CPC €,LP CR %,Click → order CR %(b2c),Click → lead CR %+Lead → order CR %(b2b),CPM €,CPL €,CPO €, anchor value. Products expose orders, AOV, margin, CRR, media budget, profit target, plus the five WOM fields. Curves expose add / set / multiply on the per-month value. Global exposesrevenue_multiplierandcost_multiplier. - Op.
=set,×multiply,+add. The value-input label adapts: to X / by × Y / by + Z. - Timing. Offset from TODAY.
FROM 3starts atM+3;TOblank = open-ended. Negative offsets retroactively modify history (rare, but allowed). - Costs. One-time at
t_start+ monthly across the window. Both add tototal_cost[t]and paint their own cost band. - Active toggle. Flip it off to compare scenarios with and without. The chart redraws live.
Composition. Multiple initiatives on the same field apply in sort_order ascending — each op acts on the running value as it's encountered. A set overrides whatever came before it; × and + then chain on the result. So set 3 followed (higher sort_order) by × 1.2 resolves to 3.6 — but flip the order and the set 3 wins outright. Order is the lever; initiatives targeting different fields never interact.
WOM — word-of-mouth, time-varying.
Paid acquisition has CAC. Recommendations are free. WOM closes the loop: orders this month produce friend-orders next month, which produce friend-of-friend orders the month after.
Each product carries five WOM inputs:
- Recommend % — share of monthly customers who recommend to a friend.
- Friend purchase % — share of recommended friends who actually buy.
- Friend AOV ratio % — friend's order value relative to base AOV. 100% = same product; <100 = starter SKU; >100 = upsell (rare).
- Lag months — integer months between original purchase and friend's.
0is treated as1internally (friends can't buy in the same month). - Decay % — per-generation attenuation of K.
0%= pure geometric cascade;50%= each next cohort half as effective.
Time-varying. If an initiative targets a WOM field, K changes from t_start onward. Orders before the window cascade with the original K; orders inside cascade with the new K (each cohort remembers the K at the moment of its original purchase). WOM has no media cost — friend buys are free acquisition, so the media-cost curve never charges for them.
Stability. The cascade converges when K × (1 − decay/100) < 1. Above that it explodes; the UI flags the row red (· UNSTABLE) and totals fall back to direct-only. Real businesses don't have K ≥ 1 — if your inputs say they do, the inputs are too optimistic.
Math reference.
One place, all the formulas. Variables: A = AOV, m = margin %, c = CRR %, K = viral coefficient, d = decay fraction. All math runs server-side in resolveScenarioPnL() and friends — the client mirrors it for live feedback, but persisted values come from the server.
Product resolved values, per anchor mode.
// orders anchor — you type o media_spend = o * A * c / 100 mrr = o * A // media_budget anchor — you type b unit_media_cost = A * c / 100 orders = b / unit_media_cost (if > 0 else 0) mrr = orders * A media_spend = b // profit_target anchor — you type t net_margin_per_order = A * (m - c) / 100 orders = t / net_margin_per_order (if > 0 else Unreachable) mrr = orders * A media_spend = orders * A * c / 100 // channels anchor — orders + media come from connected channels orders = Σ active connected channels[t].orders media_spend = Σ active connected channels[t].spend // own CRR is IGNORED mrr = orders * A // always (every mode) gross_profit = mrr * m / 100 net_profit = gross_profit - media_spend // + a lifecycle gate: orders = mrr = media_spend = gross_profit = 0 // unless t >= start_month AND (end_month == null OR t <= end_month)
Unreachable fires in profit_target mode when m ≤ c — no number of orders hits the goal, because each order destroys more margin than it earns.
Channel funnel chains.
// b2c clicks = impressions * (CTR/100) orders = clicks * (click_to_order/100) // b2b clicks = impressions * (CTR/100) leads = clicks * (click_to_lead/100) orders = leads * (lead_to_order/100) // the funnel solves bidirectionally: walk BACKWARD from the anchor metric // to impressions, then FORWARD to fill remaining steps. (back-step uses // division: e.g. clicks = orders / (click_to_order/100).) // spend spend = anchor_value // when anchor = spend // otherwise, derive from the most upstream non-zero rate, preference order: // CPM > CPC > CPL(b2b) > CPO spend = impressions/1000 * CPM // or clicks*CPC, leads*CPL, orders*CPO // always CAC = spend / orders // when orders > 0
Cost stacking + anti-double-count.
total_cost[t] = Σ cost-kind curves[t] // agency + media + custom
+ Σ active channels[t].spend // every channel, linked or not
+ Σ active initiatives[t] cost // oneshot@t_start + monthly
// ANTI-DOUBLE-COUNT: a CHANNELS-anchored product's media IS its channels'
// spend, already in the channel sum above. So the derived media-cost curve
// EXCLUDES channels-anchored products — they add no CRR media. Result:
net_media = (Σ all channel spend) + (CRR media for non-channels products only)
Profit + first-profit.
revenue[t] = (Σ revenue-kind curves[t]) * revenue_multiplier // global, after sums
total_cost[t] = total_cost[t] * cost_multiplier // global, after sums
net_profit[t] = revenue[t] - total_cost[t]
first_profit_month = first t where net_profit[t] ≥ 0
AND (revenue[t] > 0 OR total_cost[t] > 0) // skips empty (0,0) months
// PnL series length = history_months + horizon_months (t = 0 .. length-1)
WOM cascade + steady state.
K = (recommend_pct/100) * (friend_purchase_pct/100)
Kd = K * (1 - decay_pct/100)
steady_mult = K / (1 - Kd) // only when Kd < 1; total WOM as a
// multiple of the direct cohort
wom_orders[t] = Σ over generations k = 1, 2, 3, …:
direct_orders[t - k*lag] * K^k * (1 - decay/100)^(k-1)
wom_revenue[t] = wom_orders[t] * A * (friend_AOV_ratio_pct/100)
// safeguards
// - terminate when the cohort weight < 1e-4 OR k > 60
// - lag = 0 treated as 1 (no instant cascade)
// - STABILITY: Kd ≥ 1 → unstable; UI flags, totals fall back to direct-only
// - time-varying: each source month's own K/lag/decay drives its cohorts
Initiative effect ordering.
// active initiatives are applied to a per-t copy of the target field in // sort_order ASCENDING (then id). Each op acts on the RUNNING value: // set field = value // overrides whatever came before // multiply field = field * value // add field = field + value // → composition is order-driven: a `set` later in the order wins outright; // `×` / `+` chain on the running value. Different fields never interact. // global multipliers apply LAST, on the fully-summed revenue / total_cost.
Worked examples.
Nine scenarios, each isolating one mechanic. Numbers are pure model math, verified against the resolver code — no rounding shortcuts.
1. Pure direct sales.
Video course. AOV = €37, margin = 100% (digital, no COGS), CRR = 30%, orders = 400/mo.
media_spend = 400 × 37 × 0.30 = €4,440mrr = 400 × 37 = €14,800gross_profit = 14,800 × 1.00 = €14,800net_profit = 14,800 − 4,440 = €10,360
2. Media-budget anchor.
Same course. Flip the anchor to media_budget and type €5,000/mo at CRR = 30%, AOV = €37.
unit_media_cost = 37 × 0.30 = €11.10orders = 5,000 / 11.10 ≈ 450mrr = 450 × 37 ≈ €16,667media_spend = €5,000(the anchor)net_profit = 16,667 − 5,000 = €11,667
3. B2C funnel channel.
Google Ads campaign, spend = €5,000/mo, CPM = €30, CTR = 2%, click→order = 1.5%.
impressions = 5,000 / 30 × 1,000 ≈ 166,667clicks = 166,667 × 0.02 = 3,333orders = 3,333 × 0.015 = 50CAC = 5,000 / 50 = €100
4. B2B funnel channel.
LinkedIn Lead Gen, spend = €4,000/mo, CPC = €2, click→lead = 5%, lead→order = 20%. (No CPM set, so CPC drives the funnel.)
clicks = 4,000 / 2 = 2,000leads = 2,000 × 0.05 = 100orders = 100 × 0.20 = 20CAC = 4,000 / 20 = €200
5. Channel → product connector.
Product Video Course 2: anchor CHANNELS, AOV = €49, margin = 80%, starts M+6, runs forever. Channel Meta Ads VC2: drives product = Video Course 2, b2c, anchor SPEND €3,000/mo, CPM = €30, CTR = 2%, click→order = 1.5%.
The channel resolves:
impressions = 3,000 / 30 × 1,000 = 100,000clicks = 100,000 × 0.02 = 2,000orders = 2,000 × 0.015 = 30spend = €3,000(the anchor)
Video Course 2 inherits exactly those 30 orders/mo + €3,000 media — but only from M+6 onward (its lifecycle start; before M+6 it's zero even though the channel spends). Revenue = 30 × €49 = €1,470/mo; gross profit = 1,470 × 0.80 = €1,176. The cost stack shows the €3,000 once — from the channel band — never doubled by a CRR cut on the product.
6. Initiative with timing + its impact chain.
Same Google Ads channel as example 3 (~50 orders/mo at CTR 2%). Add an initiative:
- Target:
CHANNEL · Google Ads · ctr_pct - Op & value:
=set to3 - Timing:
FROM 3, open-ended - Costs:
€2,000one-time +€500/mo
From M+3 onward CTR jumps 2% → 3%. Impressions stay anchored to spend (CPM math unchanged), but clicks rise 50% to 5,000 and orders rise 50% to 75/mo. Channel spend stays €5k/mo (still spend-anchored); the initiative band adds €2k oneshot at M+3 + €500/mo thereafter.
Its honest impact chain: × / set CTR ↑ → CPC ↓ → CAC ↓. The chain stops at CAC, not orders — lifting CTR with weak creative could fail to convert, so we don't promise the order uplift in the chain even though this clean example does deliver it.
7. WOM steady state.
Premium Plan, 50 direct orders/mo, AOV = €199. WOM: recommend 40%, friend purchase 60%, friend AOV ratio 100%, lag 1 mo, decay 0%.
K = 0.40 × 0.60 = 0.24Kd = 0.24 × 1 = 0.24→ stablesteady_mult = 0.24 / (1 − 0.24) ≈ 0.316wom_orders @ steady ≈ 50 × 0.316 ≈ 16/mo- WOM share = 16 / (50 + 16) ≈ 24% → the
WOM 24%badge appears on the chart.
8. WOM-targeting initiative.
Same Premium Plan, but baseline WOM is weaker: recommend 10%, friend purchase 60% → K_baseline = 0.06. Then launch a referral program:
- Target:
PRODUCT · Premium Plan · wom_recommend_rate_pct - Op & value:
=set to40 - Timing:
FROM 3, open-ended - Costs:
€5,000one-time +€1,000/mo
From M+3 onward recommend% snaps 10 → 40, so K = 0.40 × 0.60 = 0.24. Orders before M+3 still cascade with the original K = 0.06 (steady_mult ≈ 0.064); orders in the window cascade with the new K = 0.24 (steady_mult ≈ 0.316). The WOM uplift ramps in over the lag horizon and adds roughly +13 extra orders/mo at the new steady state. Friend orders cost zero media — blended CAC drops.
9. Historic actuals validating a projection.
You ran Meta Ads for the last 6 months and want the chart to show what really happened, then continue from your forward assumptions. Set history-months = 6, open the Historic Data panel, and on the Meta Ads channel add Spend, CTR %, and CPC rows. Paste the six monthly actuals into each.
For each past month the model uses your actuals verbatim and derives the rest:
clicks = spend / CPC— from your real spend ÷ real CPC.impressions = clicks / (CTR/100)— from clicks ÷ your real CTR.orders = clicks × (click→order/100)— using your channel's click→order assumption (no historic actual for it).
Concretely: a month with spend €3,000, CPC €1.50, CTR 1.8% → 2,000 clicks, ~111,111 impressions. The history side of the chart now plots your real funnel; the projection side continues from your forward formula. If projected CTR / CPC drift from what actually happened, that's your cue to retune. Metrics you didn't enter (e.g. impressions, if you only had spend + CPC) are filled by derivation; what can't be derived stays blank — never fabricated.
Tips & failure modes.
- One anchor at a time. Don't over-anchor. Pick the metric you can measure and let the rest derive.
- CRR is your assumption, not derivable. It's a media-buying judgement — revisit it when reality drifts.
- Profit-target with margin ≤ CRR is Unreachable. Each order destroys more margin than it generates. Widen margin or tighten CRR until
margin > CRR. - WOM unstable means
K × (1 − decay) ≥ 1. The cascade explodes; numbers become meaningless. Lower K or raise decay. - Impact chains stay quiet when uncertain. A short chain isn't a bug — it's the tool refusing to invent downstream effects. Even a CTR boost stops at CAC, because clickbait CTR may never convert.
- Product lifecycle → WOM tail-off. A product that stops selling still earns a few trailing word-of-mouth orders as its last cohort rings out. Expect a short WOM tail past the end month, not a hard cliff.
- Historic data is never fabricated. What's not there isn't there. Enter only spend and the funnel internals stay blank for those months — the projection formula does not backfill once any actual is present.
- Initiative composition is order-driven. Within a field, ops fire in
sort_orderascending on the running value — asetoverrides everything before it, then×/+chain. Reorder rows to change the result. - × 0 is a kill switch. An
× 0initiative zeroes the target for the window. Use sparingly — an explicit=reads cleaner six months later. - Stale target. Delete a channel an initiative pointed at and the row shows
stalenext to the entity picker (the channel link also resets to brand/unattributed). Re-point or delete. - Mute is visual, not mathematical. Muting a band in the Costs chip declutters the chart without changing PnL. Use the entity's active toggle to actually remove its cost.
- Duplicate before you experiment. Saves are destructive (PUT replaces). The dropdown is your audit trail.
- History > horizon is allowed but unusual. Useful for retrospective “what happened last year” sims; not the default.
Conventions.
- International number format, everywhere. Period = decimal separator, comma = thousands grouping (
1,234.56,0.5,166,667,€14,800,-€1,035). One convention across the whole simulator. Type with a period for decimals. Pasted spreadsheet values are parsed leniently — a German1,1and an international1.1both parse to 1.1, and1.234,56/1,234.56both parse to 1234.56 (the separator that appears last wins as the decimal) — then redisplayed in the international form. - Timeline notation.
M-N= N months before TODAY (history);M+N= N months after (projection). There is noM0— the axis skips fromM-1toM+1with the TODAY divider between. Initiative timing and product lifecycle both use theM+Noffset, stored internally as an absolute timeline index so resizing history never shifts a real date. - Cache versioning. Every static asset is loaded with a
?v=YYYYMMDD<rev>query string (currently?v=20260521a). The app and the wiki move in lockstep. A hard reload after a deploy guarantees you're on the current build.
Roadmap.
✓ shipped · · planned.
t_start onwardM+N), inactive months contribute zero so revenue / media curves step, WOM tail-off after end, runs forever by default (Phase 12)CHANNELS anchor mode; anti-double-count keeps channel-anchored media off the CRR curve (Phase 13)From M+X → To M+Y (or ∞ indefinite) segments that override the base price for the covered months; later-starting segment wins on overlap; revenue steps at each change and threads through MRR / CRR media / budget + profit anchors / WOM / channel revenue (Phase 23)set / × / + at t_startChangelog.
Last 25 commits touching sim files. Baked at deploy.
(pending) · 2026-05-20 · feat(sim): scheduled product price changes over time (Phase 23) (pending) · 2026-05-20 · feat(sim): history/future/both chart view toggle (Phase 22) (pending) · 2026-05-20 · fix(sim): fullscreen save path + global save-state indicator (Phase 21) a515678 · 2026-05-20 · feat(sim): wider fullscreen drawers + curves header wrap fix + open/close sounds (Phase 20) 4c18c9d · 2026-05-20 · fix(sim): fullscreen drawers render content (Phase 19) dfea4d6 · 2026-05-20 · feat(sim): info tooltips on every field + wiki deeplinks (Phase 18) ec666b9 · 2026-05-20 · fix(sim): no-overshoot interpolation + every-month gridlines (Phase 17) 5e680a6 · 2026-05-20 · feat(sim): true fullscreen immersive mode with edge-drawer controls (Phase 16) 9cc744e · 2026-05-20 · feat(sim): chart-hero layout + collapsible panels + focus mode (Phase 15) 62560f0 · 2026-05-20 · feat(sim): historic funnel metrics per channel (Phase 14) 70dab82 · 2026-05-20 · feat(sim): channel↔product connector + channels anchor mode (Phase 13) b2ff8ae · 2026-05-20 · feat(sim): product lifecycle (start/end month) (Phase 12) b973f6d · 2026-05-20 · fix(sim): unify all number formatting to international (period decimal, comma thousands) (Phase 11) 5b4071b · 2026-05-20 · fix(sim): honest direction-aware impact chains (Phase 10) 1f713e6 · 2026-05-19 · feat(sim): initiative chart markers + side rail + impact-chain explainer (Phase 9) f743f0a · 2026-05-19 · feat(sim): chart overlays + smart multi-axis (Phase 8) 527bd62 · 2026-05-19 · feat(sim): unified historic data table (Phase 7) 9bd5ca9 · 2026-05-19 · docs(sim): comprehensive wiki overhaul (Phase 6) e54fa3e · 2026-05-19 · polish(sim): full English UI + extended initiative target fields (Phase 5b) 8fffe9a · 2026-05-19 · feat(sim): WOM module with viral coefficient per product (Phase 5) c7a1ee5 · 2026-05-19 · feat(sim): initiatives with target/op/timing/cost (Phase 3) e700aa1 · 2026-05-19 · feat(sim): acquisition channels with multi-anchor funnel (Phase 2) 608b9d9 · 2026-05-19 · feat(sim): history vs projection split with TODAY divider 004d204 · 2026-05-19 · feat(sim): stack cost curves + brutal P/L gap between revenue and cost-stack-top 6c8a80a · 2026-05-19 · feat(sim): brutal profit/loss zones + flatten Advanced into defaults under chart 7275357 · 2026-05-19 · feat(sim): per-product anchor-mode switch (orders / media-budget / profit-target) + dev wiki 93a4386 · 2026-05-19 · feat(sim): per-row Apply button in Advanced curves editor 7ff774e · 2026-05-19 · feat(sim): derive media-cost from product-level Media CRR % d1a8976 · 2026-05-19 · fix(sim): force [hidden] attribute to win over .login-gate display:flex 5eba42b · 2026-05-19 · fix(sim): bust client cache with ?v= on imports + script src 811b18d · 2026-05-19 · fix(sim): preserve admin token when /sim/ auth check fails 4431699 · 2026-05-19 · fix(sim): never block UI on auth — render empty state for anon visitors