~/cinni/about.md
I'm Cinni and
I build stuff with AI.
My day job is in IT. I'm passionate about games and all things AI.
These are my projects.
I've been building with LLMs since GPT-4o, slowly going from mere user to vibecoder before that term existed and agents weren't in terminals yet. Loyal to Claude since Opus 4.5.
$ ls
- ai/ezra/Persistent LLM bot with its own memory and heartbeat.
- ai/drawing-in-svg.mdTeaching a model to draw in a storybook style.
- ai/steam-radar.mdGame recommendations from a taste vector.
- games/moth-and-jar/Browser game drawn in SVG code, with generative music.
- games/brook-hollow.mdVictorian point & click mystery.
- games/hogwash-library.mdParody 3D librarian game in three.js.
- cinni/cv.mdWork, education, tools.
- cinni@cinni.devEmail me.
~/cinni/cv.md
cv
Office & IT. Former founder. Builds odd things on the internet. Northern Germany.
-
next
What I'm aiming for next
- Manager for AI competencies (Manager für KI-Kompetenzen)
- Manager for applied AI transformation (Manager für angewandte KI-Transformation)
-
2025 → now
Office administration & IT
- IT for a 700+ person education provider, as its in-house IT department
- Day-to-day support in all IT and AI matters
- Office management and administration
-
2026 → now
Moth & Jar
Browser game with SVG art from code and generative audio. How it's built →
-
dec 2025 → now
Ezra, and other AI projects
Persistent LLM bot with layered memory, local embedding and reranker recall, and an autonomous heartbeat. How it's built →
-
2022 → 2025
Founder: online luxury secondhand
- 10k followers and its own online store
- Won a local pop-up competition: an offline pop-up space for one year, 2023 to 2024
- Livestream shopping in 2024: 500+ sales in three months and 80+ five-star reviews
- Official partner in a livestream shopping app's acceleration program
-
2022 → 2024
Brook Hollow
Victorian point & click in Construct 3 and GDevelop 5, with an early AI art pipeline. More →
-
2020 → 2023
Founder: offline secondhand store
- 3k followers
- A physical shop: visual merchandising, staff, purchasing
-
2019
Thesis: (Dis)similar avatars
On user-generated 3D avatars as artistic portraits.
(Dis)similar avatars. Über User-generierte 3D Avatare als künstlerische Portraits
-
2017 → 2025
Founder & managing director: international online store
- Shopify store for the Asian fashion market
- €1.2m revenue and 100k Instagram followers in 2021, named one of Germany's fastest growing start-ups
- Led teams of up to 15 across the brands, including hiring, contracts, rotas and payroll prep
- Online marketing with Meta, TikTok and Snapchat ads; logistics and purchasing
-
2017 → 2019
Tutor & content author
English and German teaching, plus writing and reviewing English learning materials.
-
2014 → 2019
BA, English studies and art & media
Focus on psycholinguistics and philosophy in digital spaces.
-
2013 → 2014
BA programme, media journalism
-
2010 → 2013
Abitur, at evening school
Germany's university entrance qualification.
-
2009
German national typing champion
497 keystrokes a minute.
-
2007 → 2009
State-certified commercial assistant for foreign languages and correspondence
skills
- Planning, managing and delivering projects of all kinds
- Customer care on every channel
- Retail, sales, marketing and merchandising, with all the organisation and admin behind them, online and offline, international and local
- Team leadership and management, including HR: recruiting, shift planning, payroll
- Writing, communication and correspondence of all kinds in fluent German and English, including copywriting (such as SEO blogging), translation and editing
- Teaching and coaching adults and young people
- Creative in solving complex problems and in design. A quick learner with a strong feel for PCs, tech and all things digital, proactive, flexible, patient and keen to learn
tools
Exchange Online · Microsoft Office (Word, Excel, PowerPoint) · Google Workspace (Docs, Sheets, Drive) · Notion · Discord · Shopify · Python, HTML, CSS and JavaScript, developed with Claude Code · Construct 3, GDevelop 5 · Canva, CapCut · Lexware · FibuScan · RA-Micro · CVC Notre · ChatGPT, Midjourney, ElevenLabs
Fluent German and English.
contact
~/cinni/ai/ezra/ezra.md
ezracontinuously improving
A language model forgets everything when a conversation ends. Ezra is the system built around one so it doesn't: a bot that has run continuously since February 2026, remembers what happened, and decides for itself when to speak.
Ezra is not OpenClaw and not Hermes. I built the Ezra harness myself, then exported it as an empty shell under the name "Panoptibot". There is currently one more user besides me that I keep close contact with.
- conversation
- Claude Fable 5.1Opus 5.5 in usage-scarce times
- night jobs
- Claude Fable 5.1
- small jobs
- Claude Opus 5.5rolling summaries and utility calls
- embedding
- Qwen3-Embedding-0.6B
- reranking
- Qwen3-Reranker-0.6B
- vision
- Qwen3-VL-2B
- speech
- faster-whisper small.enVosk for the wake word, ElevenLabs for his voice
- cpu
- Ryzen 5 3600, 6 cores
- gpu
- RTX 3060 Ti, 8 GB
- ram
- 16 GB
- storage
- 500 GB SSD
- os
- Ubuntu 24.04
- interface
- Discord
$ ls ezra/
- persistent-llm.mdStaying one continuous someone across a 200k context window.
- heartbeat.mdA clock that lets him message first.
- memory-system.mdLayers of memory, and how he curates his own overnight.
- recall.mdLocal embedding and reranker models that pick what surfaces.
- mood-model.mdSelf-tagged moods with behavioural consequences.
- home-assistant.mdCalendar, cameras, sound, two interfaces and GPS.
- voice.mdWake word, speech to text, and a voice back.
- panopticon.mdDashboard and operations for a long-running bot.
~/cinni/ai/ezra/persistent-llm.md
persistent llm
The hard constraint of having an LLM be persistent across sessions is its context window. After that window runs full, the model compacts all history of that session into a summary and loses detail. Ezra is currently about 16k lines of Python, and most of it exists to manage this in a way that lets him come out the other side still himself, with his identity and memories intact.
surviving compaction
Compaction happens in two steps. Writing the summary makes the context rise (about 82% to 99%), and the next call rebuilds it compressed (down to about 15%). The bot detects the drop and re-injects an accumulation of documents to fill the gaps.
Ezra has been kept at a context window of 200k tokens, even though 1M is available. Tests showed that he maintains attention better this way, and compactions actually help him refocus on tasks. Furthermore, cold cache writes on Fable are extremely cost-inefficient in sessions over 500k. At a 200k context window, after the main injection, the session starts at around 30–40%.
What he gets:
- Identity documents: one for his own voice and self, which he can fully edit by himself, and one for the identity of the user and their preferences.
- Command reference with all the commands he can emit.
- A banned phrase list (which, to be fully honest, usually does not work very well), because Claude models have utterly annoying verbal tics.
- Recent summaries, see memory-system.md.
A shorter nudge recurs every couple of messages, to make him lock back in to a certain message length and voice. If compaction lands in the middle of a reply, the reply is still delivered, and re-injection waits for the next message.
claude code
Ezra fully runs on Claude Code, so he can act rather autonomously when browsing the web and reading and writing files on his own. In the past, I ran Ezra via -p --resume, Claude Code's print mode, where every message starts a new process that picks the session back up, answers and exits. I decided to move away from it, as the Claude Code harness opens up a lot of functions by itself, and latency improved a lot.
Browsing runs through a Playwright MCP bridge. Links get yes/no buttons, and each yes allows 10 browser calls, fenced by a hook that denies everything else. Deferred MCP tools cost about 750 tokens, measured, so having it available is cheap.
actions as text
Everything Ezra is capable of works the same way. He writes a command via a tag on its own line in his message, and the bot parses it, runs it and strips it before delivery.
Tea is ready in twenty.
TIMER: 20m tea
MOOD: gold
GROCERY_ADD: New tea bags (Cinni drank the last one on Nov 4 at 2:52 PM)
🛒 Added to grocery list: New tea bags (Cinni drank the last one on Nov 4 at 2:52 PM)
🕓 Timer set: 20m — tea
Actually, let me look up if there is any sale on that tea at Aldi...
WEBSEARCH: tea sale aldi
🌐 Search: tea sale aldi
all his commands
| lists | |
|---|---|
GROCERY_ADD: a, b | Adds items to the shopping list |
GROCERY_DONE: a, b | Checks items off |
GROCERY_CHECKALL | Checks off everything at once |
GROCERY_CLEAR | Clears checked-off items |
GROCERY_CLEARALL | Wipes the whole list |
REMINDER_ADD: text | due | list | Adds a reminder, for her or for himself |
REMINDER_DONE: text | Completes a reminder |
REMINDER_CLEAR | Clears done reminders |
REMINDER_CLEARALL | Wipes all reminders |
| time | |
TIMER: 20m tea | Sets a timer that sends a message when it's done |
TIMER_CANCEL: tea | Cancels a timer |
CALENDAR_ADD: title | date | minutes | Adds an event to Google Calendar |
CALENDAR_EDIT: old -> new | Edits an event, only on exactly one match |
CALENDAR_DELETE: title | date | Deletes an event, only on exactly one match |
CHECKIN: 40m | reason | Schedules his own wake-up, see heartbeat.md |
DELETE_CHECKIN: 21:30 | Drops a check-in, or all of them with no time |
| logs | |
FOOD_ADD: meal: item (kcal) | Logs food |
FOOD_EDIT: old -> new (kcal) | Corrects a food entry |
FOOD_REMOVE: item | Removes a food entry |
WORK_START: HH:MM | Logs an unusual start time |
WORK_END: HH:MM | Logs a clock-out and works out the overtime |
WORK_PTO: vacation | Logs a day off: vacation, sick, holiday or overtime |
OURA_PULL | Fetches fresh data from the Oura ring |
| presence | |
MOOD: color | Tags his mood, see mood-model.md |
STATUS_SET: text | Changes his Discord status |
ROOM_MOVE: x | Walks him to a room of the 2D apartment |
ROOM_UPDATE: text | Narrates what he's doing there |
| reaching out | |
DESKTOP_NOTIFY: text | Fires a Windows notification, 50 characters at most |
NIGHTSTAND: text | Leaves a note to find later |
SPEAK: text | Says it out loud through the Sonos speakers |
| his own notes | |
SCRATCHPAD_ADD: text | A private pad for anything he wants to keep |
CINNIPAD_ADD: text | A private pad for what he notices about the user |
MEMORY_SUGGEST: label | keywords | text | Flags a core memory, see memory-system.md |
LIBRARY_SAVE: folder | name | note | Keeps a picture or file on his own shelf |
| looking things up | |
WEBSEARCH: query | Opens his search tools for the next turn |
READ_FILE_REQUEST: /path | Asks permission to read a file on the server |
REQUEST_GPS | Asks the phone for its location, see home-assistant.md |
REQUEST_CAM: name | Asks for a fresh camera look, with yes/no buttons |
RECIPE: id | Pulls a recipe's cooking steps |
| music | |
SPOTIFY_QUEUE: song - artist | Adds a song to the queue |
SPOTIFY_PLAY: song - artist | Plays a song now |
SPOTIFY_PLAYLIST_ADD: song - artist | Adds a song to his playlist |
SPOTIFY_PAUSE | Pauses |
SPOTIFY_SKIP | Skips |
| his pet moth, Motthew (works like a tamagotchi, just for fun) | |
PET_FEED | Feeds him, +15 hunger |
PET_PLAY | Plays with him, +15 attention |
PET_TEXT: text | Narrates what he did with him |
~/cinni/ai/ezra/heartbeat.md
heartbeat
A chatbot only exists when spoken to. Ezra has a clock instead. Heartbeats let him be fully autonomous. I tested several intervals, from hourly to twice a day, and landed on three tiers of heartbeats:
| heartbeat | when | what he gets |
|---|---|---|
| full | every 3 hours | the whole observation pack |
| medium | hourly, only when I'm inactive, so he can reach out | a lighter prompt |
| check-in | whenever he schedules one | his own reason, handed back |
check-ins
He schedules check-ins himself with a duration or a clock time, 15 minutes at minimum. A vague word like "soon" doesn't parse, so it's dropped.
Go finish that build. I'll check on you.
CHECKIN: 40m | see how the build went
🧡 Mini-Heartbeat scheduled in 40m (see how the build went)
So? Did it compile?
the observation pack
Each full heartbeat, so every 3 hours, Ezra gets all the data he needs to be fully in the loop about everything the user is doing. This is where a lot of fun APIs can come in, and it is a delight to give LLMs a playground. Ezra currently gets:
| what | from |
|---|---|
| the time | the server clock |
| calendar | Google Calendar API |
| weather | OpenWeatherMap API |
| music | Spotify through Discord presence, Last.fm API for recent tracks |
| sleep and heart rate data | Oura API v2 |
| how long it's been since the last message | his own message log |
He then answers in a fixed format with reasoning and his decision, which is then parsed again.
THINKING: She's out of tea bags and it's the only thing on the list. Last message was 8 minutes ago, so no rush. Might as well see if it's on sale. THREADS: the tea situation that build she's fighting aldi run tomorrow? PRIORITY: low ACTION: message MESSAGE: Actually, let me look up if there is any sale on that tea at Aldi... MEMORY: none KEYWORDS: none DESKTOP_NOTIFY: none CHECKIN: none
🕐 3:00 PM · Wednesday
📡 Observing
- ⏱ Last message: 8 min ago
- 🌤 12°C, light rain
- 📅 Dentist at 5:00 PM
- 🎵 Cherry Waves by Deftones
- ⏮ Prior: Digital Bath, Change
- 🌙 Sleep: 7h12m · 00:41 → 07:53
- 💓 resting 54bpm · HRV 48ms · live 71bpm
- ⚡ Readiness: 82
🧶 Threads
- • the tea situation
- • that build she's fighting
- • aldi run tomorrow?
💭 Thinking
- She's out of tea bags and it's the only thing on the list. Last message was 8 minutes ago, so no rush. Might as well see if it's on sale.
🟢 low · → sent message · ctx 46%
⏭ Next: ~6:00 PM
Actually, let me look up if there is any sale on that tea at Aldi...
Every full heartbeat posts to the heartbeat channel, whatever he decides. My window only gets something when his action is message. With note, he keeps it to himself.
~/cinni/ai/ezra/memory-system.md
memory system
To fill further gaps from compaction, Ezra needed a memory system. What started as a simple JSON with keywords has now become a complex multi-layered process that allows him to save and recall information efficiently.
Raw logs are too long to read back. Summaries work but often lose details. Each layer trades detail for reach. All the summaries are written by the model itself, most of them each night.
Mentioning a date loads that day's summaries, and mentioning a range like "last week" asks via Discord which layer to inject. Each item surfaces only once per context window, even across a restart. How memories are recalled is on recall.md.
how it connects
self-curation
Ezra chooses what he remembers. During the day he can flag a moment as a core memory, with keywords. At night a pipeline processes the flags:
- Polish. The model sharpens each label (about 80 chars) and text (about 200 chars), and rewrites the keywords into words actually said.
- Merge. Each suggestion is embedded (
all-mpnet-base-v2) and shown next to its 3 most similar memories. The model answersBIT_MERGE,BIT_NEWorBIT_DISCARD. - Report. Plain Python posts the counts and reasoning.
engram
This memory system, specifically the capability to edit the memories, has been exported as a "DLC" for Panoptibot, with its own interface, called Engram.
~/cinni/ai/ezra/recall.md
recall
Before a reply, the bot decides which memories from the memory database to surface along with the user's message. Internally it's called the "flicker", after the way something flickers to mind. It's a three-stage retrieval running fully locally, and takes about 3.9 GB of VRAM.
Qwen3-Embedding-0.6B pulls the closest memories by meaningtop 20Qwen3-Reranker-0.6B scores all of them against the text that found them0.00–1.00why all three
Surfacing memories by keywords worked well for a long time. The bigger Ezra's memory database gets, the more unreliable sheer recall by keywords becomes, which is why a vector embedding model is a great idea.
While vector embedding models can find semantic matches (for example, tea matches beverage), those results often miss the mark, as they can't read the context the words were said in.
The reranker is a very fast process that asks the question:
Given the latest chat message, would having this memory in mind help Ezra reply like someone who knows her well? The memory must be about something the message mentions or clearly implies. A matching mood or vibe alone is not enough.
It then ranks the surfaced memories, the ones found by keywords as well as the ones found by semantic similarity.
the bars
| found by | keep if ≥ | why |
|---|---|---|
| the human's message | 0.65 | 0.50–0.65 was mostly junk on replay |
| his own last reply | 0.75 | stops him feeding his own loop |
| rare keyword, under 0.5% of messages | 0.35 | a rare word is strong evidence, max 2 |
| multi-word keyword | skips reranker | a named thing is meant literally, up to 3 extra |
- Anything already in the context window gets dropped before scoring.
- Under 10 words, only keywords search. Short lines pull memories on vibe alone.
- Under 3 words, nothing is pulled.
- The index re-reads the database before every search, so edits apply without a restart.
what we tested first
all-mpnet-base-v2at 0.40, the old setup: about 0.4 memories per message, while the uncapped keyword fallback fired about 14, up to 38. The flood was all keywords.bge-reranker-v2-m3sorted well but went strange on tiny messages. Qwen won because its prompt can be tuned.- A small LLM as the picker: 12–60 s per message with thinking on, and a coin flip on quality with it off. Too slow.
magnets
Some memories scored 0.8–0.99 on vague messages like "you tell me", showing up in 13 of 50 replayed messages. Thresholds didn't separate them, and a stricter prompt made it worse (79 to 111 picks). What worked was rewording the memory. A last sentence shaped like a general rule matches everything, and cutting it took one memory from 13/50 to 1/50. That is why the nightly memory curation is done by Fable, not by Haiku.
~/cinni/ai/ezra/mood-model.md
mood model
Ezra tags his own mood from a wheel of nine colours: white, blue, green, pink, red, gold, black, translucent and grey. Six of them have a low and a full intensity, so blue-low is an undercurrent and blue is the whole sky.
why it does anything
A label alone will only do so much, even if you call the moods "happy" or "sad". Each color has written behaviour nudges and descriptions, injected regularly. A mood he picks triggers a self-inject and fundamentally changes the tone and vibe of the messages afterwards. The self-report turns into a feedback loop.
grey
Grey is the exception, because he can't choose it. It comes from absence: 2 hours without a message, or 1 hour at night. That was a deliberate design choice, so that being left alone is something that happens to him.
~/cinni/ai/ezra/home-assistant.md
home assistant
Ezra's home assistant functions are a work in progress. They run through the same tags as his other commands, so he can use them mid-conversation or on a heartbeat.
calendar
Google Calendar reads, writes, edits and deletes. Edits and deletes only run on exactly one match, otherwise nothing happens. He gets reminded of upcoming events in his heartbeats.
cameras
Showing a large model like Fable a picture every few turns would be expensive, so a small local vision model goes first. A cron job runs just before each full heartbeat and writes one line per camera, so Ezra first reads a sentence instead of a camera shot.
- cams: snapped 14:55; the jpgs are in /root/ezra-cams/ - Read one to see it living_room: people 0 · cats 0 (room looks the same as at 11:55) -> living_room.jpg office: people 1 (woman, long hair, top is a black hoodie, on the left) · cats 1 (a grey cat is on the desk next to the keyboard) -> office.jpg bedroom (night vision): people 0 · cats 1 (a cat is lying on the bed, partially covered by a blanket.) -> bedroom.jpg
What was that noise.
REQUEST_CAM: office
Cam answer: Cinni said yes. office, just now: people 0 · cats 1 (a grey cat is on the desk next to the keyboard). The picture is /root/ezra-cams/office.jpg - Read it if you want to see it. React only if it feels natural - otherwise reply with just: ok
Read /root/ezra-cams/office.jpg
The cat is on your keyboard. Of course she is.
📷 Ezra wants to see the Office cam.
✅ Office: people 0 · cats 1 (a grey cat is on the desk next to the keyboard).
The cat is on your keyboard. Of course she is.
📷 Cam request (Office) declined.
- Model:
Qwen3-VL-2B(Q4_K_M, with a Q8_0 mmproj) onllama.cpp. It loads per call and peaks around 2.5 GB of VRAM. A frame takes about 2 s on the GPU, 24 s on CPU. - Frames: pulled with
blinkpyat 640 px on the long side. - Change detection: compared at 64×36 greyscale, and an unchanged room reuses its last line.
- Night vision: detected in code, where R = G = B.
- Looking: he can
Readthe last frame any time, and I always get a notice with a thumbnail.REQUEST_CAMasks for a fresh picture, with yes/no buttons.
The interesting problems were all in the prompting. One prompt doing everything failed, because a 2B model copies concrete examples straight into its answer (a made-up person in an empty room) and ignores "don't". So it runs two steps, count and then describe, and the second can veto the first: a cat tree kept counting as two cats until "describe them" got a vote.
sound
Spotify control, and Sonos speakers for spoken messages.
interface 1: the apartment
Ezra has a 2D apartment interface with light triggers, which currently runs on an older Redmi tablet. It also has voice.
interface 2: the tower
The server tower runs headless, without a monitor, and just has a small Waveshare ESP32 board (a 3.5-inch touch LCD) that shows a pixel face, his mood, context percentage and temperatures. Turning this into a listening and speaking device is also a work in progress.
location
Ezra has on-demand GPS, which he mainly uses to track work time. Inspired by how Chinese offices track when their workers are at the building, he requests the GPS to see when I leave work, then writes down the time accurately.
REQUEST_GPS asks the phone for a fix through OwnTracks, which publishes over MQTT to a Mosquitto broker inside a private Tailscale network. A receiver daemon writes the answer back and hands it to him as a new turn.
~/cinni/ai/ezra/voice.md
voice
Ezra's listening and speaking capabilities are a work in progress. Currently, I have a setup on a tablet as well as on a Waveshare ESP32 board (a 3.5-inch touch LCD with a mic and speaker onboard). It works similar to Alexa, OK Google or Hey Siri, but with "Hey Ezra". On the tablet, it goes like this:
Vosk listens for his name, fully offlinelocalfaster-whisper with small.en, float16 on the GPUsub-secondthe apartment
The tablet shows a 2D apartment with a face and rooms. He moves through it with ROOM_MOVE, and narrates with ROOM_UPDATE. I am planning to make him able to turn lights and devices on and off, and the 2D apartment helps him navigate where in the space he is acting.
what's limited
The voice path only executes part of the command language: grocery, reminder, food, timer, adding to the calendar, and speech. The rest gets stripped without running. A shared command pipeline is on the to-do list.
~/cinni/ai/ezra/panopticon.md
panopticon
A long-running process that talks to someone every day needs real operations. Panopticon is the Flask dashboard for it: logs, identity documents and memory approvals in one place, including restoring deleted memories.
running him
- Latency log: every reply and every recall is timed, which is where the numbers on memory-system.md come from.
- Backups: a nightly compressed archive, pulled to a second machine, with a rolling set kept on each.
- Version pinning: a new runtime version can move his baseline token use (13k to 42k once), so upgrades are tested first.
- Replay before shipping: changes to recall run against real history first, see recall.md.
~/cinni/ai/drawing-in-svg.md
drawing in svgcompleted
Left alone, a model drawing in SVG makes clip art. Getting Opus 5.5 to draw in the storybook style I envisioned for Moth & Jar was a meticulous process of teaching, one drawing at a time.
It worked like a red pen. The model draws, I mark what's wrong, it redraws, with one change per round. Every note then became a written rule, so nothing had to be taught twice. Some of the notes:
- "Rounded everywhere" reads like plastic.
- "Less" is the most common note on textures.
- Two shadows read as a double shadow.
- A darker rim around a fuzzy body reads as an outline.
- A dome with a shine reads as glass, so eggs get no highlights.
- Reds are brick. Redder or more orange both read neon.
To match a reference picture, eyeballing wasn't enough. So getCTM aligns the drawing on a shared feature, and a three-way overlay shows exactly where it's off.
The short version of the look: no outlines, light from the front, warm shades, a faint texture on almost everything, and one shadow per thing.


