~/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

~/cinni/cv.md

cv

Office & IT. Former founder. Builds odd things on the internet. Northern Germany.

  1. 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
  2. 2026 → now

    Moth & Jar

    Browser game with SVG art from code and generative audio. How it's built →

  3. 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 →

  4. 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
  5. 2022 → 2024

    Brook Hollow

    Victorian point & click in Construct 3 and GDevelop 5, with an early AI art pipeline. More →

  6. 2020 → 2023

    Founder: offline secondhand store

    • 3k followers
    • A physical shop: visual merchandising, staff, purchasing
  7. 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

  8. 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
  9. 2017 → 2019

    Tutor & content author

    English and German teaching, plus writing and reviewing English learning materials.

  10. 2014 → 2019

    BA, English studies and art & media

    Focus on psycholinguistics and philosophy in digital spaces.

  11. 2013 → 2014

    BA programme, media journalism

  12. 2010 → 2013

    Abitur, at evening school

    Germany's university entrance qualification.

  13. 2009

    German national typing champion

    497 keystrokes a minute.

  14. 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@cinni.dev

~/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.

models
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
home server
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/

~/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.

ezraBOT

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
Heartbeat fired at 3:00 PM
ezraBOT

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, bAdds items to the shopping list
GROCERY_DONE: a, bChecks items off
GROCERY_CHECKALLChecks off everything at once
GROCERY_CLEARClears checked-off items
GROCERY_CLEARALLWipes the whole list
REMINDER_ADD: text | due | listAdds a reminder, for her or for himself
REMINDER_DONE: textCompletes a reminder
REMINDER_CLEARClears done reminders
REMINDER_CLEARALLWipes all reminders
time
TIMER: 20m teaSets a timer that sends a message when it's done
TIMER_CANCEL: teaCancels a timer
CALENDAR_ADD: title | date | minutesAdds an event to Google Calendar
CALENDAR_EDIT: old -> newEdits an event, only on exactly one match
CALENDAR_DELETE: title | dateDeletes an event, only on exactly one match
CHECKIN: 40m | reasonSchedules his own wake-up, see heartbeat.md
DELETE_CHECKIN: 21:30Drops 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: itemRemoves a food entry
WORK_START: HH:MMLogs an unusual start time
WORK_END: HH:MMLogs a clock-out and works out the overtime
WORK_PTO: vacationLogs a day off: vacation, sick, holiday or overtime
OURA_PULLFetches fresh data from the Oura ring
presence
MOOD: colorTags his mood, see mood-model.md
STATUS_SET: textChanges his Discord status
ROOM_MOVE: xWalks him to a room of the 2D apartment
ROOM_UPDATE: textNarrates what he's doing there
reaching out
DESKTOP_NOTIFY: textFires a Windows notification, 50 characters at most
NIGHTSTAND: textLeaves a note to find later
SPEAK: textSays it out loud through the Sonos speakers
his own notes
SCRATCHPAD_ADD: textA private pad for anything he wants to keep
CINNIPAD_ADD: textA private pad for what he notices about the user
MEMORY_SUGGEST: label | keywords | textFlags a core memory, see memory-system.md
LIBRARY_SAVE: folder | name | noteKeeps a picture or file on his own shelf
looking things up
WEBSEARCH: queryOpens his search tools for the next turn
READ_FILE_REQUEST: /pathAsks permission to read a file on the server
REQUEST_GPSAsks the phone for its location, see home-assistant.md
REQUEST_CAM: nameAsks for a fresh camera look, with yes/no buttons
RECIPE: idPulls a recipe's cooking steps
music
SPOTIFY_QUEUE: song - artistAdds a song to the queue
SPOTIFY_PLAY: song - artistPlays a song now
SPOTIFY_PLAYLIST_ADD: song - artistAdds a song to his playlist
SPOTIFY_PAUSEPauses
SPOTIFY_SKIPSkips
his pet moth, Motthew (works like a tamagotchi, just for fun)
PET_FEEDFeeds him, +15 hunger
PET_PLAYPlays with him, +15 attention
PET_TEXT: textNarrates 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:

