My GitHub profile is a page of animated cards, and every one of them is an image served by a small service I run on Vercel. Five services render them, a 150-second colour clock keeps all of them on the same palette at the same moment, a Redis-backed mascot knows whether I am coding, and a GitHub Action bakes the contribution Pac-Man. This post builds the system one requirement at a time: how a Markdown file shows live data, how separate servers stay in sync, how each card answers before GitHub's image proxy gives up, and where the design is still rough.
Understand the problem#
The brief was simple to say and awkward to satisfy: a profile that looks alive and is accurate, with nothing I have to babysit.
Functional requirements#
- Show live GitHub, LeetCode and Spotify data on a README.
- Animate the cards, with no script.
- Make all cards share one colour at any moment, light and dark variants included.
- Show whether I am coding right now, with an elapsed timer.
- Show contribution activity as a Pac-Man game and keep it current.
Non-functional requirements#
- Answer inside Camo's window. About four seconds, then the image is broken.
- Be fetched fresh. A cached copy freezes a card on one colour.
- Stay in lockstep. Cards that arrive at different times must still look synchronised.
- Fail by design. A broken image looks worse than a stale one.
- Deploy independently. Five Vercel projects, each shippable alone.
README --> Camo (hides visitor, strips headers, ~4 s) --> ??? Need: a source of fresh SVG per view, one shared colour, no JS Constraint: images only
The set-up#
Core entities#
- Card. One SVG: header, typing line, stats, streak, languages, LeetCode, Spotify, mascot, Pac-Man or footer.
- Palette. A named colour set with dark and light variants. There are 30.
- Service. A Vercel project that renders some cards.
- Upstream source. GitHub, LeetCode, Spotify's proxy, WakaTime and Redis.
- Session. One editor window reporting coding or idle state.
The interface#
The interface is a set of URLs, one per card kind, with the theme chosen by query string. A theme beginning with dynamic turns on the palette clock.
capsule /api?type=waving&theme=dynamic_dark§ion=header|footer
typing /?lines=...&theme=dynamic_dark /api/spotify /api/pacman?mode=dark|light
stats /api?username=... /api/streak /api/top-langs /api/badge?metric=repos|code-time|lines
leetcode /api/card?sections=profile,solved|streak&theme=dynamic_dark
pet /mascot.svg?id=<public-id> POST /api/coding-now POST /api/coding-stoppedThe data model#
Build it one requirement at a time#
Requirement 1: live data as an image#
The first service was the stats card in February 2026, derived from the open-source stats generator and reworked for speed. It teaches the basic shape: an image URL, a function that queries an API and returns SVG.
- The README holds an
<img>inside a<picture>, with light and dark sources. - Camo fetches the URL for the visitor.
- The service queries the upstream API and renders SVG.
Requirement 2 and 3: animation, and one colour everywhere#
- On render, the service calls the engine, which builds keyframes for fill and gradient stop colours.
- It also computes the start offset from the current time, and applies it as a negative animation delay.
- The SVG carries the style block, and the browser animates it natively.
Requirement 3b: Camo must refetch#
- Any theme starting with
dynamicoverrides the default cache header withmax-age=0, no-cache, no-store, must-revalidate. - Camo therefore asks again on every view.
- When it still holds an old copy, bumping
v=in the README URL makes it a new resource.
Requirement 4: stay inside four seconds#
- The service asks
microCachefor the data. - On a shared-cache hit it answers immediately and, if the entry is older than 10 minutes, refreshes it in the background.
- On a miss it races the upstream fetch against a 2.6 second deadline. If the deadline wins it serves the last good or preseeded data, and the fetch keeps running to fill the cache.
Requirement 5: a coding mascot with state#
- The extension posts
coding-nowevery 300 seconds while I type, with a per-window session id. - The service writes one hash field per window, with a 900 second expiry.
- When Camo fetches the image, the service merges the live sessions and draws coding or sleeping.
Requirement 6: the Pac-Man#
- The workflow builds the contribution graph and commits the dark and light SVGs.
- On each view the typing service fetches the committed file, strips its styles and injects the current keyframes.
- Camo returns it, on the same clock as every other card.
Deep dive: one colour clock for five servers#
Start every animation at zero
A card whose server spent four seconds on an upstream API starts four seconds after a card that did not. The colours drift by exactly the difference in latency.
Pick a palette from the clock, hold it still
The first attempts rotated palettes on a schedule and served a single static colour per render. I tried a 10 minute coprime rotation and a fixed master timeline on 31 October, and a per-visitor starting palette from the current minute on 1 November. That last one was committed and reverted twice. Static colours cannot animate, and a start picked per visitor still depends on arrival time.
Start at the wall clock's position when rendered
Every service computes (now / 1000) mod 150 at render time and applies it as a negative animation delay. A card that arrives late starts further along its loop, which cancels the lateness.
The generator builds three keyframe tracks, text fill and two gradient stops, with one step per palette and a hold of 82 percent of each slot, so the remaining 18 percent is the blend into the next colour. The static palette in the base SVG is only a first-paint colour for renderers that ignore CSS animation; the keyframes override it. The same engine is copied into capsule, typing, stats and leetcode, and the mascot's pre-rendered cards carry the same cycle in their art.
The sync has assumptions. All services must share the 150 second period and the palette order, and server clocks must be close. Someone viewing at a different moment starts on a different colour, which is the intent.
Deep dive: Camo and cache headers#
The sync only works if Camo refetches on every view. Dynamic endpoints therefore send max-age=0, no-cache, no-store, must-revalidate. Static themes keep short public caching. The services do not all choose the same header, and the differences are deliberate.
Getting here took iteration. Beyond the keyframe experiments above, I also bumped a v= parameter through the README (v=2, then v=3) to make Camo drop stale copies. Cache-busting by URL is blunt, but when a proxy you do not own holds an old copy it is the lever that works.
Deep dive: the four-second budget#
Wait for the upstream
Cards time out in Camo whenever the API is slow, which was most of the time for the stats card.
Cache in function memory
I did this first with a 120 second TTL. On Vercel a fetch still running after the response is frozen, so the memory cache almost never filled: every card waited the full deadline and served a fallback.
Shared cache, serve stale, refresh in the background
Last good data comes from Vercel's shared runtime cache, answered at once. A refresh older than 10 minutes runs under waitUntil so it survives the response. A miss races a 2.6 second deadline and falls back to preseeded data.
waitUntil, so the second visitor gets real data.The badge endpoint applies the same idea at smaller scale. It serves a stored badge immediately, refreshes repos every 5 minutes, code time every 10 and lines crafted every hour, and draws each one through shields.io once per distinct value. On a first-ever request it waits 1.5 seconds for a live value, then draws a hard-coded last known one. Lines crafted sums additions across my non-fork repositories and treats GitHub's 202 "still computing" answers as not ready, rather than publishing a low total. The README's code time badge reads WakaTime's public total through shields directly, because WakaTime now rejects API keys outside its editor plugins.
Deep dive: session state for the mascot#
One status value per user
Two windows overwrite each other: closing one flips the card to idle while the other is still coding.
One value, short TTL
Fixes crashed editors, not two windows. Idle from one window still wins over coding in another.
One hash field per window, merged on read
The card reads coding if any live session is coding, and the oldest start wins so the elapsed timer is right. A 900 second TTL on the hash is the crash backstop.
There are no accounts. The extension generates a secret key. The README embeds a public id, the first 32 hex characters of sha256(key), which cannot be reversed to the key, so nobody can spoof my status from the README. The extension posts with Authorization: Bearer <key>, refreshes every 5 minutes while I type, and a missed beat still fits the 15 minute TTL. Closing a window sets only that window's session idle.
last:<id> lives 30 days so the "away for" text stays right after live entries expire. The merge is a pure function, mergeStatus, so it is testable without Redis. The public server is locked to my id with an ALLOWED_IDS allowlist and the README has a deploy-your-own button. The mascot art, Bloub, is generated at build time from my own animation engine and bundled as pre-rendered SVG cards.
Deep dive: GitHub Actions in the profile repo#
The profile repo has two workflows. snake.yml is titled "Generate Contribution Pacman", a name left over from the earlier snake animation. It runs every 6 hours, on manual dispatch, and on pushes that touch the workflow or .github/scripts/. It builds the graph with a third-party action, runs inject-dynamic-pacman.js to rewrite the cells into level-coloured dots with a static first palette, and commits dist/ with a [skip ci] message so it does not trigger itself. waka-stats.yml runs daily at 00:00 UTC and runs update-metrics.js.
The service-side half exists because the committed SVG is hours old and carries a static colour. The /api/pacman function fetches it from the repository's raw URL, strips the embedded styles, compacts whitespace and long timing lists (about 7 percent smaller, since Camo serves a file this size uncompressed and a late image starts its colour cycle late), and injects the live keyframes. The result carries the render-time offset like every other card.
The final design#
One request, one rebuild#
- The visitor loads the profile; the browser asks Camo for each card image.
- Camo fetches every card from its service, because the dynamic ones are no-store.
- The stats service answers from its shared cache, or races GitHub against 2.6 seconds, and refreshes in the background.
- The typing service proxies the Spotify card or serves the offline mascot after 3.5 seconds.
- The pet service merges the live Redis sessions into coding or sleeping.
- Every service stamps its SVG with the wall-clock offset and returns it. The browser plays the animation, and all cards are on the same colour.
To rebuild: one function per card kind, one shared palette file, one offset function, and the right headers. The rest is tuning.
What I'd change#
- Publish the palette and clock as one package. It is copied across four services now, and a palette edit has to be made in each.
- Bring the Pac-Man proxy's 6 second abort under Camo's window, or serve a cached copy on timeout.
- Replace the hard-coded fallback numbers. The stats, language and badge fallbacks are frozen values. A "data unavailable" state would be more honest than slightly stale numbers.
- Delete the vestigial metrics step from both workflows, and rename
snake.yml. The profile repo's CLAUDE.md still describes the old snake animation. - Retire the mascot's old Worker. The repository still carries a Cloudflare Worker with a 5 minute TTL from before the Vercel version.
Test yourself#
Why can a README card not simply start its animation when the page loads?
- README images cannot animate.
- The animation starts when each image is received, and separate servers answer at different speeds.
- Camo rewrites CSS.
- SVGs only animate in light mode.
What does a service do with (Date.now() / 1000) % 150?
- Chooses which palette to show forever.
- Sets the cache TTL.
- Rate-limits visitors.
- Applies it as a negative animation delay so the card starts where the shared 150 second cycle currently is.
Why do dynamic themes send no-store?
- Otherwise Camo would keep a cached copy and freeze the card on the colour it first saw.
- To make the SVG smaller.
- Because Vercel requires it.
- To hide the card from search engines.
The stats service caches in function memory first and later in a shared runtime cache with waitUntil. Why?
- Memory is more expensive.
- The shared cache is encrypted.
- On Vercel a fetch still running after the response is frozen, so the memory cache rarely filled and every card waited out its deadline.
- GitHub blocks memory caches.
waitUntil keeps the background fetch alive so the next visitor gets real data.Why does the mascot use a hash field per editor window?
- Redis hashes are faster than strings.
- One window closing must not overwrite another window that is still coding, so the card reads coding if any live session is coding.
- The extension cannot send a body.
- It lets the README write status.
Which timeout in the services is longer than Camo's window?
- The stats data race at 2.6 seconds.
- The Spotify upstream at 3.5 seconds.
- The Pac-Man proxy fetch at 6 seconds.
- The badge first answer at 1.5 seconds.