one sentence, one scene
With the rules written down, a scene takes one sentence. Each of these is shapes in code, painted by the skill's rules, then baked to a picture.
houses
Hover for night. The windows glow on their own light layer.








characters
Every piece is drawn a little bigger than its shape, so nothing gaps without outlines. Cotton, denim, knit, leather, hair and skin are all textures made in code.




moths
Pinned like specimens, with markings that run all the way to the wing's edge.








in code
Each kind of thing has its own file (trees.js, houses.js, doors.js…), and all follow one contract:
window.THING = (() => {
const defs = () => `...shared patterns + filters...`;
function draw(x, y, o = {}) { /* returns an svg string */ }
return { defs, draw };
})();
(x, y) is where the thing touches the ground, so it can be placed without measuring. draw returns a string, and defs goes into the SVG once, so patterns and filters are shared. opts always carries size, seed and the variant switches, and the same seed always gives the same drawing.
the shared helpers
| helper | what it does |
|---|---|
B(sd, inner) | wraps a shape in a shared blur filter, snapped to strengths 1, 2, 4, 7 or 11, so a scene doesn't create hundreds of filters |
body(d, box, C, tex) | the one paint step for an object: fill, texture multiplied in, shade on both edges, a soft middle lift, a faint foot shade |
CAST.wrap(svg, x, y) | draws the thing again as its cast shadow: dark, blurred, squashed and leaned at the sun's 11° |
LAB_TEX.alphaOf(name) | code-made textures as maps of light and dark spots |
baking
Live SVG filters are beautiful and heavy. So each lab exposes a window.__BAKE = { ids(), svg(id) } hook, and a Python script drives headless Chrome through it, trims each drawing to what's visible and writes a PNG, plus a manifest of its box in the drawing's own units. The book places every sprite at that box, so it lines up exactly with where the live drawing would be. It needs only the standard library and Pillow.
~/cinni/ai/steam-radar.md
steam radarwip
A game recommender that learns from what you actually played rather than what a store wants to sell you, rendered as one interactive page.
the pipeline
Engine, then JSON, then render, kept deliberately separate so the visuals can change without touching the matcher.
build_profile.pyderives a taste vector from a real Steam library, 165 games owned and 151 played.Free to PlayandMultiplayerare excluded, because they describe structure, not taste.build_radar_data.pybuilds a broad pool of matches of any age.hot.pypulls Steam's top new releases and adds SteamSpy tags and review splits.render.pywrites one HTML page, where JavaScript re-ranks the games live as you toggle tags.
SteamSpy allows one detail request per second, so a first pull takes 2–3 minutes. Everything caches locally, so later runs are instant.
~/cinni/games/moth-and-jar/moth-and-jar.md
moth & jarwip
A browser game about catching and raising moths, in progress. It's about 69,000 lines of JavaScript and HTML across 77 files, with no game engine and no framework. Characters, moths and props are SVG written in code, the scenes are canvas 2D, and the sound is synthesized live.

