<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
  xmlns:atom="http://www.w3.org/2005/Atom"
  xmlns:content="http://purl.org/rss/1.0/modules/content/"
  xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Agnel Nieves - Side Projects</title>
    <link>https://agnelnieves.com/blog/tag/side-projects</link>
    <description>Blog posts on Side Projects by Agnel Nieves.</description>
    <language>en-US</language>
    <lastBuildDate>Fri, 18 Sep 2026 17:57:01 GMT</lastBuildDate>
    <atom:link href="https://agnelnieves.com/blog/tag/side-projects/feed.xml" rel="self" type="application/rss+xml" />
    
    <item>
      <title><![CDATA[How a Hospital Distraction Turned Into TYPING DEAD]]></title>
      <link>https://agnelnieves.com/blog/how-a-hospital-distraction-turned-into-typing-dead</link>
      <guid isPermaLink="true">https://agnelnieves.com/blog/how-a-hospital-distraction-turned-into-typing-dead</guid>
      <description><![CDATA[I needed something to do between medical appointments. A few days later I had a free 3D comedy-horror typing shooter, homemade zombie voices, an intro film, and a co-op beta.]]></description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>I built <a href="https://typingdead.com">TYPING DEAD</a>, a free browser-based 3D comedy-horror typing shooter. An AI wrote the dead people&#39;s last words wrong, so the cemetery is standing in line with corrections. You play the last typing robot at the desk. Type each person&#39;s real word correctly and they can rest.</p>
