Virtual Pet lets a visitor adopt, raise, and travel with a persistent pet inside a Topia world. The visitor picks a species (dragon, penguin, unicorn, dog, or cat), names it, and feeds / plays with / trains / puts it to sleep on cooldowns to earn experience. As the pet levels up it ages from baby → teen → adult, unlocks new colors, grants its owner an emote expression, and awards badges. Behind the scenes each pet is spawned as a per-visitor NPC (via visitor.createNpc) — pets follow their owner around the world rather than being placed as static dropped assets. Ownership and state live on the Visitor data object, so a visitor can keep up to several pets across worlds that share the same interactive key.
- Adoption: pick from five species (
dragon,penguin,unicorn,dog,cat), name the pet from a preset list. - Care loop: four actions — Feed, Sleep, Play, Train — each with its own cooldown, XP reward, and particle effect. Attempting an action while it's still cooling down returns a
403"Pet doesn't want to …" response. - Level & age progression: 30 XP tiers; the pet ages
baby(< L5) →teen(< L10) →adult(≥ L10). Age drives the NPC sprite that gets resolved from the ecosystem inventory. - Multiple pets: a visitor's
petsmap can hold arbitrarily many pets, keyed by${petType}_${createdDate}. Only one is "in world" as an NPC at a time — spawning another auto-despawns the first. - Colors: four color variants per species, unlocked at levels 1 / 2 / 3 / 4. Changing color re-spawns the NPC with the new sprite and awards the Fresh Look badge.
- Emote unlock: on first reaching teen (level 5) the owner is granted a
pet_{petType}expression with awhiteStar_burstparticle. - Streaks: the pet tracks
currentStreak/longestStreakfor consecutive day-over-day interactions; a 7-day streak awards Consistent Care. - Badges: 14+ ecosystem badges awarded on adoption, level milestones, action counts, streaks, and long-term ownership (see Analytics & Badges).
- Owner vs. visitor view: clicking the app's key asset opens the visitor's own pet drawer; clicking a spawned pet NPC surfaces the pet's read-only card, including a non-owner code path that resolves state from the owner's
Userdata object. - Force-refresh inventory: append
&forceRefreshInventory=trueto the iframesrcto bypass the 6-hour ecosystem inventory cache.
None. This app does not look up dropped assets by unique name — the visitor's pet is a per-visitor NPC (visitor.createNpc), not a dropped asset, and the key asset is identified by whichever assetId the iframe was launched from (credentials.assetId / keyAssetId).
For reference, the credentials query still carries uniqueName (for parity with other SDK apps) but no controller reads it.
Note: the previously
virtualPetKeyAssetandpetSystem-{username}unique names were required — those belong to an older dropped-asset spawn flow that has been fully replaced by the NPC-based flow inspawnPetNpc.ts. They are no longer referenced anywhere inserver/orclient/src/.
Pet sprites and badge icons live as ecosystem inventory items (fetched via Ecosystem.fetchInventoryItems). The app matches them by shape:
| Inventory item type | Match rule | Used for |
|---|---|---|
NPC |
metadata.petDescription === "{petType}:{petAge}:{color}" (e.g. dragon:baby:0) |
The NPC that follows the visitor. Granted to the visitor with grantInventoryItem if not already owned, then spawned via visitor.createNpc(userItemId, { showNameplate: false }). |
BADGE |
item.name === badgeName |
Awarded via grantInventoryItem + fireToast. Skipped if the visitor already holds the badge. |
Inventory results are memoized in-process for 6 hours via getCachedInventoryItems (server/utils/inventoryCache.ts); on a stale-cache fetch failure the app falls back to the last good snapshot rather than erroring.
The primary owner-state surface. Keyed by pet id.
{
pets: {
[petId]: { // petId = `${petType}_${createdDate}`
id: string;
username: string; // display name at creation
petType: "dragon" | "penguin" | "unicorn" | "dog" | "cat";
name: string; // chosen from petNames[]
color: 0 | 1 | 2 | 3;
experience: number;
currentLevel: number; // 1..30
experienceNeededForNextLevel: number;
experienceNeededForTheLevelYouCurrentlyAchieved: number;
petAge: "baby" | "teen" | "adult";
isPetInWorld: boolean; // derived from getNpc() on read
feed: { timestamp?: number; actionTakenCount?: number };
sleep: { timestamp?: number; actionTakenCount?: number };
play: { timestamp?: number; actionTakenCount?: number };
train: { timestamp?: number; actionTakenCount?: number };
createdDate?: number; // ms epoch
lastInteractionDate?: string; // ISO
currentStreak?: number;
longestStreak?: number;
petSpawnedDroppedAssetId?: string; // legacy field, read-only fallback
};
};
}Legacy migration: if visitor.dataObject.pet (singular) exists from an older deployment, getVisitorAndPetStatus and handleGetPet transparently rewrite it into pets via convertPetToPets and persist the new shape with setDataObject.
Also stored on the visitor via SDK calls (not the data object):
visitor.inventoryItems— badges & NPC items granted withgrantInventoryItem.- NPC — created by
visitor.createNpc, removed byvisitor.deleteNpc. The active NPC (fromvisitor.getNpc) is matched back to a pet by comparing the item'spetDescriptionto each pet's${petType}:${petAge}:${color}. - Expression —
pet_{petType}granted byvisitor.grantExpressionwhen the pet first reaches level 5.
When a visitor clicks a spawned pet they don't own, handleGetPet loads the owner via User.create({ profileId }) and reads their pet / pets from the User data object. If the owner still has the legacy pet field it is migrated to pets and re-saved with setDataObject.
handleGetPet reads droppedAsset.dataObject.profileId on the clicked asset to determine whether the current visitor is the owner. This field is a legacy hook (nothing in the current controllers writes it); when it is absent, the current visitor is treated as the owner.
The world is used for triggerParticle on actions and receives an updateDataObject({}) call in handlePickupPet purely to attach a trades analytic. No world state is persisted.
CreatePet ──► pets[petId] persisted ──► (Baby Steps / adoption-species badges)
│
▼
GetGameState / GetPet ──► spawnPetNpc ──► visitor.createNpc(petSprite, { showNameplate: false })
│
▼
ExecuteAction (feed/sleep/play/train)
├─ cooldown gate (ACTION_COOLDOWNS)
├─ +XP (ACTION_EXPERIENCE_GAIN) → recompute level & age
├─ triggerParticle (ACTION_PARTICLE_EFFECTS)
├─ streak bookkeeping
├─ checkForLevelUp
│ ├─ level 5 crossed → grantExpression(pet_{petType}) + medal_float
│ ├─ level 10 crossed → medal_float
│ └─ re-spawn NPC at the new age tier
└─ conditional badge grants (see Analytics table)
▼
UpdatePet (rename / recolor) ──► re-spawn NPC on color change; Fresh Look badge
▼
TradePet ──► delete pets[petId], deleteNpc() | PickupPet ──► deleteNpc() only
| Action | Cooldown (prod) | Cooldown (IS_LOCALHOST=true) |
XP | Particle |
|---|---|---|---|---|
| PLAY | 15 min | 500 ms | 5 | guitar_float |
| SLEEP | 45 min | 500 ms | 15 | sleep_float |
| FEED | 60 min | 500 ms | 20 | redHeart_float |
| TRAIN | 30 min | 500 ms | 10 | pawPrint_float |
XP thresholds for levels 1–30 are defined in server/utils/getLevelAndAge.ts (100, 300, 600, 1000, … 49600).
All routes mount under /api. Every request goes through getCredentials (which verifies INTERACTIVE_KEY === interactivePublicKey) and the cleanReturnPayload response middleware.
| Method | Route | Purpose |
|---|---|---|
GET |
/system/health |
App version, server start date, and non-secret env flags. |
GET |
/game-state |
Full drawer bootstrap. Returns pets, selectedPetId, petStatus, isPetOwner, isAdmin, badges (ecosystem catalog), and visitorInventory (granted badges + NPCs). Accepts keyAssetId and forceRefreshInventory. |
GET |
/pet |
Read-only pet lookup keyed by assetId. If the current visitor isn't the owner (per droppedAsset.dataObject.profileId), falls back to reading the owner's User.dataObject. |
POST |
/create-pet |
Body { petType, name, keyAssetId }. Adds a new entry to visitor.dataObject.pets, fires starts / starts-{petType} analytics, appends a row to Google Sheets, awards adoption + count badges. |
POST |
/update-pet |
Body { selectedPetId, selectedName, selectedColor, keyAssetId }. Renames / recolors; on color change re-spawns the NPC and awards Fresh Look. Fires updates analytic. |
POST |
/spawn-pet |
Body { selectedPetId, keyAssetId }. Resolves the matching NPC sprite from ecosystem inventory and calls visitor.createNpc. |
POST |
/execute-action |
Body { action: "FEED" | "SLEEP" | "PLAY" | "TRAIN", selectedPetId, keyAssetId }. Applies cooldown → XP → age recompute → particle → level-up hooks → badge grants. Returns 403 with success: false if the action is on cooldown. Fires interactions analytic. |
POST |
/trade-pet |
Body { selectedPetId, keyAssetId }. Deletes the pet from pets, despawns the NPC if it was the in-world pet. Fires trades analytic. |
POST |
/pickup-pet |
Despawns the visitor's current NPC (visitor.deleteNpc). Fires trades analytic on the world data object. |
There is also a base GET /api/ route that returns { message: "Hello from server!" } (smoke-test only).
The 6-hour ecosystem inventory cache can be bypassed per-request from the drawer:
- Open the app by clicking the key asset in the world.
- Right-click inside the drawer → Inspect.
- Find the
<iframe>and locate itssrcattribute. - Append
&forceRefreshInventory=trueto the URL and press Enter.
Analytics are attached to Visitor / World data-object writes via the SDK's analytics option. All are per-profile (uniqueKey: profileId) except world-scoped events.
| Event | Fired when | Where |
|---|---|---|
starts |
A visitor creates their first (or any) pet. | POST /create-pet (visitor data object). Also appended to Google Sheets. |
starts-{petType} |
A visitor creates a pet, tagged by species (starts-dragon, starts-penguin, starts-unicorn, starts-dog, starts-cat). |
POST /create-pet. |
interactions |
Any successful feed / sleep / play / train action. | POST /execute-action. |
updates |
Pet rename or color change. | POST /update-pet. |
trades |
Pet traded in (deleted) or picked up (NPC despawned). | POST /trade-pet, POST /pickup-pet. |
pet_{petType}-emoteUnlocked |
The pet_{petType} expression is granted for the first time (pet crosses level 5). |
grantExpression (called from checkForLevelUp). |
Every badge grant additionally causes an ecosystem inventory event (grantInventoryItem) plus a "Badge Awarded" toast. Grants are idempotent — awardBadge short-circuits if the visitor already holds the badge.
| Badge | Trigger |
|---|---|
| Baby Steps | Create your first pet. |
| Pet Party | Reach 3 pets. |
| Flamekeeper / Dreamkeeper / Icebreaker | Adopt a dragon / unicorn / penguin. |
| Growing up Fast | Level a pet across into the teen range (updated level < 10, from < 5). |
| All Grown Up | Level a pet into the adult range (updated level ≥ 10). |
| Veteran Caretaker | Reach level 15 on any pet. |
| Loyal Friend | Interact with a pet whose createdDate is ≥ 30 days ago. |
| Consistent Care | 7-day interaction streak (currentStreak === 7). |
| Well Fed / Well Rested / Let's Play / Trainer in the Making | Perform the corresponding action 15 times on one pet. |
| Fresh Look | Change a pet's color. |
If GOOGLESHEETS_SHEET_ID, GOOGLESHEETS_CLIENT_EMAIL, and GOOGLESHEETS_PRIVATE_KEY are set, starts events from /create-pet are also appended as a row [date, time, identityId, displayName, "Virtual Pet", event, urlSlug] to the configured sheet / range (default Sheet1). Any other analytics event is Sheet-silent.
Create a .env at the app root. See .env-example for the minimal template.
| Variable | Description | Required |
|---|---|---|
INTERACTIVE_KEY |
Topia interactive app key. Also verified against interactivePublicKey on every request. |
Yes |
INTERACTIVE_SECRET |
Topia interactive app secret. | Yes |
INSTANCE_DOMAIN |
Topia API domain (api.topia.io for production, api-stage.topia.io for staging). Defaults to api.topia.io. |
No (but strongly recommended) |
INSTANCE_PROTOCOL |
https for prod/staging, http for local dev. Defaults to https. |
No |
S3_BUCKET |
S3 bucket the client fetches pet reference images from (assets/{petType}/normal/…). Defaults to sdk-virtual-pet. |
No |
PORT |
Server port. Defaults to 3000. |
No |
NODE_ENV |
Node environment. When not "development" the server serves the built React client from client/build/. |
No |
IS_LOCALHOST |
If set (any truthy value), collapses all action cooldowns to 500 ms and inflates SLEEP / FEED XP for faster iteration. | No |
GOOGLESHEETS_CLIENT_EMAIL |
Google service-account email for optional analytics logging. | No |
GOOGLESHEETS_PRIVATE_KEY |
Google service-account private key (escape \n as \\n). |
No |
GOOGLESHEETS_SHEET_ID |
Sheet id to log starts events to. If unset, Sheets logging is skipped. |
No |
GOOGLESHEETS_SHEET_RANGE |
Sheet range. Defaults to Sheet1. |
No |
Requires Node 20+.
# from the app root
npm install
# create .env at the app root (see Environment Variables above)
cp .env-example .env
# run server + client concurrently
npm run dev- Server:
http://localhost:3000(Express) - Client (Vite dev):
http://localhost:5173
The dev client proxies /api to the server; in production the server itself statically serves client/build/.
npm install
npm run build # builds client + server workspaces
npm start # runs the compiled server; serves client/build- Auth guard on every request.
getCredentialsenforcesINTERACTIVE_KEY === query.interactivePublicKey; mismatched keys are rejected before any SDK call. - Pets are NPCs, not dropped assets.
spawnPetNpcfinds the ecosystem inventory item whosemetadata.petDescriptionmatches${petType}:${petAge}:${color}, grants it to the visitor if needed, and callsvisitor.createNpc(userItemId, { showNameplate: false }).visitor.deleteNpcruns at the top ofspawnPetNpcso a re-spawn (age tier crossed, color changed) always cleans up the previous NPC. - Owner detection on
/pet.handleGetPetfirst tries the current visitor'spetsmap; if the clickeddroppedAsset.dataObject.profileIdbelongs to someone else it loads theirUser.dataObjectand returns a read-only view (isPetOwner: false). - Legacy shape migration. Both
visitor.dataObject.pet(singular) anduser.dataObject.petare rewritten topetsviaconvertPetToPetson read, then persisted withsetDataObject. - Streak logic quirk.
handleExecuteActionbumpscurrentStreakwhennew Date().getDate() - new Date(lastInteractionDate).getDate() === 1— i.e. it's based on day-of-month arithmetic, which is not month-boundary safe (Feb 28 → Mar 1 is not+1). Worth aware of before you build dashboards off it. - Level-up expression. Crossing level 5 grants
pet_{petType}viavisitor.grantExpression; if already granted, the toast informs the owner they can trade in the pet to earn another emote. Only fires once per pet-type per visitor. - Client S3 assets. The client resolves reference sprites (
baby-color-0.png, etc.) fromhttps://${S3_BUCKET}.s3.amazonaws.com/assets/{petType}/normal/…— no upload path exists on the server; S3 is read-only from this app. - Local SDK dev via yalc. See
CLAUDE.mdforyalc-push/yalc addsteps againstmc-sdk-js. - Dead client route.
App.tsxstill callsbackendAPI.get("/key-asset")when the URL containsspawned, but no/key-assetroute exists on the current server. This path is exercised only when someone opens the app from a URL containingspawned(legacy dropped-asset flow) and will surface as an error handler.
- SDK Developer docs
- View it in action: Dev, Prod
- Notion One Pager
