How I built Familiars — a self-contained AI pet simulator where pets sleep, snack, hide your keys, and miss you — and the agent-engineering lessons hidden inside.
Every once in a while you want to build something that feels alive. Not a chatbot waiting for a prompt — a creature with needs, moods, memories, and a mind of its own. So I built Familiars: a single-page AI pet simulator where you adopt fictional creatures, care for them, chat with them, and — the fun part — come back to discover what they got up to while you were away.
The whole thing is ~3,700 lines of vanilla JavaScript, zero dependencies, zero build step, no backend. You open index.html and pets start living.
This post is a developer walkthrough: the architecture, the key systems, real code snippets, and the transferable lessons for anyone building AI agents.
The big idea
Agents feel "intelligent" when several small, weak systems reinforce each other. Familiars has no single clever algorithm — it has:
- A persistent state model (needs, happiness, relationship, memories)
- A time-driven tick loop (pets change on their own, not just on input)
- A personality parameter model (one system, many characters)
- A memory system with a retention policy
- A reward loop (relationship XP + learned preferences)
- A hybrid chat brain (deterministic rule engine or an optional LLM, with graceful fallback)
- An autonomy engine (pets act while you're away)
None of these is complicated. Composed, they create the illusion of a will.
Architecture: state → logic → UI
The most important decision was separating the "agent" from the "host."
┌────────────────────────────────────────────────────────────┐
│ app.js (UI host) │
│ catalog · adoption · sanctuary · feed · chat · settings │
│ ticker loop · renderers · localStorage IO │
└──────────────┬────────────────────────────┬────────────────┘
│ reads / writes │ renders
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────┐
│ engine.js (the brain) │ │ personas.js │
│ freshState · tick · mood │ │ archetypes + per-pet │
│ doCare · memories · chat │ │ voice, likes, traits │
│ autonomy · preferences │ │ │
└──────────────┬───────────────┘ └────────────┬─────────────┘
│ state │ personality
▼ ▼
┌────────────────────────────────────────────────────────────┐
│ data.js = PALS (22 species) + PET_TYPES │
│ llm.js = optional OpenAI-compatible client + fallback │
│ localStorage = pixelpals.sanctuary.v2 (pet state) │
└────────────────────────────────────────────────────────────┘
engine.js never touches the DOM. It mutates plain JSON state and returns results. app.js reads state, renders it, calls the engine, and persists. This separation is what makes the whole thing testable — I can fast-forward a pet through 240 "game minutes" in Node with no browser at all and assert what it did.
1. The state model — everything hangs off this
Pets are plain objects. Needs are 0–100. Mood is derived, never stored — so it can never go stale. Relationship XP maps to levels (Stranger → Acquaintance → … → Best Friend).
function freshState(pal, opts) {
return {
id: pal.id,
name: opts.petName || pal.name,
adopter: opts.adopterName || "A friend",
adoptedOn: now, lastTick: now,
happiness: pal.baseHappiness ?? 65,
energy: pal.baseEnergy ?? 65,
hunger: 20 + Math.random() * 20, // 0 full .. 100 starving
boredom: 15 + Math.random() * 15,
loneliness:10 + Math.random() * 15,
xp: 0, love: 40, chatCount: 0, careCount: 0,
memories: [{ type: "first", text: "...adopted them home.", when: now, importance: 100 }],
activityFeed: [], preferences: {},
lastAutonomy: now, lastNoteAt: 0,
};
}
The lessons here:
-
Clamp everything.
clamp(v, 0, 100)is the most-used helper in the codebase. Unbounded stat drift is the #1 bug class in simulation. - Compute derived values, don't store them. Mood is a function of needs + happiness. Store facts, compute impressions.
-
Version and migrate your schema. We bumped storage from
v1 → v2and wrote a migration path. Real agents hit state-schema drift too.
2. The tick — agents act over time, not just on input
The heartbeat is a setInterval in app.js, but the abstraction that matters is tick(minutes):
function tick(state, minutes, events) {
state.hunger = clamp(state.hunger + TUNE.hungerPerMin * minutes, 0, 100);
state.energy = clamp(state.energy - TUNE.energyPerMin * minutes, 0, 100);
state.boredom = clamp(state.boredom + TUNE.boredPerMin * minutes, 0, 100);
state.loneliness = clamp(state.loneliness + TUNE.lonelyPerMin * minutes, 0, 100);
state.happiness = clamp(state.happiness - TUNE.happyDrift * minutes, 0, 100);
// threshold events: "I'm hungry!", "I'm exhausted!" → push into `events`
}
Because everything is expressed in minutes elapsed since the last tick, I can:
- Fast-forward 240 minutes in a test and assert
hunger === 100. - Catch up on time the user was away — on load, compute
(now - lastTick) / 60000and run one big tick.
This is the same "what changed since I last looked?" pattern that powers background workers, schedulers, and session-based agents. Autonomy also runs on a slower, per-pet clock (lastAutonomy), so it feels like life unfolds gradually rather than flooding the feed.
3. Personality = parameters, not hardcoded lines
Every pet gets an archetype with a trait weight profile. Autonomy rolls against these weights. Two pets share one behavior system but produce wildly different lives.
const TRAIT_PROFILES = {
exuberant: { curious:.5, playful:.9, excited:.9, mischievous:.4, restless:.6, affectionate:.4, anxious:.1, annoyed:.2 },
curious: { curious:.95, playful:.5, excited:.5, mischievous:.3, restless:.4, affectionate:.3, anxious:.3, annoyed:.2, discoverer:.9 },
gruff: { curious:.3, playful:.3, excited:.2, mischievous:.5, restless:.2, affectionate:.3, anxious:.2, annoyed:.8 },
ethereal: { curious:.6, playful:.2, excited:.3, mischievous:.2, restless:.1, affectionate:.5, anxious:.4, annoyed:.1, dreamy:.9, discoverer:.7 },
// ...
};
I tested this directly: a gruff/mischievous pal mostly caused mischief and got annoyed; an ethereal/anxious pal mostly felt lonely, checked on me, and got dreamy. Same code, different creatures.
The lesson: character diversity comes from parameters, not from special-casing. This is exactly how you model agent personas, tone, and autonomy thresholds at scale.
4. Memory with a retention policy
"Memory" for an agent is not magic — it's a data structure with a retention policy. Familiars memories are a capped, importance-ranked list:
function addMemory(state, type, text, importance) {
state.memories.push({ type, text, when: Date.now(), importance: importance || 50 });
state.memories.sort((a, b) => b.importance - a.importance || b.when - a.when);
if (state.memories.length > TUNE.memoryCap) state.memories.length = TUNE.memoryCap;
}
Significant events (first adoption, "I love you", a discovery) carry high importance; mundane ones fade. Retrieval filters by relevance to the current context — the same recency + importance + relevance pattern behind RAG and long-term memory systems.
5. The reward loop: XP, levels, and learned preferences
Every care action and chat message feeds relationship XP → levels → tier icons, and also learns preferences that change the pet over time:
engine.learnPreference(pet, "interaction", action, action === "play" ? 2 : 1);
// → "loves playtime", "snack enthusiast", "a known troublemaker" 💚/💔
This is a closed feedback loop: state → behavior → reward → state. It's what makes an agent feel like it changes rather than just responds.
6. Hybrid chat brain with graceful fallback
The chat has two brains behind one interface. A deterministic rule engine (fully offline, ~15 intents, testable) and an optional OpenAI-compatible LLM (OpenAI, Ollama, LM Studio, Groq, OpenRouter…).
// app.js — sendChat()
const cfg = llm.loadConfig();
if (llm.isUsable(cfg)) {
doLLM(); // call the endpoint
} else {
setTimeout(doLocal, 500 + Math.random() * 400); // deterministic rule engine
}
The LLM path works by serializing the pet's entire state into the system prompt — personality, needs, relationship, memories, plus the last ~10 messages. That's how the model "stays in character" and "remembers": we feed it the truth.
// llm.js — isUsable()
function isUsable(cfg) {
if (!c.enabled) return false;
if (!cfg.baseUrl || !cfg.model) return false;
const isLocal = /localhost|127\.0\.0\.1|0\.0\.0\.0|\.local/.test(cfg.baseUrl);
if (isLocal) return true; // no API key needed
if (cfg.apiKey && cfg.apiKey.trim()) return true;
return false;
}
And crucially, on any error (CORS, bad key, endpoint down) it falls back to the local engine with a toast — never a crash. I verified this end-to-end against a mock endpoint: a CORS-blocked call logged a warning, showed a toast, and switched to local.
The lesson: context injection is the core of conversational agents. The quality of an LLM agent is mostly determined by what you put in context and how you frame it — not the model.
7. Autonomy — emergent "aliveness" from small weighted rolls
Here's where pets go from interactive toys to independent creatures. Each autonomous behavior is a simple weighted roll that pushes to a feed, sometimes forms a memory, and nudges needs:
// A mischievous pet occasionally causes trouble
if (traits.mischievous > 0.4 && roll(traits.mischievous, timeMult * 0.6)) {
pushActivity(state, "mischief", "🦹",
`${name} hid your ${pick(["keys","sock","pen","headphones","favorite mug"])}. It's a secret now.`);
addMemory(state, "mischief", `${state.name} got up to a little mischief while ${state.adopter} was away.`, 50);
}
// An anxious pet checks on you
if (traits.anxious > 0.3 && roll(traits.anxious, timeMult * 0.7)) {
pushActivity(state, "anxious", "😟",
`${name} called out to check if you were okay.`);
}
Pets leave personality-flavored notes, discover things, get excited, and nap. Every action lands in a persistent activity feed ("What your pals got up to while you were away"), timestamped with their avatar — so you return to find the pet that hid your keys, and the one that sat by the window missing you.
The lesson: "intelligence" and "personality" in agents usually emerge from composition, not from one algorithm. Design each mechanism to be tiny, and let them interact.
Testing the brain independent of the UI
Because the core is pure functions, testing is clean:
// Fast-forward a pet and assert it acted autonomously
const st = engine.freshState(pal, { petName: "Nova", adopterName: "Sam" });
st.hunger = 90; st.boredom = 80;
engine.autonomyPass(st, pal, persona, 240);
assert(st.activityFeed.length > 0);
And full-browser E2E via Playwright headless Chromium drives the whole flow — adoption → chat (local and LLM against a mock endpoint) → autonomy → feed rendering — asserting zero console/page errors and no horizontal overflow. This is the difference between hobby code and code you can trust: if you can't fast-forward time and assert "hunger went up, a memory was created, the feed updated," you'll be debugging blind.
Run it yourself
git clone https://github.com/harishkotra/familiars.git
cd one-line-agent-ideas
python3 -m http.server 8080 # or just open index.html
# open http://localhost:8080
- No install. No build. No backend. All data lives in the visitor's
localStorage. - Two flat, gradient-free themes: the warm Sunny Side Up default and the retro 🧊 Brain Freeze pixel mode.
- To enable the LLM, click AI in the header and point it at any OpenAI-compatible endpoint.
What to build next
Familiars is intentionally small so you can read all of it in an afternoon. Great next steps: multi-pet interactions (pets playing with each other), schedules and rituals tied to wall-clock time, a JSON export so pets survive across devices, daily streaks, WebAudio sound design, and a PWA manifest for full offline installability.
The throughline for any builder: build your agent's brain as pure functions over a state object, drive it with a time model, parameterize personality, give it memory with a retention policy, and let autonomy emerge from small weighted rolls. The rest — the LLM, the UI, the fancy stuff — is just a host for that brain.
Code & more: https://www.dailybuild.xyz/project/230-familiars
Top comments (0)