Code
Projects, experiments and the tools behind them.
src/pagesHome, Me, Create, Code, Game, Donatesrc/componentsNav, skyline, players, video feedsrc/dataChannels, projects, games, supportsrc/libFeed parsing, validation, cachefunctions/apiPages Functions: feed and thumbnails
Cloudflare Pagesstatic output in dist/
imahappy.dev
This website: a static, long-scrolling home for streams, videos, code and games, with scroll-driven scenes, a live YouTube upload feed and an in-page game.
- Astro
- TypeScript
- GSAP ScrollTrigger
- Cloudflare Pages Functions
Source on GitHubgithub.com/ImaHappy/.dev
How it is built
A scene scrubbed by scroll
The homepage is one continuous AI-animated shot. Scroll position picks the frame. Each frame is fitted to the screen with the character kept to the right of the text, and any uncovered edge fades into the page.
src/scripts/scene.tslines 140-165 const draw = () => { const [c, i] = at(); const img = nearest(c, i); host.dataset.frame = `${c}:${i}`; // for qa:motion and debugging veil(c, i); if (!img || img === drawn) return; drawn = img; const { width: w, height: h } = canvas; const k = Math.max(w / img.naturalWidth, h / img.naturalHeight); const dw = img.naturalWidth * k; const dh = img.naturalHeight * k; // Never leave a gap on the right; a gap on the left fades into the page. // His head is about a fifth of the frame wide: keep that clear of the text. const standX = textRight ? Math.min(w * 0.78, Math.max(w / 2, textRight + dw * 0.17)) : w / 2; const x = Math.max(w - dw, standX - manifest.focusX * dw); ctx.drawImage(img, x, (h - dh) / 2, dw, dh); if (x > 0) { const fade = ctx.createLinearGradient(x, 0, x + w * 0.12, 0); fade.addColorStop(0, INK); fade.addColorStop(1, 'rgb(7 8 11 / 0)'); ctx.fillStyle = INK; ctx.fillRect(0, 0, x, h); ctx.fillStyle = fade; ctx.fillRect(x, 0, w * 0.12, h); } };One fetch for everyone, last good copy on failure
Create's video list comes from YouTube's public feed, no API key. A fresh copy is served from cache; when it goes stale, every visitor waits on one shared request; if YouTube fails, the last good copy is served and the server backs off instead of retrying on every visit.
src/lib/youtube.tslines 238-256 /** Current feed: cached while fresh; one shared refresh when stale; last good copy if the refresh fails. */ async function get(): Promise<FeedResult> { const cached = await store.get(); const age = cached ? now() - Date.parse(cached.fetchedAt) : Infinity; if (cached && age < ttl) return { state: 'fresh', snapshot: cached, error: null }; // Recently failed: do not hammer YouTube; answer from what we have. if (lastFailure && now() - lastFailure.at < retryAfter && !inflight) { return cached ? { state: 'stale', snapshot: cached, error: lastFailure.error.message } : { state: 'unavailable', snapshot: null, error: lastFailure.error.message }; } inflight ??= refresh().finally(() => (inflight = null)); try { return { state: 'fresh', snapshot: await inflight, error: null }; } catch (err) { const message = (err as Error).message; return cached ? { state: 'stale', snapshot: cached, error: message } : { state: 'unavailable', snapshot: null, error: message }; } }An autopilot on a wrapping board
Light trail's attract mode drives itself. Distance is measured around the board's wrapping edges, and it steers clear of its own trail and solid floors, and of floors still rising when it can.
src/games/light-trail/engine.tslines 248-260 const goal = target ?? detour ?? food; const options = Object.values(DIRS).filter((d) => !(d.x === -dir.x && d.y === -dir.y)); const dist = (c: Cell) => { const dx = Math.min(Math.abs(c.x - goal.x), cols - Math.abs(c.x - goal.x)); const dy = Math.min(Math.abs(c.y - goal.y), rows - Math.abs(c.y - goal.y)); // Rising structure is passable, but a careful driver avoids it. return dx + dy + (wallAt(c) === RISING ? 4 : 0); }; const scored = options .map((d) => ({ d, c: wrap({ x: head.x + d.x, y: head.y + d.y }) })) .filter((o) => !onBody(o.c) && wallAt(o.c) !== SOLID) .sort((a, b) => dist(a.c) - dist(b.c)); if (scored[0]) dir = scored[0].d;Next episode, not next video
When a video ends on Create, the one offered next is the next episode of the same series, matched from the titles, even though the list is newest first.
src/scripts/next-up.tslines 10-26 const EPISODE = /^(.*?\b(?:episode|ep\.?|part|day)\s*#?)(\d{1,5})\b(.*)$/i; function parts(title: string) { const m = EPISODE.exec(title.trim()); return m ? { series: `${m[1].trim()}|${m[3].trim()}`.toLowerCase(), n: Number(m[2]) } : null; } export function nextUp(titles: readonly string[], at: number): number { if (at < 0 || at >= titles.length) return -1; const here = parts(titles[at]); if (!here) return at + 1 < titles.length ? at + 1 : -1; return titles.findIndex((t, i) => { if (i === at) return false; const p = parts(t); return !!p && p.series === here.series && p.n === here.n + 1; }); }One sound at a time
Create has a Twitch player and a YouTube player. Whichever starts playing with sound says so on the page, and the other one pauses.
src/scripts/media-bus.tslines 8-18 export function announcePlay(source: MediaSource) { window.dispatchEvent(new CustomEvent<MediaSource>(EVENT, { detail: source })); } export function onOtherPlay(self: MediaSource, fn: () => void) { const handler = (e: Event) => { if ((e as CustomEvent<MediaSource>).detail !== self) fn(); }; window.addEventListener(EVENT, handler); return () => window.removeEventListener(EVENT, handler); }
Checked on every build: 15 feed tests against a saved copy of the real feed (hostile titles, broken XML, outages, cache expiry), 8 tests that play Light trail, 6 that check which video comes up next, and a post-build pass over every page for routes, naming, links, alt text and player settings.