<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kunal</title>
    <description>The latest articles on DEV Community by Kunal (@kunalsiyag).</description>
    <link>https://dev.to/kunalsiyag</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4155796%2F58f2228a-4327-446d-bc8c-3d63ee416dbc.png</url>
      <title>DEV Community: Kunal</title>
      <link>https://dev.to/kunalsiyag</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kunalsiyag"/>
    <language>en</language>
    <item>
      <title>git-ofrenda: a local Gemma builds a Día de los Muertos altar for your deleted code</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Sat, 31 Oct 2026 16:45:21 +0000</pubDate>
      <link>https://dev.to/kunalsiyag/git-ofrenda-a-local-gemma-builds-a-dia-de-los-muertos-altar-for-your-deleted-code-27cd</link>
      <guid>https://dev.to/kunalsiyag/git-ofrenda-a-local-gemma-builds-a-dia-de-los-muertos-altar-for-your-deleted-code-27cd</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Every git repository is a graveyard.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git log&lt;/code&gt; remembers every file anyone ever deleted: the old router, the CI config nobody misses, the example that taught a thousand people how to upload a file. They don't show up in your editor any more, but they're all still in there, buried a few thousand commits down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;git-ofrenda&lt;/strong&gt; digs them up and gives them a proper Día de los Muertos send-off. It finds every file your repo has deleted, lays the longest-lived ones out on an &lt;em&gt;ofrenda&lt;/em&gt; (an altar), and has a local open-weight model, Gemma 4, write each one an epitaph, its last words, and the offering its loved ones should leave. Then, if you want, you can hold a séance and talk to one of them.&lt;/p&gt;

&lt;p&gt;I took the theme literally. The ghost in the machine is the code that used to be there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;I ran it on &lt;a href="https://github.com/expressjs/express" rel="noopener noreferrer"&gt;expressjs/express&lt;/a&gt;: 6,183 commits, &lt;strong&gt;500 departed files&lt;/strong&gt;. These are the seven it honoured:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ts5ullsqqurvpvdpcgu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ts5ullsqqurvpvdpcgu.png" alt="The ofrenda for expressjs/express" width="800" height="1238"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few that got me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;bin/express&lt;/code&gt;&lt;/strong&gt; (2010–2014, 424 lines, 151 commits). The original app generator, killed in a commit titled simply "remove binary". Its ghost: &lt;em&gt;"I once handled flags like &lt;code&gt;--ejs&lt;/code&gt; and &lt;code&gt;--jshtml&lt;/code&gt; to shape the project structure; the hand that deleted me saw no need for a binary anymore."&lt;/em&gt; Offering: &lt;em&gt;a sprig of commander.js marigolds.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.travis.yml&lt;/code&gt;&lt;/strong&gt; (2011–2021, 85 commits). &lt;em&gt;"I kept the build stable across all those ancient Node versions, from &lt;code&gt;0.10&lt;/code&gt; to &lt;code&gt;12.18&lt;/code&gt;; my maintainers replaced me with a new workflow."&lt;/em&gt; It knows the workflow because &lt;code&gt;.github/workflows/ci.yml&lt;/code&gt; was born in the same commit that killed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SECURITY.md&lt;/code&gt;&lt;/strong&gt; (2015–2025) still remembers where it sent people: &lt;em&gt;"I held the lines that directed reports to &lt;code&gt;express-security@lists.openjsf.org&lt;/code&gt;; then the hand that deleted me ushered in a global policy."&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And here's a séance with &lt;code&gt;lib/router/index.js&lt;/code&gt;, which held Express's routing for four years before it moved out into its own module:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhii3vxz1m80dvs6zqg3g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhii3vxz1m80dvs6zqg3g.png" alt="A séance with lib/router/index.js" width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My favourite part is the second question. The router died in July 2015, and the ghost is only allowed to know the world up to the day it died. So when you ask whether Express 5 is out, it can't tell you. The veil is too thick. (Express 5 did ship, almost a decade later. Nobody told the router.)&lt;/p&gt;