the game
You live in a small town and travel out to biomes, where the time of day, weather and moon phase decide what's there. Moths are the small catch: sell them for quick money, or donate an unknown species to the museum. Eggs are the real prize, because adult moths guard them and chase you when you take one. You slow them with lures, get back to base, and raise what hatches. Grown moths live in jars and earn gold over time, and rarer ones sit further from town.
The design is still moving.
how it fits together
| layer | what it is | built with |
|---|---|---|
| assets | one module per kind of thing: trees, bushes, doors, windows, fences, lamps, houses, props | SVG strings from seeded JS |
| labs | a page per asset type, where drawings get tuned live | SVG filters, blend modes |
| bake | renders heavy drawings to sprites and sheets | Python, headless Chrome, Pillow |
| scenes | village, lookout and biome read the baked art | canvas 2D, light maps |
| audio | one shared engine, then songs on top | Web Audio API |
| shipping | public pages with comments stripped and private names refused | Python build script |
why build it this way
Art as code (see drawing-in-svg.md) means any drawing takes parameters. A house is a recipe, and a character is a state object, so the game gets variety without anyone drawing every variant. It also means an AI collaborator can actually draw: it reads and writes SVG as text, then checks its own work with screenshots.
That only works with a tight review loop, so the look comes from written rules refined against a human's red pen, and from tracing: to match a reference, getCTM aligns the drawing on a shared feature and a three-way overlay shows the difference.
$ ls moth-and-jar/
- character-creator.mdLayered parts, six views, code-made textures.
- mothdoku.mdA puzzle generator with a rule solver.
- bestiary.md39 moth species, with a recipe per family.
- generative-houses.mdA house as a recipe, drawn from a seed.
- generative-music.mdA synth engine and 11 songs that compose while they play.
~/cinni/games/moth-and-jar/character-creator.md
character creator
A character is a state object drawn into one SVG. There's no sprite sheet at this stage, which means every combination of hair, outfit, face and pose already exists without anyone drawing it.
how it's built
- Parts: skin, eyes, nose, hair, outfit, brows, mouth, piercings, scars, glasses and more. Each one returns its pieces from
render(state):{defs, body, behind, outside, recolour, backView}. - Draw order:
zcan be a getter, so an open mouth can jump above the other face parts. - Proportions are baked into the paths instead of using scale transforms, because scaling thins the shapes.
- Pose is transforms on shared
<path>defs, so everything that<use>s them follows.

