Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

Notable changes to **pixelcat**. All art and sound are original/procedural (no asset files).

## [Unreleased]

### Dog
- **Fetch on its own** - step away and a dog starts its own game rather than waiting on a butterfly: it noses a ball out, chases it down and carries it home. Cats still get the butterfly. One toggle governs both, and work mode, reduced motion and low power suppress both.

### Fixes
- **The dog stops re-fetching the ball it just delivered.** It dropped the ball at its own feet, was back inside the grab radius on the next frame, and looped pickup/deliver at frame rate: 254 heart-and-chirp bursts in 72 seconds and a pant timer that never expired. Only the first throw ever looked right.
- **Tray wording matches what the tray actually does.** A dog was offered "Give a treat 🦴" and then thrown a tennis ball; it now reads "Throw the ball 🎾", alongside "Ball to chase" and "Work mode (stay put, no ball)".
- **`npm run lint` works on a real working copy again** - it was walking local-only paths that `.gitignore` already excludes and reporting ~1.5k errors from code this repo does not own.

## [0.2.0] - 2026-07-25

### Stay on track
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ coat choice, so switching back and forth never loses your pick.
| Resting | loafs into a "cat bread" | curls nose-to-tail into a ring |
| Excited | hunting crouch, ears back | **play bow**: chest down, rump up, tail flagged |
| Tail | slow rolling S-curve, tip flicks | fast wag from the base, shaped per breed (curl / plume / feather / stub / straight) |
| Play | bats a butterfly | **fetch**: chases the thrown ball, carries it home, drops it |
| Play | bats a butterfly | **fetch**: chases the ball down, carries it home, drops it |
| Left alone | a butterfly flutters in | starts its own game of fetch |
| After exertion | grooms | pants, tongue out |
| Scroll | climbs the yarn rope hand over hand | hauls itself up the same rope, ears and snout intact |
| Reward | a fish treat | a tennis ball |
Expand Down Expand Up @@ -165,7 +166,7 @@ to your cursor, to your typing, and to its own internal mood.
| **Move your cursor** | The cat watches it and blinks now and then. Flick the cursor fast and it crouches, stalks, and pounces. A sudden jolt startles it: it puffs up, freezes, then bolts or creeps back. |
| **Type in any app** | It leans onto two big keys and kneads them with its paws. Type fast enough and it overheats, turning red with steam, then cools down. |
| **Scroll anywhere** | Grabs a yarn rope and climbs it hand over hand, up when you scroll up and down when you scroll down, with a ball of yarn anchored on the floor. |
| **Wait for a visitor** | Once in a while a butterfly flutters in. The cat tracks it, swats at it, and occasionally pounces and catches it between its paws before it flutters off. Step away from the keyboard and a butterfly comes out on its own, so the cat always has something to play with. |
| **Wait for a visitor** | Once in a while a butterfly flutters in. The cat tracks it, swats at it, and occasionally pounces and catches it between its paws before it flutters off. Step away from the keyboard and one comes out on its own, so the pet always has something to play with. Keep a dog instead and it starts its own game of fetch rather than waiting on a butterfly. |
| **Leave it be** | Left alone it keeps itself busy: it bats a drifting leaf with a paw, washes its face, loafs, and its whiskers twitch. Lively without ever getting in your way. |
| **Come back** | Return after being away and the cat notices you: happy eyes, a little heart, and a friendly chirp hello. |
| **Give it a treat** | Pick "Give a treat" from the tray and a little fish drops in. The cat trots over and noms it with hearts and a happy chirp. |
Expand Down
12 changes: 11 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,17 @@ const sharedOverlay = {
const CONSUMER_OVERLAY = ['src/renderer.js', 'src/settings-renderer.js', 'src/cat-preview.js'];

module.exports = [
{ ignores: ['node_modules/**', 'dist/**', 'site/**', 'src/climb-frames.js', '_*/**'] },
// Keep this in step with .gitignore. Without the local-only entries, a working
// copy that has picked up scratch files or the unrelated coursework folder sends
// `npm run lint` walking a vendored python venv, and the one command that should
// surface real bugs drowns in ~1.5k phantom errors. CI checks out a clean tree
// and never sees them, which is exactly why this rots unnoticed.
{
ignores: [
'node_modules/**', 'dist/**', 'out/**', 'site/**', 'src/climb-frames.js',
'_*', '_*/**', 'previews/**', '.playwright-mcp/**', '.codegraph/**', 'CODEPATH Proj/**',
],
},

js.configs.recommended,

Expand Down
11 changes: 6 additions & 5 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ function rebuildTrayMenu() {
tray.setContextMenu(Menu.buildFromTemplate([
{ label: 'Settings…', click: openSettings },
{ label: 'Start break now', click: triggerBreak },
{ label: sp.treatLabel, click: giveTreat },
{ label: sp.giveLabel, click: giveTreat },
{ label: 'Recent notifications', submenu: recentItems },
{ label: 'Snooze last reminder', submenu: [
{ label: '5 minutes', click: () => snoozeLast(5) },
Expand All @@ -413,7 +413,7 @@ function rebuildTrayMenu() {
{ label: sp.coatNoun, submenu: coatItems },
{ label: 'Follow cursor', type: 'checkbox', checked: !!(cfg && cfg.followCursor), click: () => persistAndBroadcast({ ...cfg, followCursor: !cfg.followCursor }) },
{ label: 'Mouse hunt', type: 'checkbox', checked: !!(cfg && cfg.huntOn), click: () => persistAndBroadcast({ ...cfg, huntOn: !cfg.huntOn }) },
{ label: 'Butterfly visits', type: 'checkbox', checked: !(cfg && cfg.butterflyOn === false), click: () => persistAndBroadcast({ ...cfg, butterflyOn: !(cfg && cfg.butterflyOn !== false) }) },
{ label: sp.playToggleLabel, type: 'checkbox', checked: !(cfg && cfg.butterflyOn === false), click: () => persistAndBroadcast({ ...cfg, butterflyOn: !(cfg && cfg.butterflyOn !== false) }) },
{ label: 'Mood reactions', type: 'checkbox', checked: !(cfg && cfg.moodOn === false), click: () => persistAndBroadcast({ ...cfg, moodOn: !(cfg && cfg.moodOn !== false) }) },
{ label: 'Startle at cursor', type: 'checkbox', checked: !(cfg && cfg.startleOn === false), click: () => persistAndBroadcast({ ...cfg, startleOn: !(cfg && cfg.startleOn !== false) }) },
{ label: 'Mood', submenu: [
Expand All @@ -433,7 +433,7 @@ function rebuildTrayMenu() {
] },
{ label: 'Always on top', type: 'checkbox', checked: !(cfg && cfg.onTop === false), click: () => persistAndBroadcast({ ...cfg, onTop: !(cfg && cfg.onTop !== false) }) },
{ label: 'Wander', type: 'checkbox', checked: !(cfg && cfg.roamOn === false), click: () => persistAndBroadcast({ ...cfg, roamOn: !(cfg && cfg.roamOn !== false) }) },
{ label: 'Work mode (stay put, no butterfly)', type: 'checkbox', checked: !!(cfg && cfg.workMode), click: () => persistAndBroadcast({ ...cfg, workMode: !(cfg && cfg.workMode) }) },
{ label: `Work mode (stay put, no ${sp.playNoun})`, type: 'checkbox', checked: !!(cfg && cfg.workMode), click: () => persistAndBroadcast({ ...cfg, workMode: !(cfg && cfg.workMode) }) },
{ label: 'Rest corner', submenu: [
{ label: 'Bottom-left', type: 'radio', checked: !!(cfg && cfg.restSide === 'left'), click: () => persistAndBroadcast({ ...cfg, restSide: 'left' }) },
{ label: 'Bottom-right', type: 'radio', checked: !(cfg && cfg.restSide === 'left'), click: () => persistAndBroadcast({ ...cfg, restSide: 'right' }) },
Expand Down Expand Up @@ -487,8 +487,9 @@ function triggerBreak() {
function giveTreat() {
if (!win || win.isDestroyed()) return;
// Same tray slot, species-appropriate payload: a cat is handed a fish, a dog
// gets a tennis ball thrown for it to chase down and bring back.
win.webContents.send(speciesOf(cfg && cfg.species).id === 'dog' ? 'ball' : 'treat');
// gets a tennis ball thrown for it to chase down and bring back. The channel
// comes from the same registry entry as the menu label, so the two cannot drift.
win.webContents.send(speciesOf(cfg && cfg.species).giveChannel);
}

// ---- Pomodoro: focus/break loops. Main owns the phase clock (the renderer may
Expand Down
19 changes: 13 additions & 6 deletions src/pets.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,15 @@ const SPECIES = {
coats: CAT_COATS,
coatNoun: 'Coat',
defaultCoat: 'Tuxedo',
// tray/menu wording differs per species so the app never says "treat" at a dog
// that is actually being handed a ball
treatLabel: 'Give a treat 🐟',
playLabel: 'Send a butterfly 🦋',
// The tray's single "give" slot: what it is called AND which payload it sends.
// They live together so the menu can never say "treat" at a dog that is actually
// being handed a ball, which is precisely what happened while the label and the
// channel were chosen in two different files.
giveLabel: 'Give a treat 🐟',
giveChannel: 'treat',
// The companion the pet plays with on its own once you step away.
playNoun: 'butterfly',
playToggleLabel: 'Butterfly visits',
},
dog: {
id: 'dog',
Expand All @@ -41,8 +46,10 @@ const SPECIES = {
coats: DOG_COATS,
coatNoun: 'Breed',
defaultCoat: 'Golden Retriever',
treatLabel: 'Give a treat 🦴',
playLabel: 'Throw the ball 🎾',
giveLabel: 'Throw the ball 🎾',
giveChannel: 'ball',
playNoun: 'ball',
playToggleLabel: 'Ball to chase',
},
};

Expand Down
39 changes: 37 additions & 2 deletions src/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -1290,7 +1290,7 @@ function updateBall(t, dt, f) {
roamTo = { x: home, y: floorLockOn() ? restingY() : pos.y };
roamDur = clamp(Math.abs(pos.x - home) * 2.4, 400, 1700); roamUntil = t + roamDur; nextRoam = t + 20000;
} else { // delivered: drop it, wag, pant, ask for another
ball.phase = 'rest'; ball.restAt = t; ball.y = floor - BALL_R;
ball.phase = 'rest'; ball.delivered = true; ball.restAt = t; ball.y = floor - BALL_R;
wagBoost = 1.2; pantUntil = t + 4200; addEnergy(10);
popLove(t, pos.x, pos.y - SH * 0.8, 2, 16);
if (config && config.soundOn) playChirp();
Expand All @@ -1301,6 +1301,12 @@ function updateBall(t, dt, f) {
// 'rest': lying on the floor. The dog goes and gets it.
if (f.grabbing || f.hunting || f.startleActive || f.typing || paperLen > 1) return;
if (t - ball.restAt > 45000) { ball = null; return; } // forgotten after a while
// A ball the dog has already carried home stays where it was dropped, waiting for
// you to throw it again. Without this the dog drops it at its own feet, is back
// inside FETCH_GRAB on the very next frame, picks it up, walks nowhere, "delivers"
// again... looping pickup/deliver every frame and spraying hearts, chirps and a
// permanently refreshed pant. The first throw looks perfect, which is why it shipped.
if (ball.delivered) return;
const dist = Math.abs(pos.x - ballApproachX());
if (dist <= FETCH_GRAB) {
ball.phase = 'carry'; ball.side = Math.sign(ball.x - pos.x) || 1;
Expand Down Expand Up @@ -1750,6 +1756,35 @@ function startBflyVisit(t) {
bfDriftCx = pos.x; bfDriftCy = clamp(pos.y - SH - 30, BF_TOP, viewH - BF_EDGE);
bfDriftTX = bfDriftCx; bfDriftTY = bfDriftCy; bfPhase = Math.random() * Math.PI * 2; bfNextDrift = t + 500;
}
// --- Self-play: what the pet does with itself once you step away. A cat gets a
// butterfly to stalk. A dog would rather have something thrown, and it already
// knows how to fetch, so it noses its own ball out and carries it back. Both
// species run off the same idle gates and the same tray toggle, so switching
// play off switches all of it off.
function updateSelfPlay(t, dt, step, f) {
if (!isDog()) { updateButterflyDesk(t, dt, step, f); return; }
// Swapped to a dog mid-visit: the butterfly is still DRAWN for as long as bfOn is
// set, so it has to be flown off properly rather than abandoned frozen in mid-air.
if (bfOn) { bfMode = 'out'; updateButterflyDesk(t, dt, step, f); return; }
updateDogFetchUrge(t, f);
}
// A dog with nothing to chase talks itself into a game of fetch, on the same
// schedule that earns a cat a butterfly visit. There is no separate "leaving"
// phase: updateBall() forgets an untouched ball after 45s, which re-arms this.
function updateDogFetchUrge(t, f) {
if (SHOT || ball || !pos) return; // already something in play
const allow = f.follow && !lowPower && !(config && config.reducedMotion)
&& !(config && config.butterflyOn === false) && !workModeOn() && !f.grabbing && !f.typing;
if (!allow || !f.calm) return;
if ((t - lastCursorMove) < BF_MOUSE_QUIET_MS) return; // never while the mouse is in use
const idleMs = t - Math.max(lastCursorMove, lastKeyAt);
if (!(t > bfNextVisit || (idleMs > IDLE_BUTTERFLY_MS && t > bfIdleNextVisit))) return;
throwBall();
// Re-arm on the cadence a departing butterfly uses, so the dog does not start a
// fresh game the instant the last ball is forgotten.
bfNextVisit = t + 50000 + Math.random() * 50000;
bfIdleNextVisit = t + 14000 + Math.random() * 10000;
}
// Flight + cat reaction. f = { follow, grabbing, hunting, typing, petting, startleActive, calm }.
function updateButterflyDesk(t, dt, step, f) {
const force = SHOT && qp.get('bfly') === '1';
Expand Down Expand Up @@ -2299,7 +2334,7 @@ function draw(t) {
if (lookTarget && t > lookTargetUntil) lookTarget = null;
if (t > leanUntil) leanTarget = 0;
lean += (leanTarget - lean) * 0.09 * step;
updateButterflyDesk(t, dt, step, { follow, grabbing, hunting, typing, petting, startleActive, calm });
updateSelfPlay(t, dt, step, { follow, grabbing, hunting, typing, petting, startleActive, calm });
updateTreat(t, { grabbing, hunting, typing, startleActive });
updateBall(t, dt, { grabbing, hunting, typing, startleActive });
updateDogVitals(t, dt);
Expand Down
91 changes: 91 additions & 0 deletions tests/fetch.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Fetch (dogs only) tests.
//
// The tray throws a tennis ball, the dog chases it down, carries it home and drops
// it. That whole cycle runs off one `ball` object moving through fly -> rest ->
// carry -> rest, driven entirely by the rAF loop, so it is easy to get a state
// machine that looks right for one throw and then never settles.
//
// It shipped doing exactly that: the dog dropped the ball at its own feet, was
// back inside the grab radius on the next frame, picked it up, "delivered" it
// without moving, and looped forever at frame rate. Over 72 simulated seconds
// that was 511 phase flips and 254 heart-and-chirp bursts, with the pant timer
// refreshed every frame so the dog never stopped panting. These tests pin the
// shape of one complete fetch and the fact that it ENDS.
const test = require('node:test');
const assert = require('node:assert');
const { loadOverlay } = require('../scripts/overlay-vm.js');

const STEP = 60; // > the idle-fps throttle in draw(), so no frame gets skipped

// Drive the real loop and record every ball phase change along the way.
function runFetch(h, ms) {
const flips = [];
let prev = h.run('ball ? ball.phase : null');
for (let t = STEP; t <= ms; t += STEP) {
h.run(`draw(${t})`);
const phase = h.run('ball ? ball.phase : null');
if (phase !== prev) { flips.push({ t, from: prev, to: phase }); prev = phase; }
}
return flips;
}

function dogOverlay() {
const h = loadOverlay();
h.run('setSpecies("dog")');
// Self-play off, so these tests see exactly one ball: the one they throw. An idle
// dog otherwise starts a game of its own partway through the longer runs here and
// the counts stop meaning anything. Who STARTS a game is self-play.test.js's job.
h.ipc('onConfig', { species: 'dog', soundOn: false, followCursor: true, floorLock: true, butterflyOn: false });
// Count the celebration bursts: one per delivery is right, one per frame is the bug.
h.run('var __love = 0; var __popLove = popLove; popLove = function () { __love++; return __popLove.apply(null, arguments); };');
return h;
}

test('a thrown ball is chased down, carried home and delivered exactly once', () => {
const h = dogOverlay();
h.run('throwBall()');
assert.strictEqual(h.run('ball.phase'), 'fly', 'the throw should put the ball in the air');
const flips = runFetch(h, 20000);

const seq = flips.map((f) => f.to);
assert.deepEqual(seq.slice(0, 3), ['rest', 'carry', 'rest'],
`expected land -> pick up -> deliver, got ${JSON.stringify(seq)}`);

const deliveries = flips.filter((f) => f.from === 'carry' && f.to === 'rest');
assert.equal(deliveries.length, 1, `the dog delivered ${deliveries.length} times for one throw`);
assert.equal(h.run('__love'), 1, 'one delivery should be one burst of hearts, not one per frame');
});

test('the dog leaves a delivered ball alone instead of looping on it', () => {
// The regression itself. The ball lands ~6px from the dog on delivery, well
// inside FETCH_GRAB (22px), so without a "already brought this back" marker the
// rest branch re-grabs it immediately and the cycle never terminates.
const h = dogOverlay();
h.run('throwBall()');
const flips = runFetch(h, 40000);

assert.ok(flips.length <= 6, `one fetch should be a handful of phase changes, got ${flips.length}`);
assert.ok(h.run('__love') <= 1, `hearts fired ${h.run('__love')} times for a single fetch`);
assert.strictEqual(h.run('ball && ball.delivered'), true, 'the delivered ball should be marked, not re-chased');
assert.strictEqual(h.run('ball && ball.phase'), 'rest', 'a delivered ball stays on the floor');
});

test('the pant timer stops being refreshed once the fetch is over', () => {
// Panting is driven by `pantUntil`, which the delivery branch pushes forward.
// While the loop ran it was re-armed every frame, so the dog panted permanently.
const h = dogOverlay();
h.run('throwBall()');
runFetch(h, 30000);
const pantUntil = h.run('pantUntil');
assert.ok(pantUntil < 30000, `pantUntil (${pantUntil}) is still being pushed forward after the fetch ended`);
});

test('a delivered ball is eventually forgotten so the floor does not stay littered', () => {
const h = dogOverlay();
h.run('throwBall()');
const flips = runFetch(h, 60000);
const forgotten = flips.find((f) => f.to === null);
assert.ok(forgotten, 'the ball should be cleared once it has sat untouched');
assert.ok(forgotten.t > 40000, `forgotten too eagerly at ${forgotten.t}ms`);
assert.strictEqual(h.run('ball'), null);
});
Loading