Code — how this page is built

Under the hood

The actual source of the components on this page, read straight from the repo. Copy it, read it, or open it on GitHub.


TamagotchiGameview on GitHub ↗

Applies time-based decay to hunger/happiness from the last-seen timestamp (so it ages while the tab is closed); feed/play raise the meters; >3 days untended = dead. Persisted to localStorage.

'use client';

import * as React from 'react';

import { Win98Button } from '@/components/ui/win-98-button';

const KEY = 'kaspirius:tamagotchi:state';
const H_RATE = 100 / (12 * 3600); // empties in ~12h
const J_RATE = 100 / (18 * 3600);
const NEGLECT = 3 * 86400; // dead after 3 days untended

interface State {
  hunger: number;
  happy: number;
  born: number;
  lastSeen: number;
  dead: boolean;
}

function decay(s: State, now: number): State {
  const elapsed = (now - s.lastSeen) / 1000;
  if (!s.dead && elapsed > NEGLECT) return { ...s, dead: true, lastSeen: now };
  return {
    ...s,
    hunger: Math.max(0, s.hunger - elapsed * H_RATE),
    happy: Math.max(0, s.happy - elapsed * J_RATE),
    lastSeen: now,
  };
}

export function TamagotchiGame() {
  const [s, setS] = React.useState<State | null>(null);

  React.useEffect(() => {
    let init: State;
    try {
      init = JSON.parse(localStorage.getItem(KEY) ?? 'null');
    } catch {
      init = null as unknown as State;
    }
    const now = Date.now();
    if (!init) init = { hunger: 80, happy: 80, born: now, lastSeen: now, dead: false };
    const next = decay(init, now);
    localStorage.setItem(KEY, JSON.stringify(next));
    setS(next);
    const id = window.setInterval(() => {
      setS((prev) => {
        if (!prev) return prev;
        const n = decay(prev, Date.now());
        return n;
      });
    }, 1000);
    return () => window.clearInterval(id);
  }, []);

  const persist = (next: State) => {
    localStorage.setItem(KEY, JSON.stringify(next));
    setS(next);
  };

  if (!s) return null;

  const feed = () => persist({ ...s, hunger: Math.min(100, s.hunger + 32), lastSeen: Date.now() });
  const play = () =>
    persist({ ...s, happy: Math.min(100, s.happy + 26), hunger: Math.max(0, s.hunger - 6), lastSeen: Date.now() });
  const revive = () =>
    persist({ hunger: 80, happy: 80, born: Date.now(), lastSeen: Date.now(), dead: false });

  const ageDays = Math.floor((Date.now() - s.born) / 86400000);
  const mood = s.dead ? '💀' : s.hunger < 25 || s.happy < 25 ? '😟' : s.happy > 70 ? '😄' : '🙂';

  return (
    <div className="flex w-full max-w-sm flex-col items-center gap-6">
      <div className={`text-9xl ${!s.dead ? 'animate-[bob_2s_ease-in-out_infinite]' : 'opacity-60'}`}>
        {mood}
      </div>

      {s.dead ? (
        <>
          <div className="text-center font-mono text-sm opacity-70">
            it lived {ageDays} day{ageDays === 1 ? '' : 's'}. you forgot about it.
          </div>
          <Win98Button onClick={revive} className="h-10 min-w-28 px-6 text-sm">
            New egg
          </Win98Button>
        </>
      ) : (
        <>
          <div className="w-full space-y-2">
            <Bar label="fed" value={s.hunger} color="var(--accent-amber)" />
            <Bar label="happy" value={s.happy} color="var(--accent-olive)" />
          </div>
          <div className="flex gap-3">
            <Win98Button onClick={feed} className="h-10 min-w-24 px-5">Feed</Win98Button>
            <Win98Button onClick={play} className="h-10 min-w-24 px-5">Play</Win98Button>
          </div>
          <div className="font-mono text-[11px] uppercase tracking-[0.12em] opacity-45">
            age {ageDays}d · neglect it for 3 days and it dies
          </div>
        </>
      )}
      <style>{`@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-8px)}}`}</style>
    </div>
  );
}

function Bar({ label, value, color }: { label: string; value: number; color: string }) {
  return (
    <div>
      <div className="mb-1 flex justify-between font-mono text-[11px] uppercase tracking-[0.1em] opacity-55">
        <span>{label}</span>
        <span>{Math.round(value)}%</span>
      </div>
      <div className="h-2 w-full overflow-hidden rounded-full bg-foreground/10">
        <div className="h-full rounded-full transition-[width]" style={{ width: `${value}%`, background: color }} />
      </div>
    </div>
  );
}
↖ kaspirius