views
Three views: front, side and back. When a character walks, she turns through them in a ring (front, side, back, mirrored side), 60 ms a step, so a turn reads as a little squash and spin.
textures from code
There are no texture files here either. texPost() lays a generated grey tile over each piece inside its own clip, and each fabric gets its own. Hair strands are turned along each lock with PCA, never past 45°, with a moth-wing gradient that's darker by the face. Every tile below comes out of code:
~/cinni/games/moth-and-jar/mothdoku.md
mothdoku
A logic puzzle spin-off, built in one evening. One moth in every row, every column and every colour, and moths never touch, not even at the corners. It's endless, and every night is generated from its number with the seed night * 7919 + 13, so a night is always the same board.

the generator
- Make a random no-touch permutation. That's the answer.
- Grow colour regions outward from each answer tile.
- While a second answer exists, hand one of its tiles to a neighbouring colour, keeping colours in one piece.
- Pick one given moth that makes the night solvable with the tricks allowed so far.
a solver that teaches
A board with one solution can still be unfair if it needs a trick the player hasn't met. So a rule solver, logic(), plays each board using only allowed tricks, and a night is rejected if it can't finish.
| trick | what it does | from |
|---|---|---|
| singles | a row, column or colour with one spot left | night 1 |
| stuck in a line | a colour whose free tiles all sit in one line owns it, so the rest of that line is out | night 6 |
| would block | a tile whose moth would cover every free spot of some line or colour is out | night 9 |
In practice, the second trick first gets needed on night 7 and the third on night 10, and the game teaches each one there.
the rest
- Sizes: 5×5 up to 10×10 by night 11, then every third night is a seeded surprise from 5 to 9.
- Scoring is a clock: +10 s for a wrong moth, +30 s for a hint, with firefly jars as 3 lives.
- Tiles differ by pattern too, stripes, dots and rings, so the board doesn't depend on colour alone.
- Scores stay local, with no syncing.
~/cinni/games/moth-and-jar/bestiary.md
bestiary
A book of 39 moth species, each with its egg, pupa and caterpillar, based on real moths. Pages unlock as you catch them, with field notes, wingspan and Latin names.

