In 1996, Bandai sold 82 million Tamagotchis. Kids carried egg-shaped plastic keychains everywhere, frantically pressing three buttons to feed, clean, and play with a pixelated blob that would literally die if you ignored it during math class.
It was the first time millions of people felt genuine emotional attachment to a piece of software.
30 years later, I rebuilt that entire experience β in the browser, with TypeScript, zero dependencies, completely open source.
No app store. No download. No install. Just open a tab and adopt your pet.
And in the process, I learned more about state machines, game loops, and emotional design than any computer science course ever taught me.
Why Build a Virtual Pet in 2025?
Three reasons:
1. Nostalgia Is a Distribution Hack
People share things that trigger childhood memories. It's not rational β it's emotional. A browser-based Tamagotchi hits a nerve that no todo app or dashboard ever will.
When I shared an early prototype, the response wasn't "cool tech stack." It was:
"OH MY GOD I used to cry when mine died in second grade"
That emotional reaction is worth more than any Product Hunt launch.
2. Game State Machines Are Criminally Underrated
Every tutorial teaches state machines with traffic lights or toggle buttons. Boring. Useless. Forgettable.
A virtual pet has dozens of interconnected states, real-time decay, evolution paths, conditional transitions, and edge cases that force you to actually think about state architecture. After building this, implementing complex UI flows in production apps felt trivial.
3. Not Everything Needs to Be a SaaS
The indie dev world is obsessed with "revenue-generating side projects." Sometimes you should build something purely because it makes people smile. The best projects are the ones you'd use even if nobody else existed.
Meet Your New Pet
When you open Tamagochi, you get an egg. It hatches. A tiny pixelated creature appears. It has needs. Meet them, and it thrives. Ignore them, and... well, gameOver is a valid state.
Your pet has four core needs that decay in real-time:
interface PetState {
name: string;
hunger: number; // 0 = full, 100 = starving
happiness: number; // 100 = ecstatic, 0 = depressed
energy: number; // 100 = wired, 0 = exhausted
hygiene: number; // 100 = squeaky clean, 0 = filthy
age: number; // in "pet days"
stage: 'egg' | 'baby' | 'child' | 'teen' | 'adult' | 'senior';
alive: boolean;
mood: 'happy' | 'neutral' | 'sad' | 'angry' | 'sleepy' | 'sick';
}
Every few seconds, stats decay. Your pet gets hungrier, sadder, dirtier, more tired. The clock never stops.
You interact through simple actions:
- Feed β Reduces hunger (but too much food makes them sick)
- Play β Increases happiness (but costs energy)
- Clean β Restores hygiene
- Sleep β Restores energy (but hunger still increases while sleeping)
- Medicine β Cures sickness (only available when sick)
Simple inputs. Complex emergent behavior.
The Game Loop: Real-Time Decay
The core of any virtual pet is the tick β a periodic function that decays stats and evaluates state transitions:
const TICK_INTERVAL = 3000; // Every 3 seconds
const DECAY_RATES = {
hunger: 1.5, // Gets hungrier
happiness: -0.8, // Gets sadder
energy: -0.6, // Gets more tired
hygiene: -1.0, // Gets dirtier
};
function gameTick(pet: PetState): PetState {
if (!pet.alive) return pet;
const updated = {
...pet,
hunger: clamp(pet.hunger + DECAY_RATES.hunger, 0, 100),
happiness: clamp(pet.happiness + DECAY_RATES.happiness, 0, 100),
energy: clamp(pet.energy + DECAY_RATES.energy, 0, 100),
hygiene: clamp(pet.hygiene + DECAY_RATES.hygiene, 0, 100),
age: pet.age + 0.01,
};
// Evaluate mood based on current stats
updated.mood = calculateMood(updated);
// Check for stage evolution
updated.stage = evaluateEvolution(updated);
// Check death conditions
updated.alive = checkVitals(updated);
return updated;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
This runs every 3 seconds. In a 10-minute session, your pet goes through ~200 state transitions. Leave the tab open for an hour? That's 1,200 ticks. Your pet's entire life flashes before its eyes.
The State Machine: Evolution & Life Stages
This is where it gets interesting. Your pet doesn't just exist β it evolves through life stages, and how well you care for it determines which path it takes:
[Egg]
β
(hatch: 5 ticks)
β
[Baby]
β
(age > 20)
β
βββββββββ΄ββββββββ
β β
[Happy Child] [Sad Child]
(avg mood > 6) (avg mood β€ 6)
β β
(age > 50) (age > 50)
β β
[Cool Teen] [Rebel Teen]
β β
(age > 100) (age > 100)
β β
[Majestic Adult] [Grumpy Adult]
β β
(age > 200) (age > 200)
β β
[Wise Senior] [Cranky Senior]
βββ Any Stage βββ
β
(hunger β₯ 100) OR (happiness β€ 0 for 30+ ticks)
β
[Dead] π
The branching creates replayability. You can't see all the evolution paths in one playthrough. Neglect creates different (sadder) variants. Perfect care unlocks rare stages.
function evaluateEvolution(pet: PetState): PetStage {
const avgMood = getAverageMoodScore(pet.moodHistory);
if (pet.stage === 'baby' && pet.age > 20) {
return avgMood > 6 ? 'happy_child' : 'sad_child';
}
if (pet.stage === 'happy_child' && pet.age > 50) {
return 'cool_teen';
}
if (pet.stage === 'sad_child' && pet.age > 50) {
return 'rebel_teen';
}
// ... and so on for each branch
return pet.stage;
}
The Tab-Close Problem (Hardest Bug I Shipped)
Here's a question that broke my brain for two days:
What happens when someone closes the tab for 8 hours?
Option A: Pet freezes while tab is closed. Problem: This removes all tension. Just close the tab whenever things get hard.
Option B: Pet dies instantly if you leave for too long. Problem: Cruel. Nobody will come back if death is guaranteed.
Option C: Catch-up calculation. When the tab regains focus, calculate what would have happened during the absence, with a mercy cap.
I went with Option C:
function calculateCatchUp(pet: PetState, lastTickTime: number): PetState {
const now = Date.now();
const elapsed = now - lastTickTime;
const missedTicks = Math.floor(elapsed / TICK_INTERVAL);
// Mercy cap: max 100 ticks of catch-up (5 minutes of real-time damage)
// Even if you were gone for 8 hours, your pet only suffers 5 minutes
const cappedTicks = Math.min(missedTicks, 100);
let current = { ...pet };
for (let i = 0; i < cappedTicks; i++) {
current = gameTick(current);
if (!current.alive) break; // Stop if pet died during catch-up
}
// If pet would have died, give a "revival" chance on first return
if (!current.alive && missedTicks < 500) {
current.alive = true;
current.hunger = 90; // Critical but alive
current.happiness = 5;
current.mood = 'sick';
// Show: "Your pet almost didn't make it! It missed you."
}
return current;
}
// On tab focus
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
const lastTick = localStorage.getItem('lastTickTime');
if (lastTick) {
pet = calculateCatchUp(pet, parseInt(lastTick));
renderPet(pet);
if (pet.mood === 'sick') {
showNotification("Your pet almost didn't make it! Quick, take care of it!");
}
}
}
});
The mercy cap is crucial. Without it, any absence longer than a few hours = guaranteed death = nobody plays more than once. With it, you feel urgency without hopelessness.
The CSS-Only Rendering (Zero Canvas, Zero WebGL)
Here's the constraint I set for myself: no <canvas>, no WebGL, no sprite images. The entire pet is rendered with pure CSS.
Why? Because:
- It keeps the bundle tiny (~15KB total)
- It works everywhere β even browsers that block canvas fingerprinting
- CSS animations are GPU-accelerated for free
- It's a fun challenge
Pixel Art with Box Shadows
The pet is drawn using a single <div> with a massive box-shadow declaration β each shadow is one "pixel":
.pet-sprite {
width: 4px;
height: 4px;
position: absolute;
background: transparent;
box-shadow:
/* Row 1 - head */
8px 0 0 #3d3d3d,
12px 0 0 #3d3d3d,
16px 0 0 #3d3d3d,
/* Row 2 */
4px 4px 0 #3d3d3d,
8px 4px 0 #ffffff,
12px 4px 0 #3d3d3d,
16px 4px 0 #ffffff,
20px 4px 0 #3d3d3d,
/* Row 3 - eyes */
4px 8px 0 #3d3d3d,
8px 8px 0 #000000, /* left eye */
12px 8px 0 #ffffff,
16px 8px 0 #000000, /* right eye */
20px 8px 0 #3d3d3d,
/* ... more rows for body, legs, etc. */
;
image-rendering: pixelated;
}
Different emotional states swap to different box-shadow sets:
function getSpriteForMood(mood: string): string {
switch (mood) {
case 'happy': return sprites.happy; // Eyes as ^_^ arcs
case 'sad': return sprites.sad; // Eyes as ;_; with tear pixel
case 'angry': return sprites.angry; // Furrowed brow pixels
case 'sleepy': return sprites.sleepy; // Closed eye line, Z pixels
case 'sick': return sprites.sick; // Green-tinted, swirl on head
default: return sprites.neutral; // Default dot eyes
}
}
Animations with @keyframes
The idle bounce β that tiny up-and-down hop that makes the pet feel alive β is pure CSS:
@keyframes idle-bounce {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-3px); }
}
@keyframes happy-jump {
0%, 100% { transform: translateY(0px) rotate(0deg); }
25% { transform: translateY(-8px) rotate(-5deg); }
75% { transform: translateY(-8px) rotate(5deg); }
}
@keyframes sad-droop {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(2px); }
}
@keyframes sleep-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.pet[data-mood="happy"] .pet-sprite {
animation: happy-jump 0.6s ease-in-out infinite;
}
.pet[data-mood="sad"] .pet-sprite {
animation: sad-droop 2s ease-in-out infinite;
}
.pet[data-mood="sleepy"] .pet-sprite {
animation: sleep-breathe 3s ease-in-out infinite;
}
The key insight: animation speed communicates emotion. Happy = fast bounces. Sad = slow droops. Sleepy = glacial breathing. Players read these cues subconsciously.
Emotional Design: Making People Care About a Div
The hardest part of this project wasn't the code. It was making players actually care about a collection of CSS pixels.
Here's what I learned about emotional design:
1. The Pet Should Look at You
When idle, the pet's "eyes" (two pixel dots) slowly drift toward the mouse cursor. It's a 5-line JavaScript addition that makes people say "aww, it's looking at me!"
document.addEventListener('mousemove', (e) => {
const petRect = petElement.getBoundingClientRect();
const petCenter = {
x: petRect.left + petRect.width / 2,
y: petRect.top + petRect.height / 2,
};
// Shift eye pixels 1px toward cursor (clamped)
const dx = clamp((e.clientX - petCenter.x) * 0.01, -1, 1);
const dy = clamp((e.clientY - petCenter.y) * 0.01, -1, 1);
eyeLeft.style.transform = `translate(${dx}px, ${dy}px)`;
eyeRight.style.transform = `translate(${dx}px, ${dy}px)`;
});
2. Reactions Should Be Immediate and Exaggerated
When you feed the pet:
- It doesn't just update a number
- It does a little jump
- A tiny heart particle appears
- The eyes squish into happy arcs for 1 second
- Then it returns to normal
This takes 200ms but makes the interaction feel rewarding. Without it, feeding feels like clicking a button. With it, feeding feels like caring for something alive.
3. Death Should Hurt (A Little)
When the pet dies, the screen doesn't just show "Game Over." It:
- Fades the pet sprite to grayscale slowly (2 seconds)
- Shows small "..." above the pet before it disappears
- Plays a single, quiet tone (if sound is enabled)
- Shows the pet's age, how long you kept it alive, and its best mood
- Offers "Adopt again" β not "Restart" (language matters)
The goal isn't to traumatize anyone. It's to make them want to do better next time.
4. Name Your Pet
The first thing you do is name your pet. This is deliberate. Naming creates attachment. "My pet died" hits different than "Sprinkles died."
What I Learned (That Transfers to Real Engineering)
1. State Machines Are the Answer to 80% of UI Bugs
Every time you've seen a UI in an "impossible state" β a loading spinner AND an error message, a form that's both submitted and editable β that's a state machine problem.
After building Tamagochi with explicit states and valid transitions, I started seeing production UI bugs differently. The fix is almost always: "define your states explicitly and make invalid transitions impossible."
// Bad: boolean soup
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
// Bug: what if isLoading AND isError are both true?
// Good: state machine
type FormState = 'idle' | 'loading' | 'success' | 'error';
const [state, setState] = useState<FormState>('idle');
// Impossible to be loading AND error simultaneously
2. Time-Based Systems Are Deceptively Hard
"Decay a number every 3 seconds" sounds trivial. But then you deal with:
- Tab becoming inactive (browsers throttle
setInterval) - Device going to sleep
- Timezone changes
- Daylight saving time jumps
- Users manipulating system clock
Real-time games that persist across sessions need to think about time as unreliable input, not a constant.
3. The 10% of Polish Takes 90% of the Time
The core game loop took one weekend. Making it feel good took three more weeks:
- Tuning decay rates so the game is engaging but not stressful
- Getting animations to feel organic, not mechanical
- Making the death sequence emotional but not manipulative
- Balancing difficulty so casual players survive but invested players are challenged
This ratio (10% function, 90% feel) is the same in every product. The feature works in a day. Making users love it takes a month.
The Full Feature List
| Feature | Status |
|---|---|
| Real-time stat decay | Done |
| 6 life stages with evolution branching | Done |
| 5 emotional states with unique sprites | Done |
| CSS-only pixel art rendering | Done |
| Idle animations per mood | Done |
| Eye-tracking toward cursor | Done |
| Tab-close catch-up calculation | Done |
| Feeding, playing, cleaning, sleeping | Done |
| Overfeeding sickness mechanic | Done |
| Death sequence with stats | Done |
| LocalStorage persistence | Done |
| Dark mode | Done |
| Mobile responsive (touch actions) | Done |
| Sound effects | Planned |
| Multiple pet species | Planned |
| Achievement system | Planned |
| Multiplayer pet park | Planned |
| PWA with push notifications | Planned |
Try It Now
Play Online
Deploy your own with one click, or clone and run locally:
git clone https://github.com/Tech-aficionado/Tamagochi---Open-Source-Game.git
cd Tamagochi---Open-Source-Game
npm install
npm run dev
Open localhost:3000. Name your pet. Try to keep it alive longer than I did my first time (I didn't last 20 minutes β I was testing the death sequence, I swear).
Deploy Your Own
What's Next
- Multiple species β Choose between a cat, dog, dragon, robot, or alien (each with different stat decay rates)
- Achievement system β "Kept a pet alive for 30 days", "Evolved to Majestic Adult", "Fed 1000 times"
- Multiplayer pet park β Visit friends' pets in a shared space
- Custom skins β Design your own pixel pet with an in-app editor
- PWA + push notifications β Install on your phone, get "Your pet is hungry!" alerts
- Breeding β Two maxed-out adults can create a baby with inherited traits
- Seasonal events β Halloween costumes, winter snow, birthday celebrations
The Source
- GitHub: github.com/Tech-aficionado/Tamagochi---Open-Source-Game
- License: MIT β Adopt it, fork it, breed it
- Stack: TypeScript (70.7%) + CSS (27.2%) + HTML (1.9%) + JavaScript (0.2%)
- Dependencies: Zero. Pure TypeScript + CSS. No frameworks. No libraries.
- Bundle size: ~15KB total
Final Thought
We spend all day building tools for productivity. Task managers. Dashboards. Analytics. Optimization engines. Everything is about output.
Sometimes the most impressive engineering goes into things that have no purpose other than making someone smile for 5 minutes.
A virtual pet won't get you a promotion. It won't generate MRR. It won't impress a VC.
But if you can make someone feel a genuine emotional connection to 200 bytes of CSS box-shadows β that's a kind of engineering that matters.
Build something useless. Build it beautifully. You'll learn more than any tutorial will teach you.
Quick poll: What was YOUR first virtual pet? Tamagotchi? Neopets? Nintendogs? A rock you put googly eyes on? Drop it in the comments β I want to see what generation everyone belongs to.
Built by Shivansh Goel β AI & Full Stack Developer who believes the best code makes people feel something.