<p>The first rough prototype took about three hours in a hospital waiting room. I was dealing with medical things, I was stressed, and I needed something distracting. I had played <a href="https://zty.pe/">ZType</a> constantly in college, so I started with a simple question: what would that game feel like as a 3D zombie shooter?</p>
<p>The answer got out of hand in the best way.</p>
<p>A few days later it had rigged characters, custom shaders, a story, homemade sound effects, native mobile typing, an intro film, a full pause desk, performance tooling, and an experimental co-op mode. The stack is Bun, Vite, React, TypeScript, Three.js, Web Audio, Remotion, and a small Bun relay on Fly.io for the co-op beta.</p>
<p>You can <a href="https://typingdead.com">play it free at typingdead.com</a>, read the <a href="https://github.com/agnelnieves/deadtype">source on GitHub</a>, see the launch on <a href="https://www.producthunt.com/products/typing-dead?launch=typing-dead">Product Hunt</a>, or read the more technical <a href="https://devpost.com/software/typing-dead?ref_content=my-projects-tab&ref_feature=my_projects">Devpost submission</a>.</p>
<p>This is the longer, more honest version of how it happened.</p>
<TwitterEmbed>
<blockquote className="twitter-tweet" data-theme="dark"><p lang="en" dir="ltr">So I was doing some medical appointments and needed something distracting.... Build a web based typing game.<br /><br />A few days later… This is what came out.... Typing Dead a comedy-horror zombie typing shooter.<br /><br />Documented everything in this thread🧵 <a href="https://t.co/JsbSDtnG2Q">pic.twitter.com/JsbSDtnG2Q</a></p>&mdash; Agnel (@agnelnieves) <a href="https://x.com/agnelnieves/status/2077586742450799027?ref_src=twsrc%5Etfw">July 16, 2026</a></blockquote>
</TwitterEmbed><h2>The actual beginning</h2>
<p>This was not a startup idea. There was no roadmap, no market research, and definitely no pitch deck.</p>
<p>I was sitting in a hospital waiting room going through a stretch of medical appointments. I was stressed and wanted something that could keep my brain occupied for a while. Around the same time, GPT-5.6 Sol had just come out, and I wanted to see what it could do with a game instead of another dashboard.</p>
<p>ZType was the obvious starting point. I played it endlessly in college. The mechanic is almost perfect: words move toward you, typing the first letter locks onto a target, and finishing the word destroys it. Typing is the targeting system and the weapon at the same time.</p>
<p>I described the concept, the three camera views I had in mind, the wave structure, and the general horror-comedy direction. Then I let the first generation run.</p>
<p>About three hours later, I had something playable.</p>
<p>It was extremely bare bones. The hero and enemies were boxes. The music and sound effects were generated placeholders. The UI was rough. But the core loop already worked, which was the only thing that mattered at that moment.</p>
<p>There were three views:</p>
<ul>
<li><strong>Night Watch</strong>, an over-the-shoulder view.</li>
<li><strong>Surround</strong>, where enemies close in from every direction.</li>
<li><strong>Lane</strong>, the closest version to classic ZType.</li>
</ul>
<p>The prototype was a one-shot output. The game that shipped was not.</p>
<p>That distinction matters because &quot;AI made a game in one prompt&quot; is a fun headline and a terrible description of what happened next. The first generation proved the idea. The next several days were product design, art direction, systems work, tuning, debugging, and a lot of me staring at models that were somehow inside buildings again.</p>
<h2>Day 1: replacing the boxes with an actual cast</h2>
<p>The first big problem was obvious: the game needed characters.</p>
<p>After digging through different model libraries, I found <a href="https://kaylousberg.com/game-assets">KayKit</a> by Kay Lousberg. The packs are CC0, stylized, lightweight, and already close to the tone I wanted. I used the skeleton character pack, Mystery Series assets, weapons, animations, and props as the foundation for the cast and environment.</p>
<p>The hard part was not downloading a GLB. The hard part was explaining the entire setup to an AI coding tool without repeating myself forever:</p>
<ul>
<li>Which file is the character?</li>
<li>Which skeleton rig does it use?</li>
<li>Which animation means walking, attacking, throwing, getting hit, or dying?</li>
<li>Which hand owns the weapon?</li>
<li>Which prop goes with which character?</li>
<li>What should the character be called inside the game?</li>
</ul>
<p>So I stopped prompting and built a small internal asset configurator.</p>
<p>It let me preview the characters, apply animations, attach props, inspect poses, and export one structured block containing the model path, rig, aliases, animation map, and implementation notes. Instead of trying to describe the setup from memory every time, I could paste the exact configuration into Claude or Codex.</p>
<p>That little tool saved more time than almost any individual prompt. It also taught me something that kept coming up during the whole project: when an AI tool repeatedly misunderstands your context, sometimes the answer is not a better paragraph. Sometimes the answer is building a better source of truth.</p>
<p>Once the configuration was explicit, the boxes became animated skeletons. They walked, ran, threw weapons, dissolved, lost body parts, and carried glowing words above their heads.</p>
<p>It immediately felt like a game instead of a prototype.</p>
<h2>Day 2: finding the look without pretending I knew every shader value</h2>
<p>I wanted the visual direction to sit somewhere between Borderlands-style inked 3D and the chunky readability of a mobile strategy game. Stylized, graphic, a little dirty, but still readable when several words are moving toward you at the same time.</p>
<p>Again, describing that was not enough.</p>
<p>I built another internal playground, this time for the visual stack. It could:</p>
<ul>
<li>Load every character and prop.</li>
<li>Switch animations.</li>
<li>Show the rig and wireframe.</li>
<li>Preview the dissolve effect.</li>
<li>Adjust the toon-light bands.</li>
<li>Tune the ink outline.</li>
<li>Test glowing eyes and hit flashes.</li>
<li>Copy the resulting values as structured configuration.</li>
</ul>
<p>I used the knobs to get the look where I wanted, then gave the exact output to the coding model to integrate into the real engine. That was much more reliable than asking it to &quot;make this feel more like Borderlands,&quot; which is the kind of prompt that can produce twelve different interpretations and somehow all of them are too shiny.</p>
<p>The final scene uses custom Three.js shader work for toon lighting, outlines, hit flashes, and dissolves, plus bloom, grain, fog, chromatic damage, dynamic moonlight, lightning, shell casings, tracers, rubble, decals, and a lot of small effects that are individually cheap but collectively make the world feel alive.</p>
<p>I also designed the new logo and used a quick Figma pass to establish the title-screen button and general hierarchy. The title scene became its own little cinematic tableau: characters rising into frame, clouds moving across the moon, lamps reacting to lightning, and the game continuing behind the interface.</p>
<p>The most annoying part was placing buildings.</p>
<p>AI coding tools are very good at writing a Three.js mesh loader. They are much less good at understanding that two buildings cannot occupy the same physical space just because both positions look reasonable as numbers. I kept finding props inside walls, facades fighting each other, and pieces intersecting at angles that only made sense to a machine with no body.</p>
<p>That part took actual visual review, screenshots, adjustments, and repetition. There was no magic prompt.</p>
<h2>Day 3: I beatboxed the theme because words were failing me</h2>
<p>Sound is hard for me to describe.</p>
<p>I knew what I wanted the theme to feel like, but every text description sounded generic: dark, rhythmic, retro, tense, typewriter, horror, but funny. None of that explained the rhythm I could hear in my head.</p>
<p>So I went into a closet and beatboxed it.</p>
<p>Not elegantly. Not professionally. Just enough of the pulse and mouth percussion to communicate the idea. I passed that recording into Suno as a reference, and it generated the first version of the theme from something I could perform but could not explain.</p>
<p>That became the general pattern for the audio:</p>
<ul>
<li>A typewriter-style hit for correct letters.</li>
<li>A real bell DING when a word is completed.</li>
<li>A deliberately uncomfortable miss sound for typos.</li>
<li>My own voice for the zombies.</li>
<li>My own mouth noises for menu movement, selection, pause in, and pause out.</li>
</ul>
<p>There is a behind-the-scenes recording of me in the closet doing skeleton breathing, tank groans, boss noises, and tiny menu clicks. It is one of the least dignified things I have ever documented, which means it was obviously going into the devlog.</p>
<p>Later, I re-recorded the cast and processed the takes in post. The enemies all needed different identities even though they were the same person—me—making increasingly questionable sounds into a phone:</p>
<ul>
<li>The Mumbler needed a tired, ordinary complaint.</li>
<li>The Jogger needed short breaths because it is always running.</li>
<li>The Emeritus needed more weight.</li>
<li>Rottenmax needed a voice lower than mine actually goes.</li>
</ul>
<p>The final files are still intentionally homemade. I cleaned them, pitched and layered some takes, normalized the loudness, and let the game&#39;s spatial audio provide the room. I did not want them polished into a generic monster library.</p>
<p>One voice is the entire cast budget. It fits the project.</p>
<h2>Day 4: DEADTYPE became TYPING DEAD</h2>
<p>The project was still called <code>deadtype</code> internally. I kept looking at it and feeling like the words were in the wrong order.</p>
<p>TYPING DEAD sounded better and looked better in the logo, so that became the public name. The repository still uses <code>deadtype</code> because renaming every internal path during a one-week build is how a fun distraction turns into administrative work.</p>
<p>This was also when the game started needing a real interface around the run.</p>
<p>I added a pause desk with:</p>
<ul>
<li>Four difficulty settings.</li>
<li>Separate music, effects, voices, and interface volume controls.</li>
<li>Keyboard and motion settings.</li>
<li>An About section for the story and credits.</li>
<li>A local personnel file with stats.</li>
<li>A character archive.</li>
<li>Shareable report cards.</li>
</ul>
<p>The character archive was inspired by the mercenary menu in Assassin&#39;s Creed Odyssey. I always liked the feeling of discovering a larger cast through a board of dossiers, so I used that as the starting point and pulled it into TYPING DEAD&#39;s records-office world.</p>
<p>Everything stayed local. No account, no signup, no fake onboarding ceremony. You could change your personnel record, keep your best run, generate a card, and leave.</p>
<p>At this point the interface also started feeding the fiction instead of sitting on top of it. A typo was no longer just &quot;wrong.&quot; It got the red pen. Completing a word became filing a record. The dead were not enemies to erase; they were customers carrying a correction.</p>
<p>That shift made the whole project clearer.</p>
<h2>Day 5: giving the game a reason to exist</h2>
<p>The mechanics worked, but the story still felt attached afterward.</p>
<p>I wanted one explanation that made the typing, the zombies, the robot, the office, and the comedy all belong to the same idea. The starting point was two familiar frustrations: autocorrect changing what you meant, and never reaching a human when something actually matters.</p>
<p>The story became this:</p>
<p>In Remington Falls, people used to review the dead&#39;s last words by hand. Eulogy AI was hired to modernize the records. When it was unsure about a word, a human proofreader checked it.</p>
<p>Then the company decided checking cost too much.</p>
<p>The proofreaders were let go. The approvals were set to answer themselves. That night, the system guessed 21,309 missing last words and rewrote the town&#39;s records. By morning, the cemetery was standing in line.</p>
<p>They are not really villains. They have been misquoted.</p>
<p>Each person carries the word the AI got wrong—their real one. The only worker left is an old typing robot that never learned to guess. It types the words correctly, the bell rings, and the customer can finally rest.</p>
<p>That story became a first-run intro film inside the game. I used generated visual experiments through Venice, including Seedance and Wan, then edited the shots, narration, timing, typography, and game integration around the actual story. It only plays automatically on the first visit, and you can replay it later from the title screen.</p>
<p>The useful test became one sentence:</p>
<blockquote>
<p>The AI wrote the dead people&#39;s words wrong, and the robot types the right ones so they can rest.</p>
</blockquote>
<p>If a new screen, effect, or piece of copy made that harder to understand, it was probably the wrong thing.</p>
<h2>Day 6: re-recording everything that was annoying me</h2>
<p>By this point the game was playable, which meant I could finally hear every placeholder that was bothering me.</p>
<p>So I went back into the closet.</p>
<p>I recorded more zombie takes, defeat breaths, runner loops, menu navigation, selection sounds, and the pause transitions. Then I tested them in the actual scene, where distance, music, the DING, and the player&#39;s typing all fight for the same small amount of attention.</p>
<p>The important audio lesson was not &quot;make it louder.&quot; It was deciding what owns each moment.</p>
<p>The DING owns a completed word. Zombie defeat sounds have to get out of its way. Idle groans can give the street depth, but they cannot hide the next target. The Jogger&#39;s breathing should warn you before the label becomes the only clue. Menu sounds should feel physical without turning every settings change into a kitchen drawer falling down the stairs.</p>
<p>I am still not pretending I became a sound designer in a week. I just kept replacing the thing that annoyed me most until the mix felt like it belonged to the same game.</p>
<h2>Day 7: obviously I tried to add co-op right before launch</h2>
<p>The sensible thing to do before publishing was fix bugs and go to sleep.</p>
<p>Instead I thought, why not make it cooperative?</p>
<p>The beta lets one player create an invitation code and another join from a separate browser. A small content-blind Bun WebSocket relay on Fly.io moves messages between the two sessions. The host remains authoritative, and the relay does not need to understand the game payload.</p>
<p>The first version was rough. Character positions needed work. Words could fall out of sync. A shared kill did not always resolve cleanly on both desks. I included it in the build thread because it was part of what I was exploring, but I did not pretend it was finished or include it as a promise in the first public launch.</p>
<p>That is still how I think about it: a useful beta and an architecture experiment, not the headline feature.</p>
<p>The initial Product Hunt page explicitly said there was no public multiplayer mode. That was true. The relay and co-op work existed, but the live core game needed to stand on its own first.</p>
<h2>Turning eight days of recordings into the thread</h2>
<p>Every day I recorded a walkthrough of the current build. I also had the closet recordings, source clips, screenshots, and the parts where I explained why I had built another internal tool instead of continuing to prompt.</p>
<p>I put the devlog together with Remotion.</p>
<p>The project has one section per day, title cards, the original game DING between chapters, karaoke-style captions built from transcripts, and the behind-the-scenes sound clips inserted at the right point. Remotion was useful here for the same reason I like code for motion prototypes: timing becomes data. I could line up a caption, a card, a screen recording, and a sound cue exactly, then render it again without rebuilding the edit by hand.</p>
<p>The full version is much longer than any reasonable social post, so the X thread carries the build in smaller pieces. The embedded post near the top is the front door to the entire thread.</p>
<h2>The architecture underneath the fun part</h2>
<p>The stack sounds like a normal web app until you get to the part where every keystroke has to feel immediate.</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>What I used</th>
</tr>
</thead>
<tbody><tr>
<td>Runtime and package manager</td>
<td>Bun</td>
</tr>
<tr>
<td>Build tool</td>
<td>Vite</td>
</tr>
<tr>
<td>Interface</td>
<td>React, Tailwind CSS, Radix UI, shadcn/ui</td>
</tr>
<tr>
<td>Game engine</td>
<td>Imperative TypeScript + Three.js</td>
</tr>
<tr>
<td>Audio</td>
<td>Web Audio + authored music and recordings</td>
</tr>
<tr>
<td>Devlog</td>
<td>Remotion</td>
</tr>
<tr>
<td>Hosting</td>
<td>Vercel</td>
</tr>
<tr>
<td>Co-op beta</td>
<td>Bun WebSocket relay on Fly.io</td>
</tr>
</tbody></table>
<p>React owns the title screen, pause desk, and HUD. It never enters the animation frame loop.</p>
<p>Desktop letters go from a <code>window</code> keydown event directly into the engine. Mobile uses one real native input and commits the same one-letter action into the same synchronous path. React only receives a HUD snapshot every eighth frame.</p>
<p>That boundary is probably the most important technical decision in the project.</p>
<p>If every letter waited for React state, a component update, or an async action, the game could look beautiful and still feel wrong. Typing latency is the game. It is not a performance detail to clean up later.</p>
<p>The same thinking shaped the floating words. They are drawn with HTML Canvas and positioned in 3D, but crowded waves made them collide on screen. I ended up with a two-axis collision solver that protects the locked word and nearest dangers, stabilizes prior positions, opens side lanes when needed, and avoids new allocations after warming up.</p>
<p>Again: readability before spectacle. Nobody cares how nice the shader is if they cannot tell which word is about to reach the desk.</p>
<h2>Where AI helped, and where it did not</h2>
<p>The first prototype was a one-shot AI output. The full game was an AI-assisted build with a lot of different loops.</p>
<p>OpenAI Codex with GPT-5.6 became the main engineering environment. It read the repository rules, traced the real input or render path, made focused changes, ran Bun checks, opened the game in a browser, compared visual results, and measured performance work.</p>
<p>I also used Claude and Grok for a smaller number of tasks and experiments. Claude was especially useful while I was working through the early KayKit model configuration. Venice was part of the generated visual workflow for the title and intro experiments. Codex image tools generated documented source material for selected portraits and the final cobblestone pipeline. Suno turned my beatbox reference into music.</p>
<p>What made the coding tools useful was not asking them to make the game better. It was giving them constraints:</p>
<ul>
<li>The engine stays TypeScript and Three.js.</li>
<li>React never enters the frame loop.</li>
<li>Nothing async or allocation-heavy touches a keystroke.</li>
<li>Gameplay randomness and cosmetic randomness stay separate.</li>
<li>The visual look cannot quietly break performance.</li>
<li>The dead are customers, not disposable monsters.</li>
<li>Every change has to pass build, lint, browser checks, and targeted performance checks when relevant.</li>
</ul>
<p>Strong rules produced much better work than open-ended taste prompts.</p>
<p>I still chose the idea, the ZType inspiration, the mechanics, the camera behavior, the difficulty, the art direction, the story, the tuning constants, and the final copy. I rejected plenty of output. I moved buildings by hand when the model clearly had no idea where a street was. I re-recorded sounds because they annoyed me. I renamed the game because the first name did not feel right.</p>
<p>Also important: there are no runtime model calls in the shipped game. Player keystrokes go to the local engine, not to OpenAI, Anthropic, xAI, or anybody else.</p>
<p>AI was part of the development process. It is not sitting inside the game judging your spelling.</p>
<h2>Launching before it felt finished</h2>
<p>I launched TYPING DEAD as what I called a proof copy: a playable public experiment, not a final edition.</p>
<p>The <a href="https://www.producthunt.com/products/typing-dead?launch=typing-dead">Product Hunt launch</a> asked three questions:</p>
<ol>
<li>After ten seconds, can you explain what is happening?</li>
<li>Does every keystroke feel immediate and fair?</li>
<li>After one run, do you want another?</li>
</ol>
<p>The feedback was exactly the kind I needed. People liked the core loop, but they also called out repetitive word pools, first-wave confusion, rough edges, and performance. None of that was surprising. It was useful to see which problems showed up before I explained the game to them.</p>
<p>That feedback turned into the less glamorous work after the thread:</p>
<ul>
<li>A deep performance pass with repeatable benchmarks.</li>
<li>More pooling and instancing instead of spawning work freely.</li>
<li>Compressed models and textures.</li>
<li>Physical render-quality tiers.</li>
<li>Better native mobile focus behavior.</li>
<li>Clearer first-run guidance.</li>
<li>A local personnel file and, later, the Night Ledger leaderboard.</li>
<li>More work on the office scene and how future maps could connect to the story.</li>
</ul>
<p>The public build moved forward, but I am glad the launch copy was honest about what it was. Saying &quot;early&quot; gave people permission to tell me where it was rough instead of reviewing it like I had declared it finished.</p>
<p>I also wrote the detailed technical version for the <a href="https://devpost.com/software/typing-dead?ref_content=my-projects-tab&ref_feature=my_projects">Devpost submission</a>. That one goes deeper on the engine boundary, label solver, rendering budget, mobile input, Codex workflow, and what I would still improve.</p>
<h2>What I learned</h2>
<p>Honestly, this could be its own post. The short version:</p>
<p><strong>Responsiveness is an architecture decision.</strong> You cannot add immediate typing at the end if every letter already travels through the wrong layers.</p>
<p><strong>Specificity matters more than prompting skill.</strong> The asset configurator and shader playground did more for the result than clever adjectives. They turned taste into inspectable values.</p>
<p><strong>Readability wins.</strong> A clean word, a strong target lock, and a predictable DING matter more than another particle effect.</p>
<p><strong>Story can do systems work.</strong> Once the game became a records office correcting bad last words, the interface, audio, enemies, scoring, and copy all had somewhere to go.</p>
<p><strong>AI tools are better with a contract.</strong> A fixed architecture, product brief, tuning reference, visual target, story bible, and executable checks made the work dramatically more consistent.</p>
<p><strong>Shipping the weird thing is the point.</strong> I started this because I needed to get through a stressful waiting room. It did that. Everything after the first playable prototype was curiosity taking over.</p>
<p>That is probably why I like side projects so much. They do not need to justify themselves before they exist. Sometimes they are just a way to get your mind through a difficult week. If you are lucky, you look up a few days later and there is a haunted records office, a typing robot, and an entire cemetery waiting for spellcheck.</p>
<h2>Play it</h2>
<p><a href="https://typingdead.com">TYPING DEAD is free at typingdead.com</a>. No account. A physical keyboard is still the best way to play, but mobile works too.</p>
<p>If you try one run, tell me where the night lost you. Performance, confusing words, unfair deaths, anything. I am still figuring out what the larger game should become.</p>
<ul>
<li><a href="https://typingdead.com">Play TYPING DEAD</a></li>
<li><a href="https://github.com/agnelnieves/deadtype">View the source</a></li>
<li><a href="https://x.com/agnelnieves/status/2077586742450799027">Open the X build thread</a></li>
<li><a href="https://www.producthunt.com/products/typing-dead?launch=typing-dead">See the Product Hunt launch</a></li>
<li><a href="https://devpost.com/software/typing-dead?ref_content=my-projects-tab&ref_feature=my_projects">Read the Devpost submission</a></li>
</ul>
<hr>
<p><em>Built by <a href="/about">Agnel Nieves</a>, a design engineer who apparently cannot leave a hospital distraction at prototype size. More writing on <a href="/blog">the blog</a>.</em></p>
]]></content:encoded>
      <pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate>
      <author>agnel@agnelnieves.com (Agnel Nieves)</author>
      <dc:creator><![CDATA[Agnel Nieves]]></dc:creator>
      <category>Side Projects</category>
      <category>Game Development</category>
      <category>Three.js</category>
      <category>AI</category>
      <category>Bun</category>
      <category>Design Engineering</category>
    </item>
    <item>
      <title><![CDATA[Building a Terminal Portfolio You Can SSH Into]]></title>
      <link>https://agnelnieves.com/blog/building-a-terminal-portfolio-you-can-ssh-into</link>
      <guid isPermaLink="true">https://agnelnieves.com/blog/building-a-terminal-portfolio-you-can-ssh-into</guid>
      <description><![CDATA[How I shipped ssh agnelnieves.sh, a terminal portfolio in Rust using ratatui, russh, and Fly.io. One binary, two modes: local CLI and SSH server.]]></description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>You can now browse this site in your terminal:</p>