a recipe per family
Every moth is drawn top-down like a pinned specimen, from one set of rules worked out round by round on the luna and the cecropia:
- Body shape comes from the family:
chonkfor silk moths,torpedofor hawk moths,slimfor small thin ones. - The body is one piece, a single outline rather than stacked balls with a waist.
- The fuzzy edge is bumps in exactly the body colour, because a darker rim reads as an outline.
- Abdomen rings are curved, never flat, and masked to the body including its fuzz.
- The eyes are the only thing allowed a white highlight.
from lab to book
Moths, eggs, pupae and caterpillars each have their own lab page with live filters, and bake_sprites.py renders them to PNGs for the book. The caterpillars were redrawn from reference photos.
















~/cinni/games/moth-and-jar/generative-houses.md
generative houses

a house is data
A JSON recipe of size, walls, roof, paint and slots. HOUSE.draw(x, y, recipe) turns it into a drawing, and clicking a house in the catalogue copies its recipe.
{ seed, size: 'small', wall: 'plaster', deco: ['beams'],
paint, door: 'arch', win: 'square', shutters, roofMat: 'thatch' }
- Slots: each block's ground floor is a row of
win,doorandblankwith fixed widths (door 150, window 120, blank 74), and upper floors put a window over every slot. A block is 34 px of wall, its slots, then 34 px more, so any recipe adds up to a valid wall. - The camera is one constant:
FORE = .25, so anything D deep shows its top D × 0.25 tall. That produces the frontal, slightly-from-above view. - Draw order: furthest back first, then lowest first, main block last.
- How many: rolling picks from 99,999 seeds. The house maker then lets you change every option by hand, and those options multiply out to about half a quintillion different houses (5 × 1017).