heartbeatwhenwhat he gets
fullevery 3 hoursthe whole observation pack
mediumhourly, only when I'm inactive, so he can reach outa lighter prompt
check-inwhenever he schedules onehis 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.

ezraBOT

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)
Check-in fired at 7:50 PM
ezraBOT

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:

whatfrom
the timethe server clock
calendarGoogle Calendar API
weatherOpenWeatherMap API
musicSpotify through Discord presence, Last.fm API for recent tracks
sleep and heart rate dataOura API v2
how long it's been since the last messagehis own message log

He then answers in a fixed format with reasoning and his decision, which is then parsed again.

ezraBOT
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

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

125kmessages logged since Feb 2026
300+curated long-term memories
0.7 stypical recall
1.1 sslow recall

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

happens
gets written
comes back

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:

  1. Polish. The model sharpens each label (about 80 chars) and text (about 200 chars), and rewrites the keywords into words actually said.
  2. Merge. Each suggestion is embedded (all-mpnet-base-v2) and shown next to its 3 most similar memories. The model answers BIT_MERGE, BIT_NEW or BIT_DISCARD.
  3. 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.

keywordseach memory has its own trigger words, and exact matches in the message count as hitsexact
embeddingQwen3-Embedding-0.6B pulls the closest memories by meaningtop 20
rerankerQwen3-Reranker-0.6B scores all of them against the text that found them0.00–1.00
keepthe best survive, with a stricter bar for hits from his own replymax 3

why 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 bykeep if ≥why
the human's message0.650.50–0.65 was mostly junk on replay
his own last reply0.75stops him feeding his own loop
rare keyword, under 0.5% of messages0.35a rare word is strong evidence, max 2
multi-word keywordskips rerankera 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-v2 at 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-m3 sorted 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
  • Model: Qwen3-VL-2B (Q4_K_M, with a Q8_0 mmproj) on llama.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 blinkpy at 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 Read the last frame any time, and I always get a notice with a thumbnail. REQUEST_CAM asks 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:

wakeVosk listens for his name, fully offlinelocal
listena chime plays, recording starts, and it stops by itself on silencebrowser
hearfaster-whisper with small.en, float16 on the GPUsub-second
speakElevenLabs generates the reply, which plays through Sonoscloud

the 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.

A row of items on grass: a tree, street lamps, crates, barrels, a jewelled gold chest, bushes, a sack and a clay jug.
hover for night

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.

> cloudey, paint a desert oasis. palms, a little pond, pyramids far off, a clear bright sky and lots of sand
⏺ painted 981 shapes · baked to scene_desert.webp
> cloudey, now a dark mangrove jungle. a TON of bushes and grasses, and a little tiger sitting in it, looking at us
⏺ painted 1,788 shapes · baked to scene_jungle.webp
> cloudey, a snowy village at dusk. lit windows, chimney smoke
⏺ painted 1,675 shapes · baked to scene_snow.webp
> cloudey, a cozy reading nook. armchair, tea, a fire, plants and lots of books
⏺ painted 1,461 shapes · baked to scene_nook.webp
> cloudey, a mushroom forest at twilight, with moths and fireflies
⏺ painted 1,374 shapes + three bestiary moths · baked to scene_mushroom.webp
> cloudey, a rainy harbour at night, lanterns in the puddles
⏺ painted 2,659 shapes · baked to scene_harbour.webp

houses

Hover for night. The windows glow on their own light layer.

A timber-framed cottage with a red roof
timber frame
A round thatched cottage
thatch
A long cottage with a tiled roof and ivy
tiles & ivy
A plank house with a blue roof
planks

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.

A girl with long brown hair and a green cardigan
A character with mint hair in a bun
A character with spiky blue hair
A character with freckles in a knit jumper

moths

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

Luna
Luna
Cecropia
Cecropia
Rosy maple
Rosy maple
Black witch
Black witch
Isis
Isis
Polyphemus
Polyphemus
Garden tiger
Garden tiger
Comet
Comet

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

