나는 브라우저를 위해 90 년대 다마고치를 다시 만들었습니다 — 그리고 우연히 어떤 튜토리얼보다 주 기계에 대해 더 많이 배웠습니다.

작성자

카테고리:

← 피드로
DEV Community · Shivansh Goel · 2026-07-23 개발(SW)

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';
}

Enter fullscreen mode Exit fullscreen mode

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));
}

Enter fullscreen mode Exit fullscreen mode

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] 💀

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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!");
      }
    }
  }
});

Enter fullscreen mode Exit fullscreen mode

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:

  1. It keeps the bundle tiny (~15KB total)
  2. It works everywhere — even browsers that block canvas fingerprinting
  3. CSS animations are GPU-accelerated for free
  4. 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;
}

Enter fullscreen mode Exit fullscreen mode

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
  }
}

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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)`;
});

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Deploy with Vercel

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.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다