I built a Tamagotchi of Shame for my GitHub Profile (And it dies if I stop coding)
There is a creature on my GitHub profile. It is watching my commit history. It
is not supportive.
Every night at 23:30 a GitHub Action asks one question: did I commit anything
today? If I did, it eats. If I didn't, it starves — visibly, publicly, on the
page recruiters and strangers land on. Five days without a commit and it dies
and leaves a tombstone with the date on it.
The only way to bring it back is to push a commit whose message is exactly
i'm sorry. The resurrection counter never resets.
This post is about how it's built, because the constraints turned out to be more
interesting than the joke.
The whole thing is one number
Everything hangs off days since the last commit:
| Days | State | What you see |
|---|---|---|
| 0 | thriving |
Bright greens, bouncing, unbearable smugness |
| 1–2 | hungry |
Dimmer palette, slow drift, passive aggression |
| 3–4 | feral |
Colour draining, twitching, visible ribs |
| 5+ | deceased |
Tombstone, death date, no animation at all |
Each mood is a palette, and every palette carries a sat value — 1 at full
health, 0 once it's dead:
const PALETTES = {
thriving: { x: '#5fd99a', accent: '#5fd99a', ink: '#e6fff2', sat: 1 },
hungry: { x: '#93ad78', accent: '#93ad78', ink: '#dfe6cf', sat: 0.6 },
feral: { x: '#8b8f86', accent: '#a03030', ink: '#c8c8c0', sat: 0.25 },
deceased: { s: '#8d939b', accent: '#6b7076', ink: '#9aa0a6', sat: 0 },
};
Because all eleven cards read the same palette, the entire profile drains together and freezes when the pet dies. That's the part people actually react to — not the pet, the page.
All eleven cards desaturating together
No dependencies. I mean the strict version
There is no package.json in this repo. Not an empty one — none. Which means CI has no install step, and the entire daily job finishes in about 8 seconds:
jobs:
feed:
runs-on: ubuntu-latest
steps:
# No actions/setup-node: ubuntu-latest ships Node 20+, and the script has
# zero dependencies, so there is nothing to install.
- uses: actions/checkout@v4
- name: Update the creature
run: node scripts/update_pet.js
- name: Commit if anything changed
run: |
git add pet-state.json assets README.md
git commit -m 'chore: update pet state [skip ci]'
git push
[skip ci] on the bot's own commit is load-bearing: without it the push triggers the workflow that made the push.
The database is a JSON file
{
"lastCommitDate": "2026-08-01T06:54:21.000Z",
"hunger": 0,
"mood": "thriving",
"alive": true,
"diedOn": null,
"resurrections": 0
}
That's the whole persistence layer, committed to the repo on every run. No backend, no database, no third-party service.
Death is latched. Once alive is false the script skips every decay branch and re-renders the tombstone forever:
if (!state.alive) {
if (apology) {
Object.assign(state, {
alive: true, diedOn: null, hunger: 0, mood: 'thriving',
resurrections: state.resurrections + 1,
});
} else {
state.mood = 'deceased';
}
}
resurrections is drawn inside the SVG. You can't edit it out of the caption, because it isn't in the caption.
The tombstone, the apology commit, and the counter ticking to 1
GitHub's image sandbox decides your architecture
README images are served through GitHub's camo proxy and rendered inside an HTML image element. That means:
- no external fonts
- no external CSS
- no JavaScript
- no working links inside the SVG
- inline CSS, CSS keyframes, and SMIL So the display type is drawn as runs of rects, one per colour change:
function pixelRects(grid, palette, cell) {
const out = [];
grid.forEach((row, y) => {
let x = 0;
while (x < row.length) {
const ch = row[x];
if (!palette[ch]) { x += 1; continue; }
let w = 1;
while (x + w < row.length && row[x + w] === ch) w += 1;
out.push(`<rect x="${x * cell}" y="${y * cell}" width="${w * cell}" height="${cell}" fill="${palette[ch]}"/>`);
x += w;
}
});
return out.join('');
}
And the sprites are just char grids:
thriving: [
'.......tt.......',
'....xxxxxxxx....',
'.xxwwwxxxxwwwxx.',
'.xxwbwxxxxwbwxx.',
// ...
],
Animation is entirely declarative. The idle bounce is SMIL; the feeding reaction is CSS keyframes with staggered delays; the dead state emits no animation rules at all, which is why nothing on the page moves when it dies.
The hard part was the calendar, obviously
"Days since the last commit" is undefined until you decide whose day.
The job runs at 23:30 Asia/Kathmandu — near the end of the local day — because it's asking "was anything committed today?" and asking after midnight asks about yesterday. Kathmandu is UTC+05:45, so under a UTC day boundary a commit at 00:30 local would be filed 5 hours 45 minutes earlier, in the previous day, and the pet would starve over a commit that actually happened.
17:45 UTC = 23:30 in Kathmandu (UTC+5:45)
cron: '45 17 * * *'
Change the timezone in config and you must move the cron with it. Getting one without the other is how the day boundary ends up somewhere nobody lives.
Letting strangers press the button
Anyone can feed the creature by opening an issue titled feed GRUB. A workflow picks a snack, makes the card rain it, and puts that person's username on the card for 24 hours.
Someone feeding GRUB a donut
This means a stranger's issue causes a commit to my repository, so feeding is deliberately powerless. The script asserts it:
const PROTECTED = ['lastCommitDate', 'hunger', 'mood', 'alive', 'diedOn', 'resurrections'];
const before = pick(state, PROTECTED);
// ... record the feed ...
if (pick(state, PROTECTED) !== before) {
throw new Error('petting altered protected state — refusing to save');
}
Plus one feed per person per day, a repo-wide daily cap of 25, and login validation both on write and again in the renderer before a username is ever drawn onto a public SVG.
Only commits keep it alive. A stranger's sympathy is not an apology.
What I'd tell you before you fork it
Private commits don't count by default, and turning them on is a genuine trade rather than a flag. GitHub won't date private commits at all — the only way to see them is the contribution calendar, which also counts issues, PRs and reviews. Enable it and opening an issue feeds your pet.
The "lurkers" number is repo traffic, not profile views. Nobody's profile view counter is real; the image is served through a cache that reports nothing back to anyone.
Camo caching means a few minutes of lag between the push and the profile.
It's a template — one click and about five minutes, most of which is deciding what to rename the creature. There's also a Wall of Shame ranking every public GRUB by deaths survived, which is a leaderboard you want to lose.
Repo: masabinhok/grub

Top comments (0)