helperwhat 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.

  1. build_profile.py derives a taste vector from a real Steam library, 165 games owned and 151 played. Free to Play and Multiplayer are excluded, because they describe structure, not taste.
  2. build_radar_data.py builds a broad pool of matches of any age.
  3. hot.py pulls Steam's top new releases and adds SteamSpy tags and review splits.
  4. render.py writes 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.

Two chibi characters at a campfire on a moonlit cliff above the sea, drawn in soft shapes with no outlines.

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

layerwhat it isbuilt with
assetsone module per kind of thing: trees, bushes, doors, windows, fences, lamps, houses, propsSVG strings from seeded JS
labsa page per asset type, where drawings get tuned liveSVG filters, blend modes
bakerenders heavy drawings to sprites and sheetsPython, headless Chrome, Pillow
scenesvillage, lookout and biome read the baked artcanvas 2D, light maps
audioone shared engine, then songs on topWeb Audio API
shippingpublic pages with comments stripped and private names refusedPython 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/

~/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: z can 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.
The character creator: a character in a glass jar in a moonlit flower garden, next to panels for presets, body, face, hair, eyes, outfit, extras and make up.

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:

hair
cotton
denim
knit
leather

~/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.

A Mothdoku board: an eight by eight grid of patterned coloured tiles with one moth placed, and a character at the side giving a hint.

the generator

  1. Make a random no-touch permutation. That's the answer.
  2. Grow colour regions outward from each answer tile.
  3. While a second answer exists, hand one of its tiles to a neighbouring colour, keeping colours in one piece.
  4. 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.

trickwhat it doesfrom
singlesa row, column or colour with one spot leftnight 1
stuck in a linea colour whose free tiles all sit in one line owns it, so the rest of that line is outnight 6
would blocka tile whose moth would cover every free spot of some line or colour is outnight 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.

The bestiary open on the African peach moth: the moth in a jar, its Latin name, a wingspan bar, field notes and its life cycle.

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: chonk for silk moths, torpedo for hawk moths, slim for 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.

Puss moth caterpillar
Puss moth
Regal moth caterpillar
Regal moth
Cecropia moth caterpillar
Cecropia moth
Jewel moth caterpillar
Jewel moth
Spotted apatelodes caterpillar
Spotted apatelodes
White ermine caterpillar
White ermine
Emperor moth caterpillar
Emperor moth
Oleander hawk caterpillar
Oleander hawk
Rosy maple moth egg
Rosy maple moth
Luna moth egg
Luna moth
Jewel moth egg
Jewel moth
Puss moth egg
Puss moth
Garden tiger moth egg
Garden tiger moth
Day sphinx egg
Day sphinx
Black witch egg
Black witch
Comet moth egg
Comet moth

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

generative houses

A village square seen from above: a stone plaza with a flower tub, a thatched cottage and a timber-framed house, and a market corner with crates and sacks.
the houses in use, around the village plaza

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, door and blank with 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).
The house maker: a left panel of options for shape, roof, walls and more, next to a generated house with a character for scale.
the house maker

~/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 soundtrack page: a virtual CD with a jar and moth on the cover and a track list on the back, above a play button.

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.
The sound mixer: rows of named sound voices grouped by type, with a mix panel on the right.
the sound mixer

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.

songmeterwhat it decides
moth & jar4/4, 62 bpm, D majorone 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
nursery3/4, 66 bpm, F majora 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.

The opening funeral: a young widow in a black hat and an older reverend in a misty graveyard under pale blossoming trees, with a line of narration between them.
an early build of the opening scene

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.

A large flowchart of the game: green locations, yellow dialogue, purple items and grey triggers, from the funeral to the manor and the city.
planning the story: locations, dialogue, items and triggers

why it's paused

A character portrait: a pale young man with long black hair, a dark coat, a loose cravat and a red rose.

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.

A grid of Midjourney results for one heroine: dark curly hair and a green dress, with a slightly different face in every picture.
one character, many tries. Midjourney, 2023

~/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 title screen: a witch in round glasses at a library desk, with a sleeping black cat, carved pumpkins and a small ghost reading a book.
the title screen, generated by ChatGPT (GPT Image 2)

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

The owl in Blender: a round, low-poly brown owl with big orange eyes and a cream belly.
the owl in Blender
  • 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.