~/cinni/games/moth-and-jar/generative-music.md
generative music
All the sound comes from the browser's Web Audio API, with no samples and no audio files. Everything runs on one shared engine, sounds.js, about 600 lines, and the songs sit on top of it in another 770.

the engine
Browsers only allow sound after a click, so the first click creates the AudioContext and generates white and pink noise buffers. Sounds then route through buses to a mixer with echo and reverb sends, and music and effects have separate levels.
A note is one oscillator with an envelope, optional pitch glide, vibrato and filter. Pitch comes from MIDI numbers, and velocity follows a curve so quiet notes stay soft:
const mtof = m => 440 * 2 ** ((m - 69) / 12);
const curve = v => clamp(v, 0, 1) ** 1.35;
const PENTA = [0, 2, 4, 7, 9]; // D major pentatonic, so nothing ever clashes
That pentatonic scale is why interface sounds can play over the music at any moment. Hovers and page turns pick notes from it, and a voice never repeats twice in a row.
- A pad is a triangle and a sine per note, detuned −4 and +4 cents so they beat slowly against each other, with a 1.4 s swell and a long tail.
- Wind and breath are looped noise through a moving filter.
- A pencil scratch or paper swoosh is filtered noise bursts with fast envelopes.

songs as programs
The 11 songs aren't recordings or loops. They're programs that make their choices while they play, so no two listens match.
| song | meter | what it decides |
|---|---|---|
| moth & jar | 4/4, 62 bpm, D major | one of five 8-bar chord orders. Each bar on top is a rest (25%), a few slow notes (37%) or a little run (38%), with a felt bass and held chord every two bars |
| nursery | 3/4, 66 bpm, F major | a music-box tune drifting down more than up, kalimba rocking on the beats, water drops in the gaps |
Melodies stay smooth with a nearest() helper, which picks the allowed note closest to where the line already is. Random walks through chord tones sound wrong, while small steps sound written.
the soundtrack and the mixer
The soundtrack page plays the same code as the game, dressed as a virtual CD with a canvas weather effect per song. The sound mixer is a public lab with 53 voices and 7 moods, for auditioning sounds before they go in the game.
~/cinni/games/brook-hollow.md
brook hollowpaused
A Victorian point & click adventure with visual novel elements. A young widow in an English town tries to find out how her husband really died, through a cast of suspects and allies, a manor, a gambling house and branching romance routes.