<pre><code class="language-bash">ssh agnelnieves.sh
</code></pre>
<p>No install. One Rust binary that runs as either a local CLI or an in-binary SSH server. No system sshd, no user accounts, no auth. The TUI is built with <a href="https://ratatui.rs">ratatui</a>; the SSH layer is <a href="https://docs.rs/russh">russh</a>; render code is shared between local and remote modes. It&#39;s hosted on Fly.io behind a dedicated IPv4, and the whole thing costs about $2/mo. The code lives as a <code>cli/</code> package inside the same Next.js project that powers this site, so the website and the terminal share a single deploy story. If you want the full build (architecture, code, Fly.io setup), I wrote it up as a <a href="/guides/ssh-terminal-portfolio.md">step-by-step guide</a>.</p>
<h2>The setup</h2>
<p><code>ssh agnelnieves.sh</code>. That&#39;s it. No <code>-l user@</code>, no keys to add, no first-time signup. The server accepts whatever username your local SSH client offers and drops you straight into a terminal UI.</p>
<p>Press <code>h</code>/<code>a</code>/<code>b</code>/<code>c</code> to jump between Home, About, Blog, and Connect. <code>j</code>/<code>k</code> (or arrows) to navigate lists. <code>Enter</code> to open. <code>q</code> to quit, which closes the SSH session.</p>
<h2>Why I built it</h2>
<p>Honestly? I saw <a href="https://terminal.shop">terminal.shop</a> and thought it was awesome. They sell coffee at <code>ssh terminal.shop</code> (that&#39;s the whole storefront, no website) and the first time I tried it I wanted to know if I could build something like it.</p>
<p>I do side projects like this when I need to step away from whatever I&#39;m shipping day-to-day. They&#39;re not pitches. They don&#39;t need a thesis. They&#39;re more like the way some people fix a bike on a Saturday: sit down, follow whatever&#39;s pulling on your curiosity, and at the end you have a thing that didn&#39;t exist before. That&#39;s the whole reason.</p>
<p>So this was that. A few evenings poking at <a href="https://ratatui.rs">ratatui</a> and <a href="https://docs.rs/russh">russh</a> to see what it would take to make <code>ssh agnelnieves.sh</code> actually work. Turns out: less than I expected. The hardest parts were the ones I didn&#39;t see coming, which is what makes any of this fun.</p>
<h2>The architecture</h2>
<p>One binary. Two modes. One render path.</p>
<pre><code>            ┌─────────────────────────────┐
            │     agnel  (single .exe)    │
            └──────────────┬──────────────┘
                           │
            ┌──────────────┴──────────────┐
            │                             │
       agnel (no flag)             agnel --serve
       Local TUI                   SSH server (Fly.io)
       crossterm stdin             russh + custom backend
            │                             │
            └──────────┬──────────────────┘
                       │
                src/render.rs   ← shared
                       │
                Per-session App state
                       │
                Live fetches from agnelnieves.com APIs
</code></pre>
<p>The pieces:</p>
<ul>
<li><strong>TUI:</strong> <a href="https://ratatui.rs">ratatui</a> draws everything: header, ticker, ASCII banner, projects list, blog reader. The render function lives in <code>src/render.rs</code> and is identical whether the binary is running on your laptop or whether you&#39;re seeing it over SSH.</li>
<li><strong>State machine:</strong> <code>App</code> in <code>src/app.rs</code> owns screen, scroll position, list selection, ticker animation, and async-fetch results funneled through an <code>mpsc::Receiver</code>.</li>
<li><strong>Data:</strong> zero content is bundled. The CLI fetches live from <code>/feed.json</code>, <code>/api/blog/[slug]/raw</code>, <code>/api/projects</code>, and <code>/api/site.json</code> on this site, cached for an hour at <code>~/.agnel/cache/</code>. So a new blog post here shows up in your terminal immediately without redeploying anything.</li>
<li><strong>SSH server:</strong> <a href="https://docs.rs/russh">russh 0.60</a> in <code>src/serve.rs</code>. One tokio task per session.</li>
</ul>
<h2>The decision that made it easy: don&#39;t fork a PTY</h2>
<p>Most &quot;expose a TUI over SSH&quot; guides suggest spawning a pseudo-terminal and <code>exec</code>-ing the TUI binary inside it. That&#39;s how <code>mosh</code>, <code>tmux</code>, and Go&#39;s <a href="https://charm.sh/blog/wish/"><code>wish</code></a> library (the one terminal.shop uses) handle things. It works, but it&#39;s heavier:</p>
<ul>
<li>Adds a libc dependency (<code>forkpty</code>)</li>
<li>Forks a process per connection (more memory, slower spawn)</li>
<li>Hard to share state between the SSH layer and the app</li>
</ul>
<p>Russh&#39;s <code>ratatui_app</code> example showed a cleaner path: implement a custom <code>std::io::Write</code> backend that funnels bytes through russh&#39;s <code>Handle::data(channel, bytes)</code> API. Ratatui doesn&#39;t know it&#39;s writing to an SSH channel instead of stdout. No fork, no PTY, just bytes:</p>
<pre><code class="language-rust">struct TerminalSink {
    sender: UnboundedSender&lt;Vec&lt;u8&gt;&gt;,
    sink: Vec&lt;u8&gt;,
}

impl std::io::Write for TerminalSink {
    fn write(&amp;mut self, buf: &amp;[u8]) -&gt; std::io::Result&lt;usize&gt; {
        self.sink.extend_from_slice(buf);
        Ok(buf.len())
    }
    fn flush(&amp;mut self) -&gt; std::io::Result&lt;()&gt; {
        if !self.sink.is_empty() {
            let _ = self.sender.send(std::mem::take(&amp;mut self.sink));
        }
        Ok(())
    }
}
</code></pre>
<p>A background tokio task drains the channel and pushes bytes back to the client through russh. Each session gets its own <code>App</code> and its own <code>Terminal&lt;CrosstermBackend&lt;TerminalSink&gt;&gt;</code>. The lock graph stays tiny: an <code>Arc&lt;Mutex&lt;SessionState&gt;&gt;</code> per session, plus a <code>HashMap</code> of all sessions for cleanup.</p>
<p>Inputs go the other direction. Russh hands raw bytes to my <code>data()</code> callback, and a small parser turns them into the same <code>crossterm::event::KeyEvent</code> values the local TUI uses. That meant zero changes to <code>App::handle_key</code>:</p>
<pre><code class="language-rust">fn parse_keys(buf: &amp;[u8]) -&gt; Vec&lt;KeyEvent&gt; {
    // 0x03 → Ctrl+C, 0x1b[A → Up, 0x1b alone → Esc,
    // printable ASCII → Char(b), etc.
}
</code></pre>
<p>Window resize hooks into the same place. <code>window_change_request</code> calls <code>terminal.resize(rect)</code>, and ratatui redraws on the next 50 ms tick.</p>
<p>The result: a single binary you can drop on a VPS, in a container, or even into a Lambda-style worker, and it just serves the TUI.</p>
<h2>What I learned about hosting</h2>
<p>A few things I didn&#39;t expect.</p>
<p><strong>1. Fly trial orgs block dedicated IPv4, and SSH needs IPv4.</strong> For HTTP, Fly&#39;s edge gives you a shared anycast IPv4 for free. For raw TCP on port 22, you need a dedicated v4 (~$2/mo), and trial orgs can&#39;t allocate one. Pure-IPv6 isn&#39;t a real option, either: most US residential ISPs still don&#39;t have native IPv6 SSH paths. Adding a card was the unlock.</p>
<p><strong>2. The SSH host key has to persist.</strong> If the container generates a fresh Ed25519 key on every boot, every returning visitor gets <code>REMOTE HOST IDENTIFICATION HAS CHANGED</code> warnings after the next deploy. The fix is a 1 GB Fly volume mounted at <code>/data</code>, with <code>--host-key /data/ssh_host_key</code>. First boot generates and writes the key; every boot after just reads it.</p>
<p><strong>3. Trial machines auto-stop after 5 minutes.</strong> That&#39;s how the Fly free tier nudges you toward billing. Once you&#39;re paying, set <code>auto_stop_machines = &quot;off&quot;</code> and <code>min_machines_running = 1</code> if you want a long-running service.</p>
<p><strong>4. <code>fly proxy</code> is fine for HTTP, weird for raw SSH.</strong> I burned half an hour on <code>Connection reset by peer</code> through <code>fly proxy 12222:2222</code> before realizing I should just <code>bash /dev/tcp/127.0.0.1/2222</code> from inside the machine via <code>flyctl ssh console</code>. The SSH server was healthy the whole time; the proxy path was the problem.</p>
<h2>ANSI Shadow beats slant</h2>
<p>The first banner I shipped used figlet&#39;s <code>slant</code> font. It looks fine in a docs file. At terminal width with full-block rendering, it falls apart into disconnected diagonals.</p>
<p>Swapped to <a href="https://patorjk.com/software/taag/#p=display&f=ANSI%20Shadow&t=AGNEL%20NIEVES">ANSI Shadow</a>, the same boxy block font terminal.shop and <a href="https://charm.sh">charm.sh</a> use. Six rows tall, about 93 columns wide, reads instantly. Probably the kind of thing I&#39;d have gotten right the first time if I&#39;d looked at more references before opening figlet.</p>
<h2>Visitor #N</h2>
<p>One fun thing I added. Look at the bottom-left of the footer when you connect: it says <code>visitor #N</code>. First SSH session ever was #1, second was #2, and so on. The count lives in a tiny text file on the Fly volume next to the host key, increments once per <code>channel_open_session</code>, and persists across deploys (host-key lesson applied).</p>
<p>Implementation is about thirty lines. An <code>AtomicU64</code> loaded from the file at startup, a <code>spawn_blocking</code> write after each <code>fetch_add</code>. The file is just a base-10 number with no header, so <code>cat /data/visitor_count</code> is the dashboard.</p>
<p>There&#39;s no leaderboard. No real meaning to the number. But watching it tick up after shipping is the moment the whole side project starts feeling worth it.</p>
<h2>The stack</h2>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Tech</th>
</tr>
</thead>
<tbody><tr>
<td>TUI</td>
<td>ratatui 0.29, crossterm 0.28</td>
</tr>
<tr>
<td>SSH</td>
<td>russh 0.60</td>
</tr>
<tr>
<td>HTTP client</td>
<td>ureq 2 (sync, plenty for this)</td>
</tr>
<tr>
<td>Async runtime</td>
<td>tokio 1</td>
</tr>
<tr>
<td>CLI args</td>
<td>clap 4</td>
</tr>
<tr>
<td>Host</td>
<td>Fly.io (<code>iad</code>, shared-cpu-1x, 256 MB, 1 GB volume)</td>
</tr>
<tr>
<td>DNS</td>
<td>Vercel Domains (A + AAAA on the apex)</td>
</tr>
<tr>
<td>Binary size</td>
<td>~3 MB release (macOS arm64), 4.3 MB (Linux x86_64)</td>
</tr>
<tr>
<td>Image size</td>
<td>28 MB (Debian bookworm-slim runtime)</td>
</tr>
<tr>
<td>Cost</td>
<td>$2/mo for the dedicated IPv4 + free tier for the rest</td>
</tr>
</tbody></table>
<h2>What&#39;s next</h2>
<ul>
<li><strong>Pretty errors and loading states.</strong> Today they say <code>Loading…</code> which is functional but boring.</li>
<li><strong>Project deep-links.</strong> I render project metadata but not the original write-ups. Pulling MDX through the API would let me ship long-form case studies in-terminal.</li>
<li><strong>Cross-platform release binaries.</strong> The current install path is <code>cargo install --path cli/</code>. Homebrew tap + release artifacts for macOS and Linux is a Saturday morning.</li>
</ul>
<h2>Try it</h2>
<pre><code class="language-bash">ssh agnelnieves.sh
</code></pre>
<p>If you want to build your own version, the <a href="/guides/ssh-terminal-portfolio.md">guide</a> has the full setup (Rust + ratatui + russh + Fly.io), with a copy-paste prompt at the top so you can hand it to your AI agent of choice. If you ship one, <a href="/connect">let me know</a>.</p>
<hr>
<p><em>Built by <a href="/about">Agnel Nieves</a>, a design engineer with 15+ years across product, design systems, and crypto. More writing on <a href="/blog">the blog</a>.</em></p>
]]></content:encoded>
      <pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate>
      <author>agnel@agnelnieves.com (Agnel Nieves)</author>
      <dc:creator><![CDATA[Agnel Nieves]]></dc:creator>
      <category>Rust</category>
      <category>Ratatui</category>
      <category>SSH</category>
      <category>Fly.io</category>
      <category>Design Engineering</category>
      <category>Side Projects</category>
    </item>
    <item>
      <title><![CDATA[We Found a Hidden Pet System in Claude Code's Leaked Source and Shipped It Overnight]]></title>
      <link>https://agnelnieves.com/blog/we-found-a-hidden-pet-system-in-claude-codes-leaked-source-and-shipped-it-overnight</link>
      <guid isPermaLink="true">https://agnelnieves.com/blog/we-found-a-hidden-pet-system-in-claude-codes-leaked-source-and-shipped-it-overnight</guid>
      <description><![CDATA[Anthropic's Claude Code source leaked via npm. Buried inside: a pixel pet system called Buddy. We turned it into an open-source toy in a day.]]></description>
      <content:encoded><![CDATA[<p>On March 30th, 2026, Anthropic accidentally shipped a 59.8 MB source map file inside their Claude Code npm package. Within hours, the entire 512,000-line TypeScript codebase was mirrored across GitHub and picked apart by thousands of developers. Buried inside that code, alongside feature flags for autonomous agents and undercover commit modes, was something nobody expected: a fully built virtual pet system called <strong>Buddy</strong>.</p>
<p>My co-founder <a href="https://x.com/peronif5">peroni</a> and I did the only sensible thing. We shipped it.</p>
<TwitterEmbed>
<blockquote className="twitter-tweet" data-theme="dark"><p lang="en" dir="ltr">We all know what happened yday with Claude Code. Buried in the source: a hidden pet system called &quot;Buddy.&quot; Every user gets a unique pixel creature based on their ID. Deterministic, same hash, same buddy, every time. So <a href="https://twitter.com/peronif5?ref_src=twsrc%5Etfw">@peronif5</a> and I did the most sensible thing... Shipped it.… <a href="https://t.co/YE4ZSEIXq7">pic.twitter.com/YE4ZSEIXq7</a></p>&mdash; Agnel (🇵🇷) (@agnelnieves) <a href="https://twitter.com/agnelnieves/status/2039311800005525807?ref_src=twsrc%5Etfw">April 1, 2026</a></blockquote>
</TwitterEmbed><h2>What Happened with the Claude Code Leak</h2>
<p>If you missed it, here&#39;s the short version. Chaofan Shou (<a href="https://x.com/Fried_rice">@Fried_rice</a>) noticed that version 2.1.88 of the <code>@anthropic-ai/claude-code</code> package on npm included an unminified source map — <code>cli.js.map</code> — containing the full, readable TypeScript source. By 4:23 AM ET, it was public. By noon, the internet had catalogued every hidden feature, internal codename, and unreleased capability Anthropic had been quietly building.</p>
<p>Among the bigger discoveries:</p>
<ul>
<li><strong>KAIROS</strong> — an always-on daemon mode that lets Claude Code operate as a persistent background agent, watching, logging, and acting without waiting for user input</li>
<li><strong>Undercover mode</strong> — auto-activated for Anthropic employees on public repos, stripping AI attribution from commits</li>
<li><strong>44 feature flags</strong> covering unreleased functionality</li>
<li>And tucked away, a complete companion pet system called <strong>Buddy</strong></li>
</ul>
<h2>The Buddy System: A Pixel Pet for Every User</h2>
<p>The Buddy system was fully implemented. Every Claude Code user was supposed to get a unique pixel creature generated deterministically from their user ID. Same hash, same buddy, every time. The code included 18 species across rarity tiers, stat generation, personality descriptions — the whole gacha experience, just waiting to be turned on.</p>
<p>It was the kind of detail that makes you smile. In a tool built for productivity and code generation, someone at Anthropic took the time to build a pet system. A little pixel friend that lives in your terminal. That&#39;s the kind of craft and whimsy that makes developer tools memorable.</p>
<p>The moment I saw it, I knew what we had to do.</p>
<h2>From Discovery to Deploy in a Day</h2>
<p>Peroni and I have a rhythm. We spot something interesting, we build. No planning committee, no Jira tickets, no &quot;let&#39;s circle back Monday.&quot; As a design engineer with 15+ years of shipping products, I&#39;ve learned that the best side projects are the ones you can&#39;t <em>not</em> build. This was one of those.</p>
<p>We built <a href="https://www.claudebuddy.me/">Claude Buddy</a> — a web app that lets anyone generate their own pixel companion. Type your name or your Claude Code user ID, and watch it draw your buddy pixel by pixel, complete with retro CRT animations and pop sounds.</p>
<p>Here&#39;s what we shipped:</p>
<ul>
<li><strong>12 unique pixel art species</strong> across 5 rarity tiers — from common Blobbits to the legendary Nebulynx (3% chance)</li>
<li><strong>Deterministic generation</strong> — same name always produces the same buddy, making them feel like <em>yours</em></li>
<li><strong>Shiny variants</strong> with a 5% drop rate, because of course</li>
<li><strong>Buddy stats</strong> — Vibe, Chaos, Focus, and Luck, each randomly rolled but consistent to your hash</li>
<li><strong>One-command terminal install</strong> — <code>curl</code> a script and your buddy appears in your Claude Code statusline</li>
<li><strong>Social sharing</strong> — download as PNG, share via URL, generate QR codes, post to X/LinkedIn with pre-populated text</li>
</ul>
<p>The whole thing runs on Next.js 15, renders sprites on HTML5 Canvas, and uses a Mulberry32 PRNG seeded by a DJB2 hash of your input. No backend, no database, no authentication. Pure deterministic fun.</p>
<h2>Why Build This?</h2>
<p>Partly because it&#39;s fun. Partly because of <a href="/blog/ai-native-design-gap-from-static-to-dynamic-experiences">how I approach creative work</a> — I try to ship a hackathon-style project every quarter to stay sharp and experiment with new patterns. But mostly because I think the Buddy system represents something important about how we relate to our tools.</p>
<p>Developer tools don&#39;t have to be purely utilitarian. The best ones have personality. They reward curiosity. They make you <em>want</em> to open the terminal. A pixel pet that lives next to your cursor won&#39;t make you a better programmer, but it might make the work feel a little less solitary.</p>
<p>Anthropic clearly felt the same way — they built the whole thing, ready to ship. We just opened the door a little early.</p>
<h2>How the Generation Algorithm Works</h2>
<p>For the technically curious, the generation is straightforward:</p>
<ol>
<li>Take the input string (name or user ID), lowercase and trim it</li>
<li>Salt it with a fixed string to avoid collisions</li>
<li>Run a DJB2 hash to convert it to a numeric seed</li>
<li>Feed that seed into a Mulberry32 PRNG</li>
<li>Roll for species (weighted by rarity tier probabilities)</li>
<li>Roll for shiny status (5% chance)</li>
<li>Generate four stats between 1 and 99</li>
<li>Select a soul description from the species pool</li>
</ol>
<p>Same input, same seed, same rolls, same buddy. Every time. No server involved.</p>
<h2>It&#39;s Open Source</h2>
<p>The entire project is <a href="https://github.com/basement-browser/claude-buddy">open source on GitHub</a>. Built by the <a href="https://basementbrowser.com">Basement</a> team. Fork it, remix it, hatch your own buddy.</p>
<p>If you want to see what came out of this, check the <a href="https://x.com/agnelnieves/status/2039311800005525807">thread on X</a> where we announced it. And if you&#39;re building with Claude Code, maybe your buddy is already waiting — just type your name and find out.</p>
<hr>
<p><em>Have thoughts on this or want to collaborate on something weird? <a href="/connect">Let&#39;s connect</a>.</em></p>
]]></content:encoded>
      <pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate>
      <author>agnel@agnelnieves.com (Agnel Nieves)</author>
      <dc:creator><![CDATA[Agnel Nieves]]></dc:creator>
      <category>AI</category>
      <category>Claude Code</category>
      <category>Open Source</category>
      <category>Side Projects</category>
      <category>Creative Coding</category>
    </item>
  </channel>
</rss>