Skip to content

Instantly share code, notes, and snippets.

@snoble
Created January 17, 2026 02:01
Show Gist options
  • Select an option

  • Save snoble/20bdf88f0749c7f56fdd12eb6f49a5de to your computer and use it in GitHub Desktop.

Select an option

Save snoble/20bdf88f0749c7f56fdd12eb6f49a5de to your computer and use it in GitHub Desktop.
BurritoScript Deadweight Loss Demo
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deadweight Loss - BurritoScript</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f1a;
color: #e4e4e7;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.container {
max-width: 740px;
width: 100%;
}
h1 {
font-size: 1.5rem;
margin-bottom: 16px;
color: #a78bfa;
}
canvas {
display: block;
background: #1a1a2e;
border-radius: 8px;
width: 100%;
height: auto;
}
.controls {
margin-top: 16px;
padding: 16px;
background: #1a1a2e;
border-radius: 8px;
}
.control-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.control-row label {
width: 100px;
font-size: 14px;
color: #a1a1aa;
}
.control-row input[type="range"] {
flex: 1;
accent-color: #a78bfa;
}
.control-row span {
width: 50px;
text-align: right;
font-family: monospace;
}
.buttons {
display: flex;
gap: 8px;
margin-top: 16px;
}
button {
flex: 1;
padding: 10px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: opacity 0.15s;
}
button:hover { opacity: 0.9; }
.btn-primary { background: #3b82f6; color: white; }
.btn-secondary { background: #52525b; color: #e4e4e7; }
.footer {
margin-top: 16px;
font-size: 12px;
color: #71717a;
text-align: center;
}
.footer a { color: #a78bfa; text-decoration: none; }
.stats {
position: absolute;
top: 8px;
left: 8px;
font-size: 11px;
color: rgba(255,255,255,0.5);
font-family: monospace;
}
.canvas-wrapper {
position: relative;
}
/* Provenance panel styles */
.provenance-toggle {
position: absolute;
top: 8px;
right: 8px;
font-size: 11px;
padding: 4px 8px;
background: rgba(167, 139, 250, 0.2);
color: #a78bfa;
border: 1px solid rgba(167, 139, 250, 0.3);
border-radius: 4px;
cursor: pointer;
}
.provenance-toggle.active {
background: rgba(167, 139, 250, 0.4);
border-color: #a78bfa;
}
.provenance-panel {
margin-top: 16px;
padding: 16px;
background: #1a1a2e;
border-radius: 8px;
font-family: monospace;
font-size: 12px;
max-height: 300px;
overflow-y: auto;
}
.provenance-panel.hidden { display: none; }
.provenance-frame {
margin-bottom: 12px;
padding: 8px;
background: rgba(0,0,0,0.3);
border-radius: 4px;
}
.provenance-frame-header {
color: #a78bfa;
font-weight: bold;
margin-bottom: 4px;
}
.provenance-read { color: #3b82f6; }
.provenance-write { color: #22c55e; }
.provenance-computation { color: #f59e0b; }
</style>
</head>
<body>
<div class="container">
<h1>Deadweight Loss</h1>
<div class="canvas-wrapper">
<canvas id="canvas" width="700" height="500"></canvas>
<div class="stats" id="stats"></div>
<button class="provenance-toggle" id="provenance-toggle">πŸ” Provenance</button>
</div>
<div class="controls">
<div class="control-row">
<label>Tax</label>
<input type="range"
id="ctrl-taxPerUnit"
min="0"
max="80"
step="5"
value="25">
<span id="val-taxPerUnit">25</span>
</div>
<div class="buttons">
<button class="btn-secondary" id="reset">Reset</button>
<button class="btn-primary" id="toggle">Pause</button>
</div>
</div>
<div class="provenance-panel hidden" id="provenance-panel"><div id="provenance-content">Provenance tracking enabled. Traces will appear here.</div></div>
<div class="footer">
Built with <a href="https://github.com/anthropics/burritoscript">BurritoScript</a>
<br>
<small>Effect analysis discovered: 4 state reads, 1 canvas bindings | Provenance mode enabled</small>
</div>
</div>
<script>
// ==========================================================================
// STATE (from BurritoScript initialState)
// ==========================================================================
let state = {
"goodName": "Concert Ticket",
"goodEmoji": "🎫",
"buyers": [
{
"id": 1,
"name": "Super Fan",
"emoji": "🀩",
"maxWillingToPay": 200,
"reason": "Must see this show!",
"x": 87,
"y": 100,
"targetX": 87,
"targetY": 100,
"state": "wandering",
"happiness": 0
},
{
"id": 2,
"name": "Gift Buyer",
"emoji": "🎁",
"maxWillingToPay": 150,
"reason": "Perfect birthday gift",
"x": 124,
"y": 180,
"targetX": 124,
"targetY": 180,
"state": "wandering",
"happiness": 0
},
{
"id": 3,
"name": "Casual",
"emoji": "😊",
"maxWillingToPay": 100,
"reason": "Sounds fun",
"x": 61,
"y": 260,
"targetX": 61,
"targetY": 260,
"state": "wandering",
"happiness": 0
},
{
"id": 4,
"name": "Curious",
"emoji": "πŸ€”",
"maxWillingToPay": 60,
"reason": "Maybe if cheap",
"x": 98,
"y": 340,
"targetX": 98,
"targetY": 340,
"state": "wandering",
"happiness": 0
},
{
"id": 5,
"name": "Hesitant",
"emoji": "😐",
"maxWillingToPay": 35,
"reason": "Only if a steal",
"x": 135,
"y": 420,
"targetX": 135,
"targetY": 420,
"state": "wandering",
"happiness": 0
}
],
"sellers": [
{
"id": 1,
"name": "Urgent",
"emoji": "😰",
"minWillingToAccept": 20,
"reason": "Can't go, need cash",
"x": 593,
"y": 100,
"targetX": 593,
"targetY": 100,
"state": "wandering",
"happiness": 0
},
{
"id": 2,
"name": "Flexible",
"emoji": "πŸ™‚",
"minWillingToAccept": 50,
"reason": "Rather have money",
"x": 636,
"y": 180,
"targetX": 636,
"targetY": 180,
"state": "wandering",
"happiness": 0
},
{
"id": 3,
"name": "Fair",
"emoji": "πŸ’°",
"minWillingToAccept": 80,
"reason": "Want face value",
"x": 579,
"y": 260,
"targetX": 579,
"targetY": 260,
"state": "wandering",
"happiness": 0
},
{
"id": 4,
"name": "Hopeful",
"emoji": "🀞",
"minWillingToAccept": 130,
"reason": "Hoping for profit",
"x": 622,
"y": 340,
"targetX": 622,
"targetY": 340,
"state": "wandering",
"happiness": 0
},
{
"id": 5,
"name": "Firm",
"emoji": "😀",
"minWillingToAccept": 190,
"reason": "Won't go lower",
"x": 565,
"y": 420,
"targetX": 565,
"targetY": 420,
"state": "wandering",
"happiness": 0
}
],
"taxPerUnit": 25,
"time": 0,
"phase": "gathering",
"activePairIndex": -1,
"pairs": [],
"completedDeals": 0,
"blockedDeals": 0,
"totalHappiness": 0,
"happinessLost": 0,
"taxCollected": 0,
"width": 700,
"height": 500,
"running": true
};
const initialState = {"goodName":"Concert Ticket","goodEmoji":"🎫","buyers":[{"id":1,"name":"Super Fan","emoji":"🀩","maxWillingToPay":200,"reason":"Must see this show!","x":87,"y":100,"targetX":87,"targetY":100,"state":"wandering","happiness":0},{"id":2,"name":"Gift Buyer","emoji":"🎁","maxWillingToPay":150,"reason":"Perfect birthday gift","x":124,"y":180,"targetX":124,"targetY":180,"state":"wandering","happiness":0},{"id":3,"name":"Casual","emoji":"😊","maxWillingToPay":100,"reason":"Sounds fun","x":61,"y":260,"targetX":61,"targetY":260,"state":"wandering","happiness":0},{"id":4,"name":"Curious","emoji":"πŸ€”","maxWillingToPay":60,"reason":"Maybe if cheap","x":98,"y":340,"targetX":98,"targetY":340,"state":"wandering","happiness":0},{"id":5,"name":"Hesitant","emoji":"😐","maxWillingToPay":35,"reason":"Only if a steal","x":135,"y":420,"targetX":135,"targetY":420,"state":"wandering","happiness":0}],"sellers":[{"id":1,"name":"Urgent","emoji":"😰","minWillingToAccept":20,"reason":"Can't go, need cash","x":593,"y":100,"targetX":593,"targetY":100,"state":"wandering","happiness":0},{"id":2,"name":"Flexible","emoji":"πŸ™‚","minWillingToAccept":50,"reason":"Rather have money","x":636,"y":180,"targetX":636,"targetY":180,"state":"wandering","happiness":0},{"id":3,"name":"Fair","emoji":"πŸ’°","minWillingToAccept":80,"reason":"Want face value","x":579,"y":260,"targetX":579,"targetY":260,"state":"wandering","happiness":0},{"id":4,"name":"Hopeful","emoji":"🀞","minWillingToAccept":130,"reason":"Hoping for profit","x":622,"y":340,"targetX":622,"targetY":340,"state":"wandering","happiness":0},{"id":5,"name":"Firm","emoji":"😀","minWillingToAccept":190,"reason":"Won't go lower","x":565,"y":420,"targetX":565,"targetY":420,"state":"wandering","happiness":0}],"taxPerUnit":25,"time":0,"phase":"gathering","activePairIndex":-1,"pairs":[],"completedDeals":0,"blockedDeals":0,"totalHappiness":0,"happinessLost":0,"taxCollected":0,"width":700,"height":500,"running":true};
// ==========================================================================
// SUBSCRIPTIONS (discovered by Effect analysis)
// ==========================================================================
const subscriptions = new Map([
['ball', ["main"]],
['config', ["main"]]
]);
// ==========================================================================
// HELPERS
// ==========================================================================
const getPath = (obj, path) => path.reduce((acc, key) => acc?.[key], obj);
const setPath = (obj, path, value) => {
if (path.length === 0) return value;
if (path.length === 1) return { ...obj, [path[0]]: value };
return { ...obj, [path[0]]: setPath(obj[path[0]] ?? {}, path.slice(1), value) };
};
// ==========================================================================
// PROVENANCE RUNTIME (development mode)
// ==========================================================================
const __prov = {
traces: [],
currentFrame: null,
enabled: false,
startFrame() {
if (!this.enabled) return;
this.currentFrame = {
timestamp: performance.now(),
reads: [],
writes: [],
};
},
endFrame() {
if (!this.enabled || !this.currentFrame) return;
this.traces.push(this.currentFrame);
if (this.traces.length > 10) this.traces.shift();
this.currentFrame = null;
this._updatePanel();
},
trackRead(path, value) {
if (!this.enabled || !this.currentFrame) return;
this.currentFrame.reads.push({ path, value: this._snapshot(value) });
},
trackWrite(path, oldValue, newValue) {
if (!this.enabled || !this.currentFrame) return;
this.currentFrame.writes.push({
path,
oldValue: this._snapshot(oldValue),
newValue: this._snapshot(newValue)
});
},
_snapshot(v) {
try { return JSON.parse(JSON.stringify(v)); }
catch { return '<non-serializable>'; }
},
_updatePanel() {
const el = document.getElementById('provenance-content');
if (!el) return;
const frames = this.traces.slice(-5).reverse();
if (frames.length === 0) {
el.innerHTML = 'No traces yet. Interact with the simulation.';
return;
}
el.innerHTML = frames.map((f, i) => {
const reads = f.reads.map(r =>
'<div class="provenance-read">READ: ' + r.path + ' = ' + JSON.stringify(r.value).slice(0, 50) + '</div>'
).join('');
const writes = f.writes.map(w =>
'<div class="provenance-write">WRITE: ' + w.path + ' ' + JSON.stringify(w.oldValue).slice(0, 20) + ' β†’ ' + JSON.stringify(w.newValue).slice(0, 20) + '</div>'
).join('');
return '<div class="provenance-frame"><div class="provenance-frame-header">Frame ' + (frames.length - i) + '</div>' + reads + writes + '</div>';
}).join('');
},
toggle() {
this.enabled = !this.enabled;
const btn = document.getElementById('provenance-toggle');
const panel = document.getElementById('provenance-panel');
if (btn) btn.classList.toggle('active', this.enabled);
if (panel) panel.classList.toggle('hidden', !this.enabled);
if (this.enabled) this._updatePanel();
}
};
document.getElementById('provenance-toggle')?.addEventListener('click', () => __prov.toggle());
// ==========================================================================
// UPDATE FUNCTION (pure, from .burrito.ts)
// ==========================================================================
// Helper functions
const SPEED = 3;
const WANDER_RADIUS = 30;
const moveToward = (person, speed) => {
const dx = person.targetX - person.x;
const dy = person.targetY - person.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < speed) {
return { ...person, x: person.targetX, y: person.targetY };
}
return {
...person,
x: person.x + (dx / dist) * speed,
y: person.y + (dy / dist) * speed,
};
};
const atTarget = (person) => {
const dx = person.targetX - person.x;
const dy = person.targetY - person.y;
return Math.sqrt(dx * dx + dy * dy) < 5;
};
const calculatePairs = (buyers, sellers, tax, width, height) => {
const sortedBuyers = [...buyers].sort((a, b) => b.maxWillingToPay - a.maxWillingToPay);
const sortedSellers = [...sellers].sort((a, b) => a.minWillingToAccept - b.minWillingToAccept);
const result = sortedBuyers.reduce(
(acc, buyer) => {
const availableSeller = sortedSellers.find(
seller =>
!acc.usedSellerIds.has(seller.id) &&
buyer.maxWillingToPay - seller.minWillingToAccept >= 0
);
if (!availableSeller) return acc;
const surplus = buyer.maxWillingToPay - availableSeller.minWillingToAccept;
const pairIndex = acc.pairs.length;
const newPair = {
buyer,
seller: availableSeller,
surplus,
willTrade: surplus >= tax,
meetingX: width / 2 + (pairIndex - 2) * 60,
meetingY: 120 + pairIndex * 70,
resolved: false,
};
return {
pairs: [...acc.pairs, newPair],
usedSellerIds: new Set([...acc.usedSellerIds, availableSeller.id]),
};
},
{ pairs: [], usedSellerIds: new Set() }
);
return result.pairs;
};
function update(state, dt) {
if (!state.running) return state;
const newTime = state.time + dt;
// Phase transitions based on time
if (state.phase === 'gathering' && newTime > 1) {
const pairs = calculatePairs(state.buyers, state.sellers, state.taxPerUnit, state.width, state.height);
return {
...state,
time: newTime,
phase: 'matching',
pairs,
activePairIndex: 0,
buyers: state.buyers.map(b => ({ ...b, state: 'walking' })),
sellers: state.sellers.map(s => ({ ...s, state: 'walking' })),
};
}
if (state.phase === 'matching') {
const currentPair = state.pairs[state.activePairIndex];
if (!currentPair) {
return { ...state, time: newTime, phase: 'aftermath' };
}
const movedBuyers = state.buyers.map(b => {
if (b.id === currentPair.buyer.id) {
const updated = moveToward(
{ ...b, targetX: currentPair.meetingX - 40, targetY: currentPair.meetingY },
SPEED
);
return { ...updated, state: 'walking' };
}
return b;
});
const movedSellers = state.sellers.map(s => {
if (s.id === currentPair.seller.id) {
const updated = moveToward(
{ ...s, targetX: currentPair.meetingX + 40, targetY: currentPair.meetingY },
SPEED
);
return { ...updated, state: 'walking' };
}
return s;
});
const buyer = movedBuyers.find(b => b.id === currentPair.buyer.id);
const seller = movedSellers.find(s => s.id === currentPair.seller.id);
const buyerArrived = Math.abs(buyer.x - (currentPair.meetingX - 40)) < 5;
const sellerArrived = Math.abs(seller.x - (currentPair.meetingX + 40)) < 5;
if (buyerArrived && sellerArrived) {
const negotiatingBuyers = movedBuyers.map(b =>
b.id === currentPair.buyer.id ? { ...b, state: 'negotiating' } : b
);
const negotiatingSellers = movedSellers.map(s =>
s.id === currentPair.seller.id ? { ...s, state: 'negotiating' } : s
);
return {
...state,
time: newTime,
phase: 'negotiating',
buyers: negotiatingBuyers,
sellers: negotiatingSellers,
};
}
return { ...state, time: newTime, buyers: movedBuyers, sellers: movedSellers };
}
if (state.phase === 'negotiating') {
if (newTime - state.time > 0.8) {
return { ...state, time: newTime, phase: 'resolving' };
}
return { ...state, time: newTime };
}
if (state.phase === 'resolving') {
const currentPair = state.pairs[state.activePairIndex];
if (!currentPair) {
return { ...state, time: newTime, phase: 'aftermath' };
}
const willTrade = currentPair.willTrade;
const afterTaxSurplus = currentPair.surplus - state.taxPerUnit;
const perPersonHappiness = willTrade ? afterTaxSurplus / 2 : 0;
const newTotalHappiness = willTrade ? state.totalHappiness + afterTaxSurplus : state.totalHappiness;
const newHappinessLost = willTrade ? state.happinessLost : state.happinessLost + currentPair.surplus;
const newTaxCollected = willTrade ? state.taxCollected + state.taxPerUnit : state.taxCollected;
const newCompletedDeals = willTrade ? state.completedDeals + 1 : state.completedDeals;
const newBlockedDeals = willTrade ? state.blockedDeals : state.blockedDeals + 1;
const buyers = state.buyers.map(b => {
if (b.id === currentPair.buyer.id) {
return {
...b,
state: willTrade ? 'happy' : 'sad',
happiness: perPersonHappiness,
targetX: 50 + (currentPair.buyer.id * 7) % 80,
targetY: b.y,
};
}
return b;
});
const sellers = state.sellers.map(s => {
if (s.id === currentPair.seller.id) {
return {
...s,
state: willTrade ? 'happy' : 'sad',
happiness: perPersonHappiness,
targetX: state.width - 130 + (currentPair.seller.id * 13) % 80,
targetY: s.y,
};
}
return s;
});
const pairs = state.pairs.map((p, i) =>
i === state.activePairIndex ? { ...p, resolved: true } : p
);
const nextIndex = state.activePairIndex + 1;
if (nextIndex >= pairs.length) {
return {
...state,
time: newTime,
phase: 'aftermath',
pairs,
buyers,
sellers,
activePairIndex: nextIndex,
totalHappiness: newTotalHappiness,
happinessLost: newHappinessLost,
taxCollected: newTaxCollected,
completedDeals: newCompletedDeals,
blockedDeals: newBlockedDeals,
};
}
return {
...state,
time: newTime,
phase: 'matching',
pairs,
buyers,
sellers,
activePairIndex: nextIndex,
totalHappiness: newTotalHappiness,
happinessLost: newHappinessLost,
taxCollected: newTaxCollected,
completedDeals: newCompletedDeals,
blockedDeals: newBlockedDeals,
};
}
if (state.phase === 'aftermath') {
const buyers = state.buyers.map((b) => {
const updated = moveToward(b, SPEED * 0.5);
if (atTarget(updated) && updated.state !== 'idle') {
const wanderX = ((b.id * 17 + 5) % WANDER_RADIUS) - WANDER_RADIUS / 2;
const wanderY = ((b.id * 23 + 7) % WANDER_RADIUS) - WANDER_RADIUS / 2;
return {
...updated,
state: 'idle',
targetX: updated.x + wanderX,
targetY: updated.y + wanderY,
};
}
return updated;
});
const sellers = state.sellers.map((s) => {
const updated = moveToward(s, SPEED * 0.5);
if (atTarget(updated) && updated.state !== 'idle') {
const wanderX = ((s.id * 19 + 3) % WANDER_RADIUS) - WANDER_RADIUS / 2;
const wanderY = ((s.id * 29 + 11) % WANDER_RADIUS) - WANDER_RADIUS / 2;
return {
...updated,
state: 'idle',
targetX: updated.x + wanderX,
targetY: updated.y + wanderY,
};
}
return updated;
});
return { ...state, time: newTime, buyers, sellers };
}
return { ...state, time: newTime };
}
// ==========================================================================
// RENDER FUNCTION (pure, from .burrito.ts)
// ==========================================================================
const EMOJI_SIZE = 32;
const drawPerson = (ctx, x, y, emoji, name, value, valueLabel, valueColor, state, happiness) => {
if (state === 'happy') {
ctx.shadowColor = '#22c55e';
ctx.shadowBlur = 15;
} else if (state === 'sad') {
ctx.shadowColor = '#ef4444';
ctx.shadowBlur = 15;
} else if (state === 'negotiating') {
ctx.shadowColor = '#eab308';
ctx.shadowBlur = 10;
}
ctx.font = EMOJI_SIZE + 'px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(emoji, x, y);
ctx.shadowBlur = 0;
ctx.font = 'bold 11px sans-serif';
ctx.fillStyle = '#ffffff';
ctx.fillText(name, x, y + 28);
ctx.font = '10px sans-serif';
ctx.fillStyle = valueColor;
ctx.fillText(valueLabel + ': $' + value, x, y + 42);
if (happiness > 0) {
ctx.fillStyle = '#22c55e';
ctx.font = 'bold 10px sans-serif';
ctx.fillText('+$' + happiness.toFixed(0), x, y - 25);
} else if (state === 'sad') {
ctx.fillStyle = '#ef4444';
ctx.font = '16px sans-serif';
ctx.fillText('😒', x, y - 25);
}
};
const drawNegotiation = (ctx, pair, phase, tax) => {
const { buyer, seller, surplus, willTrade, meetingX, meetingY } = pair;
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.beginPath();
ctx.roundRect(meetingX - 80, meetingY - 70, 160, 50, 8);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
if (phase === 'negotiating') {
ctx.fillText('Buyer: "I\'ll pay up to $' + buyer.maxWillingToPay + '"', meetingX, meetingY - 55);
ctx.fillText('Seller: "I need at least $' + seller.minWillingToAccept + '"', meetingX, meetingY - 38);
} else if (phase === 'resolving' || pair.resolved) {
if (willTrade) {
ctx.fillStyle = '#22c55e';
ctx.font = 'bold 14px sans-serif';
ctx.fillText('🀝 DEAL!', meetingX, meetingY - 55);
ctx.font = '11px sans-serif';
ctx.fillStyle = '#ffffff';
const price = seller.minWillingToAccept + (surplus - tax) / 2;
ctx.fillText('Price: $' + price.toFixed(0) + ' (+$' + tax + ' tax)', meetingX, meetingY - 38);
} else {
ctx.fillStyle = '#ef4444';
ctx.font = 'bold 14px sans-serif';
ctx.fillText('❌ NO DEAL', meetingX, meetingY - 55);
ctx.font = '11px sans-serif';
ctx.fillStyle = '#ffffff';
ctx.fillText('$' + tax + ' tax > $' + surplus + ' benefit', meetingX, meetingY - 38);
}
}
};
function render(ctx, state) {
const { width, height, buyers, sellers, pairs, phase, activePairIndex, taxPerUnit } = state;
// Background
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, '#1a1a2e');
gradient.addColorStop(1, '#16213e');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
// Ground
ctx.fillStyle = '#0f3460';
ctx.fillRect(0, height - 60, width, 60);
// Market stalls
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
ctx.lineWidth = 2;
[0, 1, 2, 3, 4].forEach(i => {
ctx.beginPath();
ctx.moveTo(60 + i * 140, height - 60);
ctx.lineTo(60 + i * 140, height - 100);
ctx.stroke();
});
// Title
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 20px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(state.goodEmoji + ' ' + state.goodName + ' Market ' + state.goodEmoji, width / 2, 30);
// Tax indicator
ctx.font = '14px sans-serif';
ctx.fillStyle = '#eab308';
ctx.fillText('Transaction Tax: $' + taxPerUnit + ' per sale', width / 2, 52);
// Side labels
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left';
ctx.fillStyle = '#3b82f6';
ctx.fillText('BUYERS β†’', 20, 75);
ctx.textAlign = 'right';
ctx.fillStyle = '#22c55e';
ctx.fillText('← SELLERS', width - 20, 75);
// Draw connecting lines for active/resolved pairs
pairs.forEach((pair, i) => {
if (i <= activePairIndex || pair.resolved) {
const buyer = buyers.find(b => b.id === pair.buyer.id);
const seller = sellers.find(s => s.id === pair.seller.id);
ctx.strokeStyle = pair.willTrade
? 'rgba(34, 197, 94, 0.3)'
: 'rgba(239, 68, 68, 0.3)';
ctx.lineWidth = pair.resolved ? 2 : 3;
ctx.setLineDash(pair.willTrade ? [] : [5, 5]);
ctx.beginPath();
ctx.moveTo(buyer.x, buyer.y);
ctx.lineTo(seller.x, seller.y);
ctx.stroke();
ctx.setLineDash([]);
}
});
// Draw negotiation bubbles
const currentPair = pairs[activePairIndex];
if (currentPair && (phase === 'negotiating' || phase === 'resolving')) {
drawNegotiation(ctx, currentPair, phase, taxPerUnit);
}
// Draw buyers
buyers.forEach(buyer => {
drawPerson(
ctx,
buyer.x,
buyer.y,
buyer.emoji,
buyer.name,
buyer.maxWillingToPay,
'Max',
'#3b82f6',
buyer.state,
buyer.happiness
);
});
// Draw sellers
sellers.forEach(seller => {
drawPerson(
ctx,
seller.x,
seller.y,
seller.emoji,
seller.name,
seller.minWillingToAccept,
'Min',
'#22c55e',
seller.state,
seller.happiness
);
});
// Results panel at bottom
if (phase === 'aftermath' || state.completedDeals + state.blockedDeals > 0) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.fillRect(10, height - 55, width - 20, 50);
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'left';
// Completed deals
ctx.fillStyle = '#22c55e';
ctx.fillText('βœ“ ' + state.completedDeals + ' deals', 25, height - 35);
ctx.font = '11px sans-serif';
ctx.fillStyle = '#ffffff';
ctx.fillText('Happiness: +$' + state.totalHappiness.toFixed(0), 25, height - 18);
// Tax collected
ctx.textAlign = 'center';
ctx.font = 'bold 13px sans-serif';
ctx.fillStyle = '#eab308';
ctx.fillText('πŸ’° Tax: $' + state.taxCollected.toFixed(0), width / 2, height - 35);
ctx.font = '11px sans-serif';
ctx.fillStyle = '#71717a';
ctx.fillText('(goes to government)', width / 2, height - 18);
// Blocked deals
ctx.textAlign = 'right';
ctx.font = 'bold 13px sans-serif';
ctx.fillStyle = '#ef4444';
ctx.fillText('βœ— ' + state.blockedDeals + ' blocked', width - 25, height - 35);
ctx.font = '11px sans-serif';
ctx.fillStyle = '#ffffff';
ctx.fillText('Lost: $' + state.happinessLost.toFixed(0) + ' πŸ’”', width - 25, height - 18);
}
}
// ==========================================================================
// ANIMATION LOOP
// ==========================================================================
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const statsEl = document.getElementById('stats');
let running = true;
let lastTime = performance.now();
let frameCount = 0;
let fps = 0;
let lastFpsUpdate = lastTime;
function animate(now) {
const dt = Math.min((now - lastTime) / 1000, 0.1);
lastTime = now;
frameCount++;
if (now - lastFpsUpdate > 1000) {
fps = frameCount;
frameCount = 0;
lastFpsUpdate = now;
}
if (running && state.running !== false) {
__prov.startFrame();
__prov.trackRead('state', state);
const oldState = state;
state = update(state, dt);
// Track state changes
for (const key of Object.keys(state)) {
if (oldState[key] !== state[key]) {
__prov.trackWrite(key, oldState[key], state[key]);
}
}
__prov.endFrame();
}
render(ctx, state);
statsEl.textContent = fps + ' fps';
requestAnimationFrame(animate);
}
// ==========================================================================
// CONTROLS
// ==========================================================================
document.getElementById('ctrl-taxPerUnit').addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
// Reset simulation with new value
state = setPath(JSON.parse(JSON.stringify(initialState)), ["taxPerUnit"], value);
document.getElementById('val-taxPerUnit').textContent = value;
running = true;
document.getElementById('toggle').textContent = 'Pause';
document.getElementById('toggle').className = 'btn-primary';
render(ctx, state);
});
document.getElementById('toggle').addEventListener('click', function() {
running = !running;
this.textContent = running ? 'Pause' : 'Resume';
this.className = running ? 'btn-primary' : 'btn-secondary';
});
document.getElementById('reset').addEventListener('click', function() {
state = JSON.parse(JSON.stringify(initialState));
running = true;
document.getElementById('toggle').textContent = 'Pause';
document.getElementById('toggle').className = 'btn-primary';
});
// Start
requestAnimationFrame(animate);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment