Pets
Learn how OpenPets pet packages describe sprites, reactions, speech, motion, install paths, ZIP safety, and local authoring.
Pets
A “pet” is an animated character that lives in a transparent desktop window and reacts to agent activity. This doc covers the whole pet lifecycle: what a pet is made of, how it gets onto disk, how reactions become animations, and how the windows behave. For the catalog that ships pets see Catalogs; for the command path that triggers reactions see IPC and remote control.
Source maps: apps/desktop/src/codemap.md (pet windows, controllers,
installation), packages/install-pet/codemap.md (standalone installer).
What a pet is
A pet package is small and asset-driven:
pet.json- metadata:id,displayName,description,spritesheetPath, and optionalcategory/subcategory/sourceUrl/xHandle/spriteVersionNumber/gazeAnchor.gazeAnchor, when present, is an{ x, y }pair of finite normalized values in[0, 1], measured from the top-left of one sprite frame before horizontal flipping. (Catalog entries carry the same identity plus hosting URLs - see Catalogs.)spritesheet.webporspritesheet.svg- one grid of animation frames, selected bypet.json.spritesheetPath. A package must contain exactly one matching atlas, never both formats. Frames are192x208; thumbnails are derived from the spritesheet. SVG changes the image format, not the animation engine: the same CSS background-position and preview frame selection apply.
Static SVG atlas profile
SVG atlases must be self-contained static vectors with explicit intrinsic
width and height (unitless pixels or px) and an exact viewBox="0 0 W H":
V1 is 1536×1872 (8×9), V2 is 1536×2288 (8×11). The desktop and standalone
installer share the dependency-free install-pet/spritesheet validator.
Its getPetSpritesheetSpec(petJson) resolves the canonical asset and expected
intrinsic dimensions. The separate web repository vendors generated JavaScript
from this canonical module so publishing works without a parent checkout; its
explicit sync/check command detects contract drift.
The accepted profile includes groups, paths, rectangles, circles, ellipses,
lines, polygons/polylines, gradients/stops, clip paths, and title/description
metadata. Use presentation attributes rather than CSS. Local url(#id) paint
and clip references may target reference-free gradients or clip paths only.
Scripts, event attributes, external references, entities/DOCTYPE, foreignObject,
embedded raster images, animation, CSS/style attributes, text artwork, filters,
masks, and reusable <use> subtrees are rejected. Limits are 8 MiB UTF-8,
20,000 elements, 64 levels of nesting, and 256 KiB per attribute. These bounds
apply before decoding or installing the atlas; WebP retains its existing limits.
This is not arbitrary SVG import support. Generator exports, including
Recraft output, may contain style attributes or CSS that this profile rejects.
Normalize those exports to the supported vector elements and presentation
attributes, then validate the result before packaging or publishing. OpenPets
does not automatically rewrite or sanitize unsupported SVG into this profile.
There are three sources a pet can come from at runtime:
- Built-in pet (
built-in-pet.ts) - the bundled V2 Hoodie Cat spritesheet that always works as a fallback, even offline with nothing installed. It uses the exact 8×11 atlas, animated row 0 idle loop, and V2 cursor-glance cells. - Catalog pets - downloaded from the public catalog and extracted into
userData/pets/{id}/. - Codex pets - locally-developed pets imported from
~/.codex/pets/(codex-pets.ts), the dev workflow for authoring a new pet before publishing it.
Wardrobe and outfits
A pet may optionally ship a wardrobe: extra outfits (full alternate
atlases under outfits/) and an ordered schedule that picks one by time of
day, weekday, or yearly date. Users can always wear one outfit or edit the
schedule from Control Center and the pet context menu. Pets without a wardrobe
are unchanged. The contract, resolution order, packaging, two-ZIP catalog
distribution, and compatibility rules live in Wardrobe.
Idle Activities
Control Center calls these animations Idle Activities. On a pet’s detail page, they can be played once or enabled to play automatically while idle. Each activity uses artwork matching the pet’s rendered outfit; without a matching representation, it is skipped rather than switching the pet’s clothes. Automatic play preferences and cooldowns belong to the activity and are shared across outfits.
A full pet package may optionally declare pet.json.activities: at most 16
logical one-shot animations separate from its reaction atlas. Each entry
has a unique lowercase id matching ^[a-z0-9][a-z0-9_-]{0,31}$, a trimmed
displayName of 1–40 characters, a primary outfitId of default or a declared
wardrobe outfit, and the exact spritesheetPath: "activities/{id}.webp".
Optional outfitVariants provide additional representations, each with a distinct
declared outfit (or default) and exact path activities/{id}.{outfitId}.webp.
The primary is not a fallback; an activity may exist only for a specific outfit.
Each logical activity has at most 17 representations. Empty variants are omitted.
For every representation, frameCount is an integer from 4
through 32 and fps is an integer from 1 through 12. Their derived duration
(frameCount × 1000 / fps) must be between 1000 and 12000 milliseconds,
inclusive. Omitted activities are equivalent to an empty list.
Each activity is a static, fully decodable WebP horizontal strip of exactly
192 × frameCount pixels wide and 208 pixels high, not an animated WebP. Each
file is limited to 2 MiB and all activity representations together to 16 MiB. The
activities/ directory must contain exactly the declared files: missing,
extra, nested, mismatched, symlinked, or unsafe paths reject installation.
@open-pets/pet-format/activities owns the pure metadata and duration contract;
@open-pets/pet-format/node/atlas-validation owns bounded image validation,
using the desktop’s pet-atlas-validation.ts Sharp decoder adapter.
Activities travel inside a complete pet folder or ZIP alongside pet.json and
the base atlas, never as a standalone activity install root. ZIPs may use root
files or one wrapper folder. Folder/Codex imports copy every declared activity
into the private candidate. A successful re-import replaces the entire pet
snapshot under its stable pet id, so removed declarations and assets disappear;
validation or promotion failure preserves the previous install and state through
the existing install transaction. Packages without activities retain their
existing installation behavior. Runtime reads of optional activity assets stay
separate from base-atlas validation, so a damaged optional strip need not make
the base pet unusable.
Teams-owned pets
Teams adds a fourth explicit source kind, team. Persisted source ownership
selects the dedicated Team pet root versus the personal catalog/Codex roots; the
app never infers ownership from a directory. Team pets appear separately in
snapshots while remaining selectable. Team Packs remain pending and not current
until the current artifact’s explicit first-install approval in the Teams route
succeeds; the approval displays requested permissions and network hosts and
cannot be bypassed by organization configuration. Immutable
organization/item/artifact/release references ensure reconciliation removes only
matching Team records. Rejected staged or activated Team installs roll back to
the last approved Team state, while personal catalog and Codex pets remain
isolated and are never overwritten or removed on ID collision. Removing the Team
default falls back to the built-in pet.
Default, user-pinned, agent, and LAN pets
Four distinct window roles, each with its own lifecycle:
- Default pet (
default-pet-controller.ts) - the always-on companion shown when enabled. Persistent. Remembers its position per connected monitor and clamps it back into the visible work area after display changes. Shows transient reactions and status badges, and hosts the expandable in-pet attached Pet Assistant chat panel (default-pet-chat.ts). Not lease-bound.
The default carrier’s compact composer and expanded chat are main-process-owned states. In expanded chat, the panel anchors 10px directly above the pet sprite and grows upward as content changes while the pet remains stationary at the bottom. Unpinned floating bubbles are suppressed during full chat, while assistant activity and reaction animations continue on the pet sprite without duplicating speech text. On Linux, opening the compact composer makes the carrier focusable and adds its composer rectangle to the input shape; closing it restores the passive pet-only focus and shape. Its shared maximum geometry contract bounds multiline input and error feedback so the Linux mask covers every compact control. The expanded panel uses the same transition seam, with dynamic height tracking for precise Linux hit-mask shapes.
- Agent pets (
agent-pet-controller.ts) - shown on explicit agent request, routed by a lease. The first lease opens the window; the last lease released closes it. This lets several agents each get their own pet without colliding with the default pet. Agent pets roam with the same physics as the default pet (gravity + bounce, driven bypet-roaming-controller.ts). Session lifetime is tracked via PID liveness: when a client process terminates, the lease is released within ~5 s and the pet window closes. See the lease model in IPC and remote control. - User-pinned pets (
pinned-pet-controller.ts) - installed pets the user pins with Add to desktop on their installed card or detail view in Control Center → Pets (also managed in Settings → General). They are user-owned windows, independent of agent sessions, plugins, and leases, and return when the app launches again. Each pin remembers its own position and is clamped back onto a connected display. Use Remove from desktop, or right-click the pinned pet and choose Close pet, to remove only that pin. Quitting the app preserves pins for the next launch. Uninstalling a pinned pet closes its window but retains the pin, so reinstalling it brings the window back. Pins are limited to eight extra, different pets and cannot target a broken, built-in, or current default pet. The default companion is separate: adding a pin does not select a new default, and the same pet ID cannot be pinned twice. Pinned pets can play their installed native Idle Activities under the usual idle, outfit, and opt-in rules. The implicit pet-pool assignment skips pinned pets to avoid opening a second window for the same pet. See Pinned pets in the desktop app for settings and pool behavior.
All four roles are created by the pet-window.ts lifecycle facade as transparent, frameless,
always-on-top windows. pet-window-interaction.ts owns the per-window mouse
passthrough, drag, renderer lifecycle, recovery/watchdog, and IPC bridge driven
through pet-preload.cjs.
Experimental multi-pet LAN mode adds a fourth, isolated controller for visiting
pets. Windows are keyed by LAN owner host rather than pet ID, so they do not
collide with lease-managed agent pets or with another visitor using the same
pet package. Unavailable remote assets are logged and skipped safely.
Fresh coarse work activity can temporarily render a built-in return-to-work
line with the visitor’s running animation; after the dash delay, coordinator
state returns that visitor to its owner.
While a pet is click-through it only learns that the cursor is over it through
forwarded mouse events (setIgnoreMouseEvents(true, { forward: true })), which
Electron delivers on macOS and Windows but not on Linux (Linux pet windows are
kept interactive instead). Hovering the rectangular sprite box is not enough to
grab the pet: the preload’s hit test samples the sprite sheet’s alpha channel at
the cursor (pet-alpha-hit-test.ts, fed bytes by the openpets:pet-sprite-bytes
IPC in pet-sprite-bytes.ts), so fully transparent corners, the hitbox padding
ring, and faint drop shadows let clicks reach windows behind the pet, while
sufficiently opaque artwork stays clickable and draggable. The test accounts for
the animated background-position frame, display scale, and horizontal flip, and
fails open (interactive) whenever pixels are unavailable so a broken hit test
can never make the pet ungrabbable. Both compositors can silently stop forwarding - macOS
across Space switches, display sleep, and fullscreen transitions; Windows after
rapid pet reloads and fullscreen sweeps - which would leave the pet stuck
click-through and impossible to grab. A cursor-probe watchdog in
pet-window-interaction.ts re-arms forwarding from the main process
(screen.getCursorScreenPoint()), which keeps working even when forwarding is
dead. The platform predicates live in mouse-forwarding.ts.
Right-clicking any pet offers a Size submenu with the same global scale
choices as Settings. The current size is checked; selecting another size saves
the global pet and HUD scale preferences and refreshes the default and agent pet
windows. Sizes are XS 0.35, Small 0.5, Medium 0.75 (the default), Large 1,
and Huge 1.25; a saved size from the retired 1.5 option keeps Huge. The HUD
stays at its smallest readable size for XS and Small, then grows more quickly
than the pet at each larger choice. The same
menu also offers Flip horizontally, a checked menu item that mirrors that
pet’s sprite left/right. Speech bubbles, status badges, controls, and the hit
area stay unmirrored and readable. The orientation is stored per underlying pet
ID in app state (preferences.petHorizontalFlip) and survives restart; toggling
one pet updates every live window of that pet (default, agent, plugin-spawned,
and LAN visitor) and leaves other pets unchanged. There is no vertical or
upside-down flip.
Reactions → animations → speech
A reaction is a categorical pet state (thinking, editing, testing, waiting for permission, success, error, idle, …). The rendering pipeline turns a reaction into something visible:
@open-pets/pet-format/animationresolves a reaction to a sprite animation state (resolveReactionSpriteState). This mapping is user-configurable - users can override which animation a reaction plays, and overrides persist in app state. The selectable animation states include idle, review, running, waiting, waving, jumping, and failed;wavingcovers attention/notification style reactions.reaction-messages.tspicks a speech message from the pool for that reaction;i18n/reactions/provides the localized pools so speech matches the active locale (see Internationalization).pet-window.tsrenders the chosen animation via CSS sprite animation, and shows speech bubbles, alert indicators, pinned HUDs, and status badges as requested. The sprite is sized by the pet scale preference; the pinned plugin HUD is sized by the separate HUD scale preference (hudScale, in Settings → General next to pet scale), so HUD readability is independent of pet size. The transient display (bubble) expires after a few seconds while a busy status badge (thinking/working/editing/running/testing/waiting) survives much longer; when the display reaction is gone, a badge that resolves to a looping animation (resolveEffectiveSpriteState) keeps the pet visibly animated, while a badge that resolves to a finite one-shot (waving/success/error/celebrating) falls back to idle so terminal reactions stay bounded.
The waiting animation cycle is a global preference in Control Center → Settings
→ Reactions. Normal keeps the default 1010 ms cycle and Relaxed uses
2200 ms. The renderer derives a fresh sprite-state table for the selected
duration instead of mutating defaultPetSprite.states, so all other reaction
mappings and animation durations stay unchanged. The setting applies to both
built-in and installed pets; plugin-provided sprite overrides continue to use
their own FPS and loop settings. Changing the preference refreshes open default
and agent pet windows, and the duration is part of their render identity so
already-open windows reload their CSS immediately. The Settings preview uses the
same configured duration.
This separation - mapping vs message vs render - is deliberate: agents and plugins speak in reactions, and the host owns how those look and sound.
Motion
The motion engine (pet-motion-engine.ts) drives all pet windows through a
single shared ticker (≈60 fps). Each registered pet gets its own MotionState
entry in a Map<petHandleId, MotionState>, but all pets share one setInterval
so positions advance in lock-step with one getAllDisplaysCached() read per
tick.
pet-roaming-controller.ts is the host-side orchestrator: it registers every
live pet (default and agent) with the engine and applies the active roaming
configuration (gravity + bounce). When a pet is despawned the controller
unregisters it before the window is destroyed, so the shared ticker never
touches a closed window.
Releasing a drag with motion throws the pet like a ball. The pure physics lives
in pet-throw-core.ts (release velocity from the last ~100 ms of pointer
samples, friction, edge reflection, and gravity arcs when petGravityEnabled is
on); pet-throw.ts owns the active-throw timer and bounds. A throw bounces off the
work area of the display the pet was released on, so reaching another monitor
still takes a real drag, and the motion engine yields to a thrown pet the same
way it yields to a dragged one. All active throws share one timer, and the display
work area is re-read every tick so a display change re-clamps a throw in flight.
Throwing is not available on backends that use the native CSS drag region
(Wayland/layer-shell): the app never sees pointer samples there, so a release is
always a plain drop. The petBounciness and petFloorFriction
preferences (Settings, next to gravity) set how much a pet rebounds off the floor
and how much sideways speed each landing sheds; bounciness also feeds the engine’s
gravity bounce.
Plugin-driven movement (plugin-sdk-routes.ts → plugin-pet-registry.ts) feeds
target vectors and physics overrides through the engine’s public API
(motionMoveTo, motionSetPhysics, motionSetFollowCursor). The engine is the
sole continuous position writer; all per-pet step loops were eliminated to
prevent jitter from competing writers. Sub-pixel fractional accumulators
(fracX / fracY in MotionState) ensure smooth movement at any tick rate.
Gravity and per-tick containment use the live native pet-window bounds, not only
the nominal canvas dimensions, so platform-specific window chrome cannot place
the window below a display work area.
See Plugin platform and Plugin SDK v3 for the plugin side.
Display containment and cross-display roaming
display.ts owns all screen-geometry decisions. Per-tick clamping in
clampPosition() follows a strict priority order:
- Confinement - if a pet has a terminal-bounds assignment (see below), it is always snapped into those bounds regardless of any other flag.
- Cross-display roaming (default off) - if the
petCrossDisplayEnabledpreference is on,clampToNearestDisplayIfOffscreenis used: the pet is left alone while its bottom-center anchor overlaps any display’s work area, and is only snapped to the nearest display edge when fully off-screen. This lets pets cross seams between adjacent displays freely. - Legacy single-display mode - if
petCrossDisplayEnabledis off, the originalclampToVisibleWorkAreabehavior is used (pet is clamped to the display nearest its geometric center).
Wide gaps between non-adjacent displays: a pet moving toward an empty region will stick at the edge of its current display and cannot teleport across a gap wider than the pet. This is expected behavior and is by design.
Topology changes (monitor plugged/unplugged, resolution changed) are
coordinated by pet-display-coordinator.ts. It invalidates the display cache
immediately, debounces each native display-event reason independently, and fans
out reclamping to the default pet, agent pets, LAN visitors, and plugin pets.
Pets on a removed display are snapped to the nearest remaining display; pets on
surviving displays are left untouched. The coordinator also owns immediate and
delayed recovery of default-pet mouse interop after power resume.
The petCrossDisplayEnabled toggle lives in Control Center → Settings, under
the Movement section, and is a global flag (not per-pet). It is shown
disabled with explanatory helper text until a movement plugin - one granted the
pet:move permission, such as Walkabout - is enabled, since cross-display
roaming has no effect without a mover driving motion. Confinement remains
strictly per-pet and always takes priority regardless of the cross-display flag.
Linux & Wayland
All pet motion depends on the app being able to programmatically position a
top-level window and keep it always-on-top (setPosition/setBounds plus
setAlwaysOnTop). Native Wayland deliberately forbids clients from positioning
or restacking their own toplevels, so under a native Wayland backend every
position write is silently ignored by the compositor: gravity, walkabout,
follow-cursor, cross-display roaming, drag, and z-order all become no-ops even
though the motion engine keeps computing new coordinates. (This is the root
cause behind “pet doesn’t move / gravity doesn’t work” reports on KDE/KWin
Wayland.)
To keep motion working, OpenPets forces the Linux Ozone backend to x11
(XWayland), where these window operations are honored. Drag selects its path
at window creation via isEffectiveWaylandBackend() in pet-window.ts (which
delegates the pure decision to computeEffectiveWaylandBackend() in
wayland-backend.ts): under the forced x11 backend it returns false and the
working setBounds drag path is used. The backend-forcing itself lives in main.ts and is documented in
Desktop app, including the
OPENPETS_ALLOW_WAYLAND=1 opt-out (which restores native Wayland and therefore
disables the motion/drag/always-on-top behavior above, with a one-time startup
warning).
Installation
Two install paths exist; they share the same safety rules. The running app also accepts local ZIP/folder imports and website install links.
Through the running app (preferred)
pet-installation.ts:
getCatalogPet()resolves the pet from the catalog (catalog.ts).downloadPetZip()streams the ZIP fromzip.openpets.dev, validating magic bytes.- The shared
@open-pets/pet-format/node/archiveextractor usesyauzlunder strict entry validation (node/paths): no path traversal, no symlinks, case-collision detection, size/file-count caps. - Extraction is atomic (temp dir → rename) into
userData/pets/{id}/, andinstallPetState()records it in app state.
The stable journal/schema, naming, and recovery-classification protocol lives in
pet-install-transaction-protocol.ts; pet-install-transaction.ts remains the
filesystem and side-effect orchestrator. The final promotion has a private per-pet journal under
userData/pets/.openpets-pet-transactions/. Journal records are written to a
private temporary marker and renamed into place. On process interruption,
startup recovery verifies the actual canonical final/candidate/backup
topology before either restoring the pre-install assets or cleaning a proven
committed install; it does not trust a stale phase by itself. A state-mutating
record whose relationship to app state cannot be proven is retained, warned
about, and fences operations for that same pet while other pet IDs continue to
work. Explicitly uncertain state callbacks likewise preserve the assets for
manual/retry recovery. Transaction directory names carry a trusted pet ID, so
malformed, contradictory, or duplicate same-pet transaction artifacts are
preserved and fence only that pet; another ID can still recover. Pre-metadata
staging candidates have a private ownership marker established before their
directory is created. Recovery removes only candidates validated by that
marker, leaving legacy or unmarked dot directories untouched. The marker is
handed off to the per-ID journal before it is deleted, so an interruption
cannot leave a staged candidate without an owner. A trusted empty transaction
directory is treated as completed cleanup, while directories with unknown
entries are preserved. This journal protocol does not fsync files or directory
metadata, so it does not claim power-loss durability; it only provides
process-interruption recovery and protection when cross-resource state proof is
missing.
The state mutation callback must return synchronously. A returned thenable is
recorded as an explicit uncertain outcome, and recovery never assumes it can
undo asynchronous state side effects. ZIP imports use a separate private
pre-metadata staging name, so the valid pet ID local remains independently
fenced when it is the actual imported pet.
Local pet packages can also be installed through the running app via the CLI:
openpets install --from-zip <path-to-zip>openpets install --from-folder <path-to-folder>
These send a pets.install-local request to the running app over IPC, which validates and imports the local zip file or folder.
The CLI resolves relative paths before sending them; the IPC/client protocol
itself requires an absolute path plus an explicit zip or folder kind.
Studio generates desktop-safe ASCII install ids (^[a-z0-9][a-z0-9_-]{0,63}$,
excluding builtin) independently of Unicode display names. ZIP imports retain
valid ids and regenerate invalid ones. Renaming a Studio pet changes its display
name, not its install id. Older Studio records with invalid ids are repaired by
the offline web/studio/scripts/migrate_pet_ids.py migration; owners then download
a fresh ZIP. Desktop validation remains unchanged.
From a website link
Pet Studio’s planned “Add to OpenPets” button opens (the site shows Download only until this app version ships):
openpets://install?url=<URL-encoded ZIP URL>&name=<URL-encoded display name>
The ZIP is the same two-file pack (pet.json + spritesheet) that a manual
Control Center → Pets → Import pet → ZIP accepts. Studio links point at an
owner’s signed download or, for pets shared in the Studio Community, at the
public /api/community/{id}/download; both serve that same pack, so the app
treats them alike.
pet-install-link-protocol.ts(pure) accepts onlyopenpets://installwith aurlthat ishttps:, orhttp:on a loopback host (localhost,127.0.0.1,[::1]) for local Studio development. URLs with credentials, other schemes, or a missingurlare rejected with an error dialog.nameis optional, collapsed to one line, trimmed, and capped at 60 characters; it is display-only — the installed pet’s id and name come from the validatedpet.json.pet-install-link-handler.tsalways shows a native Install / Cancel dialog naming the pet and the download host before any network request. Cancel does nothing. One link is handled at a time; repeats while a dialog or download is open are ignored.- On Install,
installPetFromZipUrlWithResult()downloads with the shared 30-second timeout, no redirects, and a 25 MB cap, then runs the exact same ZIP buffer → extract → validate →runPetInstallTransaction()path as a local ZIP import. The download stays in memory; the staging candidate is removed on every failure path. - Success refreshes the default pet and pinned pets and opens the Control Center on Pets; failure shows an error dialog with the reason.
- Logs (
pet.installscope) record the download host and outcome only, never the full (signed) URL.
Links arrive through macOS open-url, the second-instance argv on
Windows/Linux, or the cold-start argv; a link received before startup finishes
is held and handled once the app is ready.
The Pets page links back to Studio: a full-width Make your own pet banner
(the Studio hero art, bundled as renderer/public/studio/studio-hero.webp)
leads the grid on the All and Installed filters, and replaces an empty search
result (retitled “Can’t find it? Make it”). It opens openpets.dev/studio through the
openpets:open-studio handler with utm_source=openpets-desktop so Studio
sign-ups from the app can be told apart.
Standalone installer (install-pet)
packages/install-pet/ is a standalone CLI (install-pet <pet-id> or
npx -y install-pet <pet-id>). It prefers the running app via
@open-pets/client and falls back to a direct download + extract when the
app is unavailable. Direct mode uses a lock file (.install-pet.lock, 10-min
stale timeout) to prevent concurrent installs, the same ZIP safety limits (50MB
download / 200MB extracted / 500 files / 100MB per file), and the same
platform-specific user-data path resolution. This is what powers
“npx install-pet <id>” without requiring the app to be open.
ZIP safety (shared)
Both paths enforce: HTTPS-only catalog/ZIP hosts on an allowlist, no encrypted
entries, only stored/deflate compression, valid Unix modes, required files
(pet.json + its selected spritesheet.webp or spritesheet.svg, plus
outfits/{id}.webp|svg atlases only when the manifest declares a wardrobe — see Wardrobe), and atomic extraction with private
permissions. The pet id must match ^[a-z0-9][a-z0-9_-]{0,63}$ and cannot be
builtin.
Codex pets (local authoring)
codex-pets.ts imports pets from ~/.codex/pets/ with the same metadata
validation, so an author can iterate on a pet locally before it is published to
the catalog. The publishing path (zipping, thumbnailing, uploading to R2,
regenerating the catalog) lives in web/’s sync scripts. The contract those
scripts produce is in Catalogs, with release checks in
Testing and validation.
Codex sprite versions
The bundled default Hoodie Cat uses the same V2 atlas contract as imported
Codex V2 pets. Its public builtin identity and Hoodie Cat display name stay
unchanged; only the desktop asset and runtime layout have been upgraded.
OpenPets preserves the original Codex V1 package shape: an unmarked
atlas with the nine standard 192×208 animation rows. It also imports V2 only
when pet.json has "spriteVersionNumber": 2 and its source asset is either
a fully decodable, single-image, alpha-enabled WebP or a validated static SVG,
with the exact 1536×2288 (8×11) grid. SVG V1 uses the exact 1536×1872
grid. Any other supplied version marker, an invalid format, a non-alpha V2
WebP, or a mismatched atlas is rejected before the atomic import writes anything.
The V2 adapter uses the unchanged standard rows 0–8 for OpenPets reactions and horizontal run motion. Normal idle plays the six-frame breathing loop in row 0; the neutral pose (row 0, column 6) remains available to explicit Control Center previews. This matches the released Malou V2 manifest and its 8×11 atlas specification.
The Control Center carries that sprite layout with Codex-source and installed pet entries. Its Pets, Dashboard, and Settings previews therefore scale V1 atlases as 8×9, V2 atlases as 8×11, and show the static V2 neutral cell instead of treating the extra rows as part of a nine-row animation.
At startup, OpenPets also performs an idempotent repair for Codex V2 imports
created by older releases that copied the 11-row atlas but dropped the version
marker from the installed pet.json. It restores only that marker, and only
when the persisted source still points to the expected canonical Codex pet,
the source manifest is valid V2, the local atlas passes the exact V2
1536×2288 contract, and SHA-256 hashes of the source and local atlas bytes
match. Already-current and unverifiable imports are skipped; per-pet results,
repaired/skipped counts, and skip reasons are logged, and a migration problem
never prevents the desktop app from starting.
V2’s sixteen look-direction cells (rows 9–10) are retained in the imported
atlas and selected while an installed V2 pet is visually idle and the cursor
glance is active. Outside a glance, the row 0 breathing loop plays. OpenPets samples
the global cursor around the pet carrier’s bottom-center anchor by default,
preserving the existing behavior when gazeAnchor is omitted. A manifest may
instead define the target within one frame; the host resolves that normalized
point against the rendered sprite’s frame dimensions and pet scale, excluding
carrier padding and the shared pinned-HUD lift, and mirrors its x coordinate when
the sprite is horizontally flipped. The shell/card origin remains positioned by
the render’s base pet scale; plugin pet.setScale changes only the installed
sprite’s transform scale within that fixed layout. Gaze geometry therefore uses
the base scale for the shell/card origin and the override scale for the frame
anchor, matching the renderer’s top-left transform origin. The host reapplies an
active per-window override after renderer reload; without an override, the
current render scale positions both. The installed ZIP’s validated pet.json is authoritative for this
runtime setting, so a catalog entry that omits it does not discard the package
value. OpenPets quantizes the
direction into sixteen clockwise 22.5° sectors, and resumes the idle loop inside
a small dead zone. Reactions, movement, dragging, plugin sprite
overrides, and paused pets suspend gaze; V1 pets retain their existing idle
behavior. A paused V2 pet retains the static neutral pose. Horizontal flips
compensate the selected atlas cell so the pet still
looks toward the cursor. Control Center → Settings → General exposes the
persisted Idle cursor gaze setting, enabled by default; disabling it keeps
all V2 pets on the animated idle loop and stops the shared ticker, without
changing reactions, movement, or V1 behavior. When enabled, cursor movement
drives a short glance: the current direction is held while the cursor is moving
and for about 1.2 seconds afterward, then eligible idle V2 pets resume the
row 0 breathing loop. No additional blink frames are used.
The main-process gaze controller owns the active per-window plugin scale override and replays it after full renderer loads. The pet preload applies that host-owned value to the current sprite node and retains the received value for in-place stage replacement. This keeps the rendered sprite scale aligned with custom gaze-anchor geometry without a second main-process scale owner.
Catalog V2 entries may declare the same version with an exact numeric
spriteVersionNumber: 2; the desktop carries that marker into Pets previews so
their 8×11 layout is resolved consistently with imported/local V2 pets. Older
catalog entries omit the marker and retain V1 compatibility.
Codex integration remains import-only: OpenPets does not write installed or
catalog pets back into ~/.codex/pets/.
Image protocols & CSP
Pet images are served to renderers through internal protocols
(openpets-codex:, openpets-installed:, openpets-pet-preview:). These resolve
the manifest-selected atlas and serve image/webp or image/svg+xml as
appropriate. openpets-installed://spritesheet/{petId}/{outfitId} serves one
declared wardrobe outfit for Control Center previews. Pixel hit testing decodes bytes into an origin-clean browser
canvas; it falls back from createImageBitmap to a bounded Image load for
Chromium SVG support, without relying on Electron nativeImage. Both pet-window
CSP variants permit blob: images for that origin-clean decoder and revoke the
temporary blob URL after sampling. Any new
protocol or image source must be added to the CSP in both
apps/desktop/vite.config.ts and apps/desktop/src/renderer/index.html, or
images silently fall back to the default pet. This is the single most common
“why is my pet showing the wrong sprite” bug - see Desktop app.
Where to look first
| If you’re touching… | Start in |
|---|---|
| How a reaction looks | packages/pet-format/src/animation.ts |
| What a pet says | reaction-messages.ts + i18n/reactions/ |
| Window behavior (drag, click-through, horizontal flip) | pet-window.ts, pet-window-interaction.ts, pet-preload.cjs |
| Default vs agent visibility | default-pet-controller.ts, agent-pet-controller.ts |
| Installing / extracting | pet-installation.ts, packages/pet-format/src/node/archive.ts |
| Standalone install | packages/install-pet/ |
| Local pet authoring | codex-pets.ts |
| Outfits / wardrobe schedule | packages/pet-format/src/wardrobe.ts, pet-wardrobe.ts, renderer/src/pets/WardrobePanel.tsx |
| Movement | pet-motion-engine.ts |
| Display containment / cross-screen | display.ts, confinement-manager.ts |
| Topology-change reclamp | pet-display-coordinator.ts → default and pet-controller reclamp leaves |
