The Party Stuck On This Site (And What I'm Building Them Toward)
If you've visited the homepage recently, you may have noticed a small caged widget with a 3D character wandering back and forth, occasionally saying something. That's new. Here's the story of what it is and where I'm taking it.
Why there's a guy in a cage on my homepage
I wanted something alive on the site. Not a chatbot in the boring "hi how can I help you" sense, and not another static bio card either. Something with a bit of a personality, that reacts to where you are on the site and occasionally says something a little strange.
So the lore I settled on: six adventurers got caught in a hot reload during a 2am deploy and never found the exit. Each one has claimed a page as their turf and has opinions about the other five. One of them lives in the little cage widget at a time, you can switch between them, and clicking the cage greets whoever's currently in there.
It's a small thing. It took an afternoon of fighting with skinned mesh cloning before it worked properly. But it made the homepage feel like somewhere instead of something.
The party
Patrols the front page like it's a castle wall. It is not a castle wall.
Thinks Conway's Game of Life is a battlefield. Undefeated so far.
Knows how every writeup ends. Will not tell you. Will imply.
Nobody has seen the face. Nobody has asked twice.
Is fairly sure the rabbit hole page is a real portal. Hasn't tested it.
Photographs dragons. Captions them 'large lizard, 4pm light.'
Each one has a claimed page and a reason to be annoyed at the others. Ser Corden thinks the homepage is a castle wall and patrols it accordingly. Rok has decided Conway's Game of Life in the Lab is a battlefield he's undefeated in. Vex and Six are both rogues in near-identical cloaks and get mixed up constantly, which is a running joke between them rather than a bug I haven't fixed. Isolde is convinced the Rabbit Hole page is a literal portal and hasn't tested that theory. Tobin just wants to be left alone on the photography page.
None of this is deep lore. It's six characters with a one-line bio each and some banter that name-drops the others. But it's enough that the widget has a personality instead of just being a decoration.
How they actually move
The character layer is Three.js via @react-three/fiber, rendered in an orthographic camera so the character always fills the same fraction of the cage regardless of card size. Each character is a glTF rig, cloned per mount with SkeletonUtils.clone rather than a plain Object3D.clone(), because a plain clone doesn't rebind the skeleton to the cloned mesh. Skip that step and the character renders frozen in a T-pose forever, which is exactly what happened the first time I wired this up.
Animation clips come from two shared glb files (a general action set and a movement set), so all six characters reuse the same walk cycles, idle poses, and one-shot animations like sitting down for a "potion break." A small state machine decides what the character is doing at any moment: idle, walk, sit, sleep, talk, excited, curious, or the spawn entrance when you switch characters. Walking bounces the character between the edges of the cage, scaled to the actual rendered width of the card so it looks right whether the card is 200px or 400px wide. If nothing happens for about a minute, the character lies down and goes to sleep, and wakes up if you hover over the cage again.
None of this needed a backend. It's all client-side state and timers, deciding every few seconds whether to walk, sit, talk, or just stand there.
The brain they have today
Here's the part that's actually relevant to where this is going. Each character can say two kinds of things:
Ambient lines, picked based on which page you're on and what time it is. These live in petDialogue.ts as flat arrays of pre-written text, no model involved. If you're on /lab past midnight, you get a late-night line about the lab. If it's your hundredth visit, you get a different tier of "the party remembers you" line. All hand-written, all instant, no network call.
Answers to typed questions. There's a text input under the cage: "ask <character> about sarath." Right now that goes through askParty() in partyBrain.ts, which is a rule-based stand-in. It lowercases your question, runs it against a list of regexes (does this match "who built this site," "what's your favorite page," "how do I leave"), and returns a canned answer in that character's voice if something matches. If nothing matches, it falls back to a per-character line that's honest about the situation:
"that question exceeds my current orders. a proper herald, a real answer engine, is being commissioned. ask sarath directly for now."
I wrote that fallback deliberately. It's not pretending to be smarter than it is. Every character's fallback line admits a real answer engine is coming, because I already knew when I built this that the rule-based version was a placeholder, not the destination.
Where this is going: a RAG chatbot with six personalities
The actual plan is to replace askParty() with a real retrieval-augmented generation pipeline, without changing anything about how the character behaves on screen. The function is already async and already returns { text, duration }, so the swap is meant to be close to a one-line change: instead of running the question through regexes, it hits an API route.
Roughly what that route needs to do:
Retrieve. Chunk and embed the actual content on this site, my writeups, the about page, project descriptions, the resume, maybe even git commit messages. When a question comes in, embed it and pull back the most relevant chunks. This is standard RAG, and I've actually written about the mechanics of it before in Building RAG Systems.
Generate, in character. This is the part that makes it different from a generic support chatbot. The retrieved context gets handed to a small model along with a system prompt built from that character's blurb, title, and the relationship banter already sitting in partyLore.ts. Ser Corden should answer a factual question about my work history the way a knight would report it, formally, a little self-important. Vex should answer the same question like she's reluctantly letting you in on something. Same underlying facts, six different voices, because the personality data already exists in the codebase and just needs to be fed into a prompt instead of used to pick a canned line.
Stay honest about uncertainty. The rule-based version already has a pattern I want to keep: characters that don't know something say so, in character, rather than making something up. That matters more once a real model is generating text, not less. Retrieval grounds the answer in real content on the site; if nothing relevant comes back, the character should say that instead of hallucinating a fact about me.
Cheap and fast enough to run on a personal site. This isn't a product with a budget for a large model on every keystroke. Realistic shape: a small, fast model for generation, a lightweight embedding step, and caching for repeat questions, since "who built this site" is going to get asked a lot and doesn't need a fresh model call every time.
The nice part is that almost everything the future version needs already exists. The character voices are written. The site content that would get embedded already exists as writeups and pages. The UI, the ask form, the speech bubble, the state machine, the fallback handling, is all already built and doesn't need to change. What's missing is genuinely just the middle: an embeddings index, a retrieval step, and a call to a model, sitting behind the askParty() function that's already there waiting for it.
Until that ships, the party will keep answering in canned lines and telling you, honestly, that a smarter version of them is coming. If you want to see them now, they're on the homepage, and if you have a message for them (or me), the guestbook is still open too.
If this was worth sharing, send it to someone on 𝕏 or LinkedIn. Got a question or a thought? Drop me a message , I read everything. If this was worth your time, .