# Eyespot Lab > A limited working example of the eyespot adaptation — compare its vision tiers live, then spend DNA on a lootbox-style splice that equips into the Petri Dish Game. --- .. toc:: ### See through your cell The **eyespot** decides how much of the world your cell can see and how it's drawn. On the left, switch tiers to watch the same scene re-render: - **none** → blind green **radar** (blips + sweep, can't tell food from threat), - **poor** → tiny, blurry grayscale view, - **decent** → medium, muted-colour view, - **great** → wide, full-colour view with predator early-warning. ### Splice a new eyespot (lootbox) On the right, spend banked **DNA** to splice. Each spin **rerolls the odds** across the four tiers (each 0–80%, summing to 100%) and there's a **50/50** chance you actually land your preferred part — the reels animate, then the result **equips into** the [Petri Dish Game](/petri-dish-game) (shared `player-loadout`, saved locally). The tier is a gamble: you can roll a worse eye than you had. .. admonition::Where DNA comes from :color: blue Play the [Petri Dish Game](/petri-dish-game): eat plankton and smaller cells to bank DNA. At the threshold the game pauses and sends you here to evolve. .. exec::docs.adaptation_eyespot.adaptation_eyespot :code: false ### Source ```python # File: docs/adaptation_eyespot/adaptation_eyespot.py from dash import dcc, html, clientside_callback, Input, Output, State import dash_mantine_components as dmc from dash_iconify import DashIconify from lib.adaptations import ADAPTATIONS, PART_ORDER, EYESPOT_VISION, TIER_LABEL, TIER_COLOR # /adaptation-eyespot — the EYESPOT LAB. Two halves: # 1. a live vision demo: switch tier (none/poor/decent/great) and watch the same # mini-scene re-render as radar / blur / muted / full + zoom — exactly what the # Petri Dish Game does, in isolation. # 2. a DNA-spend LOOTBOX: spend banked DNA (shared player-loadout store) on a roll. # A 50/50 "preferred vs wildcard" reel + a per-spin tier distribution (each # 0-80%, summing 100%) animate slot-machine style, then commit the result to # the loadout so the game picks it up. ACCENT = "#3399ff" COST = 12 _EYE = ADAPTATIONS["eyespot"] _VARIANTS = {v["tier"]: v for v in _EYE["variants"]} _PART_LABELS = [ADAPTATIONS[p]["label"] for p in PART_ORDER] _SCENE = [ {"x": -150, "y": -110, "r": 70, "img": "/assets/petri/paramecium.svg", "kind": "food", "deg": 25, "vx": 2.1, "vy": 1.3}, {"x": 360, "y": -180, "r": 120, "img": "/assets/petri/cyclops.svg", "kind": "hunter", "deg": 210, "ring": True, "vx": -1.6, "vy": 1.1}, {"x": 240, "y": 300, "r": 64, "img": "/assets/petri/euglena.svg", "kind": "food", "deg": -100, "vx": 1.4, "vy": -2.0}, {"x": -420, "y": 260, "r": 96, "img": "/assets/petri/daphnia.svg", "kind": "threat", "deg": 135, "vx": 2.3, "vy": -1.2}, {"x": -640, "y": -480, "r": 60, "img": "/assets/petri/navicula.svg", "kind": "food", "deg": 60, "vx": 1.0, "vy": 1.8}, {"x": 700, "y": 520, "r": 80, "img": "/assets/petri/stentor.svg", "kind": "threat", "deg": 250, "vx": -2.0, "vy": -1.0}, {"x": 0, "y": 0, "r": 64, "img": "/assets/petri/volvox.svg", "kind": "player", "deg": 0}, ] _PLANKTON = [{"x": gx * 130 - 845, "y": gy * 130 - 845, "s": 16} for gx in range(14) for gy in range(14) if (gx * 7 + gy * 3) % 3 == 0] _TIERS = ["none", "poor", "decent", "great"] # ------------------------------------------------------------- vision demo ----- _demo = dmc.Paper( [ dmc.Group( [dmc.Text("VISION DEMO", fw=700, c=ACCENT, style={"letterSpacing": "2px", "fontFamily": "monospace"}), dmc.Badge("eyespot", color="grape", variant="light", styles={"root": {"fontFamily": "monospace"}})], justify="space-between", mb="xs", ), html.Div(id="eye-arena", className="petri-dish", style={"position": "relative", "borderRadius": "50%", "aspectRatio": "1 / 1", "width": "100%", "maxWidth": 360, "margin": "0 auto", "overflow": "hidden"}), dmc.SegmentedControl( id="eye-tier", value="none", fullWidth=True, color="brand", radius="md", size="sm", mt="sm", data=[{"value": t, "label": TIER_LABEL[t]} for t in _TIERS], ), dmc.Text("", id="eye-blurb", size="xs", c="dimmed", mt="xs"), dmc.Text("", id="eye-stats", size="xs", fw=600, mt=4, style={"fontFamily": "monospace"}), dcc.Interval(id="eye-demo-tick", interval=90, n_intervals=0), ], p="md", radius="md", withBorder=True, style={"flex": "1 1 330px", "maxWidth": 400, "borderColor": "rgb(51 142 234 / 24%)"}, ) # ------------------------------------------------------------------ lootbox ---- def _odd_cell(tier): return html.Div( [ html.Div(html.Div(id=f"eye-bar-{tier}", className="eye-bar"), className="eye-bar-track"), dmc.Text(f"0%", id=f"eye-pct-{tier}", size="xs", fw=700, ta="center", style={"fontFamily": "monospace"}), dmc.Badge(TIER_LABEL[tier], color=TIER_COLOR[tier], variant="light", size="xs", fullWidth=True), ], id=f"eye-odd-{tier}", className="eye-odd", ) _lootbox = dmc.Paper( [ dmc.Group( [dmc.Text("SPLICE LAB", fw=700, c=ACCENT, style={"letterSpacing": "2px", "fontFamily": "monospace"}), dmc.Group( [DashIconify(icon="mdi:dna", width=18, color="#7048e8"), dmc.Text("0", id="eye-dna", fw=700, style={"fontFamily": "monospace"}), dmc.Text("DNA", size="xs", c="dimmed")], gap=6)], justify="space-between", mb="xs", ), dmc.Text("Spend DNA to splice a new eyespot. 50/50 you get your preferred part; the tier you " "land on is a gamble — the odds reroll every spin.", size="xs", c="dimmed", mb="xs"), # adaptation (type) reel html.Div( [dmc.Text("ADAPTATION", size="9px", c="dimmed", style={"letterSpacing": "1px"}), html.Div("EYESPOT", id="eye-type-label", className="eye-type-hit")], className="eye-type-reel", ), # tier odds reel html.Div([_odd_cell(t) for t in _TIERS], className="eye-odds-row"), dmc.Group( [dmc.Button(f"SPLICE DNA (−{COST})", id="eye-splice", color="brand", leftSection=DashIconify(icon="tabler:dna-2")), dmc.Anchor(dmc.Button("Back to game", variant="default", leftSection=DashIconify(icon="tabler:arrow-back")), href="/petri-dish-game")], gap="sm", mt="xs", ), dmc.Text("", id="eye-roll-status", size="sm", fw=600, mt="xs"), dmc.Group( [dmc.Text("Currently equipped:", size="xs", c="dimmed"), dmc.Badge("none", id="eye-applied", color="gray", variant="filled", size="sm")], gap=6, mt=4, ), ], p="md", radius="md", withBorder=True, style={"flex": "1 1 330px", "maxWidth": 460, "borderColor": "rgb(51 142 234 / 24%)"}, ) component = dmc.Stack( [ dmc.Paper( [dmc.Text("EYESPOT LAB", fw=700, c=ACCENT, style={"letterSpacing": "2px", "fontFamily": "monospace"}), dmc.Text("The eyespot governs how much of the world your cell sees and how it's rendered. " "Compare the tiers on the left; splice a new one with DNA on the right — it equips " "into the Petri Dish Game.", size="sm", c="dimmed")], withBorder=True, radius="md", p="md", style={"borderColor": "rgb(51 142 234 / 24%)", "background": "linear-gradient(rgb(255 255 255 / 0%) 0%, rgb(243 247 239 / 30%) 100%)"}, ), dmc.Group([_demo, _lootbox], align="flex-start", gap="xl", wrap="wrap"), dcc.Store(id="eye-vis", data=EYESPOT_VISION), dcc.Store(id="eye-scene", data=_SCENE), dcc.Store(id="eye-plankton", data=_PLANKTON), dcc.Store(id="eye-variants", data=_VARIANTS), dcc.Store(id="eye-parts", data=_PART_LABELS), dcc.Store(id="eye-demo-sink"), ], gap="md", ) # 1) live vision demo: move the scene + render in the selected tier's style/view clientside_callback( """ function(n, tier, vis, scene0, plankton) { if (!window.CV) { return window.dash_clientside.no_update; } if (!window.__eye) { window.__eye = {cells: JSON.parse(JSON.stringify(scene0 || []))}; } const cells = window.__eye.cells; cells.forEach(c => { if (c.kind === 'player') { return; } c.x += (c.vx || 0); c.y += (c.vy || 0); if (Math.abs(c.x) > 900) { c.vx *= -1; c.x = Math.max(-900, Math.min(900, c.x)); } if (Math.abs(c.y) > 900) { c.vy *= -1; c.y = Math.max(-900, Math.min(900, c.y)); } if (Math.hypot(c.vx || 0, c.vy || 0) > 0.3) { c.deg = Math.atan2(c.vy, c.vx) * 180 / Math.PI; } }); const v = (vis && vis[tier]) || (vis && vis.none) || {view: 320, style: 'radar'}; const host = document.getElementById('eye-arena'); if (!host) { return window.dash_clientside.no_update; } host.innerHTML = window.CV.renderArena({ vb: [-v.view, -v.view, 2 * v.view, 2 * v.view], style: v.style, cells: cells, plankton: plankton, cam: {x: 0, y: 0, vr: v.view}, t: n, }); return window.dash_clientside.no_update; } """, Output("eye-demo-sink", "data"), Input("eye-demo-tick", "n_intervals"), Input("eye-tier", "value"), State("eye-vis", "data"), State("eye-scene", "data"), State("eye-plankton", "data"), ) # 2) tier -> blurb + stats text clientside_callback( """ function(tier, variants, vis) { variants = variants || {}; const v = variants[tier]; if (!v) { return ['', '']; } const vz = (vis && vis[tier]) || {}; const stats = 'stat ' + (v.mult >= 0 ? '+' : '') + v.mult.toFixed(1) + ' cost ' + v.cost.toFixed(2) + ' view ' + (vz.view || '?') + ' render ' + (vz.style || '?'); return [v.blurb, stats]; } """, Output("eye-blurb", "children"), Output("eye-stats", "children"), Input("eye-tier", "value"), State("eye-variants", "data"), State("eye-vis", "data"), ) # 3) loadout -> DNA readout + equipped badge + sync demo selector to equipped tier clientside_callback( """ function(loadout) { // player-loadout.eyespot is now a SLOT OBJECT {part,tier,...}; coerce to its // tier string (back-compat with the old bare-string shape via __migrateLoadout). const lo = window.__migrateLoadout ? window.__migrateLoadout(loadout) : (loadout || {dna: 0}); const tier = (lo.eyespot && lo.eyespot.tier) || (typeof lo.eyespot === 'string' ? lo.eyespot : 'none'); return [String(lo.dna || 0), tier, tier]; } """, Output("eye-dna", "children"), Output("eye-applied", "children"), Output("eye-tier", "value"), Input("player-loadout", "data"), ) # 4) SPLICE — the lootbox. Computes per-spin odds (each 0-80, sum 100), draws a # tier, runs the slot-machine animation, and commits to the shared loadout. clientside_callback( """ function(n, loadout, vis, parts) { const nu = window.dash_clientside.no_update; if (!n) { return [nu, nu]; } const COST = """ + str(COST) + """; // migrate to the slot-object loadout so we preserve move/feed/locks/sprite const lo = window.__migrateLoadout ? window.__migrateLoadout(loadout) : (loadout || {dna: 0}); const have = lo.dna || 0; if (have < COST) { return [nu, '⚠ Need ' + COST + ' DNA (you have ' + have + ').']; } const order = ['none', 'poor', 'decent', 'great']; // per-spin distribution: 4 weights, each 0-80, summing to 100 function randDist() { let w = [Math.random(), Math.random(), Math.random(), Math.random()]; let s = w.reduce((a, b) => a + b, 0) || 1; w = w.map(x => x / s * 100); for (let it = 0; it < 6; it++) { let over = 0; w = w.map(x => { if (x > 80) { over += x - 80; return 80; } return x; }); if (over <= 0) { break; } const room = w.map(x => 80 - x), rs = room.reduce((a, b) => a + b, 0) || 1; w = w.map((x, i) => x + room[i] / rs * over); } let r = w.map(x => Math.floor(x)); let rem = 100 - r.reduce((a, b) => a + b, 0); const frac = w.map((x, i) => ({i: i, f: x - Math.floor(x)})).sort((a, b) => b.f - a.f); for (let k = 0; k < rem; k++) { r[frac[k % 4].i]++; } return r; } const odds = randDist(); order.forEach((t, i) => { const bar = document.getElementById('eye-bar-' + t), pct = document.getElementById('eye-pct-' + t); const cell = document.getElementById('eye-odd-' + t); if (bar) { bar.style.height = Math.max(3, odds[i]) + '%'; } if (pct) { pct.textContent = odds[i] + '%'; } if (cell) { cell.classList.remove('eye-win', 'eye-hl'); } }); // draw outcome from the odds let rr = Math.random() * 100, acc = 0, drawn = 0; for (let i = 0; i < 4; i++) { acc += odds[i]; if (rr <= acc) { drawn = i; break; } } const preferred = Math.random() < 0.5; // 50/50 preferred part vs wildcard fizzle // animate the tier reel (slot machine, decelerating, lands on `drawn`) const cells = order.map(t => document.getElementById('eye-odd-' + t)); let s = 0; const total = 4 * 5 + drawn; let delay = 45; (function tierTick() { cells.forEach((c, k) => { if (c) { c.classList.toggle('eye-hl', k === (s % 4)); } }); s++; if (s <= total) { delay = Math.min(420, delay * 1.13); setTimeout(tierTick, delay); } else { cells.forEach((c, k) => { if (c) { c.classList.remove('eye-hl'); c.classList.toggle('eye-win', k === drawn); } }); } })(); // animate the adaptation (type) reel const tl = document.getElementById('eye-type-label'); if (tl) { const names = parts || ['Eyespot']; let ts = 0, td = 50; (function typeTick() { tl.textContent = names[ts % names.length].toUpperCase(); tl.className = 'eye-type-spin'; ts++; if (ts < 16) { td = Math.min(360, td * 1.12); setTimeout(typeTick, td); } else { tl.textContent = preferred ? 'EYESPOT' : '\\u2726 WILDCARD'; tl.className = preferred ? 'eye-type-hit' : 'eye-type-miss'; } })(); } // commit immediately (animation reveals the already-decided result). Write a // proper eyespot SLOT, preserving the rest of the loadout (move/feed/locks/sprite). const curTier = (lo.eyespot && lo.eyespot.tier) || 'none'; lo.dna = have - COST; lo.eyespot = {part: 'eyespot', tier: preferred ? order[drawn] : curTier, placement: 0, arc: 360, locked: false, sprite: (lo.eyespot && lo.eyespot.sprite) || null}; const newLoad = lo; const msg = preferred ? ('\\ud83e\\uddec Spliced! Eyespot \\u2192 ' + order[drawn].toUpperCase()) : '\\u2026 mutation fizzled \\u2014 DNA spent, eyespot unchanged. Re-roll!'; return [newLoad, msg]; } """, Output("player-loadout", "data", allow_duplicate=True), Output("eye-roll-status", "children"), Input("eye-splice", "n_clicks"), State("player-loadout", "data"), State("eye-vis", "data"), State("eye-parts", "data"), prevent_initial_call=True, ) ``` :defaultExpanded: false :withExpandedButton: true --- *Source: /adaptation-eyespot*