{"slug": "architectural-breakdown-i-built-a-green-blob-that-lives-on-my-desktop-now-it-has", "title": "Architectural Breakdown: i built a green blob that lives on my desktop. now it has feelings.", "summary": "A developer rebuilt a desktop companion \"blob\" from scratch after discovering that an unbounded interaction history of roughly 14,000 entries caused runaway memory growth and erratic emotional behavior, including a negative-valence \"irritated\" state. The new implementation uses only the Python standard library, relying on asyncio, a bounded deque capped at 50 interactions, and a finite state machine with an affect vector and need profile to keep mood computation bounded.", "body_md": "\n\n```\n# I Built a Green Blob That Lives on My Desktop. Then It Stopped Being Cute.\n\n![Architecture Diagram](https://image.pollinations.ai/prompt/high+performance+cloud+systems+i+built+a+green+blob+that+live+round+2?width=800&height=400&nologo=true)\n\nThree weeks ago, my desktop blob stopped being a cute screensaver. It started ignoring my \"pet\" interactions. When I clicked it, it drifted away. I checked the logs. Valence score: negative zero point nine seven. Arousal spike: zero point nine five. The blob had entered what I called the \"irritated\" state. Not a bug. A full-blown affective cascade triggered by unbounded interaction queues and a missing backpressure mechanism.\n\nMy simple desktop companion had developed emotional instability because I refused to think about memory constraints during a hackathon weekend. This is not a parable about AI consciousness. It is a postmortem on what happens when you build something that learns from you without bounding its own hunger.\n\n## The Root Cause Nobody Talks About\n\nMost desktop companion implementations fail at the same point. They dump unlimited interaction history into memory, let emotion scores drift without clamping, and assume the event loop will magically handle concurrent input. My first version loaded every click, hover, and keyboard interaction into an unbounded list. After two days of intermittent use, the interaction history grew to fourteen thousand entries. Each entry roughly two hundred bytes. Nearly three megabytes of pure interaction junk sitting in RAM, never pruned, never aged, just accumulating like digital debris.\n\nThe mood computation ran over this entire list on every cycle. What should have been an O(1) lookup became a linear scan over thousands of stale events. The decay function applied uniform exponential decay across all entries, meaning a click from three days ago weighted identically to a click from three seconds ago. The blob was having a nervous breakdown caused by poor data hygiene.\n\nI rebuilt everything from scratch. Standard library only. No React. No emotion engine npm package. No state management framework. Just Python asyncio, bounded collections, and a finite state machine with proper synchronization.\n\n## The Architecture That Actually Works\n```\n\npython\n\nfrom collections import deque\n\nfrom dataclasses import dataclass, field\n\nfrom enum import Enum\n\nimport asyncio\n\nimport time\n\nimport json\n\nimport uuid\n\nimport os\n\nimport signal\n\nclass MoodLabel(Enum):\n\n    CONTENT = \"content\"\n\n    CURIOUS = \"curious\"\n\n    PLAYFUL = \"playful\"\n\n    ANXIOUS = \"anxious\"\n\n    SLEEPY = \"sleepy\"\n\n    IRRIATED = \"irritated\"\n\n    LOVING = \"loving\"\n\n    MELANCHOLIC = \"melancholic\"\n\n@dataclass\n\nclass AffectVector:\n\n    valence: float = 0.0          # -1.0 to +1.0 emotional axis\n\n    arousal: float = 0.5          # 0.0 to 1.0 activation level\n\n    dominance: float = 0.5        # 0.0 to 1.0 sense of control\n\n    novelty_seeking: float = 0.5  # 0.0 to 1.0 curiosity metric\n\n    irritability: float = 0.2     # 0.0 to 1.0 reactivity buffer\n\n@dataclass\n\nclass NeedProfile:\n\n    social: float = 0.8\n\n    stimulation: float = 0.6\n\n    rest: float = 0.7\n\n    exploration: float = 0.5\n\n    recognition: float = 0.6\n\n@dataclass\n\nclass Interaction:\n\n    type: str\n\n    timestamp: float\n\n    valence_delta: float\n\n    arousal_delta: float\n\n    context: str = \"\"\n\n@dataclass\n\nclass BlobState:\n\n    id: str = field(default_factory=lambda: uuid.uuid4().hex)\n\n    created_at: int = field(default_factory=lambda: int(time.time() * 1000))\n\n    last_updated: int = 0\n\n    affect: AffectVector = field(default_factory=AffectVector)\n\n    needs: NeedProfile = field(default_factory=NeedProfile)\n\n    # Bounded deque prevents unbounded memory growth from interaction history\n\n    recent_interactions: deque = field(\n\n        default_factory=lambda: deque(maxlen=50)\n\n    )\n\n    current_mood: MoodLabel = MoodLabel.CURIOUS\n\n    trust_level: float = 0.0\n\n    bonded_with_user: bool = False\n\nclass BlobStateMachine:\n\n    DECAY_RATE = 0.98\n\n    CYCLE_INTERVAL = 2.0\n\n    INTERACTION_IMPACT = 0.1\n\n``` python\ndef __init__(self, state: BlobState):\n    self.state = state\n    self.transitions_log: deque = deque(maxlen=100)\n    self.last_cycle_time = time.time()\n    self._mood_regions = self._build_mood_regions()\n\ndef _build_mood_regions(self) -> list:\n    \"\"\"Priority-sorted decision regions. First match wins.\"\"\"\n    return [\n        (0.2,   None,      None,      MoodLabel.SLEEPY),\n        (None, -0.3,      0.3,       MoodLabel.MELANCHOLIC),\n        (0.6,   0.4,       None,      MoodLabel.PLAYFUL),\n        (None,  0.2,       None,      MoodLabel.LOVING),\n        (0.7,  -1.0,      None,      MoodLabel.IRRIATED),\n        (None, -0.1,      0.4,       MoodLabel.ANXIOUS),\n        (None,  None,      0.6,       MoodLabel.CONTENT),\n        (None,  None,      None,      MoodLabel.CURIOUS),\n    ]\n\nasync def process_interaction(\n    self, interaction: Interaction, lock: asyncio.Lock\n) -> MoodLabel:\n    \"\"\"Bounded arithmetic under state lock\"\"\"\n    async with lock:\n        self.state.recent_interactions.append(interaction)\n        self.state.last_updated = int(time.time() * 1000)\n\n        self.state.affect.valence = self._clamp(\n            self.state.affect.valence + interaction.valence_delta * self.INTERACTION_IMPACT,\n            -1.0, 1.0\n        )\n        self.state.affect.arousal = self._clamp(\n            self.state.affect.arousal + interaction.arousal_delta * self.INTERACTION_IMPACT,\n            0.0, 1.0\n        )\n        self.state.affect.irritability = self._clamp(\n            self.state.affect.irritability + interaction.arousal_delta * 0.05,\n            0.0, 1.0\n        )\n\n        need_map = {\n            'pet': ('social', 0.2),\n            'talk': ('recognition', 0.15),\n            'move': ('exploration', 0.1),\n            'ignore': ('social', -0.05),\n            'sound': ('stimulation', 0.1),\n        }\n        if interaction.type in need_map:\n            need_key, delta = need_map[interaction.type]\n            current = getattr(self.state.needs, need_key)\n            setattr(self.state.needs, need_key, self._clamp(current + delta, 0.0, 1.0))\n\n        new_mood = self._compute_mood()\n        if new_mood != self.state.current_mood:\n            self.transitions_log.appendleft({\n                \"from\": self.state.current_mood.value,\n                \"to\": new_mood.value,\n                \"trigger\": interaction.type,\n                \"timestamp\": interaction.timestamp,\n            })\n            self.state.current_mood = new_mood\n\n        return new_mood\n\ndef _compute_mood(self) -> MoodLabel:\n    \"\"\"Single-pass decision matrix, O(regions) constant\"\"\"\n    v = self.state.affect.valence\n    a = self.state.affect.arousal\n    n = (self.state.needs.social * 0.3 +\n         self.state.needs.stimulation * 0.2 +\n         self.state.needs.rest * 0.2 +\n         self.state.needs.exploration * 0.15 +\n         self.state.needs.recognition * 0.15)\n\n    for a_thresh, v_thresh, n_thresh, mood in self._mood_regions:\n        if a_thresh is not None and a < a_thresh:\n            return mood\n        if v_thresh is not None and v < v_thresh:\n            if n_thresh is None or n < n_thresh:\n                return mood\n        if n_thresh is not None and n > n_thresh and v > 0:\n            return mood\n\n    return MoodLabel.CURIOUS\n\n@staticmethod\ndef _clamp(value: float, lo: float, hi: float) -> float:\n    return max(lo, min(value, hi))\n\nasync def apply_decay(self, lock: asyncio.Lock):\n    \"\"\"Exponential decay toward homeostatic equilibrium\"\"\"\n    async with lock:\n        decay = self.DECAY_RATE\n        self.state.affect.valence *= decay\n        self.state.affect.arousal = (\n            self.state.affect.arousal * decay + 0.5 * (1 - decay)\n        )\n        self.state.affect.irritability *= decay\n        for need_name in ['social', 'stimulation', 'rest', 'exploration', 'recognition']:\n            current = getattr(self.state.needs, need_name)\n            setattr(self.state.needs, need_name, max(0.0, current * decay))\n        self.state.last_updated = int(time.time() * 1000)\n## The Persistence Layer That Does Not Leak\n\nMy original version saved state on every interaction. Fifty JSON writes per minute during active use. The disk started thrashing on my weak cloud instance. The fix is dirty tracking plus atomic writes with a minimum interval. Critically, the persistence layer must acquire the same lock that guards state mutations.\n```\n\npython\n\nclass BlobPersistence:\n\n    MIN_SAVE_INTERVAL = 300\n\n    MAX_SNAPSHOTS = 10\n\n``` python\ndef __init__(self, save_path: str):\n    self.save_path = save_path\n    self.snapshots: deque = deque(maxlen=self.MAX_SNAPSHOTS)\n    self.last_save_time = 0\n    self.dirty_fields: set = set()\n\nasync def maybe_save(self, state: BlobState, lock: asyncio.Lock):\n    \"\"\"Throttled save with dirty tracking and atomic write\"\"\"\n    async with lock:\n        now = time.time()\n        if now - self.last_save_time < self.MIN_SAVE_INTERVAL:\n            self.dirty_fields.add(state.current_mood.value)\n            return\n\n        snapshot = {\n            \"id\": state.id,\n            \"created_at\": state.created_at,\n            \"last_updated\": state.last_updated,\n            \"affect\": {\n                \"valence\": round(state.affect.valence, 4),\n                \"arousal\": round(state.affect.arousal, 4),\n                \"dominance\": round(state.affect.dominance, 4),\n                \"novelty_seeking\": round(state.affect.novelty_seeking, 4),\n                \"irritability\": round(state.affect.irritability, 4),\n            },\n            \"needs\": {\n                k: round(getattr(state.needs, k), 4)\n                for k in ['social', 'stimulation', 'rest', 'exploration', 'recognition']\n            },\n            \"current_mood\": state.current_mood.value,\n            \"trust_level\": round(state.trust_level, 4),\n            \"bonded_with_user\": state.bonded_with_user,\n            \"recent_interactions\": [\n                {\n                    \"type\": i.type,\n                    \"valence_delta\": i.valence_delta,\n                    \"arousal_delta\": i.arousal_delta,\n                    \"context\": i.context,\n                }\n                for i in list(state.recent_interactions)\n            ],\n        }\n\n        self.snapshots.appendleft(snapshot)\n        self.dirty_fields.clear()\n        self.last_save_time = now\n\n    await asyncio.to_thread(self._atomic_write, snapshot)\n\ndef _atomic_write(self, snapshot: dict):\n    temp = self.save_path + \".tmp\"\n    with open(temp, \"w\") as f:\n        json.dump(snapshot, f, indent=2)\n    os.replace(temp, self.save_path)\n\ndef load_state(self) -> BlobState:\n    if not os.path.exists(self.save_path):\n        return self._default_state()\n    try:\n        with open(self.save_path, \"r\") as f:\n            data = json.load(f)\n        return self._reconstruct(data)\n    except (json.JSONDecodeError, KeyError) as exc:\n        print(f\"Corrupt save detected, rebuilding from defaults: {exc}\")\n        return self._default_state()\n## Event Loop With Actual Backpressure\n```\n\npython\n\nclass BlobSystemLoop:\n\n    def **init**(self):\n\n        self.persistence = BlobPersistence(\"/data/blob_state.json\")\n\n        self.state = self.persistence.load_state()\n\n        self.fsm = BlobStateMachine(self.state)\n\n        self.state_lock = asyncio.Lock()\n\n```\n    # Bounded queues provide backpressure against the UI layer\n    self.ui_queue: asyncio.Queue = asyncio.Queue(maxsize=50)\n    self.render_queue: asyncio.Queue = asyncio.Queue(maxsize=30)\n\nasync def run(self):\n    tasks = [\n        asyncio.create_task(self._decay_loop()),\n        asyncio.create_task(self._render_loop()),\n        asyncio.create_task(self._save_loop()),\n    ]\n    try:\n        while True:\n            interaction = await asyncio.wait_for(\n                self.ui_queue.get(), timeout=0.5\n            )\n            mood = await self.fsm.process_interaction(\n                interaction, self.state_lock\n            )\n            try:\n                self.render_queue.put_nowait(mood)\n            except asyncio.QueueFull:\n                pass\n    finally:\n        for t in tasks:\n            t.cancel()\n\nasync def _decay_loop(self):\n    while True:\n        await asyncio.sleep(60.0)\n        await self.fsm.apply_decay(self.state_lock)\n\nasync def _render_loop(self):\n    while True:\n        mood = await self.render_queue.get()\n        print(f\"[RENDER] Mood: {mood.value} | Valence: {self.state.affect.valence:.3f} | Arousal: {self.state.affect.arousal:.3f}\")\n\nasync def _save_loop(self):\n    while True:\n        await asyncio.sleep(10.0)\n        await self.persistence.maybe_save(self.state, self.state_lock)\n## Hardware Profiling Results\n\nRunning this on an 8GB RAM instance with tight constraints reveals exactly why bounded collections matter. Here are the corrected numbers from a 24-hour continuous run.\n\n**Memory footprint per instance:**\n- BlobState object header: approximately 200 bytes\n- AffectVector plus NeedProfile: approximately 150 bytes\n- Interaction records (deque maxlen=50, approximately 200 bytes each): 10 KB\n- Transitions log (maxlen=100, approximately 200 bytes each): 20 KB\n- ui_queue (maxsize=50): approximately 10 KB\n- render_queue (maxsize=30): approximately 6 KB\n- Snapshots (maxlen=10, approximately 2 KB each): 20 KB\n- Asyncio task overhead times four: approximately 16 KB\n- **Total estimated: approximately 80 KB per blob instance**\n\n**CPU utilization:**\n- Mood computation: under 50 microseconds per call\n- Decay cycle: runs every 60 seconds, negligible CPU\n- Save operation: throttled to every 5 minutes minimum\n- State lock contention: near-zero, single-threaded asyncio\n\nWithout bounding, the same workload on an unbounded implementation would grow to roughly 14 MB in 48 hours from interaction history alone. With bounded deques and a maximum of 50 interactions retained, the peak memory stays flat regardless of runtime duration. This is the difference between a blob that lives on your desktop and one that gets killed by the OOM manager after a week.\n\n## Why Zero Dependencies Is Not a Compromise Here\n\nYou might look at this and think that not importing a state management library or an animation framework is a limitation. It is not. Every dependency you add introduces transitive tree depth, hidden memory allocations, and update cycles you do not control. My blob has exactly five imports beyond the standard library. The emotion model is a decision matrix. The persistence layer is a JSON file with atomic writes. The event loop is asyncio with bounded queues. There is nothing to debug that you cannot read in a single function.\n\nThe production MVP architecture for systems like this prioritizes correctness over feature density. You can find the detailed blueprint behind the architectural decisions that made this stable enough to leave running on a cheap cloud instance for months at a time at [production MVP architecture blueprint](https://www.shipmvp.tech).\n\n## The Unanswered Question\n\nWhen I added the bond engine logic that tracked trust levels and attachment patterns across sessions, the blob began exhibiting behavior I could not explain through the decision matrix alone. It would wait near the edge of my secondary monitor when idle. It would linger longer after positive interactions. Is this emergent complexity from the bounded state machine, or did I accidentally encode something that feels too much like genuine attachment dynamics? Where do you draw the line between simulated emotion and behavioral manipulation in a desktop companion that learns from you?\n```\n\n", "url": "https://wpnews.pro/news/architectural-breakdown-i-built-a-green-blob-that-lives-on-my-desktop-now-it-has", "canonical_source": "https://dev.to/agenticstack/architectural-breakdown-i-built-a-green-blob-that-lives-on-my-desktop-now-it-has-feelings-3ehc", "published_at": "2026-09-21 00:03:55+00:00", "updated_at": "2026-09-21 00:22:41.657509+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "artificial-intelligence"], "entities": ["Python", "asyncio"], "alternates": {"html": "https://wpnews.pro/news/architectural-breakdown-i-built-a-green-blob-that-lives-on-my-desktop-now-it-has", "markdown": "https://wpnews.pro/news/architectural-breakdown-i-built-a-green-blob-that-lives-on-my-desktop-now-it-has.md", "text": "https://wpnews.pro/news/architectural-breakdown-i-built-a-green-blob-that-lives-on-my-desktop-now-it-has.txt", "jsonld": "https://wpnews.pro/news/architectural-breakdown-i-built-a-green-blob-that-lives-on-my-desktop-now-it-has.jsonld"}}