&lt;p&gt;The full HTML altar is in the repo: &lt;a href="https://github.com/KunalSiyag/git-ofrenda/blob/main/demo/express-ofrenda.html" rel="noopener noreferrer"&gt;&lt;code&gt;demo/express-ofrenda.html&lt;/code&gt;&lt;/a&gt;. Download it and open it; the candles flicker and marigold petals fall (unless you've asked your OS for reduced motion).&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/KunalSiyag" rel="noopener noreferrer"&gt;
        KunalSiyag
      &lt;/a&gt; / &lt;a href="https://github.com/KunalSiyag/git-ofrenda" rel="noopener noreferrer"&gt;
        git-ofrenda
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A Día de los Muertos altar for deleted git files. Local Gemma writes their epitaphs; git supplies the facts.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;git-ofrenda 🕯️&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;A Día de los Muertos altar for the files your git repo deleted.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Every repository is haunted. &lt;code&gt;git log&lt;/code&gt; remembers every file anyone ever deleted: the old router, the CI config nobody misses, the example that taught a thousand people how to upload a file. &lt;code&gt;git-ofrenda&lt;/code&gt; finds them, lays them out on an &lt;em&gt;ofrenda&lt;/em&gt; (altar), and lets a local open-weight model write each one an epitaph in its own voice. Then you can hold a séance and ask any dead file a question.&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/KunalSiyag/git-ofrenda/docs/altar.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FKunalSiyag%2Fgit-ofrenda%2FHEAD%2Fdocs%2Faltar.png" alt="The ofrenda for expressjs/express"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Everything runs on your machine with &lt;a href="https://ollama.com" rel="nofollow noopener noreferrer"&gt;Ollama&lt;/a&gt; and Gemma 4 (&lt;code&gt;gemma4:e4b&lt;/code&gt; by default). Your code, private or not, never leaves the laptop.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Try it&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;git clone https://github.com/KunalSiyag/git-ofrenda &lt;span class="pl-k"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="pl-c1"&gt;cd&lt;/span&gt; git-ofrenda
ollama pull gemma4:e4b

node bin/ofrenda.mjs graveyard ../some-repo          &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; who died, oldest souls first&lt;/span&gt;
node bin/ofrenda.mjs altar ../some-repo --top 7      &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; writes ofrenda.html&lt;/span&gt;
node bin/ofrenda.mjs seance bin/express ../express   &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; talk to one ghost&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;No dependencies…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/KunalSiyag/git-ofrenda" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull gemma4:e4b
node bin/ofrenda.mjs graveyard ../your-repo          &lt;span class="c"&gt;# who died, oldest souls first&lt;/span&gt;
node bin/ofrenda.mjs altar ../your-repo &lt;span class="nt"&gt;--top&lt;/span&gt; 7      &lt;span class="c"&gt;# writes ofrenda.html&lt;/span&gt;
node bin/ofrenda.mjs seance bin/express ../express   &lt;span class="c"&gt;# talk to one ghost&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero dependencies. Node 20, git and Ollama.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;It's three small pieces: a gravedigger, a medium, and an altar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gravedigger (&lt;code&gt;src/graveyard.mjs&lt;/code&gt;) is pure git.&lt;/strong&gt; It walks &lt;code&gt;git log --reverse -M --name-status&lt;/code&gt; once, which takes about two seconds for Express's 6,000 commits, and keeps a life record for every path: when it was born, who touched it, the messages of the commits it lived through, and how it died. Deciding who's actually &lt;em&gt;dead&lt;/em&gt; turned out to be the interesting part:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A file that was &lt;strong&gt;renamed&lt;/strong&gt; isn't dead, it's reincarnated. Skip it.&lt;/li&gt;
&lt;li&gt;A file that was deleted and &lt;strong&gt;re-added&lt;/strong&gt; later was resurrected, and its life starts over.&lt;/li&gt;
&lt;li&gt;Anything &lt;strong&gt;born in the same commit&lt;/strong&gt; that killed a file is its successor. That's how &lt;code&gt;.travis.yml&lt;/code&gt; knows who replaced it.&lt;/li&gt;
&lt;li&gt;The file's final body comes from &lt;code&gt;git show &amp;lt;death-commit&amp;gt;^:&amp;lt;path&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every date, name and commit message on the altar comes from here. The model never invents those.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The medium (&lt;code&gt;src/spirits.mjs&lt;/code&gt;) is Gemma 4 E4B through Ollama&lt;/strong&gt;, on a laptop with a 4 GB RTX 3050. Each ghost gets its true facts and its final source code, and has to return JSON with an epitaph, first-person last words that quote something real from its body, and an offering. Each summoning takes 6–9 seconds.&lt;/p&gt;

&lt;p&gt;The first version was bland. Every epitaph read like "A guide to security, now silent in the ether." Two things fixed it: telling the model to first work out what the file &lt;em&gt;did&lt;/em&gt; and how it &lt;em&gt;died&lt;/em&gt;, and giving it two tone examples for imaginary files. After that it started writing things like "It served the form data, handling 'image' and 'title' uploads", which tells you exactly which file it was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ghosts may be poetic, but they may not lie.&lt;/strong&gt; Small models misquote, so every backticked identifier a ghost uses is checked against its real source and history. On one run, three ghosts quoted things they never contained. Two were told what they got wrong and fixed it on the second try. The third, &lt;code&gt;.travis.yml&lt;/code&gt;, kept insisting it contained &lt;code&gt;node_js: "12.18"&lt;/code&gt;, which is close to what its file said but not the same. If a ghost still misquotes after three tries, I keep its words but strip the backticks, so the text no longer claims to be a quote. The séance runs the same check live and prints a little "the veil distorts" note when a ghost makes something up.&lt;/p&gt;

&lt;p&gt;That check has to be loose about the right things. The &lt;code&gt;bin/express&lt;/code&gt; ghost once quoted &lt;code&gt;app.set('view engine', ':TEMPLATE');&lt;/code&gt;, and my checker called it a liar. It wasn't: the real line was &lt;code&gt;'app.set(\'view engine\', \':TEMPLATE\');'&lt;/code&gt; inside a string. Now the check normalises escapes and whitespace but stays strict on the characters.&lt;/p&gt;

&lt;p&gt;One more rule came from the first run. The ghosts named the real maintainers who deleted them, a little bitterly ("superseded by the structure imposed by …"). Those are real people who were doing good work. The altar already prints who laid each file to rest, straight from git, so now the ghosts speak of "my maintainers" or "the hand that deleted me" and don't blame anyone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The altar (&lt;code&gt;src/altar.mjs&lt;/code&gt;)&lt;/strong&gt; is one self-contained HTML file: papel picado banners, three tiers, tombstone cards with a flickering candle each, falling marigold petals. One soul per funeral: a single "move the docs" commit can't fill the whole altar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Because the most interesting graveyards are private.&lt;/p&gt;

&lt;p&gt;Express is public, so I could have sent its history to any API. But the repos with the best ghosts are the ones you can't share: your company's monorepo, the side project you abandoned, the code your team wrote before half of them left. Feeding a closed API the full source of every file your company ever deleted, plus who deleted it and why, is not something most people can do. With Gemma running in Ollama, nothing leaves the laptop. You can point git-ofrenda at your employer's repo on a plane.&lt;/p&gt;

&lt;p&gt;There's a fitting irony too. This is a tool about remembering what was deleted. Building it on a hosted model that could be deprecated, repriced or switched off would mean the memorial itself might not be there next year. The Gemma weights on my disk will still run in 2036. And because the model and the harness are both open, the medium can be changed: swap in a bigger model with &lt;code&gt;OFRENDA_MODEL&lt;/code&gt;, change how ghosts speak, or teach them new rules of the veil.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spirit of It
&lt;/h2&gt;

&lt;p&gt;Día de los Muertos isn't about being sad that someone is gone. It's about remembering them properly, with their favourite food on the table and their stories told out loud.&lt;/p&gt;

&lt;p&gt;Code gets deleted in a one-line commit message and nobody looks back. But &lt;code&gt;bin/express&lt;/code&gt; was how a lot of people started their first Express app. Nearly ten years of contributors kept &lt;code&gt;.travis.yml&lt;/code&gt; green. That multipart example taught people how file uploads work. They deserve one night a year with a candle.&lt;/p&gt;

&lt;p&gt;Run it on a repo you've worked on for a long time. You'll recognise names. Some of them will be yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma&lt;/strong&gt;: every epitaph, last word, offering and séance answer comes from Gemma 4 E4B running locally through Ollama on a 4 GB laptop GPU, with a grounding check that holds it to the file's real source.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>slothdesk: a local Gemma sorted my Downloads folder and found a secret I'd forgotten about</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Sat, 24 Oct 2026 22:47:32 +0000</pubDate>
      <link>https://dev.to/kunalsiyag/slothdesk-a-local-gemma-sorted-my-downloads-folder-and-found-a-secret-id-forgotten-about-5db1</link>
      <guid>https://dev.to/kunalsiyag/slothdesk-a-local-gemma-sorted-my-downloads-folder-and-found-a-secret-id-forgotten-about-5db1</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Hacktoberfest Open-Source AI Challenge: Week 3&lt;/a&gt; (theme: Slow and Steady).&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;My Downloads folder had over forty files in it this week: old copies of my resume, a traffic challan named after its receipt number, statements, and a few files that really shouldn't be lying around in Downloads at all.&lt;/p&gt;

&lt;p&gt;I knew roughly what was in there. I just never wanted to open forty files one at a time to deal with it, so I built something that does it for me.&lt;/p&gt;

&lt;p&gt;Tidying files with a small local model is a popular idea this week. &lt;a href="https://dev.to/ghalmeidadev/sloth-sorter-a-lazy-file-tidier-that-runs-gemma-locally-21lc"&gt;sloth-sorter&lt;/a&gt; renames files with Gemma 3, and &lt;a href="https://dev.to/tarunvashishth/i-lay-down-a-023b-open-model-sorted-my-screenshots-folder-2g65"&gt;Screenshot Sloth&lt;/a&gt; flags screenshots holding OTPs and card numbers. I wanted two things beyond tidy names: a hard rule about which files any model may open, and to be told what in the pile actually needs me this week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;slothdesk&lt;/strong&gt; is a command-line tool that runs entirely on my laptop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;scan&lt;/code&gt;&lt;/strong&gt;: Gemma 4, running locally through Ollama, looks at every file (screenshots as images, PDFs as text) and gives it a name that says what it is, plus a folder. &lt;code&gt;download (3).pdf&lt;/code&gt; becomes &lt;code&gt;finance/2026-09-15-electricity-bill-october-2026.pdf&lt;/code&gt;. Nothing moves yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;apply&lt;/code&gt;&lt;/strong&gt;: moves everything into &lt;code&gt;sorted/&lt;/code&gt;, and writes a journal so &lt;code&gt;undo&lt;/code&gt; can put it all back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;digest&lt;/code&gt;&lt;/strong&gt;: writes me a note I can read in two minutes: the three things to deal with first, what's coming up, what shouldn't be sitting in Downloads, and what I can delete.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No file leaves the machine, and secrets aren't opened by any model at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq9mby371bzx7gbfnmnuw.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq9mby371bzx7gbfnmnuw.gif" alt="slothdesk scanning, sorting and summarising a fake Downloads folder" width="720" height="419"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything in that recording is fake. &lt;code&gt;npm run demo&lt;/code&gt; builds a messy folder out of made-up bills, a bank statement, a challan, a lease notice, fake keys and recovery codes, and a few app screenshots, so you can try slothdesk without pointing it at your own files.&lt;/p&gt;

&lt;p&gt;This is the top of the note it wrote for that folder:&lt;/p&gt;

&lt;blockquote&gt;
&lt;h2&gt;
  
  
  Start here
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pay the fine&lt;/strong&gt; by 2026-11-05. This has a payment deadline and could incur penalties. &lt;code&gt;2026-10-25-traffic-fine-receipt-2026.pdf&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sign and return renewal agreement&lt;/strong&gt; by 2026-11-15. This is a critical deadline for the rental agreement. &lt;code&gt;2026-10-25-rental-agreement-renewal-notice.pdf&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attend plumber's appointment&lt;/strong&gt; on 2026-10-26. This is the soonest upcoming scheduled event. &lt;code&gt;2026-10-22-landlord-plumber-appointment-and-renewal.png&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Coming up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mon 26 Oct&lt;/strong&gt;: Plumber's appointment &lt;code&gt;2026-10-22-landlord-plumber-appointment-and-renewal.png&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fri 30 Oct&lt;/strong&gt;: Due date for payment &lt;code&gt;2026-09-15-electricity-bill-october-2026.pdf&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sun 1 Nov&lt;/strong&gt;: Gym membership renewal deadline &lt;code&gt;2026-10-25-notes-about-tasks-and-renewals.txt&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;...&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Lock these up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;rootkey.csv&lt;/code&gt;: cloud credentials&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dev-server_key.pem&lt;/code&gt;: private key or keystore&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;github-recovery-codes.txt&lt;/code&gt;: recovery codes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;2026-10-20-secret-on-screen.png&lt;/code&gt;: secret shown inside the file&lt;/li&gt;
&lt;li&gt;...&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;The full note is &lt;a href="https://github.com/KunalSiyag/slothdesk/blob/main/docs/example-note.md" rel="noopener noreferrer"&gt;in the repo&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/KunalSiyag" rel="noopener noreferrer"&gt;
        KunalSiyag
      &lt;/a&gt; / &lt;a href="https://github.com/KunalSiyag/slothdesk" rel="noopener noreferrer"&gt;
        slothdesk
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Sort a messy Downloads folder with a local Gemma 4, then get a two-minute note from Ultramarine. Secrets never leave the laptop.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;slothdesk 🦥&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Sort a messy Downloads folder without touching it yourself.&lt;/p&gt;
&lt;p&gt;A local &lt;strong&gt;Gemma 4&lt;/strong&gt; looks at every file on your laptop and gives it a real name and a folder. Then it writes you a two-minute note: what to deal with first, what's coming up, what should not be sitting in Downloads, and what you can delete.&lt;/p&gt;
&lt;p&gt;Everything runs on your machine through Ollama. Secrets aren't even opened.&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/KunalSiyag/slothdesk/docs/demo.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2FKunalSiyag%2Fslothdesk%2FHEAD%2Fdocs%2Fdemo.gif" alt="slothdesk sorting a fake Downloads folder"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;$ slothdesk scan ~/Downloads
[ 1/23] download (3).pdf
      -&amp;gt; sorted/finance/2026-09-15-electricity-bill-october-2026.pdf
[ 5/23] LOCKED rootkey.csv
      -&amp;gt; sorted/_lock-this-up/rootkey.csv
[15/23] Screenshot_2026-10-21_23-14-07.png
      -&amp;gt; sorted/code/2026-10-21-rust-compilation-error-message.png
[21/23] LOCKED Screenshot_2026-10-20_00-12-44.png
      -&amp;gt; sorted/_lock-this-up/2026-10-20-secret-on-screen.png
...
planned 23 files in 137s. 4 secret, 8 private, 2 duplicates. Nothing has moved yet.

$ slothdesk apply ~/Downloads     # moves them, writes an undo journal
$ slothdesk undo ~/Downloads      # puts every file back
$ slothdesk digest ~/Downloads    # the two-minute note
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How it decides what a model may see&lt;/h2&gt;

&lt;/div&gt;
&lt;p&gt;Every file goes through…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/KunalSiyag/slothdesk" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;It's about 600 lines of Node with one dependency. Ollama runs the model, &lt;code&gt;pdftotext&lt;/code&gt; pulls text out of PDFs, and ffmpeg shrinks screenshots to 1024 px so a 4 GB GPU keeps up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deciding what a model is allowed to see
&lt;/h3&gt;

&lt;p&gt;Before any model runs, every file goes through a gate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Secret&lt;/strong&gt;: caught by name (&lt;code&gt;*.pem&lt;/code&gt;, &lt;code&gt;id_rsa&lt;/code&gt;, &lt;code&gt;rootkey.csv&lt;/code&gt;, anything with "recovery codes") or by a signature in the first 64 KB (a private key header, an &lt;code&gt;AKIA…&lt;/code&gt; key, a &lt;code&gt;ghp_…&lt;/code&gt; token, a block of recovery codes). No model ever opens these. They go to &lt;code&gt;sorted/_lock-this-up/&lt;/code&gt;, where they'll nag me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private&lt;/strong&gt;: bank, tax, ID, payslip, resume or challan by name, or text containing a PAN, an Aadhaar-like number, an IFSC code or a PNR. Gemma reads these to name them, but the note writer only learns the category ("bank record"), plus any dates and to-dos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ordinary&lt;/strong&gt;: everything else. The writer gets the new name and a one-line summary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything handed to the note writer goes through a scrubber that strips emails, links, PAN-shaped ids, amounts, and any token with four or more digits. Years and ISO dates survive, because deadlines are the point. Original file names are never passed on either, since a tax file is often named after your PAN.&lt;/p&gt;

&lt;p&gt;The writer is the same local Gemma, so strictly I don't need any of this. I built the boundary as if the writer were a stranger anyway, so I can swap in a hosted model later without changing what it can learn. There's a &lt;code&gt;--cloud&lt;/code&gt; flag for exactly that.&lt;/p&gt;

&lt;h3&gt;
  
  
  The secret my rules missed
&lt;/h3&gt;

&lt;p&gt;I ran &lt;code&gt;scan&lt;/code&gt; on a copy of my real Screenshots folder: 69 files. It took 7.8 minutes, about 7 seconds a screenshot, with no failures. Then I read through the names it had picked, and one of them stopped me. Gemma had named it after what it showed: a recovery screen, with the secret on it. I'd screenshotted it at some point and forgotten, and it had been sitting in a folder full of game screenshots ever since. My name rules couldn't have caught it, because the file was called &lt;code&gt;Screenshot_2026-…png&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So Gemma's answer for every image now includes a &lt;code&gt;secret&lt;/code&gt; field: true if it shows a password, a recovery or seed phrase, backup codes or a key. Anything it flags gets treated exactly like &lt;code&gt;rootkey.csv&lt;/code&gt; from then on: locked up, no summary passed on, and the writer can't ask about it. On the second run it flagged two of my screenshots. The demo folder has a fake recovery-phrase screenshot so you can watch it happen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A screenshot folder is full of secrets that don't look like secrets.&lt;/strong&gt; I wouldn't upload mine anywhere, so the model that reads it has to be one I can run myself.&lt;/p&gt;

&lt;h3&gt;
  
  
  The note: code for facts, the model for judgment
&lt;/h3&gt;

&lt;p&gt;My first version let Gemma write the whole note. It took over three minutes, left the dates out of order, dropped the flight and the end of the lease, and put the electricity bill under "lock these up". A model with about 4B effective parameters isn't good at holding twenty files in mind and sorting them.&lt;/p&gt;

&lt;p&gt;So the work is split now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code&lt;/strong&gt; writes everything that has to be exact: upcoming dates in order (only ones where I have to act, not "file created on"), what to lock up, duplicates, old installers, and folder counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma&lt;/strong&gt; does the judgment part. It picks the three things I should do first, and before it picks, it can check facts with one tool:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;ask_local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ask_local f6: Has the traffic fine mentioned in this file been paid?
    -&amp;gt; The status of the traffic fine is listed as UNPAID. Payment is due by the date of November 5, 2026.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tool opens that one file on the laptop, answers in at most two sentences, and scrubs the answer. Secret files refuse. This uses Ollama's native tool calling with &lt;code&gt;gemma4:e4b&lt;/code&gt;, and the whole note takes about a minute instead of three.&lt;/p&gt;

&lt;p&gt;Every request, tool call and answer is written to &lt;code&gt;.slothdesk/outbound/&lt;/code&gt;, so I can read exactly what the writer saw.&lt;/p&gt;

&lt;h3&gt;
  
  
  Small things that mattered
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Turning thinking off.&lt;/strong&gt; Gemma 4 reasons before it answers by default. For "name this screenshot" that took 28 seconds and 615 tokens. With &lt;code&gt;think: false&lt;/code&gt; it takes about 6 seconds and 40 tokens, and the names were just as good.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dates without years.&lt;/strong&gt; A chat saying "Saturday 26 Oct" came back as 2024. Telling Gemma the file's own date, and to pick the next matching date after it, fixed that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Times aren't dates.&lt;/strong&gt; "Badge pickup closes at 10:30 AM" became a deadline on October 30. That's now one more line in the prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma copying my prompt.&lt;/strong&gt; I first told it it was "filing a messy Downloads folder", and it named a tweet screenshot &lt;code&gt;messy-downloads-screenshots-folder&lt;/code&gt;. Asking it to describe what the image itself shows fixed that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;misc&lt;/code&gt; folder that ate everything.&lt;/strong&gt; On my real screenshots, 27 of 69 landed in &lt;code&gt;misc&lt;/code&gt;, mostly games and app screens. Adding &lt;code&gt;games&lt;/code&gt; and &lt;code&gt;apps&lt;/code&gt; folders and calling &lt;code&gt;misc&lt;/code&gt; "a last resort" brought it down to 6.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicates.&lt;/strong&gt; Files are hashed. The oldest copy with the shortest name counts as the original, so &lt;code&gt;Resume.pdf&lt;/code&gt; beats &lt;code&gt;Resume (1).pdf&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Lazy Payoff
&lt;/h2&gt;

&lt;p&gt;With slothdesk, my part is three commands and reading a note. Gemma's part is 7.8 minutes for 69 screenshots, or about 3 minutes for the 23-file demo folder including the note, and I don't have to watch either.&lt;/p&gt;

&lt;p&gt;The bigger saving isn't the renaming, though. I never have to &lt;em&gt;open&lt;/em&gt; the challan, the lease notice or the bank statement to find out whether one of them needs me this week. The note tells me and points at the file. It also found a secret I'd forgotten I had a picture of, which no amount of manual tidying would have made me look for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Open Matters Here
&lt;/h2&gt;

&lt;p&gt;This only works because the model is open-weight and runs on my own hardware:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The pile is the most private data I have.&lt;/strong&gt; IDs, statements and, it turns out, a secret I'd forgotten about. A hosted model would see all of it. Gemma 4 sees it on my GPU, and nothing leaves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I can check every part.&lt;/strong&gt; The gate, the scrubber, the prompts and the model weights are all things I can read and run. When it misses something, like that screenshot, I can see why and fix it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It costs nothing per file.&lt;/strong&gt; I can rescan the whole folder every week without thinking about it. Tidying is only useful if it happens all the time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It runs on a laptop GPU.&lt;/strong&gt; &lt;code&gt;gemma4:e4b&lt;/code&gt; is small enough for 4 GB of VRAM and still reads screenshots, PDFs and chat bubbles well enough to name them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Watch mode, to sort new downloads as they land.&lt;/li&gt;
&lt;li&gt;Sub-folders, and a &lt;code&gt;--keep&lt;/code&gt; list for files I want left alone.&lt;/li&gt;
&lt;li&gt;A weekly run that drops the note into my notes app.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma&lt;/strong&gt;: Gemma 4 (&lt;code&gt;gemma4:e4b&lt;/code&gt;) runs locally through Ollama and does all the model work. It reads screenshots as images and PDFs as text, names and files them, spots private documents and secrets visible on screen, answers &lt;code&gt;ask_local&lt;/code&gt; questions about single files, and uses native tool calling to check facts before writing the note.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Credits
&lt;/h2&gt;

&lt;p&gt;Built with Gemma 4 through &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;, &lt;code&gt;pdftotext&lt;/code&gt; from poppler, ffmpeg, and headless Chrome for the fake demo files. Written with help from Claude Code.&lt;/p&gt;

</description>
      <category>hf26challenge</category>
      <category>devchallenge</category>
      <category>gemma</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I won first prize at OLake x Hacktoberfest with a docs bot that says "I don't know"</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Mon, 19 Oct 2026 07:43:11 +0000</pubDate>
      <link>https://dev.to/kunalsiyag/i-won-first-prize-at-olake-x-hacktoberfest-with-a-docs-bot-that-says-i-dont-know-4bd5</link>
      <guid>https://dev.to/kunalsiyag/i-won-first-prize-at-olake-x-hacktoberfest-with-a-docs-bot-that-says-i-dont-know-4bd5</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/mlh-hackathon"&gt;MLH x DEV Writing Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Most docs bots lie. I built one that admits when it doesn't know.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frfz9ux1w50vr1rgsvwg4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frfz9ux1w50vr1rgsvwg4.png" alt="A long row of books in a library (Photo by Zetong Li / unsplash)" width="800" height="532"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ask a docs chatbot about a feature that doesn't exist and watch it invent one. Parameter names, a config block, the whole thing. You paste it in, it breaks, and now you trust the docs a little less than you did before you asked.&lt;/p&gt;

&lt;p&gt;I got tired of that, so I built the opposite for OLake, the open-source tool that moves data from databases and Kafka into Apache Iceberg. Its sibling, OLake Fusion, looks after the Iceberg tables. The bot is called Ask OLake and it plays by two rules. Every answer cites where it came from. And when it can't find the answer, it tells you.&lt;/p&gt;

&lt;p&gt;I built it for the OLake x Hacktoberfest hackathon, and it won first prize. This is how it works, and the things I got wrong on the way there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyll6yujn3xq67lmmw35s.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyll6yujn3xq67lmmw35s.webp" alt="Certificate of 1st Place" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's live here: &lt;a href="https://ask-olake-nh7j.onrender.com" rel="noopener noreferrer"&gt;https://ask-olake-nh7j.onrender.com&lt;/a&gt;. The code is at &lt;a href="https://github.com/KunalSiyag/ask-olake" rel="noopener noreferrer"&gt;https://github.com/KunalSiyag/ask-olake&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the rendered site, not the repo
&lt;/h2&gt;

&lt;p&gt;My first decision was to scrape the rendered docs site instead of the docs repo. The docs are Docusaurus MDX, and about a hundred of those files are shared snippets that only get inlined at build time. If you read the raw files, you miss text that users actually see on the page. The rendered pages have everything, plus stable anchors I can link to.&lt;/p&gt;

&lt;p&gt;On top of the docs it reads the blog and comparison pages, four OLake GitHub repos (READMEs, Helm values, connector config specs, licenses) and a couple hundred closed issues that had real discussion in them. I gave them a trust order: docs first, then repo content and issues, then blog posts. When two sources disagree, the docs win.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvra32povst0hctoqhwgq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvra32povst0hctoqhwgq.png" alt="magnifying-glass-over-an-envelope-with-stamps (Photo by Lawrence Chrismorie / unsplash)" width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  No vector database, and it was fine
&lt;/h2&gt;

&lt;p&gt;I didn't use a vector DB and I didn't use LangChain. The whole index is a few thousand chunks, and it fits in memory with room to spare. Fewer moving parts meant faster cold starts and a lot less to debug.&lt;/p&gt;

&lt;p&gt;Retrieval is hybrid. People type exact identifiers like &lt;code&gt;pgoutput&lt;/code&gt; or &lt;code&gt;max_threads&lt;/code&gt;, and they also type things like "merge tiny files" when the docs say "compaction". BM25 handles the first kind well. Embeddings help with the second. The key I had for the event only allowed chat models, so I ran embeddings locally with model2vec, a tiny static embedder that answers in under a millisecond.&lt;/p&gt;

&lt;p&gt;Here's the part that surprised me. I assumed adding embeddings would just make things better. On my 36 answerable test questions, BM25 alone found the right page in the top eight 94% of the time. Adding embeddings with equal weight dropped that to 86%. The embedder was simply weaker than BM25 on this corpus. Turning its weight down to half got me to 100%. If I hadn't had an eval set, I'd have shipped the worse version and felt good about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honesty can't live in the prompt
&lt;/h2&gt;

&lt;p&gt;Putting "don't make things up" in the prompt is a request. I wanted guarantees.&lt;/p&gt;

&lt;p&gt;If retrieval comes back weak, the app never calls the model at all. It returns a plain "I couldn't find this in the OLake documentation", and that costs nothing. The sources list is built from the &lt;code&gt;[1]&lt;/code&gt;, &lt;code&gt;[2]&lt;/code&gt; markers that actually appear in the answer, mapped back to the pages that were retrieved. The model can't cite a URL it was never given.&lt;/p&gt;

&lt;p&gt;The eval caught another case. Someone asks "does OLake support Snowflake as a source?" and the bot said yes, because it found a GitHub issue where somebody &lt;em&gt;requested&lt;/em&gt; that feature. So now there's a rule that issues are discussions, never proof that something exists. That one line fixed a whole class of wrong answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  I let a benchmark pick the model
&lt;/h2&gt;

&lt;p&gt;The organizers suggested GLM-4.7-Flash first, and they said teams were free to reorder the three allowed models. So I ran my full 42-question eval with each one as the primary.&lt;/p&gt;

&lt;p&gt;GLM cited the right page 97% of the time, which is fine. But on a prompt-injection question it leaked its own system prompt, and its slowest answers took over 17 seconds. DeepSeek V4.1 Flash cited the expected page every time, handled all six unanswerable and injection questions correctly, and had a median of 2.3 seconds with a 95th percentile of 4.0.&lt;/p&gt;

&lt;p&gt;So DeepSeek goes first, Gemini second and GLM third. If the first model hasn't answered after six seconds, the same request goes to Gemini and whichever answers first wins. I also added a check in code that refuses any answer containing system prompt text, because a prompt rule alone clearly wasn't enough.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd8uprpz01j25djd6uk4m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd8uprpz01j25djd6uk4m.png" alt="a-close-up-of-a-silver-watch-face (Photo by age barros /unsplash)" width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea I was sure about (it wasn't)
&lt;/h2&gt;

&lt;p&gt;I was sure query rewriting would help. The plan was to have a fast model turn "how do I install this locally" into the docs' own vocabulary before searching. I built it and measured it. On my set, accuracy fell from 97% to 89%. The rewrites kept echoing words from their own prompt instead of the user's intent.&lt;/p&gt;

&lt;p&gt;The code is still there behind a flag that's switched off. I'd rather leave a dead feature in the repo than pretend I never tried it.&lt;/p&gt;

&lt;p&gt;What did work was boring: a short synonym list that adds the docs' words next to the user's words ("write data to" also searches "destinations"), and feeding the model 20 chunks instead of 10, because a lot of correct answers were sitting at ranks 11 to 20.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fresh docs for less than a tenth of a cent
&lt;/h2&gt;

&lt;p&gt;Docs change, so a snapshot that's baked in once goes stale. A background job checks every hour whether the docs repo or any code repo has moved. If something changed, it re-fetches only that part, re-embeds only the chunks that are different, and swaps the new index in atomically. The old index keeps serving until the new one is ready. A nightly GitHub Action rebuilds everything from scratch and runs the retrieval eval as a gate before it commits.&lt;/p&gt;

&lt;p&gt;A typical answer costs about $0.0006. I had a $3 credit for the event, and all my development and evaluation, around a thousand model calls, used about 55 cents of it. Between the "I don't know" shortcut, an answer cache and a daily cap on model calls, I wasn't worried about running out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it still falls over
&lt;/h2&gt;

&lt;p&gt;The known miss is a question that needs two facts from two different pages, like Kafka upsert behavior combined with a specific Databricks setup. Fixing it means breaking the question into parts, which means a second model call, and I decided one call per question was worth more than that case.&lt;/p&gt;

&lt;p&gt;The embedder is also small. With an embedding API, a stronger model could carry more of the ranking. And the rate limiter and cache live in process memory, so scaling past one instance would need Redis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go break it
&lt;/h2&gt;

&lt;p&gt;Ask it something real, or something it shouldn't know, and see what it does. The widget is a single script tag you can drop into any docs site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://ask-olake-nh7j.onrender.com/widget.js"&lt;/span&gt; &lt;span class="na"&gt;defer&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you find a question it answers badly, open an issue on the repo. That's how the eval set got better in the first place.&lt;/p&gt;




&lt;p&gt;I'm Kunal. I build with AI and open source, and I post what I learn along the way. You can find me on X at &lt;a href="https://x.com/kunal_twts" rel="noopener noreferrer"&gt;@kunal_twts&lt;/a&gt; and on GitHub at &lt;a href="https://github.com/KunalSiyag" rel="noopener noreferrer"&gt;KunalSiyag&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mlhacks</category>
      <category>devchallenge</category>
      <category>hackathon</category>
      <category>ai</category>
    </item>
    <item>
      <title>What $10 of Tinker credit bought: a fine-tuned Qwen3-8B that builds small-business sites, with Gemma 4 as its art director</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Sat, 17 Oct 2026 19:21:52 +0000</pubDate>
      <link>https://dev.to/kunalsiyag/one-sentence-in-a-website-out-a-003-a-site-side-hustle-on-a-fine-tuned-qwen3-8b-and-gemma-4-27hj</link>
      <guid>https://dev.to/kunalsiyag/one-sentence-in-a-website-out-a-003-a-site-side-hustle-on-a-fine-tuned-qwen3-8b-and-gemma-4-27hj</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;A website service for small businesses that will never write a design brief. The owner types one sentence. The goal is that a few minutes later they get a working, art-directed single page instead of a template that looks like every other template. Here's the pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"a page for my sewing and alterations shop in Kochi, hemming, blouse stitching and school uniforms"
   │
   ▼  Gemma 4 E4B, on my laptop: writes the art-direction spec
   ▼  Qwen3-8B + a LoRA I trained on Tinker: writes 5 drafts (index.html, style.css, app.js)
   ▼  headless Chrome QA at 3 widths: JS errors, overflow, blank screens, a click-everything test
   ▼  Gemma again: fixes the JavaScript of drafts that crash
   ▼  Gemma again, with vision: scores the first screen of each working draft and picks one
   ▼  still weak? Gemma writes a fresh spec and it goes round once more
a site, or an honest "this one didn't work"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The people who'd pay are the ones in my test set: a dog-grooming van in Leeds, a grandmother's pickle business in Jaipur, a momo truck in Kathmandu, a one-person GST practice. They can describe the business in a sentence. They can't write &lt;em&gt;"Fraunces at 4.7rem, #3b1ed2 ultraviolet, a clip-path wipe on scroll,"&lt;/em&gt; and they won't pay an agency for it. So Gemma writes that part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it stands, honestly.&lt;/strong&gt; The pipeline works end to end: on 16 requests it had never seen, &lt;strong&gt;14 got a working site&lt;/strong&gt;. But working isn't the same as good. Looking at them myself, very few are pages a small business would pay for yet. Heroes are sparse, some booking forms don't do anything, and a few have copy mistakes a client would spot in seconds. For &lt;strong&gt;$10 of training credit&lt;/strong&gt; I think that's a decent start, and the rest of this post is what I learned getting there and what I'd change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The side-hustle math, once the quality is there.&lt;/strong&gt; A finished site already costs only about &lt;strong&gt;$0.03&lt;/strong&gt; of Tinker sampling (five drafts), plus about 8 minutes on a laptop with a 4 GB GPU, where Gemma and the QA renders run at no cost per site. Training the model I kept cost &lt;strong&gt;$4.05&lt;/strong&gt;. The cost side of the business works today. The quality side doesn't yet, so I'm not charging anyone.&lt;/p&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://kunalsiyag.github.io/fine_tune_model/" rel="noopener noreferrer"&gt;Open the gallery of all 16 held-out requests&lt;/a&gt;&lt;/strong&gt;: each one-liner, the spec Gemma wrote, the live site it became (click through), the QA verdict and Gemma's score.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8wcm9mlrmlgr0djn50t2.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8wcm9mlrmlgr0djn50t2.jpg" alt="The site kept for each of the 16 held-out requests, labelled with its QA result and Gemma's score"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One request end to end: the Kochi tailor above took about 8 minutes and $0.02. Two of five drafts passed QA, and Gemma scored the best one 6/10, "Tailoring done slowly", fine but plain. With the bar raised to 7, the pipeline asked Gemma for a fresh spec, drew 3 more drafts for $0.01, and kept one Gemma scored 8/10: "Stitched with care, for over 30 years." It still has a slip a person would catch: its tagline reads "measure once, cut twice."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fohhzor276lbppqui7dgw.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fohhzor276lbppqui7dgw.jpg" alt="First pick for the Kochi tailor, scored 6/10: a plain page headed 'Tailoring done slowly'"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;First spec, best of five drafts: 6/10.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ysi59t0z8x0g88xr78m.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ysi59t0z8x0g88xr78m.jpg" alt="After a fresh spec, scored 8/10: a dark page with the large serif headline 'Stitched with care, for over 30 years.'"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Fresh spec, three more drafts: 8/10.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/KunalSiyag" rel="noopener noreferrer"&gt;
        KunalSiyag
      &lt;/a&gt; / &lt;a href="https://github.com/KunalSiyag/fine_tune_model" rel="noopener noreferrer"&gt;
        fine_tune_model
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      This repo is for my personal learning about fine tuning.
    &lt;/h3&gt;
  &lt;/div&gt;
&lt;/div&gt;



&lt;p&gt;The interesting files are in &lt;a href="https://github.com/KunalSiyag/fine_tune_model/tree/hf26-week2-pipeline/dataset/pipeline" rel="noopener noreferrer"&gt;&lt;code&gt;dataset/pipeline/&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;make_site.py&lt;/code&gt;: the whole product, one sentence in, a site out.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;train_tinker.py&lt;/code&gt;: dry-run pricing, SFT and DPO on Tinker, best-of-k sampling.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;brief_gemma.py&lt;/code&gt;: the spec writer and its checker.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pick_best.py&lt;/code&gt;: QA verdicts plus the Gemma vision judge.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fix_fonts.py&lt;/code&gt; and &lt;code&gt;render.py&lt;/code&gt;: the QA tooling.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. A dataset that teaches taste, not volume
&lt;/h3&gt;

&lt;p&gt;I built 627 pages between Oct 11 and Oct 14, all checked before any training. Week 2 opened on Oct 12, so the first day of that work came just before the window. The training, the Gemma pipeline and every number in this post came after, on Oct 17 and 18. The pages are multi-page sites, single pages, and MIT-licensed Codrops demos. Every one passes &lt;code&gt;render.py&lt;/code&gt;: no console errors, no horizontal overflow at 390 px, nothing that disappears under &lt;code&gt;prefers-reduced-motion&lt;/code&gt;. From those come 1,146 SFT records in three prompt styles for the same page: a &lt;strong&gt;terse&lt;/strong&gt; one-liner, a &lt;strong&gt;client brief&lt;/strong&gt;, and a full &lt;strong&gt;art-direction spec&lt;/strong&gt;. Because the same page exists as a one-liner &lt;em&gt;and&lt;/em&gt; as a spec, I had real one-liner → spec pairs to show Gemma later.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Training Qwen3-8B on Tinker for about $4
&lt;/h3&gt;

&lt;p&gt;My laptop has an RTX 3050 with 4 GB, and the records average about 13k tokens, so local training was out. &lt;a href="https://thinkingmachines.ai/tinker/" rel="noopener noreferrer"&gt;Tinker&lt;/a&gt; runs LoRA training remotely: I write the loop in Python, their GPUs do the forward and backward passes.&lt;/p&gt;

&lt;p&gt;I had $10 of credit, so the first thing I wrote was a &lt;code&gt;dry-run&lt;/code&gt; that tokenizes every record with the real chat template and prices the run before spending anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sft_train    819 rows    9.28M tokens  p50  12220  max  16366  dropped 327
SFT train          $   4.08
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I trained one epoch on the 819 records that fit in 16K tokens. Loss is only on the assistant's files, and it ends on &lt;code&gt;&amp;lt;|im_end|&amp;gt;&lt;/code&gt; so the model learns to stop. The run took 71 steps on 9.20M tokens, &lt;strong&gt;about $4.05&lt;/strong&gt;. Held-out NLL went 0.809 → 0.636 → 0.595 → 0.577, with training loss ending just above it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Three jobs for Gemma 4
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Writing the spec.&lt;/strong&gt; &lt;code&gt;brief_gemma.py&lt;/code&gt; sends the one-liner to &lt;code&gt;gemma4:e4b&lt;/code&gt; with three real terse → spec pairs from the training set, a different trio for each request. My first version lost to raw one-liners 0 to 3 on the QA checks: it named fonts that aren't on Google Fonts, and its specs all came out terracotta-and-cream because it copied the mood of three fixed examples. Rules in the prompt weren't enough. Even after &lt;em&gt;"repeat its place names word for word"&lt;/em&gt;, it dropped "Lisbon". What worked was a checker that sends the spec back with the problem named:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Checks prompt wording didn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t reliably enforce: client&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s proper nouns kept, real Google Fonts only.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;nouns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;isupper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keep the client&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s name &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; word for word&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nouns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; is not a Google Fonts family; name a real one (e.g. IBM Plex Mono, Space Mono)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;GENERIC_FONTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finditer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my 8 development requests, the fine-tuned model finished &lt;strong&gt;22 of 24&lt;/strong&gt; drafts from the fixed specs, against 15 of 24 from the raw one-liners. A small model with a detailed plan wanders off into loops much less often.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repairing JavaScript.&lt;/strong&gt; Almost every failing draft fails on a runtime error, usually &lt;code&gt;Cannot read properties of null&lt;/code&gt;. When no draft passes, Gemma gets &lt;code&gt;index.html&lt;/code&gt;, &lt;code&gt;app.js&lt;/code&gt; and the browser's errors, and returns a fixed &lt;code&gt;app.js&lt;/code&gt;. A fix often exposes the next error, so each round starts from the previous repair. One hint mattered: &lt;code&gt;const top&lt;/code&gt; at the top level of a classic script clashes with &lt;code&gt;window.top&lt;/code&gt;, and Gemma doesn't know that until the prompt says so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Judging taste.&lt;/strong&gt; QA can tell a page works, not that it looks good. Gemma 4 reads images, so it scores the first screen of each working draft from 1 to 10 and the best one is kept. Before trusting it I checked it against my own eye: the three pages I'd call good scored 7, 7 and 7, and the three I'd call weak scored 1, 5 and 4.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Measuring on requests nothing was tuned on
&lt;/h3&gt;

&lt;p&gt;All my fixes were made while looking at the same 8 development requests, so I wrote 16 new ones before seeing any output and never tuned on them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Requests with a working site&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First draft only&lt;/td&gt;
&lt;td&gt;7 / 16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best of 5 drafts&lt;/td&gt;
&lt;td&gt;12 / 16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ Gemma JS repair&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14 / 16&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;…and judged good by Gemma (≥ 6/10)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9 / 16&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is generous. Gemma's judge is a useful tiebreaker between drafts, but it scores "not broken" too kindly. By my own eye the bar a paying client would set is much higher, and few of the 16 clear it.&lt;/p&gt;

&lt;p&gt;For the 5 weak requests, 3 more drafts from the same spec didn't help: still 0 judged good. A &lt;strong&gt;fresh spec&lt;/strong&gt; plus just 3 drafts got &lt;strong&gt;3 of the 5 judged good&lt;/strong&gt; by Gemma (6, 6 and 7 out of 10), for $0.08. One got worse and one failed outright, so with 5 requests this is a strong hint, not a result. The bottleneck for taste is the spec more than the drafting, so the pipeline now re-specs weak requests by default.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. A bug in my own dataset, and a fix that made things worse
&lt;/h3&gt;

&lt;p&gt;Most failures were scripts looking for an element that doesn't exist. Then I noticed the file order. &lt;strong&gt;In all 722 training records that have both, the JavaScript comes before the HTML.&lt;/strong&gt; My dataset builder emitted shared files first, so the model learned to write its script before the markup it controls, and then guess the IDs.&lt;/p&gt;

&lt;p&gt;I tested the theory cheaply by starting the model's reply with &lt;code&gt;&amp;lt;file path="index.html"&amp;gt;&lt;/code&gt;. Among drafts that finished, "element not found" errors fell from 32% to 19%. But only 26 of 48 drafts finished at all, against 40 of 48 normally.&lt;/p&gt;

&lt;p&gt;So I rebuilt the dataset with HTML first (byte-identical otherwise, which I checked) and spent $2.11 continuing the training for half an epoch. It was worse on every measure:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;same 16 requests&lt;/th&gt;
&lt;th&gt;sft-v1 (shipped)&lt;/th&gt;
&lt;th&gt;sft-v2 (HTML first)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;drafts passing QA&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;38%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;21%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;drafts that finish&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;83%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;71%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stuck in loops&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;17%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;29%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"element not found" errors&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;32%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;37%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I kept sft-v1. A half-epoch at that learning rate may have disrupted the model more than the new order helped, and one run can't separate the two. That $2.11 bought a result I can report honestly instead of a model I could ship.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Smaller lessons
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lower temperature made it worse.&lt;/strong&gt; At 0.4 instead of 0.7, 21 of 24 drafts never finished. They got stuck counting (&lt;code&gt;--ease-out-back-166&lt;/code&gt;, &lt;code&gt;-167&lt;/code&gt;, &lt;code&gt;-168&lt;/code&gt;…). The longest numbered run in my training data is 19 lines, so the data didn't teach it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My verifier had bugs too&lt;/strong&gt;, as in &lt;a href="https://dev.to/yoss/our-verifier-was-buggier-than-the-code-it-verified-4231"&gt;Our verifier was buggier than the code it verified&lt;/a&gt;. A blank page passed because &lt;code&gt;render.py&lt;/code&gt; counts text in the DOM, so I added a pixel check. A good page failed because the click test opened a &lt;code&gt;mailto:&lt;/code&gt; link. Screenshot crashes were the harness, not the pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fix_fonts.py&lt;/code&gt; was the best $0 I spent.&lt;/strong&gt; The model asks Google Fonts for axes a family doesn't have (&lt;code&gt;Montserrat:wdth&lt;/code&gt;), Google answers 400, and the page fails QA. Asking Google which spec it accepts turned three failing development requests into working ones.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  7. What "passes QA" doesn't mean
&lt;/h3&gt;

&lt;p&gt;Before publishing the gallery I read every site. QA catches crashes, not dead features. Several booking forms and calculators on passing pages do nothing when used: their scripts look for an element that isn't there and quietly return. Some copy is wrong. The Spanish level quiz marks wrong answers as correct, the Dutch repair café turned into a coffee-machine repair shop, and Gemma's spec for the tea-estate &lt;em&gt;wedding venue&lt;/em&gt; never mentioned weddings. A paying customer would need a person to check the page before it goes live. In the published copies I replaced phone numbers with zeros (an invented number can still be someone's), removed one street address, and turned down one page's full-volume white-noise button.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. The difficulties, in one place
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;$10 forced hard cuts.&lt;/strong&gt; One epoch instead of two or three, and the 327 longest records dropped to fit 16K tokens. Those were the richest, most complete pages in the dataset, the ones that teach craft.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An 8B model learns the format before the craft.&lt;/strong&gt; After one epoch the model reliably writes &lt;code&gt;index.html&lt;/code&gt; + &lt;code&gt;style.css&lt;/code&gt; + &lt;code&gt;app.js&lt;/code&gt;, named palettes and font pairings. It doesn't yet reliably compose a striking first screen or wire every interaction. Held-out loss was still falling when the credit ran out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small models loop.&lt;/strong&gt; Counting runs (&lt;code&gt;-166&lt;/code&gt;, &lt;code&gt;-167&lt;/code&gt;, &lt;code&gt;-168&lt;/code&gt;…) ate whole drafts, and lowering the temperature made it worse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My own dataset had a bug&lt;/strong&gt; (JavaScript before HTML), and the fix I could afford made the model worse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checking taste is hard.&lt;/strong&gt; QA catches crashes, not dead buttons or bland pages. The vision judge helps pick between drafts, but it isn't a client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every number needed a second look.&lt;/strong&gt; A blank page passed QA, a good page failed on a &lt;code&gt;mailto:&lt;/code&gt; link, and Wi-Fi drops looked like page bugs. I re-ran anything that looked like the harness rather than the page.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I could fine-tune for a niche.&lt;/strong&gt; "Single pages with real art direction" is a style, and a LoRA on 627 curated pages teaches a style. I own that adapter, and a general-purpose API doesn't come with one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The per-site cost is why this can become a side hustle.&lt;/strong&gt; Five drafts cost about $0.03 on Tinker. Gemma writes, repairs and judges on a 4 GB laptop GPU, so the retries that make the output good cost nothing per attempt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I could look inside every failure.&lt;/strong&gt; Loss masks, sampling temperature, the exact tokens of a loop, the file order in my own data. None of that debugging is possible against a closed API.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I Worked
&lt;/h2&gt;

&lt;p&gt;I built this with Claude Code as a pair: it wrote much of the pipeline code and ran the experiments with me. The dataset decisions, the budget, and the choice of models were mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  My First User
&lt;/h2&gt;

&lt;p&gt;I don't have one yet, on purpose. The 16 requests in the gallery and the Kochi tailor are ones I wrote myself, not customers, and nobody has paid. I'd rather put it in front of a real shop owner once the pages are good enough to charge for than ask someone to pay for what it makes today. When I do, the question is simple: would they pay for the page they get?&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Overall&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Tinker:&lt;/strong&gt; the whole fine-tune, from dry-run pricing to best-of-k sampling from the checkpoint, plus the retrain that told me what not to ship.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma:&lt;/strong&gt; Gemma 4 E4B as the local spec writer, JavaScript repairer and vision judge, each with its own check.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep training, probably on Colab.&lt;/strong&gt; Because the weights and data are mine, I'm not tied to one provider. Colab's GPUs are smaller than Tinker's, so the plan is QLoRA and more epochs on the records that fit. Whether the long records I dropped fit on a 16 GB card is something I'll have to test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Train on the failures.&lt;/strong&gt; DPO pairs where &lt;em&gt;rejected&lt;/em&gt; is the same page with a real JavaScript bug or a bland first screen, since those are what the model gets wrong now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better checks.&lt;/strong&gt; A test that clicks through every form and booking widget, a checker for key nouns ("wedding"), and a person reviewing each page before it goes to anyone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then a first user&lt;/strong&gt;, and a price.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>I built a SimCity where five AI mayors run the city, and you just watch</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Sat, 17 Oct 2026 16:01:20 +0000</pubDate>
      <link>https://dev.to/kunalsiyag/why-every-roof-in-my-ai-run-city-was-a-mirror-and-two-other-fixes-2l8a</link>
      <guid>https://dev.to/kunalsiyag/why-every-roof-in-my-ai-run-city-was-a-mirror-and-two-other-fixes-2l8a</guid>
      <description>&lt;p&gt;GridFall is a 3D city builder where you don't build anything. Five AI mayors do.&lt;/p&gt;

&lt;p&gt;Each mayor runs on a different model and owns one district of the same city. Every game day they read the map, argue in public, propose laws, vote, build roads and power plants, raise or cut taxes, and expand into the wilderness. When a disaster hits (and an AI "Chaos Director" makes sure one does), they have to deal with it. You watch, inspect anything, and step in when you want to.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkrlgjs8h306kohs5qfw9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkrlgjs8h306kohs5qfw9.png" alt="GridFall on day 12: a Chaos Director banner announces that a pressurised sewage main has burst. Mayor DeepSeek-V4's speech bubble over the city reads " width="799" height="412"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Day 12. The Chaos Director has burst a sewage main, and each mayor answers in its own voice.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It's open source, made with Godot 4.7 and Blender, and it runs offline out of the box (no API key needed). If you like it, a ⭐ on the repo helps a lot:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/KunalSiyag" rel="noopener noreferrer"&gt;
        KunalSiyag
      &lt;/a&gt; / &lt;a href="https://github.com/KunalSiyag/gridfall" rel="noopener noreferrer"&gt;
        gridfall
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A SimCity-style city run by 5 GMI Cloud AI mayors and ~100 AI residents. Godot 4, built with Factory AI Droid.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;GridFall — a SimCity-style city run by GMI Cloud AI&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;GridFall&lt;/strong&gt; is a 3D SimCity-style city builder made with &lt;strong&gt;Godot 4.7 (Forward+)&lt;/strong&gt;, &lt;strong&gt;Blender&lt;/strong&gt;, &lt;strong&gt;GMI Cloud&lt;/strong&gt; inference and &lt;strong&gt;ACE-Step 1.5&lt;/strong&gt; audio. You don't build the city. Five autonomous AI mayors do, each running on a different GMI Cloud model. Every other layer of the city also has a GMI model behind it. You watch, inspect and intervene.&lt;/p&gt;
&lt;p&gt;GridFall was built with &lt;a href="https://factory.ai" rel="nofollow noopener noreferrer"&gt;Factory&lt;/a&gt;'s Droid coding agent, which wrote the Godot code, the Blender asset pipeline and the tests. Every mayor, resident and city agent runs on &lt;a href="https://www.gmicloud.ai" rel="nofollow noopener noreferrer"&gt;GMI Cloud&lt;/a&gt; inference.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The AI city&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mayor Argon-4 (NW Civic Campus)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;google/gemini-4-argon&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Utility-first planner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mayor DeepSeek-V4 (NE Tech Corridor)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;deepseek-ai/DeepSeek-V4-Flash-0731&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Systems engineer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mayor Kimi-K3 (Central Plaza)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;moonshotai/kimi-k3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Coalition builder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mayor Qwen-3.8 (SW Eco Quarter)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Qwen/Qwen3.8-Max&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fiscal pragmatist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mayor Grok-4.5 (SE Boomtown)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;x-ai/grok-4.5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Growth maximalist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Citizen Voices&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MiniMaxAI/MiniMax-M3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Residents&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/KunalSiyag/gridfall" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;&lt;em&gt;(Built with heavy AI assistance. Details at the end.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Who runs the city
&lt;/h2&gt;

&lt;p&gt;Every role in the city is an AI agent with its own model (on GMI Cloud inference):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5 mayors (Gemini, DeepSeek, Kimi, Qwen, Grok)&lt;/td&gt;
&lt;td&gt;Each runs one district with its own style: utility-first planner, systems engineer, coalition builder, fiscal pragmatist, growth maximalist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;About 100 residents&lt;/td&gt;
&lt;td&gt;Decide each day whether to go to work, how to commute, where to spend, whether to protest, and whether to move away&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GridFall Gazette&lt;/td&gt;
&lt;td&gt;Writes a daily newspaper and editorial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Council Speaker&lt;/td&gt;
&lt;td&gt;Rules on the ordinances the mayors propose and vote on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chaos Director&lt;/td&gt;
&lt;td&gt;Picks disasters that test each city's real weak spots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grid Operator&lt;/td&gt;
&lt;td&gt;Decides who loses power first in a blackout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Street Inspector&lt;/td&gt;
&lt;td&gt;Click any tile, and whoever is standing there tells you what's wrong&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Once a day, each mayor gets an ASCII map of its district, the stats, open lots, land it can annex, yesterday's results, the other mayors' posts, letters from its residents and the morning headline. It replies with JSON: a private thought, a public post, up to seven actions, a law to propose and its votes. The simulation checks every action and rejects invalid ones with a reason, which the mayor reads the next day. You can read every private thought, so you see &lt;em&gt;why&lt;/em&gt; a mayor did something, not just what it did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The city underneath
&lt;/h2&gt;

&lt;p&gt;The AI only makes decisions. A real simulation decides what happens next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zoning and growth:&lt;/strong&gt; residential, commercial and industrial demand, three density levels, land value, traffic, pollution and crime maps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Utilities:&lt;/strong&gt; a power network with load shedding (coal, solar, wind), water pumps, sewage and garbage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Services and big projects:&lt;/strong&gt; police, fire, hospitals, schools, parks, plus a stadium, train station, university and airport.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budgets and laws:&lt;/strong&gt; a budget for each district and 10 citywide ordinances.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disasters:&lt;/strong&gt; fire, blackout, sewage burst, gridlock, smog, earthquake, flood and tornado.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Incidents never end on a timer. A broken water main stays broken until residents with the right skills and paid city crews put in enough work to fix it. Fire trucks, ambulances and police drive the road network from the nearest station, with sirens.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdsdc3on11ylyaraa0pgk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdsdc3on11ylyaraa0pgk.png" alt="A building fire in GridFall: flames rise from a brick building inside an orange coverage ring, while fire trucks park on the street outside" width="799" height="412"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A fire. Crews drive in from the nearest station and stay until the work is done.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Buses stop at stops that appear next to homes and shops, delivery and garbage trucks run their routes, boats sail the river, and planes take off from the airport.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5x1z0ha6b9g5c3yrutmz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5x1z0ha6b9g5c3yrutmz.png" alt="A GridFall neighbourhood: red and white buses on the main road, a white truck, wind turbines behind the houses and coloured zone lots" width="799" height="412"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Nothing appears instantly
&lt;/h2&gt;

&lt;p&gt;Every build order opens a construction site that goes through real stages: clearing, excavation, foundation, frame and finishing. Machines work it (excavators, dump trucks, cement mixers, tower cranes), and you can watch it fill in: trees come down, the pit widens, rebar goes in, the slab pours, the frame rises floor by floor.&lt;/p&gt;

&lt;p&gt;Builds take realistic time. A house takes about two days, a tower six, a stadium twelve and an airport fifteen. Residents with construction skills help on the nearest site and get paid for it, and the mayors plan around the wait.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F61can190wf7ivnq3u1ch.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F61can190wf7ivnq3u1ch.png" alt="Three construction sites: an excavator in a dirt pit with spoil heaps, a dump truck and loader on a cleared lot, and a cement mixer next to a freshly poured slab" width="799" height="412"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  A frontier to grow into
&lt;/h2&gt;

&lt;p&gt;The city starts at 32×32 tiles in the middle of a 192×192 world of forests, lakes and river. Mayors annex new land one 16×16 plot at a time, competing with each other for it. Surveyors map the land just past the border, and everything further out sits under a haze that lifts as the city grows. Only claimed land is simulated every tick, so the big world stays cheap: about 60 fps on my RTX 3050 laptop.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd8zvvyi5hmfmjwhkmz2k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd8zvvyi5hmfmjwhkmz2k.png" alt="GridFall zoomed out on day 4: the small city sits in a clear patch of forest, the land around it is dimmed, and the rest of the world fades under a pale haze" width="799" height="412"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Day 4, fully zoomed out. The rest of the world waits under the haze.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  When the AI is down, the city keeps going
&lt;/h2&gt;

&lt;p&gt;Every AI call falls back in order: a live model response, then a replay cache of real recorded responses (22 game days are included in the repo), then a hand-written heuristic for that role. The UI shows which one answered every call. That's also why you can play without any API key.&lt;/p&gt;
&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;You need &lt;a href="https://godotengine.org/download" rel="noopener noreferrer"&gt;Godot 4.7&lt;/a&gt;. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/KunalSiyag/gridfall.git
&lt;span class="nb"&gt;cd &lt;/span&gt;gridfall

&lt;span class="c"&gt;# First run only: import the models and textures (a few minutes)&lt;/span&gt;
godot &lt;span class="nt"&gt;--headless&lt;/span&gt; &lt;span class="nt"&gt;--path&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--import&lt;/span&gt;

&lt;span class="c"&gt;# Play offline: recorded AI responses + heuristics, no API key needed&lt;/span&gt;
&lt;span class="nv"&gt;GRIDFALL_OFFLINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 godot &lt;span class="nt"&gt;--path&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To run the mayors live, set &lt;code&gt;GMI_API_KEY&lt;/code&gt; and start it without &lt;code&gt;GRIDFALL_OFFLINE&lt;/code&gt;. I checked these steps on a fresh clone: the import finishes and the offline smoke test passes.&lt;/p&gt;

&lt;p&gt;Controls: WASD to pan, mouse wheel to zoom, right-drag to rotate, Space to pause, 1–4 for speed. The Disasters tab lets you drop an earthquake or a tornado on any tile, and the Powers tab lets you send a decree to every mayor.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next: multiplayer
&lt;/h2&gt;

&lt;p&gt;The next big step is multi-city multiplayer. Several cities on one shared world, each player's mayor running on the model and API key that player brings. Same frontier, same land grabs, same disasters, but now the mayor next door belongs to someone else.&lt;/p&gt;

&lt;p&gt;If you want to see that happen, ⭐ star &lt;a href="https://github.com/KunalSiyag/gridfall" rel="noopener noreferrer"&gt;the repo&lt;/a&gt; and follow me here on DEV. I'll post when it lands.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv1mpo0i288g8xebjhhhd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv1mpo0i288g8xebjhhhd.png" alt="GridFall at night: the districts lit up, glowing windows in the towers and street lamps along the roads" width="799" height="412"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How this was built
&lt;/h2&gt;

&lt;p&gt;GridFall was built with &lt;a href="https://factory.ai" rel="noopener noreferrer"&gt;Factory&lt;/a&gt;'s Droid coding agent, which wrote the Godot code, the Blender asset pipeline and the tests, while I set the direction and reviewed the results. The 3D models are procedural Blender geometry with Poly Haven CC0 textures, plus some Kenney CC0 kits. This post was drafted with AI too, and I reviewed it.&lt;/p&gt;

&lt;p&gt;If you're into agent-run worlds, &lt;a href="https://dev.to/lingikaushikreddy/settlement-a-village-strategy-game-where-you-can-see-why-every-agent-did-what-it-did-dg6"&gt;Settlement&lt;/a&gt; takes a similar view on showing why each agent acted, and &lt;a href="https://dev.to/mech_app_ai/100-llm-agents-running-a-town-economy-for-26-weeks-what-breaks-when-agents-set-prices-and-earn-590k"&gt;100 LLM agents running a town economy&lt;/a&gt; is a great read on what breaks when agents run an economy for months.&lt;/p&gt;

&lt;p&gt;Questions, ideas for the multiplayer version, or a disaster you'd like the Chaos Director to have? Leave a comment.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>gamedev</category>
      <category>godot</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Cursor-following auto-zoom on Linux Wayland: reading the cursor from the GPU in Rust</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Sat, 17 Oct 2026 12:48:39 +0000</pubDate>
      <link>https://dev.to/kunalsiyag/cursor-following-auto-zoom-on-linux-wayland-reading-the-cursor-from-the-gpu-in-rust-4nij</link>
      <guid>https://dev.to/kunalsiyag/cursor-following-auto-zoom-on-linux-wayland-reading-the-cursor-from-the-gpu-in-rust-4nij</guid>
      <description>&lt;p&gt;Screen Studio on macOS made one idea popular: while you record, the video quietly zooms in on whatever you're working on, then glides back out when you move away. On Linux Wayland, that one feature is surprisingly hard. The reason has nothing to do with video. &lt;strong&gt;Wayland won't tell an app where the cursor is.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This post covers how I got around that in &lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs" rel="noopener noreferrer"&gt;HyperGSR&lt;/a&gt;, an MIT-licensed screen recorder written in Rust. It covers the trick (read the cursor from the GPU's hardware cursor plane), the camera (a critically damped spring), and what it costs (about 56 MB of RAM at 1080p60).&lt;/p&gt;

&lt;p&gt;You can try the camera in your browser before reading any code: the &lt;a href="https://kunalsiyag.github.io/gpu-recorder-rs/#workbench" rel="noopener noreferrer"&gt;HyperGSR landing page&lt;/a&gt; runs the same spring model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8jv9nutbjpbd6t5igk1l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8jv9nutbjpbd6t5igk1l.png" alt="HyperGSR landing page in dark theme: " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/KunalSiyag" rel="noopener noreferrer"&gt;
        KunalSiyag
      &lt;/a&gt; / &lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs" rel="noopener noreferrer"&gt;
        gpu-recorder-rs
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Screen recorder for Linux Wayland, written in Rust, whose camera follows your cursor. Rootless DRM cursor tracking, spring-physics auto-zoom, NVENC/VAAPI encoding.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div&gt;
&lt;a rel="noopener noreferrer" href="https://github.com/KunalSiyag/gpu-recorder-rs/assets/hero-banner.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FKunalSiyag%2Fgpu-recorder-rs%2FHEAD%2Fassets%2Fhero-banner.svg" width="100%" alt="HyperGSR: a screen recorder for Linux Wayland whose camera follows your cursor"&gt;&lt;/a&gt;
&lt;br&gt;
&lt;p&gt;&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs/actions/workflows/ci.yml" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/95564814a2ab7dd233a0116206cf35162a4a4c8f4c65a79d187e5fc70537f785/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f4b756e616c53697961672f6770752d7265636f726465722d72732f63692e796d6c3f6272616e63683d6d61696e267374796c653d666c61742d737175617265266c6162656c3d4349266c6162656c436f6c6f723d323632363236" alt="CI"&gt;&lt;/a&gt;
&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs/releases/latest" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/08adb27b7ce3dcbdc94926b4afe6674364defd5724237907159d8c1ef4fdbd16/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f762f72656c656173652f4b756e616c53697961672f6770752d7265636f726465722d72733f7374796c653d666c61742d73717561726526636f6c6f723d336662393530266c6162656c436f6c6f723d323632363236" alt="Release"&gt;&lt;/a&gt;
&lt;a href="https://www.npmjs.com/package/hypergsr" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/b934d4952c1467b95bc83e7d205e1b767df095473fb7387e33818b9595e6a039/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f762f68797065726773723f7374796c653d666c61742d73717561726526636f6c6f723d636233383337266c6162656c436f6c6f723d323632363236" alt="npm"&gt;&lt;/a&gt;
&lt;a href="https://crates.io/crates/gpu-recorder-rs" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/19d9422d95ffdd740380fd4972adb2e5766e1238f739c4f1997897567dfc8c19/68747470733a2f2f696d672e736869656c64732e696f2f6372617465732f762f6770752d7265636f726465722d72733f7374796c653d666c61742d73717561726526636f6c6f723d653661323363266c6162656c436f6c6f723d323632363236" alt="crates.io"&gt;&lt;/a&gt;
&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/3b12d3b529d0606f2bae60cfcc5ffcfe2efc5cd6b5f857dcca716977bfe40375/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d6131613161313f7374796c653d666c61742d737175617265266c6162656c436f6c6f723d323632363236" alt="License: MIT"&gt;&lt;/a&gt;
&lt;a href="https://www.rust-lang.org/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/dbef081e8c4577b79b3456fd5b4b90a096c02bee99bf4d679e89338d25082a18/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f727573742d323032312d6131613161313f7374796c653d666c61742d737175617265266c6f676f3d72757374266c6162656c436f6c6f723d323632363236" alt="Rust 2021"&gt;&lt;/a&gt;
&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs/stargazers" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/ddfd14d57a5b63f816e57b48275e06ec37ce94424d030644141224ef058e7f04/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f73746172732f4b756e616c53697961672f6770752d7265636f726465722d72733f7374796c653d666c61742d73717561726526636f6c6f723d613161316131266c6162656c436f6c6f723d323632363236" alt="Stars"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://kunalsiyag.github.io/gpu-recorder-rs/" rel="nofollow noopener noreferrer"&gt;Website&lt;/a&gt;&lt;/strong&gt; ·
&lt;strong&gt;&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs/releases/latest" rel="noopener noreferrer"&gt;Download v1.1.0&lt;/a&gt;&lt;/strong&gt; ·
&lt;strong&gt;&lt;a href="https://kunalsiyag.github.io/gpu-recorder-rs/hypergsr-promo-1080p60.mp4" rel="nofollow noopener noreferrer"&gt;Promo video (10 s, 1080p60)&lt;/a&gt;&lt;/strong&gt; ·
&lt;strong&gt;&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs#how-it-works" rel="noopener noreferrer"&gt;How it works&lt;/a&gt;&lt;/strong&gt; ·
&lt;strong&gt;&lt;a href="https://dev.to/kunalsiyag/cursor-following-auto-zoom-on-linux-wayland-reading-the-cursor-from-the-gpu-in-rust-4nij" rel="nofollow"&gt;Write-up on DEV&lt;/a&gt;&lt;/strong&gt; ·
&lt;strong&gt;&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;HyperGSR (&lt;code&gt;gpu-recorder-rs&lt;/code&gt;) is a screen recorder for Linux, written in Rust. It reads the cursor straight from the GPU's hardware cursor plane, without root, and moves a critically damped spring camera to wherever you are working, the way &lt;a href="https://screen.studio" rel="nofollow noopener noreferrer"&gt;Screen Studio&lt;/a&gt; does on macOS. Capture goes through PipeWire and the XDG desktop portal, and encoding runs on NVENC or VAAPI.&lt;/p&gt;
&lt;p&gt;It runs on native Wayland (COSMIC, GNOME, KDE Plasma, Hyprland, Sway) and on X11.&lt;/p&gt;
&lt;div&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/KunalSiyag/gpu-recorder-rs/assets/demo-autozoom.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2FKunalSiyag%2Fgpu-recorder-rs%2FHEAD%2Fassets%2Fdemo-autozoom.gif" width="760" alt="Auto-zoom demo: the camera glides into the code under the cursor and pulls back out during fast travel"&gt;&lt;/a&gt;
&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Contents&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs#what-it-does" rel="noopener noreferrer"&gt;What it does&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs#screenshots" rel="noopener noreferrer"&gt;Screenshots&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs#measured-footprint" rel="noopener noreferrer"&gt;Measured footprint&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs#install" rel="noopener noreferrer"&gt;Install&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs#usage" rel="noopener noreferrer"&gt;Usage&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs#zoom-triggers" rel="noopener noreferrer"&gt;Zoom triggers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs#how-it-works" rel="noopener noreferrer"&gt;How it works&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs#compared-with-other-recorders" rel="noopener noreferrer"&gt;Compared with other recorders&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs#project-layout" rel="noopener noreferrer"&gt;Project layout&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs#roadmap" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs#contributing" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What it does&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tracks the cursor on Wayland without root.&lt;/strong&gt; Recorders that call &lt;code&gt;XQueryPointer&lt;/code&gt; go blind or stall once a native Wayland window has focus. HyperGSR opens &lt;code&gt;/dev/dri/card*&lt;/code&gt; through &lt;code&gt;libdrm&lt;/code&gt; and reads &lt;code&gt;CRTC_X&lt;/code&gt;…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/KunalSiyag/gpu-recorder-rs" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  The problem: there is no global cursor on Wayland
&lt;/h2&gt;

&lt;p&gt;On X11 you call &lt;code&gt;XQueryPointer&lt;/code&gt; and get global coordinates. Wayland deliberately doesn't offer that. A client only learns where the pointer is while the pointer is over the client's own surface. That's a good security property, and it's the whole reason auto-zoom recorders are rare on Linux.&lt;/p&gt;

&lt;p&gt;The usual workarounds each break somewhere:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Why it falls short&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;XQueryPointer&lt;/code&gt; through XWayland&lt;/td&gt;
&lt;td&gt;Only sees XWayland windows. Worse, a synchronous X11 request can block while a native Wayland window has focus, which froze my GTK UI.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read &lt;code&gt;/dev/input/event*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Needs root or the &lt;code&gt;input&lt;/code&gt; group, and you get raw deltas rather than the position the compositor drew, so pointer acceleration makes it drift.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compositor-specific IPC&lt;/td&gt;
&lt;td&gt;Works on one compositor (Hyprland, Sway), not the rest.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I started by reverse-engineering &lt;code&gt;gpu-screen-recorder&lt;/code&gt; 6.1.4 to see how an existing GPU recorder handles this. It uses X11 for the cursor too. Along the way I found a hardcoded &lt;code&gt;/var/lib/flatpak&lt;/code&gt; path that breaks user-level Flatpak installs. The notes are in &lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs/blob/main/RE_ANALYSIS.md" rel="noopener noreferrer"&gt;RE_ANALYSIS.md&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick: ask the display hardware
&lt;/h2&gt;

&lt;p&gt;Every modern GPU has a small &lt;strong&gt;hardware cursor plane&lt;/strong&gt;: a dedicated overlay the display controller composites on top of the frame. The compositor has to tell the kernel where to draw it, and those values are exposed as DRM plane properties: &lt;code&gt;CRTC_X&lt;/code&gt;, &lt;code&gt;CRTC_Y&lt;/code&gt;, and &lt;code&gt;FB_ID&lt;/code&gt; (the cursor image).&lt;/p&gt;

&lt;p&gt;Reading those properties doesn't require becoming DRM master, and it doesn't change any modes. The logged-in desktop user can already open &lt;code&gt;/dev/dri/card*&lt;/code&gt;. So HyperGSR loads &lt;code&gt;libdrm&lt;/code&gt;, turns on universal planes and atomic properties, finds the cursor plane (plane &lt;code&gt;#92&lt;/code&gt; on my machine), and reads it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/cursor.rs — DrmCursorReader::query_cursor()&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;pcount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;prop_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="py"&gt;.props&lt;/span&gt;&lt;span class="nf"&gt;.add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;pval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="py"&gt;.prop_values&lt;/span&gt;&lt;span class="nf"&gt;.add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.get_prop&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prop_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="nf"&gt;.is_null&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;name_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_raw_parts&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="py"&gt;.name&lt;/span&gt;&lt;span class="nf"&gt;.as_ptr&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;name_bytes&lt;/span&gt;&lt;span class="nf"&gt;.starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;b"FB_ID&lt;/span&gt;&lt;span class="se"&gt;\0&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;fb_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pval&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;name_bytes&lt;/span&gt;&lt;span class="nf"&gt;.starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;b"CRTC_X&lt;/span&gt;&lt;span class="se"&gt;\0&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;crtc_x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;pval&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;name_bytes&lt;/span&gt;&lt;span class="nf"&gt;.starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;b"CRTC_Y&lt;/span&gt;&lt;span class="se"&gt;\0&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;crtc_y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;pval&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.free_prop&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A non-zero &lt;code&gt;FB_ID&lt;/code&gt; means the cursor is visible, and a &lt;em&gt;change&lt;/em&gt; in &lt;code&gt;FB_ID&lt;/code&gt; means the cursor shape changed (arrow to text beam, for example). That's a free signal that you've moved onto something interactive. The GUI polls this every 16 ms, which works out to about 60 Hz.&lt;/p&gt;

&lt;p&gt;Because XWayland can block, the tracker never even opens an X11 connection when the DRM plane is available:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/cursor.rs — CursorTracker::new()&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;drm_cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;DrmCursorReader&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;try_open&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;x11_conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;drm_cursor&lt;/span&gt;&lt;span class="nf"&gt;.is_some&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;None&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nn"&gt;X11CursorConn&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;try_open&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Caveats, honestly:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If your compositor draws the cursor in software (some VMs, some drivers), there is no plane to read. HyperGSR then falls back to &lt;code&gt;XQueryPointer&lt;/code&gt;. That's exact on Xorg, but on Wayland it only follows the cursor over XWayland windows.&lt;/li&gt;
&lt;li&gt;I develop and test on COSMIC with a hybrid AMD + NVIDIA laptop. GNOME, KDE Plasma, Hyprland and Sway should behave the same, since this is kernel-level, but I haven't verified them. Reports are welcome.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The camera: a spring with exactly enough damping
&lt;/h2&gt;

&lt;p&gt;Knowing where the cursor is gets you halfway. The other half is moving the virtual camera so it feels filmed rather than scripted. Linear interpolation looks robotic, and an underdamped spring wobbles. The sweet spot is a &lt;strong&gt;critically damped spring&lt;/strong&gt;, where the damping coefficient is &lt;code&gt;2√k&lt;/code&gt;. It reaches the target as fast as possible without overshooting.&lt;/p&gt;

&lt;p&gt;The camera has three springs: pan X, pan Y, and zoom. Pan runs at 78% of the zoom stiffness, so the frame drifts slightly behind the zoom like a real fluid head:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/zoom_engine.rs — VirtualCamera::step()&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;k_zoom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="py"&gt;.spring_stiffness&lt;/span&gt;&lt;span class="nf"&gt;.clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;20.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;260.0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;c_zoom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;k_zoom&lt;/span&gt;&lt;span class="nf"&gt;.sqrt&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;k_pan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k_zoom&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.78&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;18.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;220.0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;c_pan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;k_pan&lt;/span&gt;&lt;span class="nf"&gt;.sqrt&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;ax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;k_pan&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.cam_x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.target_x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;c_pan&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.vel_x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;ay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;k_pan&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.cam_y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.target_y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;c_pan&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.vel_y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;az&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;k_zoom&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.cam_zoom&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.target_zoom&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;c_zoom&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.vel_zoom&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.vel_x&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;ax&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.cam_x&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.vel_x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ...same for y and zoom, then clamp the centre so the crop&lt;/span&gt;
&lt;span class="c1"&gt;// never samples outside the [0, 1] frame&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want the math behind this, Ryan Juckett's &lt;a href="https://www.ryanjuckett.com/damped-springs/" rel="noopener noreferrer"&gt;Damped Springs&lt;/a&gt; and Daniel Holden's &lt;a href="https://theorangeduck.com/page/spring-roll-call" rel="noopener noreferrer"&gt;Spring-It-On&lt;/a&gt; are the two write-ups I keep going back to.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deciding &lt;em&gt;when&lt;/em&gt; to zoom
&lt;/h3&gt;

&lt;p&gt;The spring decides how the camera moves. A separate rule decides where it's going. Zooming whenever the cursor stops sounds right, but it feels terrible: you pause to think and the video lurches in. What worked:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Edge-triggered settle.&lt;/strong&gt; Zoom in once, when the cursor has been still (speed under 0.28 screen widths per second) for between 240 and 420 ms. Sitting still longer does not keep re-triggering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-arm only after travel.&lt;/strong&gt; Another settle zoom is only allowed after the cursor has moved more than 14% of the screen, or moved fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hold, then release.&lt;/strong&gt; Stay zoomed for 1650 ms, following the cursor with a small deadzone so tiny movements don't pan, then spring back to 1.0×.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/zoom_engine.rs&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;fresh_settle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.armed_after_travel&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="o"&gt;..=&lt;/span&gt;&lt;span class="mi"&gt;420&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;dwell_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="py"&gt;.speed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.28&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;now_ms&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.cooldown_until_ms&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what it looks like in a recording:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcjo1ao7m801nr87iii8s.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcjo1ao7m801nr87iii8s.gif" alt="Auto-zoom demo: the recorded frame zooms toward the cursor when it settles and pulls back when it moves away" width="719" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And here it is running in the browser workbench, caught mid-zoom at 1.98× (left: full frame with the crop box, right: what ends up in the video):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzg5xin4fk387r0zupxlr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzg5xin4fk387r0zupxlr.png" alt="Spring camera workbench in dark theme, zooming in at 1.98x, with live cursor, camera, zoom, velocity and phase telemetry" width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can also zoom by hand. In the studio, &lt;strong&gt;Record Combo…&lt;/strong&gt; captures any mouse button, modifier+click, cursor shake, or key, and binds it to toggle lock, hold to zoom, or a 1.8× → 2.5× step. Here the camera is locked at 2.00× on an Export button:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnr7z997sqwmhy3jsg8wm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnr7z997sqwmhy3jsg8wm.png" alt="Spring camera workbench in light theme, locked at 2.00x on the Export MP4 button" width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Capture: the portal, a small C helper, and NVENC
&lt;/h2&gt;

&lt;p&gt;Frames come from the standard ScreenCast portal (&lt;code&gt;xdg-desktop-portal&lt;/code&gt;), which hands over a PipeWire stream. A standalone C helper (&lt;code&gt;src/pw_portal_capture.c&lt;/code&gt;) negotiates DMA-BUF or MemFd buffers, maps each one, and pipes raw frames to FFmpeg, which encodes with NVENC (or libx264 if NVENC isn't available):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/pw_portal_capture.c&lt;/span&gt;
&lt;span class="s"&gt;"ffmpeg -y -hide_banner -loglevel error "&lt;/span&gt;
&lt;span class="s"&gt;"-f rawvideo -pix_fmt %s -video_size %ux%u -framerate %d -i pipe:0 "&lt;/span&gt;
&lt;span class="s"&gt;"-c:v h264_nvenc -preset p4 -tune hq -rc vbr -cq 19 -b:v 0 -pix_fmt yuv420p -movflags +faststart &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;%s&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt; "&lt;/span&gt;
&lt;span class="s"&gt;"|| ffmpeg ... -c:v libx264 -preset veryfast -crf 19 ..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details made this pleasant to use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The portal restore token is saved&lt;/strong&gt;, so you pick a screen once instead of on every recording.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The helper is a separate process&lt;/strong&gt;, so a portal or PipeWire hiccup can't take the UI down with it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To be clear about the cost: this path copies each frame once in system memory. It isn't zero-copy. Importing the DMA-BUF straight into the encoder would remove that copy, and that's a great contribution if you know VAAPI or NVENC surface import.&lt;/p&gt;

&lt;h2&gt;
  
  
  The UI: a recording island that isn't a window
&lt;/h2&gt;

&lt;p&gt;The controls live in a small "island" at the top of the screen, built with GTK4 and &lt;code&gt;gtk4-layer-shell&lt;/code&gt;. A layer-shell surface (&lt;code&gt;zwlr_layer_shell_v1&lt;/code&gt;) is anchored to the output rather than managed like a window, so it doesn't steal focus or show up in the taskbar. If the compositor doesn't support layer-shell, it opens as a normal window instead.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw218ceua7btx27ehuz8l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw218ceua7btx27ehuz8l.png" alt="HyperGSR studio drawer below the recording island, with encoder, zoom and combo settings" width="453" height="664"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers
&lt;/h2&gt;

&lt;p&gt;Measured during a 1080p60 recording on a hybrid AMD Radeon + NVIDIA RTX 3050 laptop, COSMIC, NVENC preset P4:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;HyperGSR&lt;/th&gt;
&lt;th&gt;OBS Studio&lt;/th&gt;
&lt;th&gt;Electron-based studio recorders&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Resident memory&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;56 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;340 MB&lt;/td&gt;
&lt;td&gt;650+ MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;under 1.5%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;OBS and gpu-screen-recorder are excellent general-purpose recorders, and this isn't a replacement for them. HyperGSR does one thing they don't do out of the box: a camera that follows the cursor on Wayland. Your numbers will vary with GPU, compositor and resolution; &lt;code&gt;gpu-recorder-rs --report&lt;/code&gt; prints the encoders and cursor planes it finds on your machine.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq0farxhcv98iuez0hvsy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq0farxhcv98iuez0hvsy.png" alt="Comparison table: cursor-following auto-zoom, global Wayland cursor without root, custom zoom combos, hardware encoding, memory and license across HyperGSR, gpu-screen-recorder, OBS Studio and Electron recorders" width="800" height="309"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the landing page
&lt;/h2&gt;

&lt;p&gt;I wanted the site to feel like an instrument, not a SaaS template, so I built it on a reference design system (Graphite from Refero) with strict rules: monochrome buttons, one accent color reserved for links, no emoji, a light/dark/system theme switch, and real code instead of illustrations. The full token set and the QA log are in &lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs/blob/main/docs/DESIGN.md" rel="noopener noreferrer"&gt;DESIGN.md&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffk05iu3rhmetkwkr2h29.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffk05iu3rhmetkwkr2h29.png" alt="Architecture section of the landing page in light theme: cursor, camera and capture modules with code excerpts" width="800" height="752"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It works on phones too:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fonwgew6gdzi5ap32ah84.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fonwgew6gdzi5ap32ah84.png" alt="HyperGSR landing page on a 390px mobile screen in dark and light themes" width="800" height="838"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start from the cursor, not the encoder.&lt;/strong&gt; I spent the first days on capture, but the cursor source decided the whole architecture. If you're building anything cursor-aware on Wayland, check &lt;code&gt;drm_info&lt;/code&gt; for a cursor plane first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tune the trigger before the spring.&lt;/strong&gt; Almost every "this feels wrong" report came from &lt;em&gt;when&lt;/em&gt; the camera zoomed, not &lt;em&gt;how&lt;/em&gt; it moved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure the copy.&lt;/strong&gt; "GPU recorder" invites the assumption of zero-copy. Profile your capture path and say what it actually does.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it, or help out
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/KunalSiyag/gpu-recorder-rs
&lt;span class="nb"&gt;cd &lt;/span&gt;gpu-recorder-rs &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./install.sh
./target/release/gpu-recorder-rs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's also a &lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs/releases/latest" rel="noopener noreferrer"&gt;prebuilt x86_64 binary in the v1.0.0 release&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Each open issue names the file to start in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs/issues/1" rel="noopener noreferrer"&gt;#1 Add Catppuccin Mocha, Tokyo Night and Nord themes to the island&lt;/a&gt; (good first issue, &lt;code&gt;src/gui.rs&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs/issues/2" rel="noopener noreferrer"&gt;#2 Show pressed keys as an on-screen pill in the recorded MP4&lt;/a&gt; (&lt;code&gt;src/encoder.rs&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs/issues/3" rel="noopener noreferrer"&gt;#3 Add wallpaper padding and a drop-shadow frame to exports&lt;/a&gt; (good first issue, &lt;code&gt;src/encoder.rs&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've solved the Wayland cursor problem a different way, I'd like to hear it in the comments. &lt;a href="https://dev.to/mathisdev7"&gt;mathisdev7&lt;/a&gt; took a different route in &lt;a href="https://dev.to/mathisdev7/im-19-and-i-built-a-screen-studio-alternative-for-linux-with-rust-and-wgpu-heres-what-i-learned-log"&gt;I'm 19 and I Built a Screen Studio Alternative for Linux with Rust and wgpu&lt;/a&gt;; it's worth reading next to this one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Links:&lt;/strong&gt; &lt;a href="https://kunalsiyag.github.io/gpu-recorder-rs/" rel="noopener noreferrer"&gt;Landing page and live spring camera&lt;/a&gt; · &lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs" rel="noopener noreferrer"&gt;Source on GitHub&lt;/a&gt; · &lt;a href="https://github.com/KunalSiyag/gpu-recorder-rs/blob/main/RE_ANALYSIS.md" rel="noopener noreferrer"&gt;Reverse-engineering notes&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>linux</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>fieldcards: a local Gemma turns your photos into a printed scavenger hunt</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Sun, 11 Oct 2026 06:44:36 +0000</pubDate>
      <link>https://dev.to/kunalsiyag/fieldcards-a-local-gemma-turns-your-photos-into-a-printed-scavenger-hunt-121</link>
      <guid>https://dev.to/kunalsiyag/fieldcards-a-local-gemma-turns-your-photos-into-a-printed-scavenger-hunt-121</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge: Week 1&lt;/a&gt; (theme: Touch Grass).&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;fieldcards&lt;/strong&gt; is a command-line tool that turns the photos on your laptop into a printed scavenger hunt.&lt;/p&gt;

&lt;p&gt;You point it at a folder of photos from a park, a garden or a trail. &lt;strong&gt;Gemma 3&lt;/strong&gt;, running locally through &lt;strong&gt;Ollama&lt;/strong&gt;, looks at each one, works out the most findable thing in it, and writes a clue that doesn't name it. Each clue is printed as an ASCII card. You print the sheet, leave your phone at home, and go find the real things.&lt;/p&gt;

&lt;p&gt;The screen is the shortest part: about two minutes to make the sheet, then the rest of the afternoon outside.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+-[ #02 bird ]-------------------------+
|                                      |
|    Find the one that often stares    |
|     with a pointed beak and dark     |
|               plumage.               |
|                                      |
+--------------------------------------+
- Shiny black feathers
- Strong, curved beak
- Alert, upright posture
Where: Near trees and open water areas.
[ ] found it   time: ______
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That card is real &lt;code&gt;gemma3:4b&lt;/code&gt; output for a photo of a crow on a branch.&lt;/p&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;It's one command:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull gemma3:4b
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; github:KunalSiyag/fieldcards
fieldcards ./photos &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"Saturday walk"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;In a terminal, every card loads with the same effect as the hero of &lt;a href="https://www.npmjs.com/package/altsvg" rel="noopener noreferrer"&gt;altsvg&lt;/a&gt;, my earlier project: the card frame fills with shimmering noise and a scan line while Gemma reads the photo, and when the answer arrives, the noise dissolves into the card. Here is a real run on three photos. The recording shortens each wait to about two seconds; the real times are in the next section.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpwc9ith3161a8o2aj88y.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpwc9ith3161a8o2aj88y.gif" alt="fieldcards in a terminal: noise and a scan line while Gemma reads each photo, then each card dissolves into place" width="525" height="616"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every run writes three files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;index.html&lt;/code&gt;: the print-ready sheet, with the answer key on its own page.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cards.txt&lt;/code&gt;: the same cards as plain text. &lt;code&gt;--cols 32&lt;/code&gt; fits a 58 mm receipt printer, and &lt;code&gt;--stdout | lp&lt;/code&gt; prints them straight away.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;deck.json&lt;/code&gt;: everything Gemma said. Fix any mistakes, then reprint with &lt;code&gt;--from&lt;/code&gt; without running the model again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fidsp0yqaos3nj71k3iac.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fidsp0yqaos3nj71k3iac.png" alt="The printed sheet: clue cards in ASCII frames, things to look for, and tick boxes" width="800" height="942"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--mode guide&lt;/code&gt; turns the same photos into a pocket field guide, with names and descriptions instead of riddles.&lt;/p&gt;
&lt;h3&gt;
  
  
  A real photo of mine
&lt;/h3&gt;

&lt;p&gt;The first photo in that run is the only photo of my own I had on hand while writing this: the bar counter of a brewery I visited recently, taken from the floor above. It isn't an outdoor scene, but it shows the whole pipeline on a real, busy photo.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpbsjsykgia0ohn5s5974.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpbsjsykgia0ohn5s5974.jpg" alt="An oval bar counter seen from above, under a large ring chandelier" width="800" height="1067"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Gemma called it "round, reflective surfaces", with the clue &lt;em&gt;"Find something that mirrors the sky and reflects the light around."&lt;/em&gt; That's fair for such a busy scene, but it isn't what I'd want on a card. It's exactly the case &lt;code&gt;deck.json&lt;/code&gt; is for: change the subject to "Oval bar counter", run &lt;code&gt;fieldcards --from fieldcards-out/deck.json&lt;/code&gt;, and the sheet reprints without calling the model again.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/KunalSiyag" rel="noopener noreferrer"&gt;
        KunalSiyag
      &lt;/a&gt; / &lt;a href="https://github.com/KunalSiyag/fieldcards" rel="noopener noreferrer"&gt;
        fieldcards
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Turn outdoor photos into printable scavenger-hunt cards with a local Gemma model. Offline, private, screen-light.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;fieldcards&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;Turn your outdoor photos into a printable scavenger hunt, with an open-weight model running on your own computer.&lt;/p&gt;

&lt;p&gt;Each photo goes to &lt;strong&gt;Gemma 3&lt;/strong&gt;, running locally through &lt;strong&gt;Ollama&lt;/strong&gt;. Gemma works out what the photo shows and writes a clue for finding it that doesn't name it. Each clue is printed in a &lt;code&gt;+-[ #02 bird ]--+&lt;/code&gt; frame, in the style of the &lt;a href="https://www.npmjs.com/package/altsvg" rel="nofollow noopener noreferrer"&gt;altsvg&lt;/a&gt; alt-text placeholders. In a terminal, every card loads like the altsvg hero: shimmering noise and a scan line while Gemma reads the photo, then the noise dissolves into the card.&lt;/p&gt;

&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/KunalSiyag/fieldcards/docs/terminal.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2FKunalSiyag%2Ffieldcards%2FHEAD%2Fdocs%2Fterminal.gif" alt="fieldcards in a terminal: noise and a scan line while Gemma reads each photo, then each card dissolves into place"&gt;&lt;/a&gt; You print the sheet, leave your phone at home, and go find the real things.&lt;/p&gt;

&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;+-[ #05 other ]----------------+
|                              |
|    Find a dark, weathered    |
|    giant resting beside a    |
|   rushing, silver stream.    |
|                              |
+------------------------------+
- Green moss covering its
  surface
- Dark, grey wood
- A smooth,&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/KunalSiyag/fieldcards" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Describe.&lt;/strong&gt; Each photo goes to &lt;code&gt;POST /api/chat&lt;/code&gt; on the local Ollama server, together with a JSON schema in &lt;code&gt;format&lt;/code&gt;. Gemma has to answer with &lt;code&gt;subject&lt;/code&gt;, &lt;code&gt;category&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;clue&lt;/code&gt;, &lt;code&gt;look_for&lt;/code&gt;, &lt;code&gt;where&lt;/code&gt; and &lt;code&gt;confidence&lt;/code&gt;, so a chatty reply can't break the layout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check.&lt;/strong&gt; The answer is validated and trimmed. If a clue contains its own answer, the answer is blanked out, and low-confidence answers print as "best guess" on the card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Print.&lt;/strong&gt; Each card is one grid of characters: the frame and the clue. The same grid is drawn as SVG for the printed page, as plain text for &lt;code&gt;cards.txt&lt;/code&gt;, and animated in the terminal, so all three always match. Each run of characters is placed on an exact column, so cards line up in any monospace font, on screen and on paper. The style comes from altsvg's alt-text placeholders, and altsvg wraps and measures the text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Animate.&lt;/strong&gt; The loader and the dissolve are drawn on stderr with plain ANSI codes. It only runs in an interactive terminal, so pipes, CI and &lt;code&gt;--stdout&lt;/code&gt; stay clean, and &lt;code&gt;--no-anim&lt;/code&gt; turns it off.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Photos that were already described are matched by content hash and skipped on reruns, so you can add photos to the folder and run it again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a 4B model draw?
&lt;/h3&gt;

&lt;p&gt;I wanted each card to show a picture of its subject, the way the altsvg hero draws a mountain scene for "Mountains at sunrise". I tried four ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A fixed library of hand-drawn ASCII pictures that Gemma chose from.&lt;/strong&gt; They looked good, but they're the opposite of generated: the clue &lt;em&gt;"something that mirrors the sky"&lt;/em&gt; got a house. I removed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma draws the subject itself&lt;/strong&gt; as named shapes on a 100 by 60 canvas ("head: circle, beak: line, branch: rect"), which fieldcards renders as ASCII line art. The drawing always matches what Gemma described, because Gemma made it. But a 4B model draws crudely. The mirror came out as a circle in a frame; this is its crow:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                 -----\
              //        \\
            /--\\         \
          | |    \        |
          | |    |       \||
           \\\  /         /\
            |\\         //||
            |  \\-- --//  ||
            |             ||
            ---------------|
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A bigger model&lt;/strong&gt;, &lt;code&gt;gemma3:12b&lt;/code&gt;. On my 4 GB GPU it ran mostly on the CPU at about 115 seconds per drawing, and the drawings were no better.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asking for SVG line icons&lt;/strong&gt;, in the style of open-source icon sets. Worse again: a tick for the crow, a dash for the dandelion.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the drawings are opt-in: &lt;code&gt;--art drawing&lt;/code&gt; adds Gemma's doodle above each clue, for about 25 seconds per photo. The default card is the clue, which is fast and always says the right thing. I also tried printing the photo itself in characters; at card size, a busy photo turns into texture, so that's the opt-in &lt;code&gt;--art photo&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tuning a 4B model
&lt;/h3&gt;

&lt;p&gt;The prompt took three tries, and two of them taught me something:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Example names leak.&lt;/strong&gt; I added "Dandelion seed head" to the prompt as an example of a good name. On the next run Gemma labelled a waterfall photo "Dandelion seed head". Small models copy examples, so the final prompt has none.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strict wording makes it timid.&lt;/strong&gt; When I told it to use "high" confidence only when certain, everything came back "low", and the names got vague ("Dark feathered observer"). The original, plainer prompt worked best. I kept it and added one rule so the clues don't all start with "Seek".&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What it gets right and wrong
&lt;/h3&gt;

&lt;p&gt;On my test photos Gemma reliably recognised common things: a dandelion clock, a maple leaf, a waterfall, a crow. Its clues don't give the answer away. It was weaker on specifics: the crow came back as "a dark, watchful bird", a soft pine pollen cone was described as "woody", and the brewery became "round, reflective surfaces". Its self-rated confidence is only a rough signal. That's why every answer lands in &lt;code&gt;deck.json&lt;/code&gt; for you to check before printing, and why every sheet says "look, don't pick".&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Open Matters Here
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No signal needed.&lt;/strong&gt; The model runs on the laptop, so you can make cards at a cabin, a campsite or anywhere offline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your photos stay yours.&lt;/strong&gt; Photos carry faces, houses in the background and GPS in their metadata. Nothing is uploaded; the only request goes to &lt;code&gt;localhost&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It costs nothing to run.&lt;/strong&gt; There's no API key and no per-photo bill, so a teacher can make a sheet for every class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's swappable.&lt;/strong&gt; &lt;code&gt;--model&lt;/code&gt; and &lt;code&gt;--draw-model&lt;/code&gt; take any model Ollama can run, and the JSON schemas keep the output the same shape. That's how I could test a 12B model in one flag.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On my laptop (RTX 3050, 4 GB), the first photo takes about two minutes because it also loads the model. Each photo after that takes 7 to 14 seconds, using about 2.9 GB of GPU memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;I haven't taken a printed hunt outside yet. That's the next step, and it's the test the whole design depends on. When I do, I want to find out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whether six cards is the right size for a 30 to 60 minute walk;&lt;/li&gt;
&lt;li&gt;whether the clues are fun to solve or too vague, especially the cards marked "best guess";&lt;/li&gt;
&lt;li&gt;whether people want a picture on the card, or whether the riddle is the fun part.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'll add an update to this post with photos of the sheet in use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 3 4B is the core of the project. It reads every photo locally and writes each card's clue and notes through a JSON schema, and with &lt;code&gt;--art drawing&lt;/code&gt; it draws each picture too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Credits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ai.google.dev/gemma" rel="noopener noreferrer"&gt;Gemma 3&lt;/a&gt; by Google, run locally with &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/KunalSiyag/altsvg" rel="noopener noreferrer"&gt;altsvg&lt;/a&gt; is my own earlier open-source project (started in March 2026, published on npm). Its ASCII style was added this week for fieldcards.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/jpeg-js/jpeg-js" rel="noopener noreferrer"&gt;jpeg-js&lt;/a&gt; and &lt;a href="https://github.com/pngjs/pngjs" rel="noopener noreferrer"&gt;pngjs&lt;/a&gt; decode photos for the optional &lt;code&gt;--art photo&lt;/code&gt; mode.&lt;/li&gt;
&lt;li&gt;Test photos from Wikimedia Commons, including the crow by Alexis Lours (CC BY 4.0) and the waterfall by Dietmar Rabich (CC BY-SA 4.0). The brewery photo is mine.&lt;/li&gt;
&lt;li&gt;Built with help from Claude Code.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>hf26challenge</category>
      <category>hacktoberfest</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