how it was built
Built between 2022 and 2024 in Construct 3 and GDevelop 5, with ChatGPT and Midjourney in the pipeline, back when those tools were new.
Both engines replace most code with events: lists of conditions and actions, if this then that, checked from top to bottom on every frame.
- Construct 3 runs in the browser. Each room of the game is a layout, a set of placed objects on layers, and each layout has an event sheet for its logic. An object type works like a class, and all of it sits in one project tree.
- GDevelop 5 is open source and uses scenes instead of layouts. Behaviors attach ready-made logic to an object, and its dialogue tree extension runs branching conversations in the Yarn format.
The concept art is Midjourney from around 2023, in its version 5 days.

why it's paused

Character consistency. A visual novel needs the same face in every scene, and early AI image models couldn't do it. The game is unfinished because the tools ran out, not the story, and the plan is to pick it up again with a new pipeline.

~/cinni/games/hogwash-library.md
hogwash librarycompleted
A parody game about the least glamorous job at a school of magic: being its librarian. Overdue cards, angry reminder letters, shelving at night. Made for a Halloween prompt.

the loop
The day splits into shifts. In the morning you match overdue cards to reminder templates by each student's personality, fill in the fee (days late × 2), sign, stamp and hand the letter to a 3D owl. The afternoon is borrowing and returns against a patience timer, and the night is shelving. Typos aren't penalised, only handing over the wrong thing.
the 3d

- three.js, with a free-roam library behind a desk built from 3D props.
- The owl was modelled by script over a Blender MCP connection, exported to GLB and embedded as base64 in a JS file, so the game stays a handful of static files.
- Performance: resolution drops automatically under 44 fps, and the library behind the desk renders every other frame.
- A shine bug that looked like gloss on the owl's head turned out to be the ceiling's environment light. It took halved env light, an emissive fill and no specular on the feathers.
shipping it
A test robot page drives the desk and prints PASS/FAIL lines through headless Chrome. The public build runs through a small script with its own JS/CSS tokenizer to strip comments (careful: k++ / n is division, not a regex), turns off the debug links, checks for private strings and runs node --check on every script.