diff --git a/app/globals.css b/app/globals.css index 27bb0d73..4d3c859b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -656,6 +656,44 @@ button { animation: b20-confetti-pop 900ms cubic-bezier(0.16, 1, 0.3, 1) both; } +/* A light band sweeping across the inclusion badge once, then twice more, + spaced out — enough to draw the eye to the timing claim without looping + forever. The overlay is a gradient on an ::after so the badge's own + background token stays untouched. */ +@keyframes inclusion-shimmer { + 0% { transform: translateX(-100%); } + 18% { transform: translateX(100%); } + 100% { transform: translateX(100%); } +} + +.inclusion-shimmer { + position: relative; + overflow: hidden; +} + +.inclusion-shimmer::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 105deg, + transparent 30%, + rgba(255, 255, 255, 0.7) 50%, + transparent 70% + ); + animation: inclusion-shimmer 2.4s ease-out 0.3s 3; + pointer-events: none; +} + +[data-theme='dark'] .inclusion-shimmer::after { + background: linear-gradient( + 105deg, + transparent 30%, + rgba(115, 162, 255, 0.35) 50%, + transparent 70% + ); +} + @media (prefers-reduced-motion: reduce) { @keyframes fade-in { from { opacity: 0; } @@ -667,6 +705,7 @@ button { .animate-in-delay-1, .animate-in-delay-2, .animate-in-delay-3 { animation-delay: 0ms; } + .inclusion-shimmer::after { animation: none; } .animate-spin { animation: spin 3s linear infinite; } /* Suppress continuous/infinite motion for reduced-motion users */ .animate-gentle-ping { animation: none; opacity: 0.75; } diff --git a/app/vibenet/demos/200/BlockRunner.tsx b/app/vibenet/demos/200/BlockRunner.tsx new file mode 100644 index 00000000..64df0235 --- /dev/null +++ b/app/vibenet/demos/200/BlockRunner.tsx @@ -0,0 +1,1194 @@ +'use client'; + +// Block Runner: an 8-bit pixel runner in Base colors where the vibenet chain is +// the spawner. Every new head (one per 200 ms under Cobalt) is spat out as a +// block by the boss on the right edge. The player is a round Base-blue glutton: +// tap ONE button — Space, X, or anywhere on the picture — and each tap is a +// bite: the mouth opens briefly, swallows at most one block — one eaten. +// Heavy walls drag slower while the chain keeps coming. Any block +// that reaches him uneaten costs a heart; fill the belly and he goes FULL — +// briefly invulnerable, rolling the tops — then hungry again. A city at night. +// +// Kept deliberately small: one canvas, requestAnimationFrame, hand-drawn +// sprites, synthesized sounds, and the validity demo's JSON-RPC WebSocket +// client for heads with an HTTP poll fallback. + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { Button } from '../../../components/ui/Button'; +import { Text } from '../../../components/ui/Text'; +import { VIBENET_EXPLORER_PATH, VIBENET_RPC_URL, VIBENET_WS_URL } from '../../library/config'; +import { connectJsonRpcStream } from '../validity/lib/stream'; +import { + BOSS_X, + blockLabel, + createGame, + GROUND_Y, + HEIGHT, + MAX_HEARTS, + PLAYER_H, + PLAYER_W, + PLAYER_X, + fullMeter, + INHALE_RANGE, + restart, + tap, + slotOf, + spawnBlock, + step, + WIDTH, + type Game, + type Head, +} from './lib/game'; +import gluttonSheet from './glutton-sheet-v2.png'; +import { + fetchBest, + fetchBoard, + scoreWallet, + shortAddr, + submitScore, + type BoardEntry, +} from './lib/leaderboard'; +import { Sound } from './lib/sound'; +import { + BOSS_CLOSED, + BOSS_OPEN, + CRATE_BODY, + CRATE_FACE, + CRATE_TOP, + drawSprite, + HEART, + HEART_EMPTY, + NIGHT, + NIGHT_FAR, + NIGHT_HORIZON, + type Palette, + spriteHeight, +} from './lib/sprites'; + +const BEST_KEY = 'block-runner:best'; +const SCALE = 3; + +// One committed night look in both site themes. +const P: Palette = NIGHT; + +// The glutton's sprite sheet: seventeen 20 × 16 frames at 1×, blitted at 3× +// with smoothing off so the pixels stay hard. Column order per the spec +// (GULPY-SPRITE-SPEC.md): +const GLUTTON = { + idleA: 0, + happy: 1, + runA: 2, + runB: 3, + inhale: 4, + full: 5, // chew A + hurt: 6, + dead: 7, + inhaleSmall: 8, + gulp: 9, + chewB: 10, + dash: 11, + roundRunA: 12, + roundRunB: 13, + roundInhale: 14, + stuffed: 15, + stuffedB: 16, +}; +const G_W = 20; +const G_H = 16; +let gluttonImg: HTMLImageElement | null = null; +function glutton(): HTMLImageElement { + if (!gluttonImg) { + gluttonImg = new window.Image(); + gluttonImg.src = gluttonSheet.src; + } + return gluttonImg; +} + +function drawGlutton(ctx: CanvasRenderingContext2D, frame: number, x: number, y: number, swell = 1, px = SCALE): void { + const img = glutton(); + if (!img.complete || img.naturalWidth === 0) return; + // The art is 20 wide over a 16-wide hitbox: ears and arms overhang evenly. + // `swell` grows the whole body around the feet — the gulp bulge. + const w = G_W * px * swell; + const h = G_H * px * swell; + const dx = x - ((G_W - 16) / 2) * px - (w - G_W * px) / 2; + const dy = y - (h - G_H * px); + ctx.drawImage(img, frame * G_W, 0, G_W, G_H, Math.round(dx), Math.round(dy), Math.round(w), Math.round(h)); +} + +type RawHead = { number?: string; timestampMs?: string; gasUsed?: string }; + +function parseHead(raw: RawHead | null | undefined): Head | null { + if (!raw || typeof raw.number !== 'string') return null; + const number = Number.parseInt(raw.number, 16); + if (!Number.isFinite(number)) return null; + const ts = typeof raw.timestampMs === 'string' ? Number.parseInt(raw.timestampMs, 16) : NaN; + const gas = typeof raw.gasUsed === 'string' ? Number.parseInt(raw.gasUsed, 16) : 0; + return { number, timestampMs: Number.isFinite(ts) ? ts : null, gasUsed: Number.isFinite(gas) ? gas : 0 }; +} + +function readBest(): number { + try { + return Number.parseInt(window.localStorage.getItem(BEST_KEY) ?? '0', 10) || 0; + } catch { + return 0; + } +} + +function writeBest(score: number): void { + try { + window.localStorage.setItem(BEST_KEY, String(score)); + } catch { + /* Best score is a convenience. */ + } +} + +// Deterministic scenery from a seed so nothing allocates per frame beyond a +// few small objects. Three parallax layers sell the speed: far towers crawl, +// near towers drift, kerb marks on the street whip past. +function hash(i: number): number { + return ((i * 2654435761) >>> 0) % 1000; +} + +// The sky (bands + dither) never scrolls, so it is rendered once to an +// offscreen canvas and blitted each frame. +let skyCache: HTMLCanvasElement | null = null; +function skyBackdrop(): HTMLCanvasElement { + if (skyCache) return skyCache; + const c = document.createElement('canvas'); + c.width = WIDTH; + c.height = HEIGHT; + const ctx = c.getContext('2d')!; + ctx.fillStyle = P.sky; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + // Horizon glow with a classic 8-bit dither: solid low band, then rows of + // checkerboard thinning out upward. + const horizonTop = GROUND_Y - 150; + ctx.fillStyle = NIGHT_HORIZON; + ctx.fillRect(0, horizonTop + 90, WIDTH, GROUND_Y - horizonTop - 90); + for (let row = 0; row < 9; row += 1) { + const y = horizonTop + row * 10; + const density = row + 1; // sparser at the top + for (let x = 0; x < WIDTH; x += 4) { + const cell = (x / 4 + row) % 10; + if (cell < density) ctx.fillRect(x + ((row % 2) * 2), y, 2, 2); + } + } + skyCache = c; + return c; +} + +function drawSky(ctx: CanvasRenderingContext2D, distance: number): void { + ctx.drawImage(skyBackdrop(), 0, 0); + + // A few stars, barely drifting (5% of scroll). + const starOff = distance * 0.02; + for (let i = 0; i < 26; i += 1) { + const x = ((hash(i) * 37 - starOff) % (WIDTH + 20) + WIDTH + 20) % (WIDTH + 20) - 10; + const y = 8 + (hash(i + 91) % 110); + ctx.globalAlpha = 0.25 + (hash(i + 7) % 40) / 100; + ctx.fillStyle = P.w; + ctx.fillRect(Math.round(x), y, 2, 2); + } + ctx.globalAlpha = 1; + + // Moon, small and dim. + ctx.fillStyle = P.c; + ctx.globalAlpha = 0.8; + ctx.fillRect(WIDTH - 120, 30, 22, 22); + ctx.fillStyle = P.w; + ctx.fillRect(WIDTH - 117, 33, 16, 16); + ctx.globalAlpha = 1; + + // Far towers at 10% of scroll: violet silhouettes, no windows. + const farOff = distance * 0.04; + const farW = 64; + const ffirst = Math.floor(farOff / farW) - 1; + ctx.fillStyle = NIGHT_FAR; + for (let i = ffirst; i < ffirst + WIDTH / farW + 3; i += 1) { + const x = i * farW - farOff; + const h = 60 + (hash(i * 7) % 80); + ctx.fillRect(Math.round(x) + 6, GROUND_Y - h, farW - 16, h); + } + + // Near towers at 22% of scroll: near-black slabs, sparse lit slits in white + // and pale cyan — a city mostly asleep. One in six roofs carries a neon sign. + const nearOff = distance * 0.1; + const nearW = 118; + const nfirst = Math.floor(nearOff / nearW) - 1; + for (let i = nfirst; i < nfirst + WIDTH / nearW + 3; i += 1) { + const x = Math.round(i * nearW - nearOff); + const h = 90 + (hash(i * 13) % 130); + const w = nearW - 22 - (hash(i * 5) % 24); + ctx.fillStyle = P.hills; + ctx.fillRect(x, GROUND_Y - h, w, h); + // Window slits: wide and low, lit rarely. + for (let wy = GROUND_Y - h + 10; wy < GROUND_Y - 10; wy += 12) { + for (let wx = x + 7; wx < x + w - 12; wx += 16) { + const seed = hash(wx * 31 + wy * 17 + i); + if (seed % 9 > 1) continue; + ctx.fillStyle = seed % 5 === 0 ? P.c : P.w; + ctx.globalAlpha = 0.9; + ctx.fillRect(wx, wy, 7, 3); + } + } + ctx.globalAlpha = 1; + if (hash(i * 29) % 6 === 0) drawNeonSign(ctx, x + Math.floor(w / 2), GROUND_Y - h); + } +} + +/** Rooftop neon: a framed sign glowing Base blue. */ +function drawNeonSign(ctx: CanvasRenderingContext2D, cx: number, roofY: number): void { + const w = 46; + const h = 16; + const x = cx - w / 2; + const y = roofY - h - 6; + ctx.fillStyle = P.k; + ctx.fillRect(cx - 2, roofY - 6, 4, 6); // post + ctx.fillRect(x - 2, y - 2, w + 4, h + 4); // frame + ctx.fillStyle = P.hills; + ctx.fillRect(x, y, w, h); + ctx.fillStyle = P.B; + ctx.globalAlpha = 0.35; + ctx.fillRect(x - 4, y - 4, w + 8, h + 8); // glow + ctx.globalAlpha = 1; + ctx.font = `bold 11px ${dotoFamily()}`; + ctx.textAlign = 'center'; + ctx.fillStyle = P.B; + ctx.fillText('BASE', cx, y + 12); +} + +function drawGround(ctx: CanvasRenderingContext2D, distance: number, barreling: boolean): void { + // Street: a sidewalk lip, then asphalt. A kerb mark every 112 px — one per + // 200 ms at base speed — keeps the chain's cadence painted on the floor. + ctx.fillStyle = P.railEdge; + ctx.fillRect(0, GROUND_Y, WIDTH, 3); + ctx.fillStyle = P.rail; + ctx.fillRect(0, GROUND_Y + 3, WIDTH, 10); + ctx.fillStyle = P.ground; + ctx.fillRect(0, GROUND_Y + 13, WIDTH, HEIGHT - GROUND_Y - 13); + + const period = 112; + const off = distance % period; + ctx.fillStyle = P.d; + for (let x = -off; x < WIDTH; x += period) { + ctx.fillRect(Math.round(x), GROUND_Y + 3, 3, 10); + } + // Lane line at 1.6× scroll: the fastest layer, the one that reads as speed. + // Longer marks while he barrels along FULL. + const streakOff = (distance * 1.6) % 120; + ctx.fillStyle = P.y; + for (let x = -streakOff; x < WIDTH; x += 120) { + ctx.fillRect(Math.round(x), GROUND_Y + 30, barreling ? 56 : 26, 4); + } +} + +function drawBlock(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number): void { + // Width fixes the sprite scale (16 px face); height is filled with body rows. + const scale = Math.max(1, Math.round(w / 16)); + const faceH = spriteHeight(CRATE_FACE, scale) - scale; + if (h <= faceH) { + ctx.save(); + ctx.beginPath(); + ctx.rect(x, y, w, h); + ctx.clip(); + drawSprite(ctx, CRATE_FACE, x, y, scale, P); + ctx.restore(); + ctx.fillStyle = P.k; + ctx.fillRect(x, y + h - scale, w, scale); + return; + } + drawSprite(ctx, CRATE_FACE, x, y, scale, P); + const bodyH = spriteHeight(CRATE_BODY, scale); + for (let yy = y + faceH; yy < y + h - scale; yy += bodyH) { + ctx.save(); + ctx.beginPath(); + ctx.rect(x, y, w, h - scale); + ctx.clip(); + drawSprite(ctx, CRATE_BODY, x, yy, scale, P); + ctx.restore(); + } + drawSprite(ctx, CRATE_TOP.slice(0, 1), x, y + h - scale, scale, P); +} + +// Canvas font strings cannot resolve CSS var()s, so the real families are +// read off the document once and cached. Doto is the game-UI face; the mono +// stays for the dialog's longer sentences. +let dotoCache: string | null = null; +function dotoFamily(): string { + if (dotoCache === null) { + const v = window.getComputedStyle(document.documentElement).getPropertyValue('--font-doto').trim(); + dotoCache = v ? `${v}, monospace` : 'ui-monospace, monospace'; + } + return dotoCache; +} +let monoCache: string | null = null; +function monoFamily(): string { + if (monoCache === null) { + const v = window.getComputedStyle(document.documentElement).getPropertyValue('--font-roboto-mono').trim(); + monoCache = v ? `${v}, monospace` : 'ui-monospace, monospace'; + } + return monoCache; +} + +/** A row of colored key hints ("R restart · M mute…"), centered. */ +function keysLine(ctx: CanvasRenderingContext2D, cx: number, y: number): void { + const parts: Array<[string, string]> = [ + ['R', P.b], + [' restart · ', P.g], + ['M', P.b], + [' mute · ', P.g], + ['F', P.b], + [' full screen', P.g], + ]; + ctx.font = `bold 12px ${dotoFamily()}`; + ctx.textAlign = 'left'; + const total = parts.reduce((n, [t]) => n + ctx.measureText(t).width, 0); + let x = cx - total / 2; + for (const [t, color] of parts) { + ctx.fillStyle = color; + ctx.fillText(t, x, y); + x += ctx.measureText(t).width; + } + ctx.textAlign = 'center'; +} + +/** The title / game-over card, styled like a riveted arcade bezel. */ +function drawDialog(ctx: CanvasRenderingContext2D, game: Game, frame: number): void { + const dead = game.phase === 'dead'; + const w = 660; + const h = dead ? 190 : 232; + const x = Math.round((WIDTH - w) / 2); + const y = 62; + const METAL = '#8d95a6'; + const METAL_DARK = '#4a5164'; + const METAL_LIGHT = '#c3cad9'; + const INNER = '#0b1030'; + + // Shadow, metal frame, dark inner. + ctx.fillStyle = 'rgba(0,0,0,0.5)'; + ctx.fillRect(x + 7, y + 9, w, h); + ctx.fillStyle = METAL_DARK; + ctx.fillRect(x - 10, y - 10, w + 20, h + 20); + ctx.fillStyle = METAL; + ctx.fillRect(x - 7, y - 7, w + 14, h + 14); + ctx.fillStyle = METAL_LIGHT; + ctx.fillRect(x - 7, y - 7, w + 14, 3); + ctx.fillStyle = P.k; + ctx.fillRect(x - 1, y - 1, w + 2, h + 2); + ctx.fillStyle = INNER; + ctx.fillRect(x, y, w, h); + + // Rivets along the frame. + ctx.fillStyle = METAL_DARK; + for (let rx = x + 8; rx < x + w - 6; rx += 52) { + ctx.fillRect(rx, y - 6, 3, 3); + ctx.fillRect(rx, y + h + 3, 3, 3); + } + // Glowing side lights, three per rail. + for (let i = 0; i < 3; i += 1) { + const ly = y + 30 + i * ((h - 60) / 2); + for (const lx of [x - 6, x + w + 1]) { + ctx.fillStyle = P.D; + ctx.fillRect(lx, ly, 5, 12); + ctx.fillStyle = P.c; + ctx.fillRect(lx + 1, ly + 2, 3, 8); + } + } + + // Gulpy peeking over the top edge, standing on a small notch plate. + ctx.fillStyle = METAL; + ctx.fillRect(WIDTH / 2 - 42, y - 10, 84, 4); + drawGlutton(ctx, GLUTTON.idleA, WIDTH / 2 - 8, y - 10 - 16 * 2 + 6); + + // Title with arrow flourishes. + const titleY = y + 14; + const title = dead ? 'GAME OVER' : 'BLOCK RUNNER'; + ctx.font = `40px ${dotoFamily()}`; + ctx.textAlign = 'center'; + ctx.fillStyle = P.w; + ctx.fillText(title, WIDTH / 2, titleY + 34); + const tw = ctx.measureText(title).width; + ctx.fillStyle = P.b; + const gap = tw / 2 + 16; + ctx.fillRect(WIDTH / 2 - gap - 30, titleY + 20, 24, 3); + ctx.fillRect(WIDTH / 2 - gap - 6, titleY + 17, 5, 9); + ctx.fillRect(WIDTH / 2 + gap + 6, titleY + 20, 24, 3); + ctx.fillRect(WIDTH / 2 + gap + 1, titleY + 17, 5, 9); + + ctx.textAlign = 'center'; + if (dead) { + ctx.font = `26px ${dotoFamily()}`; + ctx.fillStyle = P.y; + ctx.fillText(`${game.score} BLOCKS EATEN`, WIDTH / 2, y + 96); + keysLine(ctx, WIDTH / 2, y + 122); + } else { + // Icon bullets: real game sprites next to each line. + const lines: Array<[() => void, string]> = [ + [() => drawBlock(ctx, x + 34, y + 66, 16, 14), 'Every crate is a real vibenet block — one every 200 ms, spat by the sequencer.'], + [() => drawGlutton(ctx, GLUTTON.happy, x + 30, y + 84, 1, 1.5), 'Tap to bite: one tap, one block. Uneaten blocks cost a heart.'], + [() => drawSprite(ctx, HEART, x + 34, y + 118, 2, P), 'Fill the belly for FULL mode — briefly unstoppable, then hungry again.'], + ]; + ctx.font = `12px ${monoFamily()}`; + ctx.textAlign = 'left'; + lines.forEach(([icon, text], i) => { + icon(); + ctx.fillStyle = P.w; + ctx.fillText(text, x + 62, y + 78 + i * 26); + }); + ctx.textAlign = 'center'; + // Dotted divider with a little face. + ctx.fillStyle = P.D; + for (let dx = x + 30; dx < x + w - 30; dx += 8) { + if (Math.abs(dx - WIDTH / 2) > 26) ctx.fillRect(dx, y + 148, 4, 3); + } + drawGlutton(ctx, GLUTTON.idleA, WIDTH / 2 - 12, y + 136, 1, 1.5); + keysLine(ctx, WIDTH / 2, y + 178); + } + + // TAP TO START button bar. + const bw = 240; + const bh = 30; + const bx = Math.round(WIDTH / 2 - bw / 2); + const by = y + h - bh - 12; + ctx.fillStyle = METAL; + ctx.fillRect(bx - 4, by - 4, bw + 8, bh + 8); + ctx.fillStyle = P.k; + ctx.fillRect(bx - 1, by - 1, bw + 2, bh + 2); + ctx.fillStyle = '#141a3d'; + ctx.fillRect(bx, by, bw, bh); + ctx.fillStyle = P.c; + ctx.fillRect(bx - 4, by + bh / 2 - 4, 3, 8); + ctx.fillRect(bx + bw + 1, by + bh / 2 - 4, 3, 8); + if (Math.floor(frame / 24) % 2 === 0) { + ctx.font = `20px ${dotoFamily()}`; + ctx.textAlign = 'center'; + ctx.fillStyle = P.y; + ctx.fillText(dead ? '\u25b6 TAP TO RUN AGAIN' : '\u25b6 TAP TO START', WIDTH / 2, by + 21); + } + + // Bottom-left hearts + belly chip; bottom-right crate stack (ready only). + if (!dead) { + for (let i = 0; i < 3; i += 1) drawSprite(ctx, HEART, x + 22 + i * 22, y + h - 34, 2, P); + ctx.fillStyle = P.D; + ctx.fillRect(x + 22, y + h - 14, 62, 6); + ctx.fillStyle = P.b; + ctx.fillRect(x + 23, y + h - 13, 40, 4); + drawBlock(ctx, x + w - 50, y + h - 34, 20, 16); + drawBlock(ctx, x + w - 72, y + h - 34, 20, 16); + drawBlock(ctx, x + w - 61, y + h - 52, 20, 16); + } +} + +function render(ctx: CanvasRenderingContext2D, game: Game, frame: number, feedQuiet: boolean, happyFrames: number): void { + ctx.imageSmoothingEnabled = false; + const barreling = game.stuffed; + + ctx.save(); + if (game.shake > 0) { + const s = game.shake; + ctx.translate(Math.round((Math.random() - 0.5) * s), Math.round((Math.random() - 0.5) * s)); + } + + drawSky(ctx, game.distance); + drawGround(ctx, game.distance, barreling); + + // Speed lines while barreling along FULL. + if (barreling) { + ctx.fillStyle = P.w; + for (let i = 0; i < 7; i += 1) { + const y = 40 + ((hash(i + frame / 3) + i * 37) % (GROUND_Y - 60)); + const x = (WIDTH - ((frame * 41 + hash(i) * 3) % (WIDTH + 200))); + ctx.fillRect(Math.round(x), Math.round(y), 40 + (i % 3) * 16, 2); + } + } + + // Boss at the right edge, mouth open just after spitting a block. + const bossY = GROUND_Y - spriteHeight(BOSS_CLOSED, SCALE) - 2; + drawSprite(ctx, game.bossMouth > 0 ? BOSS_OPEN : BOSS_CLOSED, BOSS_X, bossY, SCALE, P); + if (feedQuiet) { + ctx.fillStyle = P.w; + ctx.font = `bold 12px ${dotoFamily()}`; + ctx.textAlign = 'center'; + ctx.fillText('zzz', BOSS_X + 48, bossY - 8); + } + + // Blocks. + for (const b of game.blocks) drawBlock(ctx, Math.round(b.x), Math.round(b.y), b.w, b.h); + + // Afterimages: fading ghosts while he barrels along FULL. + for (const a of game.afterimages) { + ctx.globalAlpha = 0.35 * (1 - a.age / 0.28); + const trail = a.age * 260; + drawGlutton(ctx, GLUTTON.dash, PLAYER_X - trail, a.y); + } + ctx.globalAlpha = 1; + + // Suction stream while inhaling: converging streaks from the range edge + // into the mouth. + const mouthY = game.player.y + 24; + if (game.inhaling && game.phase === 'running' && !game.stuffed) { + ctx.fillStyle = P.c; + for (let i = 0; i < 9; i += 1) { + const phase = ((frame * 16 + i * 47) % INHALE_RANGE); + const x = PLAYER_X + PLAYER_W + INHALE_RANGE - phase; + const spread = (phase / INHALE_RANGE) * 26; + const y = mouthY - 26 + spread + (i % 3) * ((26 - spread) / 1.5); + ctx.globalAlpha = 0.25 + 0.55 * (phase / INHALE_RANGE); + ctx.fillRect(Math.round(x), Math.round(y), 14, 2); + } + ctx.globalAlpha = 1; + } + + // Runner: the glutton, from its sheet. + const gframe = + game.phase === 'dead' + ? GLUTTON.dead + : game.invuln > 0.6 + ? GLUTTON.hurt + : game.stuffed + ? GLUTTON.full // too full to inhale — digest first + : game.puffed > 0 + ? GLUTTON.full // chew: a short mouth-shut beat after every gulp + : happyFrames > 0 + ? GLUTTON.happy // digesting: the satisfied face when a heart refills + : game.inhaling && game.phase === 'running' + ? GLUTTON.inhale + : game.phase === 'ready' + ? GLUTTON.idleA + : [GLUTTON.runA, GLUTTON.runB][Math.floor(frame / 6) % 2]; + // Blink while invulnerable after a hit; lean forward while inhaling. + if (game.invuln <= 0 || Math.floor(frame / 4) % 2 === 0) { + const lean = gframe === GLUTTON.inhale ? 6 : 0; + // The body is the meter: the round sprite tiers carry most of it, and a + // gentle scale plus a gulp pulse blends between tiers. + const gulpPulse = game.puffed > 0 ? 0.12 * (game.puffed / 0.12) : 0; + const swell = 1 + 0.22 * game.fullness + gulpPulse; + drawGlutton(ctx, gframe, PLAYER_X + lean, game.player.y, swell); + } + + // Particles. + for (const p of game.particles) { + ctx.globalAlpha = 1 - p.age / 0.6; + ctx.fillStyle = p.kind === 'shard' ? P.d : p.kind === 'dust' ? P.w : P.B; + const size = p.kind === 'dust' ? 5 : 4; + ctx.fillRect(Math.round(p.x), Math.round(p.y), size, size); + if (p.kind === 'shard') { + ctx.fillStyle = P.k; + ctx.fillRect(Math.round(p.x), Math.round(p.y), 1, size); + } + } + ctx.globalAlpha = 1; + + // Revealed labels, with a dark plate so they read over anything. + ctx.font = `bold 13px ${dotoFamily()}`; + ctx.textAlign = 'center'; + for (const l of game.labels) { + ctx.globalAlpha = Math.max(0, 1 - l.age / 1.2); + const w = ctx.measureText(l.text).width + 12; + ctx.fillStyle = P.B; + ctx.fillRect(Math.round(l.x - w / 2), Math.round(l.y - 13), Math.round(w), 18); + ctx.fillStyle = P.w; + ctx.fillText(l.text, Math.round(l.x), Math.round(l.y)); + } + ctx.globalAlpha = 1; + + ctx.restore(); + + // Belly gauge, HUD-fixed (not shaken): fills as he eats; during FULL it + // drains over the whole state — cruise plus shrink — reaching empty on the + // exact frame he is hungry again. The dialog carries its own hearts and + // belly chip, so the live HUD only draws mid-run. + if (game.phase === 'running') { + const meterW = 120; + const fillFrac = game.stuffed ? fullMeter(game) : game.fullness; + ctx.fillStyle = P.railEdge; + ctx.fillRect(16, 322, meterW + 6, 14); + ctx.fillStyle = P.rail; + ctx.fillRect(19, 325, meterW, 8); + ctx.fillStyle = game.stuffed ? P.y : P.b; + ctx.fillRect(19, 325, Math.round(meterW * Math.max(0, Math.min(1, fillFrac))), 8); + ctx.fillStyle = P.hud; + ctx.font = `bold 10px ${dotoFamily()}`; + ctx.textAlign = 'left'; + ctx.fillText(game.stuffed ? 'FULL!' : 'BELLY', 16, 350); + + // Hearts: pixel hearts next to the meter; empty ones are outlined. + for (let i = 0; i < MAX_HEARTS; i += 1) { + drawSprite(ctx, i < game.hearts ? HEART : HEART_EMPTY, 150 + i * 22, 320, 2, P); + } + } + + // Overlays: an 8-bit dialog — layered pixel border, drop shadow, accent + // bar — instead of a flat slab. + if (game.phase !== 'running') { + drawDialog(ctx, game, frame); + } +} + +export function BlockRunner() { + const canvasRef = useRef(null); + const gameRef = useRef(createGame()); + const soundRef = useRef(null); + const frameRef = useRef(0); + const feedRef = useRef<'connecting' | 'live' | 'polling' | 'quiet'>('connecting'); + const [muted, setMuted] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + // Immersive takeover: the game fills the viewport by default on load. + // (True fullscreen needs a user gesture, so this is the load-time version.) + const [immersive, setImmersive] = useState(true); + const [board, setBoard] = useState([]); + const [chainBest, setChainBest] = useState(0); + const [myAddress, setMyAddress] = useState(null); + const [submitState, setSubmitState] = useState<'' | 'funding' | 'submitting' | 'confirming' | 'done' | 'error'>(''); + const stageRef = useRef(null); + const [score, setScore] = useState(0); + const [best, setBest] = useState(0); + const [phase, setPhase] = useState('ready'); + const [head, setHead] = useState(null); + const [rate, setRate] = useState(0); + const [feed, setFeedState] = useState<'connecting' | 'live' | 'polling' | 'quiet'>('connecting'); + const headTimes = useRef([]); + const lastHeadAt = useRef(0); + const lastFrameAt = useRef(0); + + const setFeed = useCallback((next: typeof feedRef.current | ((f: typeof feedRef.current) => typeof feedRef.current)) => { + const value = typeof next === 'function' ? next(feedRef.current) : next; + feedRef.current = value; + setFeedState(value); + }, []); + + const sound = () => { + if (!soundRef.current) soundRef.current = new Sound(); + return soundRef.current; + }; + + const apply = useCallback((next: Game) => { + gameRef.current = next; + }, []); + + // Head feed: WebSocket newHeads, falling back to a 200 ms HTTP poll. + useEffect(() => { + let cancelled = false; + let stopPoll: (() => void) | null = null; + let closeStream: (() => void) | null = null; + + const onHead = (h: Head) => { + if (cancelled) return; + const now = performance.now(); + lastHeadAt.current = now; + // Rate from the span between oldest and newest head in a ~3 s window. + // Counting heads per fixed bucket makes a steady 200 ms cadence flicker + // between 5.0 and 5.5 as the window edge crosses a head; the span + // measure reads a constant 5.0. + headTimes.current = [...headTimes.current.filter((t) => now - t < 3000), now]; + const span = headTimes.current.length > 1 ? (now - headTimes.current[0]) / 1000 : 0; + setRate(span > 0 ? (headTimes.current.length - 1) / span : 0); + setHead(h); + // If the loop has stalled (a throttled tab), skip the spawn so blocks do + // not pile up at the boss and greet the player with a wall on return. + if (now - lastFrameAt.current > 400) return; + const next = spawnBlock(gameRef.current, h); + if (next !== gameRef.current) sound().tick(); + apply(next); + }; + + const startPoll = () => { + setFeed('polling'); + let last = -1; + let inFlight = false; + const id = window.setInterval(async () => { + if (inFlight) return; + inFlight = true; + try { + const res = await fetch(VIBENET_RPC_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getBlockByNumber', params: ['latest', false] }), + }); + const body = (await res.json()) as { result?: RawHead }; + const h = parseHead(body.result); + if (h && h.number !== last) { + last = h.number; + onHead(h); + } + } catch { + /* Next tick retries. */ + } finally { + inFlight = false; + } + }, 200); + stopPoll = () => window.clearInterval(id); + }; + + const startStream = async (url: string) => { + const stream = connectJsonRpcStream(url); + closeStream = stream.close; + stream.setOnClose(() => { + if (!cancelled) startPoll(); + }); + await stream.ready; + await stream.subscribe(['newHeads'], (result) => { + const h = parseHead(result as RawHead); + if (h) onHead(h); + }); + if (!cancelled) setFeed('live'); + }; + + if (VIBENET_WS_URL) { + void startStream(VIBENET_WS_URL).catch(() => { + if (!cancelled) startPoll(); + }); + } else { + startPoll(); + } + + // Quiet detector: no head for 1.5 s means the chain (or the feed) stalled. + const quiet = window.setInterval(() => { + if (lastHeadAt.current && performance.now() - lastHeadAt.current > 1500) { + setFeed('quiet'); + // No heads means no rate; the last reading would otherwise stick. + headTimes.current = []; + setRate(0); + } else if (lastHeadAt.current) setFeed((f) => (f === 'quiet' ? 'live' : f)); + }, 500); + + return () => { + cancelled = true; + closeStream?.(); + stopPoll?.(); + window.clearInterval(quiet); + }; + }, [apply, setFeed]); + + // Onchain high scores: poll the board, know our own best. + useEffect(() => { + let cancelled = false; + const wallet = scoreWallet(); + // Deferred so the effect body itself does not set state synchronously. + const t = window.setTimeout(() => setMyAddress(wallet.address), 0); + const load = async () => { + try { + const [entries, mine] = await Promise.all([fetchBoard(), fetchBest(wallet.address)]); + if (!cancelled) { + setBoard(entries); + setChainBest(mine); + } + } catch { + /* Board is decoration; the next poll retries (or the chain regenesised). */ + } + }; + void load(); + const id = window.setInterval(() => { + if (!document.hidden) void load(); + }, 12_000); + return () => { + cancelled = true; + window.clearTimeout(t); + window.clearInterval(id); + }; + }, []); + + const postScore = useCallback(async () => { + const s = gameRef.current.score; + if (s <= 0 || submitState === 'funding' || submitState === 'submitting' || submitState === 'confirming') return; + setSubmitState('funding'); + try { + await submitScore(s, (st) => setSubmitState(st)); + setSubmitState('done'); + setChainBest((b) => Math.max(b, s)); + setBoard(await fetchBoard()); + } catch { + setSubmitState('error'); + } + }, [submitState]); + + // Fullscreen: the stage wrapper goes fullscreen; the canvas letterboxes + // inside it with object-contain so the aspect never distorts. + const toggleFullscreen = useCallback(() => { + const stage = stageRef.current; + if (!stage) return; + if (document.fullscreenElement) void document.exitFullscreen(); + else if (stage.requestFullscreen) void stage.requestFullscreen(); + }, []); + + useEffect(() => { + const onChange = () => setIsFullscreen(Boolean(document.fullscreenElement)); + document.addEventListener('fullscreenchange', onChange); + return () => document.removeEventListener('fullscreenchange', onChange); + }, []); + + // Input. + const doTap = useCallback(() => apply(tap(gameRef.current)), [apply]); + const doRestart = useCallback(() => { + setSubmitState((st) => (st === 'done' || st === 'error' ? '' : st)); + apply(restart(gameRef.current)); + }, [apply]); + + useEffect(() => { + const isTyping = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + return Boolean(target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (isTyping(e)) return; + sound().unlock(); + if (e.repeat) return; + if (e.code === 'Space' || e.code === 'KeyX' || e.code === 'Enter') { + e.preventDefault(); + if (gameRef.current.phase === 'dead') doRestart(); + else doTap(); + } else if (e.code === 'KeyR') { + // Same as Space on the game-over screen; never resets a live run. + if (gameRef.current.phase === 'dead') doRestart(); + } else if (e.code === 'KeyF') { + toggleFullscreen(); + } else if (e.code === 'KeyM') { + const next = !sound().muted; + sound().setMuted(next); + setMuted(next); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => { + window.removeEventListener('keydown', onKeyDown); + }; + }, [doTap, doRestart, toggleFullscreen]); + + // Main loop. requestAnimationFrame when the tab is visible; a 60 Hz timer + // takes over when the browser stops issuing frames (hidden or throttled), + // so the simulation never freezes mid-run. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = WIDTH * dpr; + canvas.height = HEIGHT * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + let last = performance.now(); + let raf = 0; + let shownScore = -1; + let shownPhase: Game['phase'] | null = null; + // Satisfied pose: a short victory face when a heart is won back. + let happyUntil = 0; + + const tick = (now: number) => { + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + lastFrameAt.current = now; + frameRef.current += 1; + if (frameRef.current === 1) { + setBest(readBest()); + setMuted(sound().muted); + } + + const next = step(gameRef.current, dt); + gameRef.current = next; + + if (next.events.includes('heart')) happyUntil = frameRef.current + 40; + for (const ev of next.events) { + if (ev === 'inhale-on') sound().inhaleOn(); + else if (ev === 'gulp') sound().gulp(); + else if (ev === 'stuffed') sound().stuffed(); + else if (ev === 'land') sound().land(); + else if (ev === 'die') sound().die(); + else if (ev === 'hurt') sound().hurt(); + else if (ev === 'heart') sound().heart(); + else if (ev === 'thud') sound().thud(); + } + if (next.score !== shownScore) { + shownScore = next.score; + setScore(next.score); + } + if (next.phase !== shownPhase) { + shownPhase = next.phase; + setPhase(next.phase); + if (next.phase === 'running') sound().startMusic(); + else sound().stopMusic(); + if (next.phase === 'dead') { + setBest((b) => { + const nb = Math.max(b, next.score); + if (nb !== b) writeBest(nb); + return nb; + }); + } + } + + render(ctx, next, frameRef.current, feedRef.current === 'quiet', Math.max(0, happyUntil - frameRef.current)); + }; + + let lastRafAt = 0; + const loop = (now: number) => { + lastRafAt = now; + tick(now); + raf = window.requestAnimationFrame(loop); + }; + raf = window.requestAnimationFrame(loop); + // Once frames stop arriving, the timer carries the whole simulation at + // 60 Hz until they resume. + const fallback = window.setInterval(() => { + const now = performance.now(); + if (now - lastRafAt > 100) tick(now); + }, 1000 / 60); + if (process.env.NODE_ENV !== 'production') { + // Dev-only peek so a script (or a curious tab) can read the live state. + (window as unknown as { __blockRunner?: unknown }).__blockRunner = { get: () => gameRef.current }; + } + return () => { + window.cancelAnimationFrame(raf); + window.clearInterval(fallback); + soundRef.current?.stopMusic(0.2); + }; + }, []); + + const onPointerDown = (e: React.PointerEvent) => { + e.preventDefault(); + sound().unlock(); + if (gameRef.current.phase === 'dead') return doRestart(); + // One action everywhere: a tap is a bite. + doTap(); + }; + + const slot = head ? slotOf(head.timestampMs) : null; + const feedLabel = + feed === 'live' ? 'live · newHeads' : feed === 'polling' ? 'polling · 200 ms' : feed === 'quiet' ? 'chain quiet' : 'connecting'; + + return ( +
+
+
+ + + + +
+
+ +
+ e.preventDefault()} + className={ + isFullscreen || immersive + ? 'h-full w-full select-none object-contain [image-rendering:pixelated]' + : 'mx-auto w-full max-w-[800px] select-none rounded-xl bg-[#12093a] [image-rendering:pixelated]' + } + style={{ aspectRatio: `${WIDTH} / ${HEIGHT}`, touchAction: 'none', background: P.sky, WebkitTouchCallout: 'none' }} + aria-label={`Block Runner. ${phase === 'running' ? `Score ${score}.` : 'Tap space or the screen to start eating.'}`} + role="img" + /> + {/* Once a submit starts, the board's own poll can raise chainBest past + the score mid-flight — keep the prompt mounted on submitState so the + progress and the success line survive their own success. */} +
+
+ Latest block +
+ {head ? head.number.toLocaleString() : '———'} + {slot ? {slot} : null} +
+
+
+ + + +
+
+ {phase === 'dead' && score > 0 && (score > chainBest || submitState !== '') ? ( +
+
+ {submitState === 'done' ? ( + + On the board — tap the game to run again. + + ) : ( + <> + + New onchain best! + + + + )} +
+
+ ) : null} + {immersive && !isFullscreen ? ( +
+ + + +
+ ) : null} +
+ +
+
+ Onchain high scores +
+ {/* Once a submit starts, the board's own poll can raise chainBest past + the score mid-flight — keep the prompt mounted on submitState so the + progress and the success line survive their own success. */} +
+
+ Latest block +
+ {head ? head.number.toLocaleString() : '———'} + {slot ? {slot} : null} +
+
+
+ + + +
+
+ {phase === 'dead' && score > 0 && (score > chainBest || submitState !== '') ? ( + + ) : null} + {submitState === 'error' ? ( + + Submission failed — try again. + + ) : null} +
+
+
+ {board.length === 0 ? ( + + No scores onchain yet — die proudly and post yours. + + ) : ( +
    + {board.map((e, i) => ( +
  • + {i + 1} + + {shortAddr(e.player)} + + {myAddress && e.player.toLowerCase() === myAddress.toLowerCase() ? ( + you + ) : null} + {e.score} +
  • + ))} +
+ )} +
+ + Scores live in a contract on vibenet — no names, only the wallet that posted them. Your browser plays as{' '} + {myAddress ? {shortAddr(myAddress as `0x${string}`)} : '…'} and the + faucet funds its first submission. A devnet regenesis wipes the board. + +
+ + + The boss on the right is the sequencer: every block it spits is a real vibenet block, pushed over WebSocket as + it lands, one every 200 ms. Block height follows gas used. Swallow one to read its number and slot + {head ? `, like ${blockLabel(head)}` : ''}. Tap Space, X, or the picture to bite — each tap opens the + mouth for one block, and the next block needs the next tap. Heavy walls drag in slower. Every block that + reaches him uneaten costs a heart. Fill the belly gauge and he is FULL — for a few seconds nothing can hurt him and he rolls + along the top of the blocks while the gauge drains, then he is hungry again. The beat runs at one note per + block. R restarts after a run, M mutes music and effects, F goes full screen. + +
+ ); +} + +function GameStat({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + diff --git a/app/vibenet/demos/200/GULPY-SPRITE-SPEC.md b/app/vibenet/demos/200/GULPY-SPRITE-SPEC.md new file mode 100644 index 00000000..8f93a2cd --- /dev/null +++ b/app/vibenet/demos/200/GULPY-SPRITE-SPEC.md @@ -0,0 +1,70 @@ +# Gulpy sprite sheet — requirements v2 + +The character sheet for Block Runner (`/vibenet/demos/200`). Version 1 is +`glutton-sheet.png` (8 frames); this spec adds the frames the game now fakes +with scaling: progressive inhale, a real chew, and fullness body tiers +(the body is the meter — it visibly grows as he eats, Kirby-style, drawn +original — no Nintendo art). + +## File format (non-negotiable) + +- **One PNG, single row, fully transparent background.** No baked shadows, + glow, or backdrop. +- **Identical cells: 20 × 16 px per frame**, character centered, **feet on the + bottom row of the cell** in every frame. Same body proportions throughout — + no per-frame size drift. +- **True 1× resolution preferred** (the file is exactly `20·N × 16`): hard + pixels, no anti-aliasing. Aseprite/Piskel export this directly. + *Acceptable fallback:* painted large at a consistent ~12× like v1 — we have a + resampler — but 1× skips that step and loses nothing. +- **Facing right.** Gulpy runs right and eats to the right. +- Deliver as `glutton-sheet.png` (drop-in replacement path). + +## Palette (for harmony with the night scene) + +Body blues `#266eff` `#0052ff` `#4684ff`, pale accents `#92b6ff` `#f5f8ff`, +metal grays `#dadada` `#9a9a9a` `#6f6f6f`, outline near-black (pure `#000000` +fine), yellow accent `#ffe436`. Not strict — but keep blues in this family. + +## Frames, in column order + +Columns 0–7 match v1 so the sheet stays a drop-in; 8+ are new. + +| Col | Name | Shows | +|-----|---------------|--------------------------------------------------------------| +| 0 | idle | standing, eyes open (title screen) | +| 1 | happy | closed-eye smile (heart regained / satisfied) | +| 2 | run A | run pose, legs apart | +| 3 | run B | run pose, legs together | +| 4 | inhale wide | mouth at maximum, body leaning right | +| 5 | chew A | mouth shut, cheeks bulged | +| 6 | hurt | wince (post-hit flash) | +| 7 | dead | X eyes | +| 8 | inhale small | mouth part-open — start of the suck | +| 9 | gulp | mouth mid-close, bulge passing into the body | +| 10 | chew B | cheeks bulged to the other side (pairs with 5 for munching) | +| 11 | dash | full forward lean, action lines okay inside the cell | +| 12 | round run A | ~half-full body: visibly rounder, same feet position | +| 13 | round run B | rounder, legs together | +| 14 | round inhale | rounder body, mouth wide | +| 15 | stuffed | maximum roundness, strained face — too full to eat | +| 16 | stuffed B | stuffed wobble variant (optional; repeat 15 if skipped) | + +17 columns → file is **340 × 16** at 1×. + +## How the game will use them + +- Inhale animates 8 → 4 (small → wide) instead of one static frame. +- Each swallow: 4 → 9 (gulp) → 5/10 alternating (chew) → back to 8/4. +- Fullness < 0.45 uses columns 0–11; ≥ 0.45 swaps run/inhale to 12–14; + stuffed locks to 15/16. Scaling then only fine-tunes between tiers. +- Rounder frames may widen the silhouette *within* the 20 px cell (ears can + touch the edges); the feet baseline must not move. + +## Acceptance checklist + +- [ ] transparent background, single row, 20×16 cells +- [ ] every frame same baseline (feet at cell bottom) and consistent scale +- [ ] faces right in every frame +- [ ] columns 0–7 semantically identical to v1 +- [ ] 1× hard pixels (or consistent ~12× paint, flagged so we resample) diff --git a/app/vibenet/demos/200/contract/BlockRunnerScores.json b/app/vibenet/demos/200/contract/BlockRunnerScores.json new file mode 100644 index 00000000..1457b031 --- /dev/null +++ b/app/vibenet/demos/200/contract/BlockRunnerScores.json @@ -0,0 +1,107 @@ +{ + "abi": [ + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "player", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint96", + "name": "score", + "type": "uint96" + } + ], + "name": "NewScore", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "best", + "outputs": [ + { + "internalType": "uint96", + "name": "", + "type": "uint96" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "board", + "outputs": [ + { + "internalType": "address", + "name": "player", + "type": "address" + }, + { + "internalType": "uint96", + "name": "score", + "type": "uint96" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint96", + "name": "score", + "type": "uint96" + } + ], + "name": "submit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "top", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "player", + "type": "address" + }, + { + "internalType": "uint96", + "name": "score", + "type": "uint96" + } + ], + "internalType": "struct BlockRunnerScores.Entry[10]", + "name": "", + "type": "tuple[10]" + } + ], + "stateMutability": "view", + "type": "function" + } + ], + "bytecode": "0x6080604052348015600e575f5ffd5b506106608061001c5f395ff3fe608060405234801561000f575f5ffd5b506004361061004a575f3560e01c806312ca564f1461004e57806328ed6e3f14610063578063d53bb092146100a2578063fe6dcdba146100e2575b5f5ffd5b61006161005c36600461051b565b6100f7565b005b610076610071366004610548565b610446565b604080516001600160a01b0390931683526001600160601b039091166020830152015b60405180910390f35b6100ca6100b036600461055f565b600a6020525f90815260409020546001600160601b031681565b6040516001600160601b039091168152602001610099565b6100ea610475565b6040516100999190610585565b5f816001600160601b0316116101415760405162461bcd60e51b815260206004820152600a6024820152697a65726f2073636f726560b01b60448201526064015b60405180910390fd5b335f908152600a60205260409020546001600160601b039081169082161161019b5760405162461bcd60e51b815260206004820152600d60248201526c1b9bdd081e5bdd5c8818995cdd609a1b6044820152606401610138565b335f818152600a602090815260409182902080546bffffffffffffffffffffffff19166001600160601b03861690811790915591519182527f41962b14da4c981f83dac8e262111e9ad37614600f0ea9f119b03af61c544162910160405180910390a2600a5f5b600a81101561023e57335f82600a811061021e5761021e6105d4565b01546001600160a01b0316036102365780915061023e565b600101610202565b5080600a03610284576009546001600160a01b0316158015906102775750600954600160a01b90046001600160601b0390811690831611155b15610280575050565b5060095b604080518082019091523381526001600160601b03831660208201525f82600a81106102b2576102b26105d4565b82516020909301516001600160601b0316600160a01b026001600160a01b03909316929092179101555b5f8111801561033a57505f6102f26001836105fc565b600a8110610302576103026105d4565b0154600160a01b90046001600160601b03165f82600a8110610326576103266105d4565b0154600160a01b90046001600160601b0316115b15610442575f8061034c6001846105fc565b600a811061035c5761035c6105d4565b604080518082019091529101546001600160a01b0381168252600160a01b90046001600160601b0316602082015290505f82600a811061039e5761039e6105d4565b015f6103ab6001856105fc565b600a81106103bb576103bb6105d4565b8254910180546001600160a01b0319166001600160a01b03909216918217815591546001600160601b03600160a01b918290041602179055805f83600a8110610406576104066105d4565b82516020909301516001600160601b0316600160a01b026001600160a01b03909316929092179101558161043981610615565b925050506102dc565b5050565b5f81600a8110610454575f80fd5b01546001600160a01b0381169150600160a01b90046001600160601b031682565b61047d6104e2565b6040805161014081019091525f600a81835b828210156104d95760408051808201909152848301546001600160a01b0381168252600160a01b90046001600160601b03166020808301919091529082526001909201910161048f565b50505050905090565b604051806101400160405280600a905b604080518082019091525f80825260208201528152602001906001900390816104f25790505090565b5f6020828403121561052b575f5ffd5b81356001600160601b0381168114610541575f5ffd5b9392505050565b5f60208284031215610558575f5ffd5b5035919050565b5f6020828403121561056f575f5ffd5b81356001600160a01b0381168114610541575f5ffd5b610280810181835f5b600a8110156105cb57815180516001600160a01b031684526020908101516001600160601b0316818501526040909301929091019060010161058e565b50505092915050565b634e487b7160e01b5f52603260045260245ffd5b634e487b7160e01b5f52601160045260245ffd5b8181038181111561060f5761060f6105e8565b92915050565b5f81610623576106236105e8565b505f19019056fea264697066735822122065c9501d2b15d374c83f39a8c53052366e0254c856e9a850d97efc826538ddcc64736f6c634300081c0033", + "solc": "0.8.28", + "optimizer": 200 +} diff --git a/app/vibenet/demos/200/contract/BlockRunnerScores.sol b/app/vibenet/demos/200/contract/BlockRunnerScores.sol new file mode 100644 index 00000000..bfe512da --- /dev/null +++ b/app/vibenet/demos/200/contract/BlockRunnerScores.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// Block Runner's onchain high-score board (vibenet devnet). +/// No names — a score belongs to the wallet that submitted it, and only a +/// wallet's best is kept. The top ten live in a fixed array, sorted on write +/// so reading is one call. NOTE: vibenet is regenesised periodically; when +/// that happens, redeploy with deploy.mjs and update the address constant in +/// ../lib/leaderboard.ts. +contract BlockRunnerScores { + struct Entry { + address player; + uint96 score; + } + + Entry[10] public board; + mapping(address => uint96) public best; + + event NewScore(address indexed player, uint96 score); + + function submit(uint96 score) external { + require(score > 0, "zero score"); + require(score > best[msg.sender], "not your best"); + best[msg.sender] = score; + emit NewScore(msg.sender, score); + + // Find the sender's existing slot, or take the last one if they beat it. + uint256 idx = 10; + for (uint256 i = 0; i < 10; i++) { + if (board[i].player == msg.sender) { + idx = i; + break; + } + } + if (idx == 10) { + if (board[9].player != address(0) && score <= board[9].score) return; + idx = 9; + } + board[idx] = Entry(msg.sender, score); + while (idx > 0 && board[idx].score > board[idx - 1].score) { + Entry memory t = board[idx - 1]; + board[idx - 1] = board[idx]; + board[idx] = t; + idx--; + } + } + + function top() external view returns (Entry[10] memory) { + return board; + } +} diff --git a/app/vibenet/demos/200/contract/deploy.mjs b/app/vibenet/demos/200/contract/deploy.mjs new file mode 100644 index 00000000..e236c82f --- /dev/null +++ b/app/vibenet/demos/200/contract/deploy.mjs @@ -0,0 +1,31 @@ +// Deploy BlockRunnerScores to vibenet and print the address. +// +// node app/vibenet/demos/200/contract/deploy.mjs +// +// Uses DEPLOYER_PK if set; otherwise generates a throwaway key and funds it +// from the vibenet faucet. After a vibenet regenesis, run this again and put +// the printed address into app/vibenet/demos/200/lib/leaderboard.ts. +import { readFileSync } from 'node:fs'; +import { createPublicClient, createWalletClient, http } from 'viem'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; + +const RPC = process.env.VIBENET_RPC_URL || 'https://rpc.vibes.base.org'; +const FAUCET = 'https://api.vibes.base.org/api/vibenet/faucet/drip'; +const chain = { id: 84538453, name: 'Vibenet', nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, rpcUrls: { default: { http: [RPC] } } }; +const artifact = JSON.parse(readFileSync(new URL('./BlockRunnerScores.json', import.meta.url), 'utf8')); + +const pk = process.env.DEPLOYER_PK ?? generatePrivateKey(); +const account = privateKeyToAccount(pk); +const pub = createPublicClient({ chain, transport: http(RPC) }); +const wallet = createWalletClient({ account, chain, transport: http(RPC) }); + +if ((await pub.getBalance({ address: account.address })) === 0n) { + console.log('funding deployer', account.address, 'from faucet…'); + const res = await fetch(FAUCET, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ address: account.address }) }); + if (!res.ok) throw new Error(`faucet: ${res.status} ${await res.text()}`); + while ((await pub.getBalance({ address: account.address })) === 0n) await new Promise((r) => setTimeout(r, 500)); +} + +const hash = await wallet.deployContract({ abi: artifact.abi, bytecode: artifact.bytecode }); +const receipt = await pub.waitForTransactionReceipt({ hash, pollingInterval: 200 }); +console.log('BlockRunnerScores deployed at:', receipt.contractAddress); diff --git a/app/vibenet/demos/200/glutton-sheet-v2.png b/app/vibenet/demos/200/glutton-sheet-v2.png new file mode 100644 index 00000000..747de984 Binary files /dev/null and b/app/vibenet/demos/200/glutton-sheet-v2.png differ diff --git a/app/vibenet/demos/200/layout.tsx b/app/vibenet/demos/200/layout.tsx new file mode 100644 index 00000000..182f20a5 --- /dev/null +++ b/app/vibenet/demos/200/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; +import type { ReactNode } from 'react'; + +// Unlisted: not in the catalogue, nav, or sitemap, and asked not to be indexed. +export const metadata: Metadata = { + title: 'Block Runner · Vibenet', + description: 'A pixel runner paced by vibenet’s 200 ms blocks. Swallow a block to read its number and slot.', + robots: { index: false, follow: false }, +}; + +export default function BlockRunnerLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/app/vibenet/demos/200/lib/game.test.ts b/app/vibenet/demos/200/lib/game.test.ts new file mode 100644 index 00000000..7141de98 --- /dev/null +++ b/app/vibenet/demos/200/lib/game.test.ts @@ -0,0 +1,460 @@ +import { describe, expect, it } from 'vitest'; + +import { + blockSizeFor, + blockLabel, + BOSS_X, + createGame, + fullMeter, + INHALE_RANGE, + GROUND_Y, + MAX_HEARTS, + MOUTH_Y, + PLAYER_H, + PLAYER_X, + restart, + RESTART_GRACE, + PLAYER_W, + tap, + slotOf, + spawnBlock, + speedFor, + start, + step, + weightFor, + type Block, + type Game, + type Head, +} from './game'; + +const head = (number: number, over: Partial = {}): Head => ({ + number, + timestampMs: 1_788_419_137_200, + gasUsed: 220_000, + ...over, +}); + +const zeroRng = () => 0.5; + +const blk = (over: Partial & Pick): Block => ({ + id: 1, + y: GROUND_Y - over.h, + vy: 0, + landed: true, + w: 48, + weight: 1, + number: 1, + timestampMs: null, + ...over, +}); + +function run(game: Game, seconds: number, dt = 1 / 120): Game { + let g = game; + for (let t = 0; t < seconds; t += dt) g = step(g, dt, zeroRng); + return g; +} + +describe('slotOf / blockLabel', () => { + it('names the 200 ms slot and formats the block number', () => { + expect(slotOf(1_788_419_137_200)).toBe('.200'); + expect(slotOf(1_788_419_137_000)).toBe('.000'); + expect(slotOf(null)).toBeNull(); + expect(blockLabel({ number: 148_646, timestampMs: 1_788_419_137_200 })).toBe('148,646 · .200'); + expect(blockLabel({ number: 16, timestampMs: null })).toBe('16'); + }); +}); + +describe('blockSizeFor', () => { + it('grows continuously with gas: deposits-only blocks are small, busy blocks are tall and wide', () => { + const quiet = blockSizeFor(200_000); + const mid = blockSizeFor(500_000); + const busy = blockSizeFor(1_000_000, 60); + expect(quiet.w).toBe(32); + expect(mid.w).toBe(48); + expect(busy.w).toBe(64); + expect(quiet.h).toBeLessThan(mid.h); + expect(mid.h).toBeLessThan(busy.h); + expect(busy.h).toBeLessThanOrEqual(112); + }); + + it('warms up: early in a run even busy blocks stay small, then grow', () => { + const early = blockSizeFor(1_000_000, 0, 1, 1); + const later = blockSizeFor(1_000_000, 0, 1, 20); + expect(early.w).toBe(32); + expect(early.h).toBeLessThan(later.h); + expect(later.w).toBe(48); + }); + + it('gets bigger as the score climbs, and mixes widths every fourth block', () => { + expect(blockSizeFor(300_000, 60).h).toBeGreaterThan(blockSizeFor(300_000, 0).h); + expect(blockSizeFor(300_000, 60, 4).w).toBe(48); + expect(blockSizeFor(300_000, 60, 5).w).toBe(32); + // Early in a run the widest size is held back. + expect(blockSizeFor(1_000_000, 0).w).toBe(48); + }); +}); + +describe('speedFor', () => { + it('ramps with score and caps', () => { + expect(speedFor(0)).toBe(560); + expect(speedFor(10)).toBe(590); + expect(speedFor(1_000)).toBe(720); + }); +}); + +describe('spawnBlock', () => { + it('ignores heads until the run starts', () => { + expect(spawnBlock(createGame(), head(1)).blocks).toHaveLength(0); + }); + + it('queues the head, then spits it from the boss mouth on the next beat', () => { + let g = spawnBlock(start(createGame()), head(148_646)); + expect(g.pending).toHaveLength(1); + expect(g.blocks).toHaveLength(0); + g = step(g, 1 / 120, zeroRng); + expect(g.pending).toHaveLength(0); + expect(g.blocks).toHaveLength(1); + expect(g.blocks[0].x).toBeGreaterThanOrEqual(BOSS_X - 60); + expect(g.blocks[0].number).toBe(148_646); + expect(g.blocks[0].landed).toBe(false); + expect(g.bossMouth).toBeGreaterThan(0); + }); + + it('spits queued heads exactly one per 200 ms, so spacing stays even', () => { + // Three heads arrive in a burst (network jitter): they must NOT spawn in + // a clump. + let g = start(createGame()); + g = spawnBlock(g, head(1)); + g = spawnBlock(g, head(2)); + g = spawnBlock(g, head(3)); + g = step(g, 1 / 120, zeroRng); + expect(g.blocks).toHaveLength(1); + g = run(g, 0.21); + expect(g.blocks).toHaveLength(2); + g = run(g, 0.21); + expect(g.blocks).toHaveLength(3); + // Spacing between consecutive spits ≈ speed × 0.2 (equal gaps). + const xs = [...g.blocks].sort((a, b) => a.x - b.x).map((b) => b.x); + const gap1 = xs[1] - xs[0]; + const gap2 = xs[2] - xs[1]; + expect(Math.abs(gap1 - gap2)).toBeLessThan(8); + }); + + it('a spat block arcs, falls, and thuds onto the rail with dust', () => { + let g = spawnBlock(start(createGame()), head(1)); + let thudded = false; + for (let t = 0; t < 0.6; t += 1 / 120) { + g = step(g, 1 / 120, zeroRng); + if (g.events.includes('thud')) thudded = true; + } + expect(thudded).toBe(true); + expect(g.blocks[0].landed).toBe(true); + expect(g.blocks[0].y + g.blocks[0].h).toBe(GROUND_Y); + }); +}); + +describe('inhale', () => { + it('start the run from the ready screen', () => { + expect(tap(createGame()).phase).toBe('running'); + }); + +}); + +describe('weightFor / swallowing', () => { + it('wider blocks are heavier', () => { + expect(weightFor(32)).toBe(1); + expect(weightFor(48)).toBe(2); + expect(weightFor(64)).toBe(2); + }); + + it('inhaling drags the nearest block in and swallows it, counting one eaten', () => { + let g = tap(start(createGame())); + g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 200, h: 60, weight: 2, number: 148_646, timestampMs: 1_788_419_137_200 })] }; + g = run(g, 0.2); + expect(g.blocks).toHaveLength(0); + expect(g.score).toBe(1); + expect(g.labels[0]?.text).toBe('148,646 · .200'); + expect(g.puffed).toBeGreaterThan(0); + }); + + it('a heavy wall drags in slower than a light block', () => { + const timeToEat = (weight: number) => { + let g = tap(start(createGame())); + g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 250, h: 60, weight })] }; + let t = 0; + while (g.blocks.length > 0 && t < 2) { + g = step(g, 1 / 120, zeroRng); + t += 1 / 120; + } + return t; + }; + expect(timeToEat(2)).toBeGreaterThan(timeToEat(1)); + }); + + it('blocks beyond inhale range are left alone', () => { + let g = tap(start(createGame())); + const farX = PLAYER_X + PLAYER_W + INHALE_RANGE + 100; + g = { ...g, blocks: [blk({ id: 9, x: farX, h: 60 })] }; + g = step(g, 1 / 120, zeroRng); + // It only scrolled; suction did not add pull. + expect(g.blocks[0].x).toBeGreaterThan(farX - speedFor(0) / 60 - 1); + }); + + it('contact while inhaling is a meal, not a crash', () => { + let g = tap(start(createGame())); + g = { ...g, blocks: [blk({ x: PLAYER_X + 80, h: 96, weight: 2 })] }; + g = run(g, 0.3); + expect(g.hearts).toBe(MAX_HEARTS); + expect(g.phase).toBe('running'); + expect(g.blocks).toHaveLength(0); + expect(g.score).toBeGreaterThanOrEqual(1); + }); + + it('the bite window expires on its own — holding gives nothing extra', () => { + let g = tap(start(createGame())); + expect(g.inhaling).toBe(true); + g = run(g, 0.35); + expect(g.inhaling).toBe(false); + // A block arriving after expiry is not eaten by the stale tap. + g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 200, h: 60 })] }; + g = step(g, 1 / 120, zeroRng); + expect(g.blocks).toHaveLength(1); + }); + + it('one tap eats at most one block — the second needs a second tap', () => { + let g = tap(start(createGame())); + g = { + ...g, + blocks: [ + blk({ id: 9, x: PLAYER_X + 120, h: 60 }), + blk({ id: 10, x: PLAYER_X + 420, h: 60 }), + ], + }; + g = run(g, 0.25); + expect(g.score).toBe(1); + expect(g.inhaling).toBe(false); + g = tap(g); + g = run(g, 0.35); + expect(g.score).toBe(2); + }); +}); + +describe('fullness / stuffed', () => { + it('every block is a collision unless eaten: the first touch costs one heart', () => { + let g = start(createGame()); + g = { ...g, blocks: [blk({ x: PLAYER_X + 80, h: 30 })] }; + let hurt = false; + for (let t = 0; t < 0.4; t += 1 / 120) { + g = step(g, 1 / 120, zeroRng); + if (g.events.includes('hurt')) hurt = true; + } + expect(hurt).toBe(true); + expect(g.hearts).toBe(MAX_HEARTS - 1); + expect(g.phase).toBe('running'); + // He stays on the road — blocks are never walked on in normal play. + expect(g.player.y).toBe(GROUND_Y - PLAYER_H); + }); + + it('a heavy wall still counts as ONE eaten, not its weight', () => { + let g = tap(start(createGame())); + g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 200, h: 96, weight: 2 })] }; + g = run(g, 0.4); + expect(g.blocks).toHaveLength(0); + expect(g.score).toBe(1); + }); + + it('each swallow fills the belly; heavier blocks fill it more', () => { + const eat = (weight: number) => { + let g = tap(start(createGame())); + g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 200, h: 60, weight })] }; + g = run(g, 0.3); + return g.fullness; + }; + expect(eat(1)).toBeGreaterThan(0); + expect(eat(2)).toBeGreaterThan(eat(1)); + }); + + it('eating to the max enters FULL mode: no more eating, and a power fanfare', () => { + let g = tap(start(createGame())); + g = { ...g, fullness: 0.95 }; + g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 200, h: 60, weight: 2 })] }; + let announced = false; + for (let t = 0; t < 0.4; t += 1 / 120) { + g = step(g, 1 / 120, zeroRng); + if (g.events.includes('stuffed')) announced = true; + } + expect(g.stuffed).toBe(true); + expect(announced).toBe(true); + expect(g.fullTime).toBeGreaterThan(2); + // A new block is not eaten while FULL — it just rolls under him. + g = { ...g, blocks: [blk({ id: 10, x: PLAYER_X + 260, h: 60, weight: 1 })] }; + const before = g.score; + g = run(g, 0.15); + expect(g.score).toBe(before); + }); + + it('while FULL he rolls over even the tallest wall unharmed', () => { + let g = { ...start(createGame()), fullness: 1, stuffed: true, fullTime: 4 }; + g = { ...g, blocks: [blk({ x: PLAYER_X + 60, h: 96, weight: 2 })] }; + g = run(g, 0.25); + expect(g.hearts).toBe(MAX_HEARTS); + expect(g.phase).toBe('running'); + // He is standing on top of it. + expect(g.player.y).toBeLessThan(GROUND_Y - PLAYER_H - 40); + }); + + it('FULL mode times out, he shrinks back to empty, and is hungry again', () => { + let g = { ...tap(start(createGame())), fullness: 1, stuffed: true, fullTime: 0.2 }; + g = run(g, 1); + expect(g.fullness).toBe(0); + expect(g.stuffed).toBe(false); + // Hungry again: a fresh tap eats a block in range. + g = tap({ ...g, blocks: [blk({ id: 11, x: PLAYER_X + 200, h: 60, weight: 1 })] }); + g = run(g, 0.3); + expect(g.score).toBeGreaterThan(0); + }); + + it('outside FULL mode, fullness does not drain on its own', () => { + let g = { ...start(createGame()), fullness: 0.5 }; + g = run(g, 2); + expect(g.fullness).toBe(0.5); + }); +}); + +describe('blocks are never road', () => { + it('a wall cannot be climbed — it hits him', () => { + let g = start(createGame()); + g = { ...g, blocks: [blk({ id: 2, x: PLAYER_X + 70, h: 96, weight: 2 })] }; + let hurt = false; + for (let t = 0; t < 0.4; t += 1 / 120) { + g = step(g, 1 / 120, zeroRng); + if (g.events.includes('hurt')) hurt = true; + } + expect(hurt).toBe(true); + }); + + it('after FULL ends he ghost-falls through the stream to the road, unharmed', () => { + let g = { ...start(createGame()), fullness: 0.01, stuffed: true, fullTime: 0 }; + // Up on a wall with a continuous stream below — the exact situation that + // used to strand him surfing the tops forever. + g = { + ...g, + player: { y: GROUND_Y - 96 - PLAYER_H, vy: 0, grounded: true }, + blocks: [ + blk({ id: 1, x: PLAYER_X - 10, h: 96, weight: 2 }), + blk({ id: 2, x: PLAYER_X + 60, h: 96, weight: 2 }), + blk({ id: 3, x: PLAYER_X + 130, h: 60 }), + ], + }; + let g2 = g; + let landed = false; + for (let t = 0; t < 0.8 && !landed; t += 1 / 120) { + // Keep the stream continuous under him while he falls. + g2 = { ...g2, blocks: g2.blocks.map((b, i) => ({ ...b, x: PLAYER_X - 10 + i * 70 })) }; + g2 = step(g2, 1 / 120, zeroRng); + landed = !g2.descending && !g2.stuffed; + } + // He reached the road through the stream without losing a heart. + expect(landed).toBe(true); + expect(g2.hearts).toBe(MAX_HEARTS); + expect(g2.player.y).toBe(GROUND_Y - PLAYER_H); + }); +}); + +describe('fullMeter', () => { + it('tracks the whole FULL experience and hits zero exactly when it ends', () => { + expect(fullMeter({ stuffed: false, fullTime: 0, fullness: 0.5 })).toBe(0); + // Entry: full timer + full belly = 1. + expect(fullMeter({ stuffed: true, fullTime: 2.5, fullness: 1 })).toBe(1); + // Timer done but still deflating: the bar is NOT empty yet. + expect(fullMeter({ stuffed: true, fullTime: 0, fullness: 0.5 })).toBeGreaterThan(0); + // The meter and the state end together. + let g = { ...start(createGame()), fullness: 1, stuffed: true, fullTime: 0.3 }; + let lastMeter = 1; + for (let t = 0; t < 2; t += 1 / 120) { + g = step(g, 1 / 120, zeroRng); + if (g.stuffed) lastMeter = fullMeter(g); + else break; + } + expect(g.stuffed).toBe(false); + expect(lastMeter).toBeLessThan(0.03); + }); +}); + +describe('run dust', () => { + it('kicks up dust at the feet while running on the ground', () => { + const g = run(start(createGame()), 0.4); + expect(g.particles.some((p) => p.kind === 'dust')).toBe(true); + }); +}); + +describe('restart', () => { + it('ignores a press in the first beat after dying, then works', () => { + let g = { ...start(createGame()), hearts: 1 }; + g = { ...g, blocks: [blk({ x: PLAYER_X + 80, h: 60 })] }; + for (let t = 0; t < 0.5 && g.phase === 'running'; t += 1 / 120) g = step(g, 1 / 120, zeroRng); + expect(g.phase).toBe('dead'); + expect(restart(g).phase).toBe('dead'); + g = run(g, RESTART_GRACE + 0.05); + expect(restart(g).phase).toBe('running'); + }); +}); + +describe('step', () => { + it('an airborne runner falls back to the rail and lands', () => { + let g = start(createGame()); + g = { ...g, player: { y: GROUND_Y - PLAYER_H - 60, vy: 0, grounded: false } }; + g = run(g, 1); + expect(g.player.grounded).toBe(true); + expect(g.player.y).toBe(GROUND_Y - PLAYER_H); + }); + + + it('running into a block side costs a heart, crumbles the block, and grants a moment of invulnerability', () => { + let g = start(createGame()); + g = { ...g, blocks: [blk({ x: PLAYER_X + 80, h: 60 })] }; + let hurtAt = -1; + for (let t = 0; t < 0.5 && hurtAt < 0; t += 1 / 120) { + g = step(g, 1 / 120, zeroRng); + if (g.events.includes('hurt')) hurtAt = t; + } + expect(hurtAt).toBeGreaterThanOrEqual(0); + expect(g.phase).toBe('running'); + expect(g.hearts).toBe(MAX_HEARTS - 1); + expect(g.blocks).toHaveLength(0); + expect(g.invuln).toBeGreaterThan(0); + expect(g.shake).toBeGreaterThan(0); + }); + + it('a wall during invulnerability is passed through without another heart lost', () => { + let g = start(createGame()); + g = { ...g, hearts: 2, invuln: 1, blocks: [blk({ x: PLAYER_X + 80, h: 60 })] }; + g = run(g, 0.4); + expect(g.hearts).toBe(2); + expect(g.blocks).toHaveLength(0); + expect(g.phase).toBe('running'); + }); + + it('the last heart ends the run', () => { + let g = start(createGame()); + g = { ...g, hearts: 1, blocks: [blk({ x: PLAYER_X + 80, h: 60 })] }; + g = run(g, 0.5); + expect(g.phase).toBe('dead'); + }); + + + it('swallowing blocks earns a heart back', () => { + let g = tap({ ...start(createGame()), hearts: 1, heartProgress: 11 }); + g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 200, h: 60 })] }; + g = run(g, 0.5); + expect(g.hearts).toBe(2); + }); + + + it('does nothing but decay effects when not running', () => { + const g = step({ ...createGame(), labels: [{ x: 0, y: 0, text: 'x', age: 0 }], shake: 5 }, 2, zeroRng); + expect(g.phase).toBe('ready'); + expect(g.labels).toHaveLength(0); + expect(g.shake).toBe(0); + }); +}); diff --git a/app/vibenet/demos/200/lib/game.ts b/app/vibenet/demos/200/lib/game.ts new file mode 100644 index 00000000..6e6eab7f --- /dev/null +++ b/app/vibenet/demos/200/lib/game.ts @@ -0,0 +1,599 @@ +// Block Runner — the pure game model. No canvas, no DOM, no timers, so it can +// be unit-tested and rendered by anything. +// +// The chain is the spawner: every new vibenet head (one per 200 ms under +// Cobalt) becomes one block that the boss on the right edge spits out. The +// player is a round little glutton who auto-runs and holds ONE button to +// inhale: the nearest block in range is dragged into its mouth and swallowed — +// one eaten — revealing its number and 200 ms slot. Busy walls are +// heavy — they drag in slower while the next blocks keep coming. EVERY block +// is a collision unless eaten: one heart per hit. Eat enough and he goes FULL +// for a few seconds — invulnerable, rolling over everything, unable to eat — +// then the belly empties and he is hungry again. One action, so it plays the +// same on a phone as on a keyboard. + +export const WIDTH = 800; +export const HEIGHT = 360; +export const GROUND_Y = 300; + +export const PLAYER_X = 96; +export const PLAYER_W = 48; +export const PLAYER_H = 48; +/** Blocks in front within this range get pulled while inhaling. */ +export const INHALE_RANGE = 280; +/** Extra approach speed suction adds, divided by the block's weight. */ +const PULL_SPEED = 1300; +/** Seconds of chew (mouth shut, cheeks full) after each swallow. */ +const PUFF_TIME = 0.12; +/** The mouth must be visibly open this long before a pulled block goes down. */ +const OPEN_MIN = 0.07; +/** One tap opens the mouth for this long — one bite per tap. */ +const INHALE_BURST = 0.28; +/** Fullness gained per unit of block weight. Only the full-mode cycle empties it. */ +const FULL_PER_WEIGHT = 0.11; +/** Seconds of FULL mode: too round to hurt, rolling over every block. */ +const FULL_TIME = 2.5; +/** How fast he slims back down once full mode ends. */ +const DEFLATE_RATE = 2.2; + +/** + * The FULL gauge, 0..1: how much of the whole FULL experience remains — + * the cruise timer plus the shrink back down — so the bar hits empty on the + * exact frame the state ends, never before. + */ +export function fullMeter(game: Pick): number { + if (!game.stuffed) return 0; + const total = FULL_TIME + 1 / DEFLATE_RATE; + const remaining = game.fullTime + game.fullness / DEFLATE_RATE; + return Math.max(0, Math.min(1, remaining / total)); +} + +export const BOSS_X = WIDTH - 104; +const SPAWN_X = BOSS_X + 8; +/** Where the boss's mouth is; blocks are spat from here and fall to the rail. */ +export const MOUTH_Y = GROUND_Y - 26; +const SPIT_VY = -300; +const BLOCK_GRAVITY = 2400; +/** Sprite pixels per block; width = 16 × scale. */ +const MIN_BLOCK_H = 30; +const MAX_BLOCK_H = 112; + +const BASE_SPEED = 560; +const MAX_SPEED_BONUS = 160; +const GRAVITY = 2000; +const LABEL_LIFE = 1.2; +const PARTICLE_LIFE = 0.6; +const AFTERIMAGE_LIFE = 0.28; +const AFTERIMAGE_EVERY = 0.045; +const BOSS_MOUTH_OPEN = 0.16; +export const MAX_HEARTS = 3; +const HURT_INVULN = 1.1; +/** Bursts needed to earn a heart back. */ +const HEART_REFILL = 12; +/** Seconds of small blocks at the start of a run, so the rhythm lands first. */ +const WARMUP = 8; +export const RESTART_GRACE = 0.4; + +export type Head = { + number: number; + /** Cobalt millisecond timestamp, or null on chains without it. */ + timestampMs: number | null; + gasUsed: number; +}; + +export type Block = { + id: number; + x: number; + /** Top edge. Falls from the boss's mouth until it lands on the rail. */ + y: number; + vy: number; + landed: boolean; + w: number; + h: number; + /** Suction weight: heavy walls pull in slower and fill the belly faster. */ + weight: number; + number: number; + timestampMs: number | null; +}; + +export type Label = { x: number; y: number; text: string; age: number }; +export type ParticleKind = 'shard' | 'dust' | 'spark'; +export type Particle = { x: number; y: number; vx: number; vy: number; age: number; kind: ParticleKind }; +export type Afterimage = { y: number; age: number; frame: number }; + +export type Game = { + phase: 'ready' | 'running' | 'dead'; + time: number; + distance: number; + score: number; + player: { y: number; vy: number; grounded: boolean }; + /** Afterimage cadence timer while FULL (he barrels along up there). */ + sinceImage: number; + blocks: Block[]; + /** Heads waiting to be spat. Arrival jitter is absorbed here: the boss + * emits exactly one every 200 ms of game time, so spacing stays even. */ + pending: Head[]; + /** Time banked toward the next spit. */ + spawnClock: number; + labels: Label[]; + particles: Particle[]; + afterimages: Afterimage[]; + /** Camera shake amplitude in pixels; decays each step. */ + shake: number; + /** Seconds the boss mouth stays open after spitting a block. */ + bossMouth: number; + /** The mouth is open (a tap's bite window is running). */ + inhaling: boolean; + /** Seconds left in the current bite window. */ + inhaleTime: number; + /** Chew timer after a swallow; suction pauses while it runs. */ + puffed: number; + /** Seconds the mouth has been open in the current inhale cycle. */ + mouthOpen: number; + /** Timer for the little dust puffs at the feet while running. */ + runDust: number; + /** How full the belly is, 0..1. The body visibly grows with it. */ + fullness: number; + /** FULL mode: maxed out — invulnerable, rolls over every block, cannot eat. */ + stuffed: boolean; + /** Seconds of FULL mode remaining before he shrinks back to empty. */ + fullTime: number; + /** After FULL ends: ghost-falls through blocks until he touches the road. */ + descending: boolean; + /** Seconds since the run ended; a restart needs a deliberate press after a beat. */ + sinceDeath: number; + hearts: number; + /** Seconds of invulnerability left after a hit. */ + invuln: number; + /** Bursts banked toward the next heart. */ + heartProgress: number; + nextId: number; + /** Set for one step when something audible happened; the renderer clears it. */ + events: GameEvent[]; +}; + +export type GameEvent = 'inhale-on' | 'gulp' | 'stuffed' | 'hurt' | 'die' | 'land' | 'thud' | 'heart'; + +export function createGame(): Game { + return { + phase: 'ready', + time: 0, + distance: 0, + score: 0, + player: { y: GROUND_Y - PLAYER_H, vy: 0, grounded: true }, + sinceImage: 0, + blocks: [], + pending: [], + spawnClock: 0.2, + labels: [], + particles: [], + afterimages: [], + shake: 0, + bossMouth: 0, + inhaling: false, + inhaleTime: 0, + puffed: 0, + mouthOpen: 0, + runDust: 0, + fullness: 0, + stuffed: false, + fullTime: 0, + descending: false, + sinceDeath: 0, + hearts: MAX_HEARTS, + invuln: 0, + heartProgress: 0, + nextId: 1, + events: [], + }; +} + +/** Scroll speed: ramps gently with score, dino-style, and caps. */ +export function speedFor(score: number): number { + return BASE_SPEED + Math.min(score * 3, MAX_SPEED_BONUS); +} + +/** + * Block size from gas used and how far the run has gone. Vibenet blocks always + * carry two system deposits (~200k gas together), so anything above that is + * user activity: a taller wall. Height is continuous in gas so no two blocks + * look alike, and both dimensions grow with score so the lane visibly changes + * as the run goes on. Width is snapped to whole sprite pixels (16 × 2, 3, 4). + */ +export function blockSizeFor(gasUsed: number, score = 0, blockNumber = 0, time = WARMUP): { w: number; h: number } { + const activity = Math.min(1, Math.max(0, (gasUsed - 200_000) / 800_000)); + const difficulty = Math.min(1, score / 60); + let h = Math.round(MIN_BLOCK_H + (MAX_BLOCK_H - MIN_BLOCK_H) * Math.min(1, activity * (0.55 + 0.45 * difficulty) + difficulty * 0.25)); + // Base width from activity; every fourth block gets a size bump once the run + // is under way, so widths mix rather than stepping up in lockstep. + let scale = activity < 0.15 ? 2 : activity < 0.5 ? 3 : 4; + if (difficulty > 0.3 && blockNumber % 4 === 0) scale = Math.min(4, scale + 1); + if (difficulty < 0.15 && scale === 4) scale = 3; + // Warm-up: the first seconds are small blocks the runner can step over, so + // the 200 ms rhythm is felt before the walls arrive. Every block still lands. + if (time < WARMUP) { + const ease = time / WARMUP; + h = Math.round(Math.min(h, MIN_BLOCK_H + (MAX_BLOCK_H - MIN_BLOCK_H) * ease * 0.5)); + scale = Math.min(scale, ease < 0.5 ? 2 : 3); + } + return { w: 16 * scale, h: Math.max(h, 12 * scale) }; +} + +/** Suction weight: quiet blocks are light (1), busy walls heavy (2). */ +export function weightFor(w: number): number { + return Math.min(2, Math.max(1, Math.round(w / 16) - 1)); +} + +/** The 200 ms slot inside the second, `.000` … `.800`, or null pre-Cobalt. */ +export function slotOf(timestampMs: number | null): string | null { + if (timestampMs === null) return null; + return `.${String(timestampMs % 1000).padStart(3, '0')}`; +} + +export function blockLabel(block: Pick): string { + const slot = slotOf(block.timestampMs); + return slot ? `${block.number.toLocaleString()} · ${slot}` : block.number.toLocaleString(); +} + +/** + * A new head arrived: queue it. The step spits queued heads on a strict + * 200 ms metronome, so network delivery jitter never shows up as uneven + * spacing on the street. Ignored unless running. + */ +export function spawnBlock(game: Game, head: Head): Game { + if (game.phase !== 'running') return game; + return { ...game, pending: [...game.pending, head] }; +} + +export function start(game: Game): Game { + // Keys still held through a restart keep working: a player mashing the + // button comes back inhaling. + return { + ...createGame(), + phase: 'running', + nextId: game.nextId, + }; +} + +/** + * Restart after a death. Ignored for a beat after dying so a key that was + * already being mashed does not skip the game-over screen — that reads as the + * runner passing straight through the block. + */ +export function restart(game: Game): Game { + if (game.phase === 'dead' && game.sinceDeath < RESTART_GRACE) return game; + return start(game); +} + +/** + * One tap = one bite: opens the mouth for a short window that can swallow at + * most one block, then it closes. Holding a key does nothing more — the next + * block needs the next tap. Tapping mid-window refreshes it. + */ +export function tap(game: Game): Game { + if (game.phase === 'ready') return { ...start(game), inhaling: true, inhaleTime: INHALE_BURST }; + if (game.phase !== 'running') return game; + return { + ...game, + inhaling: true, + inhaleTime: INHALE_BURST, + events: game.inhaling ? game.events : [...game.events, 'inhale-on'], + }; +} + +function shards(x: number, y: number, rng: () => number): Particle[] { + const out: Particle[] = []; + for (let i = 0; i < 12; i += 1) { + const angle = rng() * Math.PI * 2; + const power = 120 + rng() * 220; + out.push({ x, y, vx: Math.cos(angle) * power, vy: Math.sin(angle) * power - 100, age: 0, kind: 'shard' }); + } + return out; +} + +function dust(x: number, y: number, rng: () => number): Particle[] { + const out: Particle[] = []; + for (let i = 0; i < 5; i += 1) { + out.push({ x: x + rng() * 20 - 10, y, vx: -60 - rng() * 120, vy: -30 - rng() * 60, age: 0, kind: 'dust' }); + } + return out; +} + +/** Advance the world by `dt` seconds. `rng` is injectable so tests are deterministic. */ +export function step(game: Game, dt: number, rng: () => number = Math.random): Game { + const decay = (labels: Label[], particles: Particle[], afterimages: Afterimage[]) => ({ + labels: labels + .map((l) => ({ ...l, y: l.y - 34 * dt, age: l.age + dt })) + .filter((l) => l.age < LABEL_LIFE), + particles: particles + .map((p) => ({ + ...p, + x: p.x + p.vx * dt, + y: p.y + p.vy * dt, + vy: p.vy + (p.kind === 'dust' ? 120 : 800) * dt, + age: p.age + dt, + })) + .filter((p) => p.age < PARTICLE_LIFE), + afterimages: afterimages.map((a) => ({ ...a, age: a.age + dt })).filter((a) => a.age < AFTERIMAGE_LIFE), + }); + + if (game.phase !== 'running') { + return { + ...game, + ...decay(game.labels, game.particles, game.afterimages), + shake: Math.max(0, game.shake - 40 * dt), + bossMouth: Math.max(0, game.bossMouth - dt), + sinceDeath: game.phase === 'dead' ? game.sinceDeath + dt : 0, + events: [], + }; + } + + const events: GameEvent[] = []; + let score = game.score; + let hearts = game.hearts; + let heartProgress = game.heartProgress; + let invuln = Math.max(0, game.invuln - dt); + + // FULL mode leaves afterimages — the unstoppable barrel-along up top. + let sinceImage = game.sinceImage + dt; + let afterimages = game.afterimages; + if (game.stuffed && sinceImage >= AFTERIMAGE_EVERY) { + sinceImage = 0; + afterimages = [...afterimages, { y: game.player.y, age: 0, frame: Math.floor(game.time * 12) % 3 }]; + } + + const speed = speedFor(score); + + // Scroll blocks; airborne ones fall until they thud onto the rail. Drop the + // ones that left the screen. + let thuds: Block[] = []; + let blocks = game.blocks + .map((b) => { + const x = b.x - speed * dt; + if (b.landed) return { ...b, x }; + const vy = b.vy + BLOCK_GRAVITY * dt; + const y = b.y + vy * dt; + if (y + b.h >= GROUND_Y) { + const landed = { ...b, x, y: GROUND_Y - b.h, vy: 0, landed: true }; + thuds = [...thuds, landed]; + return landed; + } + return { ...b, x, y, vy }; + }) + .filter((b) => b.x + b.w > -4); + + // The boss's metronome: spit one queued head per 200 ms of game time. + let pending = game.pending; + let spawnClock = Math.min(0.2, game.spawnClock + dt); + let nextId = game.nextId; + let bossMouth = Math.max(0, game.bossMouth - dt); + while (spawnClock >= 0.2 && pending.length > 0) { + const head = pending[0]; + pending = pending.slice(1); + spawnClock -= 0.2; + const { w, h } = blockSizeFor(head.gasUsed, score, head.number, game.time); + blocks = [ + ...blocks, + { + id: nextId, + x: SPAWN_X, + y: MOUTH_Y - h, + vy: SPIT_VY, + landed: false, + w, + h, + weight: weightFor(w), + number: head.number, + timestampMs: head.timestampMs, + }, + ]; + nextId += 1; + bossMouth = BOSS_MOUTH_OPEN; + } + + const fx = decay(game.labels, game.particles, afterimages); + let { labels, particles } = fx; + afterimages = fx.afterimages; + let shake = Math.max(0, game.shake - 40 * dt); + for (const t of thuds) { + particles = [...particles, ...dust(t.x + t.w / 2, GROUND_Y - 2, rng)]; + shake = Math.max(shake, Math.min(3, t.w / 24)); + events.push('thud'); + } + // Suction: the nearest landed block ahead, within range, is dragged toward + // the mouth — heavy walls drag slower. Reaching the mouth is a swallow: it + // scores the block's weight and shows its label. Everything else keeps + // scrolling at them meanwhile. + const mouthX = PLAYER_X + PLAYER_W - 4; + let inhaleTime = Math.max(0, game.inhaleTime - dt); + let inhaling = game.inhaling && inhaleTime > 0; + let puffed = Math.max(0, game.puffed - dt); + const chewing = puffed > 0; + // FULL mode runs on a timer, then he deflates back to empty and is hungry + // again. Outside full mode, fullness only ever goes up (by eating). + let fullness = game.fullness; + let stuffed = game.stuffed; + let fullTime = game.fullTime; + let descending = game.descending; + if (stuffed) { + if (fullTime > 0) { + fullTime = Math.max(0, fullTime - dt); + } else { + fullness = Math.max(0, fullness - DEFLATE_RATE * dt); + if (fullness === 0) { + stuffed = false; + descending = true; + } + } + } + // The mouth cycle: open (pull) → chomp → chew → open. The open phase must + // last a beat before anything goes down, so the animation always shows it. + let mouthOpen = inhaling && !chewing ? game.mouthOpen + dt : 0; + let target: Block | null = null; + const mouthReach = game.player.y + PLAYER_H + 6; + if (inhaling && !chewing && !stuffed) { + for (const b of blocks) { + if (!b.landed || b.x + b.w < mouthX || b.x > mouthX + INHALE_RANGE) continue; + // The mouth can only eat what it can reach: a block whose top is below + // his feet (he is perched somewhere) is out of range. + if (b.y > mouthReach) continue; + if (!target || b.x < target.x) target = b; + } + } + const eatingId = target ? target.id : null; + if (target) { + // A block that reaches the mouth waits pressed against it until the open + // phase has lasted long enough, then goes down in one chomp. + const pulled = { ...target, x: Math.max(mouthX, target.x - (PULL_SPEED / target.weight) * dt) }; + if (pulled.x <= mouthX && mouthOpen >= OPEN_MIN) { + blocks = blocks.filter((b) => b.id !== target.id); + // Score is a true count of blocks eaten — heavy walls reward through + // the belly instead (they fill it twice as fast). + score += 1; + heartProgress += 1; + puffed = PUFF_TIME; + labels = [...labels, { x: mouthX + 20, y: pulled.y - 14, text: blockLabel(pulled), age: 0 }]; + particles = [...particles, ...shards(mouthX + 10, pulled.y + pulled.h / 2, rng)]; + events.push('gulp'); + mouthOpen = 0; + inhaling = false; + inhaleTime = 0; + fullness = Math.min(1, fullness + FULL_PER_WEIGHT * pulled.weight); + if (fullness >= 1) { + stuffed = true; + fullTime = FULL_TIME; + events.push('stuffed'); + } + } else { + blocks = blocks.map((b) => (b.id === target.id ? pulled : b)); + } + } + // Player physics: gravity, then resolve against the ground and block tops. + const prevBottom = game.player.y + PLAYER_H; + let vy = game.player.vy + GRAVITY * dt; + let y = game.player.y + vy * dt; + let floor = GROUND_Y; + let dead = false; + const left = PLAYER_X + 6; + const right = PLAYER_X + PLAYER_W - 6; + let bonked: Block | null = null; + for (const b of blocks) { + if (b.id === eatingId) continue; // you cannot stand on what you are swallowing + if (descending) continue; // ghost-fall: he drops through everything to the road + const overlapsX = right > b.x && left < b.x + b.w; + if (!overlapsX) continue; + const top = b.y; + // Consistency rule: every block is a collision unless eaten. Blocks are + // only walkable in FULL mode, where he rolls over everything. The descent + // after FULL is a ghost-fall (handled above): in a stream this dense there + // is always another block underfoot, so walking the descent down would + // strand him on the tops forever. + if (stuffed && vy >= 0) { + floor = Math.min(floor, top); + } else if (y + PLAYER_H > top + 1) { + bonked = bonked ?? b; + } + } + if (bonked && inhaling && !stuffed && puffed <= 0) { + // Mouth-first: while inhaling with the mouth free, contact is a meal, not + // a crash. Mid-chew the mouth is busy — a wall then is a real hit, which + // is what makes back-to-back walls dangerous. + blocks = blocks.filter((b) => b.id !== bonked.id); + score += 1; + heartProgress += 1; + puffed = PUFF_TIME; + mouthOpen = 0; + inhaling = false; + inhaleTime = 0; + fullness = Math.min(1, fullness + FULL_PER_WEIGHT * bonked.weight); + if (fullness >= 1) { + stuffed = true; + fullTime = FULL_TIME; + events.push('stuffed'); + } + labels = [...labels, { x: PLAYER_X + PLAYER_W, y: bonked.y - 14, text: blockLabel(bonked), age: 0 }]; + events.push('gulp'); + } else if (bonked && invuln <= 0) { + // The block you ran into crumbles (no score) and costs a heart. A short + // invulnerability window keeps one wall from taking every heart at once. + blocks = blocks.filter((b) => b.id !== bonked.id); + particles = [...particles, ...shards(bonked.x + bonked.w / 2, bonked.y + bonked.h / 2, rng)]; + hearts -= 1; + heartProgress = 0; + if (hearts <= 0) { + dead = true; + } else { + invuln = HURT_INVULN; + shake = Math.max(shake, 8); + events.push('hurt'); + } + } else if (bonked) { + // Still flashing from the last hit: pass through the block harmlessly. + blocks = blocks.filter((b) => b.id !== bonked.id); + } + if (heartProgress >= HEART_REFILL && hearts < MAX_HEARTS) { + hearts += 1; + heartProgress = 0; + events.push('heart'); + } + // Run dust: a puff kicked up behind the feet on a quick cadence. + // The little cloud that sells the run. + let runDust = game.runDust + dt; + if (game.player.grounded && runDust >= 0.13) { + runDust = 0; + particles = [ + ...particles, + { x: PLAYER_X + 2 + rng() * 8, y: game.player.y + PLAYER_H - 2, vx: -70 - rng() * 90, vy: -20 - rng() * 40, age: 0, kind: 'dust' as const }, + { x: PLAYER_X + 6 + rng() * 10, y: game.player.y + PLAYER_H - 4, vx: -50 - rng() * 70, vy: -10 - rng() * 30, age: 0, kind: 'dust' as const }, + ]; + } + + let grounded = false; + if (descending && y + PLAYER_H >= GROUND_Y - 1) descending = false; + if (y + PLAYER_H >= floor && vy >= 0) { + if (!game.player.grounded) { + events.push('land'); + particles = [...particles, ...dust(PLAYER_X + PLAYER_W / 2, floor - 2, rng)]; + } + y = floor - PLAYER_H; + vy = 0; + grounded = true; + } + + if (dead) { + events.push('die'); + shake = 10; + } + + return { + ...game, + phase: dead ? 'dead' : 'running', + time: game.time + dt, + distance: game.distance + speed * dt, + score, + player: { y, vy, grounded }, + sinceImage, + blocks, + labels, + particles, + afterimages, + pending, + spawnClock, + nextId, + shake, + bossMouth, + inhaling, + inhaleTime, + puffed, + mouthOpen, + runDust, + fullness, + stuffed, + fullTime, + descending, + hearts, + invuln: dead ? 0 : invuln, + heartProgress, + events, + }; +} diff --git a/app/vibenet/demos/200/lib/leaderboard.test.ts b/app/vibenet/demos/200/lib/leaderboard.test.ts new file mode 100644 index 00000000..4d7641f3 --- /dev/null +++ b/app/vibenet/demos/200/lib/leaderboard.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { parseBoard, shortAddr } from './leaderboard'; + +const A = '0x1111111111111111111111111111111111111111' as const; +const B = '0x2222222222222222222222222222222222222222' as const; +const ZERO = '0x0000000000000000000000000000000000000000' as const; + +describe('shortAddr', () => { + it('keeps only the ends of the address — that IS the name on the board', () => { + expect(shortAddr('0x24411613aab0b4f551942f50af090941bd57e07d')).toBe('0x2441…e07d'); + }); +}); + +describe('parseBoard', () => { + it('drops empty slots from the fixed-size board', () => { + const board = parseBoard([ + { player: A, score: 42n }, + { player: B, score: 7n }, + ...Array.from({ length: 8 }, () => ({ player: ZERO, score: 0n })), + ]); + expect(board).toEqual([ + { player: A, score: 42 }, + { player: B, score: 7 }, + ]); + }); +}); diff --git a/app/vibenet/demos/200/lib/leaderboard.ts b/app/vibenet/demos/200/lib/leaderboard.ts new file mode 100644 index 00000000..41cb6550 --- /dev/null +++ b/app/vibenet/demos/200/lib/leaderboard.ts @@ -0,0 +1,115 @@ +// Block Runner's onchain high-score board. No names: a score belongs to the +// wallet address that submitted it, and only your best is kept. Each browser +// gets a throwaway devnet key (localStorage); the first submission funds it +// from the vibenet faucet. The contract keeps the top ten sorted, so reading +// is one call. +// +// Vibenet is regenesised periodically. When the board vanishes, redeploy with +// contract/deploy.mjs and update SCORES_ADDRESS below. +import { createPublicClient, createWalletClient, http, type Address, type Hex } from 'viem'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; + +import { VIBENET_RPC_URL } from '../../../library/config'; +import { vibenetApi } from '../../../library/client'; +import artifact from '../contract/BlockRunnerScores.json'; + +export const SCORES_ADDRESS: Address = '0x24411613aab0b4f551942f50af090941bd57e07d'; + +const KEY_STORAGE = 'block-runner:pk'; + +const chain = { + id: 84538453, + name: 'Vibenet', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: [VIBENET_RPC_URL] } }, +} as const; + +export type BoardEntry = { player: Address; score: number }; + +const ZERO: Address = '0x0000000000000000000000000000000000000000'; + +/** Drop empty slots and normalize the fixed-size board the contract returns. */ +export function parseBoard(raw: readonly { player: Address; score: bigint }[]): BoardEntry[] { + return raw + .filter((e) => e.player !== ZERO && e.score > 0n) + .map((e) => ({ player: e.player, score: Number(e.score) })); +} + +/** `0x1234…abcd` — the whole identity on the board. */ +export function shortAddr(a: Address): string { + return `${a.slice(0, 6)}…${a.slice(-4)}`; +} + +function pub() { + return createPublicClient({ chain, transport: http(VIBENET_RPC_URL) }); +} + +/** The browser's throwaway score wallet (created on first use). */ +export function scoreWallet(): { address: Address; pk: Hex } { + let pk: Hex | null = null; + try { + pk = window.localStorage.getItem(KEY_STORAGE) as Hex | null; + } catch { + pk = null; + } + if (!pk || !/^0x[0-9a-fA-F]{64}$/.test(pk)) { + pk = generatePrivateKey(); + try { + window.localStorage.setItem(KEY_STORAGE, pk); + } catch { + /* Ephemeral key: scores just will not persist across reloads. */ + } + } + return { address: privateKeyToAccount(pk).address, pk }; +} + +export async function fetchBoard(): Promise { + const raw = (await pub().readContract({ + address: SCORES_ADDRESS, + abi: artifact.abi, + functionName: 'top', + })) as readonly { player: Address; score: bigint }[]; + return parseBoard(raw); +} + +export async function fetchBest(address: Address): Promise { + const raw = (await pub().readContract({ + address: SCORES_ADDRESS, + abi: artifact.abi, + functionName: 'best', + args: [address], + })) as bigint; + return Number(raw); +} + +/** + * Submit a score from the browser's throwaway wallet. Funds it from the + * faucet on first use. Resolves to the tx hash once mined. + */ +export async function submitScore(score: number, onStatus?: (s: 'funding' | 'submitting' | 'confirming') => void): Promise { + const { pk } = scoreWallet(); + const account = privateKeyToAccount(pk); + const client = pub(); + if ((await client.getBalance({ address: account.address })) === 0n) { + onStatus?.('funding'); + await vibenetApi.faucet.drip({ address: account.address }); + const until = Date.now() + 20_000; + while ((await client.getBalance({ address: account.address })) === 0n) { + if (Date.now() > until) throw new Error('The faucet did not fund the score wallet in time.'); + await new Promise((r) => setTimeout(r, 500)); + } + } + onStatus?.('submitting'); + const wallet = createWalletClient({ account, chain, transport: http(VIBENET_RPC_URL) }); + const hash = await wallet.writeContract({ + address: SCORES_ADDRESS, + abi: artifact.abi, + functionName: 'submit', + args: [BigInt(score)], + }); + onStatus?.('confirming'); + // Replacement detection would cost an extra round trip per poll; a fresh + // throwaway key never replaces anything. + await client.waitForTransactionReceipt({ hash, pollingInterval: 200, timeout: 30_000, checkReplacement: false }); + return hash; +} diff --git a/app/vibenet/demos/200/lib/selfplay.test.ts b/app/vibenet/demos/200/lib/selfplay.test.ts new file mode 100644 index 00000000..853120c9 --- /dev/null +++ b/app/vibenet/demos/200/lib/selfplay.test.ts @@ -0,0 +1,88 @@ +// Self-play: a simple bot plays the model at 60 Hz with a synthetic chain +// feeding one head every 200 ms. This guards balance, not correctness — if a +// tuning change makes the lane unwinnable or trivial, these numbers move and +// the test says so. + +import { describe, expect, it } from 'vitest'; + +import { + createGame, + restart, + tap, + spawnBlock, + start, + step, + type Game, + type Head, +} from './game'; + +/** Deterministic PRNG so runs are reproducible. */ +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +type Bot = (g: Game) => Game; + +/** Taps on every beat the mouth is free: the intended way to play. */ +const tapBot: Bot = (g) => (g.inhaling || g.stuffed ? g : tap(g)); + +/** Never inhales: the baseline that must die once real walls arrive. */ +const idleBot: Bot = (g) => g; + +function play(bot: Bot, seconds: number, seed: number) { + const rng = mulberry32(seed); + const dt = 1 / 60; + let g = start(createGame()); + let nextHeadAt = 0; + let number = 200_000; + let deaths = 0; + let bestScore = 0; + let longestRun = 0; + let runStart = 0; + for (let t = 0; t < seconds; t += dt) { + if (t >= nextHeadAt) { + nextHeadAt += 0.2; + number += 1; + // Mostly deposit-only blocks, some busy ones — roughly what vibenet shows. + const busy = rng(); + const gasUsed = busy < 0.7 ? 200_000 + rng() * 60_000 : 300_000 + rng() * 700_000; + const head: Head = { number, timestampMs: Math.round(t * 1000), gasUsed }; + g = spawnBlock(g, head); + } + if (g.phase === 'dead') { + deaths += 1; + longestRun = Math.max(longestRun, t - runStart); + g = step(g, 0.5, rng); + g = restart(g); + runStart = t; + continue; + } + g = bot(g); + g = step(g, dt, rng); + bestScore = Math.max(bestScore, g.score); + } + longestRun = Math.max(longestRun, seconds - runStart); + return { deaths, bestScore, longestRun }; +} + +describe('self-play balance', () => { + it('tapping on the beat keeps the eat → FULL → shrink cycle alive and scoring', () => { + const r = play(tapBot, 60, 1); + expect(r.longestRun).toBeGreaterThan(15); + expect(r.bestScore).toBeGreaterThan(30); + }); + + + it('never inhaling loses shortly after the warm-up', () => { + const r = play(idleBot, 30, 3); + expect(r.deaths).toBeGreaterThanOrEqual(1); + expect(r.longestRun).toBeLessThan(28); + }); +}); diff --git a/app/vibenet/demos/200/lib/sound.ts b/app/vibenet/demos/200/lib/sound.ts new file mode 100644 index 00000000..4c1802fb --- /dev/null +++ b/app/vibenet/demos/200/lib/sound.ts @@ -0,0 +1,212 @@ +// Tiny synthesized sound set plus one looping backing track. Effects are +// short oscillator or noise bursts through one gain node, so they never block +// on a network fetch. The music is a 300 KB chiptune lofi loop rendered by +// scripts/block-runner-music.mjs; it is fetched lazily and decoded into an +// AudioBuffer so the loop point is sample-accurate. +// +// Browsers only allow audio after a user gesture, so the context is created +// lazily on the first play call, which the game reaches from a key press. + +const STORAGE_KEY = 'block-runner:muted'; +const MUSIC_URL = '/audio/block-runner-lofi.mp3'; +const MUSIC_GAIN = 0.5; + +export class Sound { + private ctx: AudioContext | null = null; + private master: GainNode | null = null; + private musicGain: GainNode | null = null; + private musicSource: AudioBufferSourceNode | null = null; + private musicBuffer: AudioBuffer | null = null; + private musicLoad: Promise | null = null; + /** The game wants music; playback follows this once the buffer is decoded. */ + private musicWanted = false; + muted = false; + + constructor() { + try { + this.muted = typeof window !== 'undefined' && window.localStorage.getItem(STORAGE_KEY) === '1'; + } catch { + this.muted = false; + } + } + + setMuted(muted: boolean): void { + this.muted = muted; + try { + window.localStorage.setItem(STORAGE_KEY, muted ? '1' : '0'); + } catch { + /* Persistence is a convenience. */ + } + if (muted) this.haltMusic(0.05); + else if (this.musicWanted) this.startMusic(); + } + + /** + * Create or resume the context from inside a user gesture. Mobile browsers + * refuse audio started later from a timer, so input handlers call this + * before the game loop gets to play anything. + */ + unlock(): void { + if (this.muted) return; + this.ensure(); + // Kick off the fetch now so the loop is decoded by the time a run starts. + void this.loadMusic(); + } + + /** Loop the backing track. Safe to call repeatedly; starts once decoded. */ + startMusic(): void { + this.musicWanted = true; + if (this.muted || this.musicSource) return; + const ctx = this.ensure(); + if (!ctx || !this.master) return; + if (!this.musicBuffer) { + void this.loadMusic().then(() => { + if (this.musicWanted) this.startMusic(); + }); + return; + } + if (!this.musicGain) { + this.musicGain = ctx.createGain(); + this.musicGain.connect(this.master); + } + const src = ctx.createBufferSource(); + src.buffer = this.musicBuffer; + src.loop = true; + src.connect(this.musicGain); + const now = ctx.currentTime; + this.musicGain.gain.cancelScheduledValues(now); + this.musicGain.gain.setValueAtTime(0.001, now); + this.musicGain.gain.exponentialRampToValueAtTime(MUSIC_GAIN, now + 0.4); + src.start(now); + this.musicSource = src; + } + + /** Fade the track out; the next startMusic begins from the top. */ + stopMusic(fade = 0.8): void { + this.musicWanted = false; + this.haltMusic(fade); + } + + private haltMusic(fade: number): void { + const src = this.musicSource; + if (!src || !this.ctx || !this.musicGain) return; + this.musicSource = null; + const now = this.ctx.currentTime; + this.musicGain.gain.cancelScheduledValues(now); + this.musicGain.gain.setValueAtTime(Math.max(0.001, this.musicGain.gain.value), now); + this.musicGain.gain.exponentialRampToValueAtTime(0.001, now + fade); + src.stop(now + fade + 0.02); + } + + private loadMusic(): Promise { + if (this.musicLoad) return this.musicLoad; + const ctx = this.ensure(); + if (!ctx) return Promise.resolve(); + this.musicLoad = fetch(MUSIC_URL) + .then((res) => (res.ok ? res.arrayBuffer() : Promise.reject(new Error(res.statusText)))) + .then((bytes) => ctx.decodeAudioData(bytes)) + .then((buffer) => { + this.musicBuffer = buffer; + }) + .catch(() => { + // No music is fine; the effects still play. Allow a retry later. + this.musicLoad = null; + }); + return this.musicLoad; + } + + private ensure(): AudioContext | null { + if (typeof window === 'undefined') return null; + if (!this.ctx) { + const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!Ctor) return null; + this.ctx = new Ctor(); + this.master = this.ctx.createGain(); + this.master.gain.value = 0.18; + this.master.connect(this.ctx.destination); + } + if (this.ctx.state === 'suspended') void this.ctx.resume(); + return this.ctx; + } + + private tone(type: OscillatorType, from: number, to: number, duration: number, volume = 1): void { + if (this.muted) return; + const ctx = this.ensure(); + if (!ctx || !this.master) return; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + const now = ctx.currentTime; + osc.type = type; + osc.frequency.setValueAtTime(from, now); + osc.frequency.exponentialRampToValueAtTime(Math.max(1, to), now + duration); + gain.gain.setValueAtTime(volume, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + duration); + osc.connect(gain).connect(this.master); + osc.start(now); + osc.stop(now + duration); + } + + private noise(duration: number, volume = 1): void { + if (this.muted) return; + const ctx = this.ensure(); + if (!ctx || !this.master) return; + const length = Math.floor(ctx.sampleRate * duration); + const buffer = ctx.createBuffer(1, length, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < length; i += 1) data[i] = (Math.random() * 2 - 1) * (1 - i / length); + const src = ctx.createBufferSource(); + const gain = ctx.createGain(); + src.buffer = buffer; + gain.gain.value = volume; + src.connect(gain).connect(this.master); + src.start(); + } + + /** Quiet click on every new head — the chain's 200 ms metronome. */ + tick(): void { + this.tone('triangle', 1200, 900, 0.03, 0.25); + } + + /** Suction kicking in: a rising whoosh. */ + inhaleOn(): void { + this.noise(0.22, 0.3); + this.tone('sawtooth', 140, 520, 0.28, 0.3); + } + + /** A block going down the hatch. */ + gulp(): void { + this.tone('square', 520, 160, 0.09, 0.6); + this.tone('triangle', 260, 700, 0.12, 0.4); + } + + /** A block hitting the rail; heavier for bigger blocks. */ + thud(weight = 0.5): void { + this.noise(0.05, 0.3 + 0.4 * weight); + this.tone('square', 140 - 40 * weight, 60, 0.08, 0.35 + 0.3 * weight); + } + + land(): void { + this.tone('triangle', 200, 120, 0.04, 0.4); + } + + /** FULL: a rising power-up fanfare — he is briefly unstoppable. */ + stuffed(): void { + this.tone('square', 260, 520, 0.12, 0.5); + this.tone('square', 390, 780, 0.16, 0.45); + this.tone('triangle', 520, 1040, 0.25, 0.4); + } + + hurt(): void { + this.noise(0.08, 0.5); + this.tone('square', 300, 90, 0.22, 0.6); + } + + heart(): void { + this.tone('triangle', 660, 990, 0.08, 0.5); + this.tone('triangle', 990, 1320, 0.12, 0.4); + } + + die(): void { + this.tone('sawtooth', 440, 110, 0.35, 0.7); + } +} diff --git a/app/vibenet/demos/200/lib/sprites.ts b/app/vibenet/demos/200/lib/sprites.ts new file mode 100644 index 00000000..cac99fe5 --- /dev/null +++ b/app/vibenet/demos/200/lib/sprites.ts @@ -0,0 +1,184 @@ +// Hand-drawn 8-bit pixel sprites as character maps. Bold outlines and flat +// fills, colored from the Base Design System palette so the game sits inside +// the site rather than on top of it. Placeholder art: swap for a real sprite sheet later by replacing these +// maps and `drawSprite` with an image blit; the game model is unaffected. +// +// Palette letters: `.` transparent, `k` ink, `w` white, `B` Base blue, `D` deep +// blue, `b` light blue, `c` pale blue, `s` visor, `r` crate, `R` crate shade, +// `p` paper, `y` yellow, `o` orange, `g` light gray, `d` mid gray. + +export type Sprite = string[]; + +// Base Design System tokens (see app/globals.css), one set per theme. The +// letters are the sprite map keys; the rest are scene colors. +export type Palette = { + k: string; w: string; B: string; D: string; b: string; c: string; s: string; + r: string; R: string; p: string; y: string; o: string; g: string; d: string; + sky: string; hills: string; rail: string; railEdge: string; ground: string; + /** HUD text drawn on the sky. */ + hud: string; +}; + +// One committed look: an 8-bit city at night. Values from the BDS dark +// spectrum (see app/globals.css) so it still reads as Base. +export const NIGHT: Palette = { + k: '#000d21', // outline ink, near-black navy + w: '#f5f8ff', // blue-0 + B: '#266eff', // blue-40: Base blue, bright enough on navy + D: '#0052ff', // blue-60 + b: '#4684ff', // blue-30 + c: '#92b6ff', // blue-15 + s: '#f5f8ff', // visor + r: '#6f6f6f', // gray-50: crate fill + R: '#525252', // gray-70: crate shade + p: '#f5f8ff', + y: '#ffe436', // yellow-20: lit windows + o: '#f48c4c', // orange-30 + g: '#dadada', + d: '#9a9a9a', + sky: '#12093a', // deep violet night (toward BDS purple-100) + hills: '#060a1c', // near towers, almost black + rail: '#3a3a3a', // gray-80 sidewalk + railEdge: '#525252', // gray-70 kerb + ground: '#262626', // gray-90 asphalt + hud: '#f5f8ff', +}; + +/** Far tower silhouettes: violet, half-dissolved into the sky. */ +export const NIGHT_FAR = '#241a4d'; + +/** Lower sky at the horizon; the dither band blends sky into this. */ +export const NIGHT_HORIZON = '#2c1c5e'; + +// Grumpy concrete crate — the block. 16 × 16, height is stretched by the renderer. +export const CRATE_FACE: Sprite = [ + 'kkkkkkkkkkkkkkkk', + 'krrrrrrrrrrrrrrk', + 'krRRRRRRRRRRRRrk', + 'krRrrrrrrrrrrRrk', + 'krRrkkrrrrkkrRrk', + 'krRrkwkrrkwkrRrk', + 'krRrkkkrrkkkrRrk', + 'krRrrrrrrrrrrRrk', + 'krRrrrkkkkrrrRrk', + 'krRrrkwwwwkrrRrk', + 'krRrrrkkkkrrrRrk', + 'krRrrrrrrrrrrRrk', + 'krRRRRRRRRRRRRrk', + 'krrrrrrrrrrrrrrk', + 'kkkkkkkkkkkkkkkk', + '................', +]; + +export const CRATE_TOP: Sprite = [ + 'kkkkkkkkkkkkkkkk', + 'krrrrrrrrrrrrrrk', + 'krRRRRRRRRRRRRrk', + 'krRrrrrrrrrrrRrk', +]; + +export const CRATE_BODY: Sprite = ['krRrrrrrrrrrrRrk', 'krRrrrrrrrrrrRrk']; + +// The boss: a big red face at the right edge, mouth closed / open. 32 × 30. +export const BOSS_CLOSED: Sprite = [ + '.....kkk................kkk.....', + '....kyyyk..............kyyyk....', + '....koyok..............koyok....', + '...kkyyykkkkkkkkkkkkkkkkyyykk...', + '..kwwwwwwwwwwwwwwwwwwwwwwwwwwk..', + '.kwwwwwwwwwwwwwwwwwwwwwwwwwwwwk.', + '.kwwBBBBBBBBBBBBBBBBBBBBBBBBwwk.', + 'kwwBBBBBBBBBBBBBBBBBBBBBBBBBBwwk', + 'kBBBBBkkkkkBBBBBBBBBBkkkkkBBBBBk', + 'kBBBBkkkkkkkBBByyBBBkkkkkkkBBBBk', + 'kBBBkkwwwwkkBBByyBBBkkwwwwkkBBBk', + 'kBBBkwwkkwwkBBBBBBBBkwwkkwwkBBBk', + 'kBBBkwwkkwwkBBBBBBBBkwwkkwwkBBBk', + 'kBBBkwwwwwwkBBBBBBBBkwwwwwwkBBBk', + 'kBBBBkwwwwkBBBBBBBBBBkwwwwkBBBBk', + 'kBBBBBkkkkBBBBBBBBBBBBkkkkBBBBBk', + 'kBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBk', + 'kBBBBBBBBBBBBkkkkkkkBBBBBBBBBBBk', + 'kBBBBBBBBBBBkwwwwwwwkBBBBBBBBBBk', + 'kBBBBBBBBBBBBkkkkkkkBBBBBBBBBBBk', + 'kDBBBBBBBBBBBBBBBBBBBBBBBBBBBBDk', + 'kDDBBBBBBBBBBBBBBBBBBBBBBBBBBDDk', + '.kDDDBBBBBBBBBBBBBBBBBBBBBBDDDk.', + '.kDDDDDDDDDDDDDDDDDDDDDDDDDDDDk.', + '..kkkkkkkkkkkkkkkkkkkkkkkkkkkk..', +]; + +export const BOSS_OPEN: Sprite = [ + '.....kkk................kkk.....', + '....kyyyk..............kyyyk....', + '....koyok..............koyok....', + '...kkyyykkkkkkkkkkkkkkkkyyykk...', + '..kwwwwwwwwwwwwwwwwwwwwwwwwwwk..', + '.kwwwwwwwwwwwwwwwwwwwwwwwwwwwwk.', + '.kwwBBBBBBBBBBBBBBBBBBBBBBBBwwk.', + 'kwwBBBBBBBBBBBBBBBBBBBBBBBBBBwwk', + 'kBBBBBkkkkkBBBBBBBBBBkkkkkBBBBBk', + 'kBBBBkkkkkkkBBByyBBBkkkkkkkBBBBk', + 'kBBBkkwwwwkkBBByyBBBkkwwwwkkBBBk', + 'kBBBkwkkwwwkBBBBBBBBkwwwkkwkBBBk', + 'kBBBkwkkwwwkBBBBBBBBkwwwkkwkBBBk', + 'kBBBkwwwwwwkBBBBBBBBkwwwwwwkBBBk', + 'kBBBBkwwwwkBBBBBBBBBBkwwwwkBBBBk', + 'kBBBBBkkkkBBBBBBBBBBBBkkkkBBBBBk', + 'kBBBBBBBBBBBkkkkkkkkkBBBBBBBBBBk', + 'kBBBBBBBBBBkwwkkkkkwwkBBBBBBBBBk', + 'kBBBBBBBBBBkkkkkkkkkkkBBBBBBBBBk', + 'kBBBBBBBBBBkwwkkkkkwwkBBBBBBBBBk', + 'kDBBBBBBBBBBkkkkkkkkkBBBBBBBBBDk', + 'kDDBBBBBBBBBBBBBBBBBBBBBBBBBBDDk', + '.kDDDBBBBBBBBBBBBBBBBBBBBBBDDDk.', + '.kDDDDDDDDDDDDDDDDDDDDDDDDDDDDk.', + '..kkkkkkkkkkkkkkkkkkkkkkkkkkkk..', +]; + +// Hearts for the HUD, 9 × 8 at 2×. Filled is Base blue; empty is an outline. +export const HEART: Sprite = [ + '.kk...kk.', + 'kBBk.kBBk', + 'kBBBkBBBk', + 'kBBBBBBBk', + '.kBBBBBk.', + '..kBBBk..', + '...kBk...', + '....k....', +]; + +export const HEART_EMPTY: Sprite = [ + '.kk...kk.', + 'k..k.k..k', + 'k...k...k', + 'k.......k', + '.k.....k.', + '..k...k..', + '...k.k...', + '....k....', +]; + +export function drawSprite( + ctx: CanvasRenderingContext2D, + sprite: Sprite, + x: number, + y: number, + scale: number, + pal: Palette, + tint?: string, +): void { + for (let row = 0; row < sprite.length; row += 1) { + const line = sprite[row]; + for (let col = 0; col < line.length; col += 1) { + const ch = line[col]; + if (ch === '.') continue; + ctx.fillStyle = tint ?? (pal as unknown as Record)[ch] ?? pal.k; + ctx.fillRect(Math.round(x + col * scale), Math.round(y + row * scale), scale, scale); + } + } +} + +export function spriteHeight(sprite: Sprite, scale: number): number { + return sprite.length * scale; +} diff --git a/app/vibenet/demos/200/page.tsx b/app/vibenet/demos/200/page.tsx new file mode 100644 index 00000000..f5814eee --- /dev/null +++ b/app/vibenet/demos/200/page.tsx @@ -0,0 +1,22 @@ +import { BlockRunner } from './BlockRunner'; +import { Text } from '../../../components/ui/Text'; + +export default function BlockRunnerPage() { + return ( +
+
+ + Vibenet · 200 ms blocks + + + Block Runner + + + Base's Cobalt upgrade mints a block every 200 ms. This is what that cadence feels like: the chain sets the + pace, and every obstacle is a block that just landed. + +
+ +
+ ); +} diff --git a/app/vibenet/demos/_shared/InclusionBadge.tsx b/app/vibenet/demos/_shared/InclusionBadge.tsx new file mode 100644 index 00000000..d9bf28e3 --- /dev/null +++ b/app/vibenet/demos/_shared/InclusionBadge.tsx @@ -0,0 +1,67 @@ +// "Landed in 200 ms · block 136,522 · .400" — the inclusion line shown wherever +// a demo reports a sent transaction. Base's Cobalt upgrade produces a block +// every 200 ms and stamps each with a millisecond timestamp, so a tx can name +// the slot it landed in. The latency is chain time: the inclusion block's +// timestamp minus that of the newest block the page had seen at broadcast. +// Vibenet runs Cobalt today; on chains without it the slot is omitted. + +import { cn } from '../../../components/ui/cn'; +import { Text } from '../../../components/ui/Text'; +import { blocksAfterSendLabel, type Inclusion, latencyLabel, slotLabel } from './inclusion'; + +/** + * The line under the badge in a transaction result: the claim, in words. Only + * for blocks that carry Cobalt's millisecond timestamp, so it never shows on a + * chain without 200 ms blocks. + */ +export function InclusionTagline({ inclusion }: { inclusion: Inclusion }) { + if (inclusion.blockTimestampMs === null) return null; + const blocks = blocksAfterSendLabel(inclusion); + return ( + + {blocks ? `Sealed ${blocks} after broadcast. ` : ''}Brought to you by 200 ms blocks on Base. + + ); +} + +const SEP = ( + +); + +function badgeTitle(inclusion: Inclusion, slot: string | null): string { + const cadence = slot ? ` Landed in the ${slot} block of its second; vibenet seals one every 200 ms.` : ''; + if (inclusion.chainMs === null) return `Shows the block that carried the transaction.${cadence}`; + const blocks = blocksAfterSendLabel(inclusion); + return `Chain time: this block was sealed ${latencyLabel(inclusion.chainMs)} (${blocks}) after the newest block this page had received when it broadcast.${cadence}`; +} + +export function InclusionBadge({ inclusion, className }: { inclusion: Inclusion; className?: string }) { + const slot = slotLabel(inclusion.blockTimestampMs); + return ( + + {inclusion.chainMs !== null ? ( + <> + Landed in {latencyLabel(inclusion.chainMs)} + {SEP} + block {inclusion.blockNumber.toLocaleString()} + + ) : ( + Block {inclusion.blockNumber.toLocaleString()} + )} + {slot ? ( + <> + {SEP} + {slot} + + ) : null} + + ); +} diff --git a/app/vibenet/demos/_shared/TransactionModal.tsx b/app/vibenet/demos/_shared/TransactionModal.tsx index 111fa083..902a1d37 100644 --- a/app/vibenet/demos/_shared/TransactionModal.tsx +++ b/app/vibenet/demos/_shared/TransactionModal.tsx @@ -15,10 +15,13 @@ import { Button } from '../../../components/ui/Button'; import { Modal } from '../../../components/ui/Modal'; import { Spinner } from '../../../components/ui/Spinner'; import { Text } from '../../../components/ui/Text'; +import type { Inclusion } from './inclusion'; +import { InclusionBadge, InclusionTagline } from './InclusionBadge'; import { ViewTransactionButton } from './ViewTransactionButton'; export type TxStep = 'build' | 'review' | 'submitted'; -export type TxResult = { txHash?: string } | null; +/** `inclusion` is set once the tx landed — which 200 ms block, and how fast. */ +export type TxResult = { txHash?: string; inclusion?: Inclusion } | null; const DEFAULT_TITLES: Record = { build: 'Create Transaction', @@ -104,6 +107,12 @@ function DefaultSubmitted({ ✓ {renderSuccess ? renderSuccess() : Transaction submitted} + {result?.inclusion ? ( +
+ + +
+ ) : null} ); } diff --git a/app/vibenet/demos/_shared/inclusion.test.ts b/app/vibenet/demos/_shared/inclusion.test.ts new file mode 100644 index 00000000..179b9df3 --- /dev/null +++ b/app/vibenet/demos/_shared/inclusion.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; + +import { + blocksAfterSendLabel, + formatInclusion, + inclusionFromChain, + latencyLabel, + quantityToNumber, + slotLabel, +} from './inclusion'; + +describe('quantityToNumber', () => { + it('parses hex quantities', () => { + expect(quantityToNumber('0x2140a')).toBe(136_202); + expect(quantityToNumber('0x0')).toBe(0); + }); + + it('rejects non-quantities', () => { + expect(quantityToNumber(undefined)).toBeNull(); + expect(quantityToNumber('136202')).toBeNull(); + expect(quantityToNumber('')).toBeNull(); + }); +}); + +describe('inclusionFromChain', () => { + // Block 136,202 stamped at 1_788_419_125_000; the head seen at broadcast was + // 136,201 stamped 200 ms earlier. + const receipt = { blockNumber: '0x2140a' }; + const block = { timestampMs: '0x1a066162f08' }; + + it('measures chain time between the block seen at broadcast and the inclusion block', () => { + expect(inclusionFromChain(receipt, block, { number: 136_201, timestampMs: 1_788_419_124_800 })).toEqual({ + blockNumber: 136_202, + blockTimestampMs: 1_788_419_125_000, + chainMs: 200, + blocksAfterSend: 1, + }); + }); + + it('counts two slots when the transaction missed the next block', () => { + expect(inclusionFromChain(receipt, block, { number: 136_200, timestampMs: 1_788_419_124_600 })).toEqual({ + blockNumber: 136_202, + blockTimestampMs: 1_788_419_125_000, + chainMs: 400, + blocksAfterSend: 2, + }); + }); + + it('accepts the block timestamp already parsed, as the head stream delivers it', () => { + expect( + inclusionFromChain(receipt, { timestampMs: 1_788_419_125_000 }, { number: 136_201, timestampMs: 1_788_419_124_800 }), + ).toMatchObject({ blockTimestampMs: 1_788_419_125_000, chainMs: 200 }); + }); + + it('reports block and slot only without a block seen at broadcast', () => { + expect(inclusionFromChain(receipt, block, null)).toEqual({ + blockNumber: 136_202, + blockTimestampMs: 1_788_419_125_000, + chainMs: null, + blocksAfterSend: null, + }); + }); + + it('derives chain time from the block count when the anchor has no millisecond timestamp', () => { + expect(inclusionFromChain(receipt, block, { number: 136_199, timestampMs: null })).toMatchObject({ + chainMs: 600, + blocksAfterSend: 3, + }); + }); + + it('gives no latency when the anchor is at or ahead of the inclusion block', () => { + expect(inclusionFromChain(receipt, block, { number: 136_202, timestampMs: 1_788_419_125_000 })).toMatchObject({ + chainMs: null, + blocksAfterSend: null, + }); + expect(inclusionFromChain(receipt, block, { number: 136_205, timestampMs: 1_788_419_125_600 })).toMatchObject({ + chainMs: null, + blocksAfterSend: null, + }); + }); + + it('omits the slot when the block has no millisecond timestamp (pre-Cobalt)', () => { + expect(inclusionFromChain({ blockNumber: '0x10' }, {}, { number: 15, timestampMs: null })).toEqual({ + blockNumber: 16, + blockTimestampMs: null, + chainMs: 200, + blocksAfterSend: 1, + }); + expect(inclusionFromChain({ blockNumber: '0x10' }, null, null)?.blockTimestampMs).toBeNull(); + }); + + it('returns null without a block number', () => { + expect(inclusionFromChain(null, block, null)).toBeNull(); + expect(inclusionFromChain({}, block, null)).toBeNull(); + }); +}); + +describe('slotLabel', () => { + it('names the 200 ms slot inside the second', () => { + expect(slotLabel(1_788_419_137_000)).toBe('.000'); + expect(slotLabel(1_788_419_137_200)).toBe('.200'); + expect(slotLabel(1_788_419_137_800)).toBe('.800'); + }); + + it('is absent without Cobalt metadata', () => { + expect(slotLabel(null)).toBeNull(); + }); +}); + +describe('latencyLabel', () => { + it('uses milliseconds under a second and seconds from a second up', () => { + expect(latencyLabel(400)).toBe('400 ms'); + expect(latencyLabel(999.6)).toBe('1.0 s'); + expect(latencyLabel(1_840)).toBe('1.8 s'); + }); +}); + +describe('blocksAfterSendLabel', () => { + it('pluralises the block count', () => { + expect(blocksAfterSendLabel({ blocksAfterSend: 1 })).toBe('1 block'); + expect(blocksAfterSendLabel({ blocksAfterSend: 3 })).toBe('3 blocks'); + }); + + it('is absent without an anchor', () => { + expect(blocksAfterSendLabel({ blocksAfterSend: null })).toBeNull(); + }); +}); + +describe('formatInclusion', () => { + it('joins latency, block, and slot', () => { + expect( + formatInclusion({ blockNumber: 136_522, blockTimestampMs: 1_788_419_191_400, chainMs: 400, blocksAfterSend: 2 }), + ).toBe('Landed in 400 ms · block 136,522 · .400'); + }); + + it('drops the slot when there is no millisecond timestamp', () => { + expect(formatInclusion({ blockNumber: 16, blockTimestampMs: null, chainMs: 1_600, blocksAfterSend: 8 })).toBe( + 'Landed in 1.6 s · block 16', + ); + }); + + it('drops the latency when no block was seen at broadcast', () => { + expect( + formatInclusion({ blockNumber: 136_522, blockTimestampMs: 1_788_419_191_400, chainMs: null, blocksAfterSend: null }), + ).toBe('block 136,522 · .400'); + }); +}); diff --git a/app/vibenet/demos/_shared/inclusion.ts b/app/vibenet/demos/_shared/inclusion.ts new file mode 100644 index 00000000..4b706f4f --- /dev/null +++ b/app/vibenet/demos/_shared/inclusion.ts @@ -0,0 +1,97 @@ +// Inclusion timing for a broadcast transaction, measured on the chain's clock +// only. Base's Cobalt upgrade (200 ms blocks) stamps every block with a +// millisecond `timestampMs`, so a transaction can say which 200 ms slot it +// landed in and how many blocks after broadcast that was. The latency shown is +// the inclusion block's timestamp minus the timestamp of the newest block the +// page had seen when it broadcast: two chain timestamps, never a browser clock, +// so it is always a multiple of 200 ms and cannot drift with clock skew. +// Vibenet runs Cobalt today; on chains without it `blockTimestampMs` is null +// and only the block number is shown. + +export const BLOCK_INTERVAL_MS = 200; + +export type Inclusion = { + /** Block the transaction was included in. */ + blockNumber: number; + /** Block time in unix milliseconds from the Cobalt `timestampMs` field, or null without it. */ + blockTimestampMs: number | null; + /** + * Chain time from broadcast to inclusion: the inclusion block's timestamp + * minus the timestamp of the newest block seen at broadcast. Null when no + * block was seen at broadcast (socket down, recovered transaction). + */ + chainMs: number | null; + /** The same fact in blocks: inclusion block minus the block seen at broadcast. */ + blocksAfterSend: number | null; +}; + +/** The newest head the page had received when it broadcast. */ +export type SendAnchor = { number: number; timestampMs: number | null }; + +/** Parse a JSON-RPC quantity (`0x…`) to a number; null when absent or malformed. */ +export function quantityToNumber(value: unknown): number | null { + if (typeof value !== 'string' || !/^0x[0-9a-fA-F]+$/.test(value)) return null; + const parsed = Number.parseInt(value, 16); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +/** A millisecond timestamp given either parsed (from the head stream) or as a JSON-RPC quantity. */ +function toMilliseconds(value: unknown): number | null { + if (typeof value === 'number') return Number.isSafeInteger(value) ? value : null; + return quantityToNumber(value); +} + +/** + * Build an Inclusion from the receipt's block, that block's header, and the + * head seen at broadcast. A block count that is zero or negative means the + * anchor came from a replica ahead of the inclusion block; it yields no + * latency rather than a wrong one. + */ +export function inclusionFromChain( + receipt: { blockNumber?: unknown } | null | undefined, + block: { timestampMs?: unknown } | null | undefined, + anchor: SendAnchor | null, +): Inclusion | null { + const blockNumber = quantityToNumber(receipt?.blockNumber); + if (blockNumber === null) return null; + const blockTimestampMs = toMilliseconds(block?.timestampMs); + const blocksAfterSend = anchor && blockNumber - anchor.number > 0 ? blockNumber - anchor.number : null; + let chainMs: number | null = null; + if (blocksAfterSend !== null) { + chainMs = + blockTimestampMs !== null && anchor?.timestampMs != null + ? blockTimestampMs - anchor.timestampMs + : blocksAfterSend * BLOCK_INTERVAL_MS; + } + return { blockNumber, blockTimestampMs, chainMs, blocksAfterSend }; +} + +/** The 200 ms slot inside the second: `.000`, `.200`, … or null without Cobalt metadata. */ +export function slotLabel(blockTimestampMs: number | null): string | null { + if (blockTimestampMs === null) return null; + return `.${String(blockTimestampMs % 1000).padStart(3, '0')}`; +} + +/** Latency for display: `400 ms` under a second, `1.8 s` from a second up. */ +export function latencyLabel(ms: number): string { + const rounded = Math.round(ms); + if (rounded < 1000) return `${rounded} ms`; + return `${(rounded / 1000).toFixed(1)} s`; +} + +/** `1 block` / `3 blocks` after broadcast, or null without an anchor. */ +export function blocksAfterSendLabel(inclusion: Pick): string | null { + const n = inclusion.blocksAfterSend; + if (n === null) return null; + return `${n} ${n === 1 ? 'block' : 'blocks'}`; +} + +/** One-line summary: `Landed in 200 ms · block 136,522 · .200`, or without latency `block 136,522 · .200`. */ +export function formatInclusion(inclusion: Inclusion): string { + const parts: string[] = []; + if (inclusion.chainMs !== null) parts.push(`Landed in ${latencyLabel(inclusion.chainMs)}`); + parts.push(`block ${inclusion.blockNumber.toLocaleString()}`); + const slot = slotLabel(inclusion.blockTimestampMs); + if (slot) parts.push(slot); + return parts.join(' · '); +} diff --git a/app/vibenet/demos/_shared/receiptWatcher.test.ts b/app/vibenet/demos/_shared/receiptWatcher.test.ts new file mode 100644 index 00000000..0d3bf6a1 --- /dev/null +++ b/app/vibenet/demos/_shared/receiptWatcher.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { JsonRpcStream } from '../validity/lib/stream'; +import { createReceiptWatcher, type RawReceipt, ReceiptTimeoutError } from './receiptWatcher'; + +type Hex = `0x${string}`; + +const TX: Hex = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const OTHER: Hex = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const BLOCK_HASH: Hex = '0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'; + +const receiptFor = (hash: Hex): RawReceipt => ({ transactionHash: hash, blockHash: BLOCK_HASH, blockNumber: '0x2140a', status: '0x1' }); + +/** A stand-in for connectJsonRpcStream: opens, fails, emits, and disconnects on command. */ +function fakeStream() { + const subs = new Map void>(); + let onClose: (() => void) | undefined; + let open!: () => void; + let fail!: (err: Error) => void; + const ready = new Promise((resolve, reject) => { + open = resolve; + fail = reject; + }); + // A rejected `ready` that nobody awaits yet must not trip the unhandled-rejection guard. + ready.catch(() => {}); + const close = vi.fn(); + const stream: JsonRpcStream = { + ready, + request: vi.fn(async () => null), + subscribe: async (params, onResult) => { + subs.set(String(params[0]), onResult); + return () => { + subs.delete(String(params[0])); + }; + }, + setOnClose: (handler) => { + onClose = handler; + }, + close, + }; + return { + stream, + subs, + close, + open: async () => { + open(); + await vi.advanceTimersByTimeAsync(0); + }, + fail: async () => { + fail(new Error('WebSocket failed')); + await vi.advanceTimersByTimeAsync(0); + }, + emit: (name: string, result: unknown) => subs.get(name)?.(result), + disconnect: () => onClose?.(), + }; +} + +describe('createReceiptWatcher', () => { + let clock = 0; + beforeEach(() => { + clock = 0; + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const tick = async (ms: number) => { + clock += ms; + await vi.advanceTimersByTimeAsync(ms); + }; + + it('resolves a waiter from a pushed transactionReceipts array', async () => { + const fake = fakeStream(); + const fetchReceipt = vi.fn(async () => null); + const w = createReceiptWatcher({ connect: () => fake.stream, fetchReceipt, now: () => clock }); + await fake.open(); + expect(w.mode()).toBe('live'); + const pending = w.waitForReceipt(TX, { timeoutMs: 5_000 }); + fake.emit('transactionReceipts', [receiptFor(OTHER), receiptFor(TX)]); + await expect(pending).resolves.toMatchObject({ transactionHash: TX }); + expect(fetchReceipt).not.toHaveBeenCalled(); + }); + + it('resolves from the recent buffer when the receipt was pushed before the hash was known', async () => { + const fake = fakeStream(); + const w = createReceiptWatcher({ connect: () => fake.stream, fetchReceipt: async () => null, now: () => clock }); + await fake.open(); + fake.emit('transactionReceipts', [receiptFor(TX)]); + await expect(w.waitForReceipt(TX, { timeoutMs: 5_000 })).resolves.toMatchObject({ transactionHash: TX }); + }); + + it('ignores pushed receipts for other hashes', async () => { + const fake = fakeStream(); + const w = createReceiptWatcher({ connect: () => fake.stream, fetchReceipt: async () => null, now: () => clock }); + await fake.open(); + const outcome = expect(w.waitForReceipt(TX, { timeoutMs: 400 })).rejects.toBeInstanceOf(ReceiptTimeoutError); + fake.emit('transactionReceipts', [receiptFor(OTHER)]); + await tick(400); + await outcome; + }); + + it('exposes the newest head as the anchor until it goes stale', async () => { + const fake = fakeStream(); + const w = createReceiptWatcher({ connect: () => fake.stream, fetchReceipt: async () => null, now: () => clock }); + await fake.open(); + expect(w.latestHead()).toBeNull(); + fake.emit('newHeads', { number: '0x2140a', hash: BLOCK_HASH, timestampMs: '0x1a066162f08' }); + expect(w.latestHead()).toEqual({ number: 136_202, hash: BLOCK_HASH, timestampMs: 1_788_419_125_000 }); + await tick(1_501); + expect(w.latestHead()).toBeNull(); + }); + + it('finds a recent head by block hash for the inclusion block timestamp', async () => { + const fake = fakeStream(); + const w = createReceiptWatcher({ connect: () => fake.stream, fetchReceipt: async () => null, now: () => clock }); + await fake.open(); + fake.emit('newHeads', { number: '0x2140a', hash: BLOCK_HASH, timestampMs: '0x1a066162f08' }); + expect(w.headByHash(BLOCK_HASH)?.timestampMs).toBe(1_788_419_125_000); + expect(w.headByHash(OTHER)).toBeNull(); + }); + + it('polls the receipt every 100 ms when there is no socket', async () => { + let calls = 0; + const fetchReceipt = vi.fn(async () => (++calls >= 3 ? receiptFor(TX) : null)); + const w = createReceiptWatcher({ connect: () => null, fetchReceipt, now: () => clock }); + expect(w.mode()).toBe('polling'); + const pending = w.waitForReceipt(TX, { timeoutMs: 5_000 }); + await tick(100); + expect(fetchReceipt).toHaveBeenCalledTimes(1); + await tick(200); + expect(fetchReceipt).toHaveBeenCalledTimes(3); + await expect(pending).resolves.toMatchObject({ transactionHash: TX }); + expect(w.latestHead()).toBeNull(); + }); + + it('falls back to fast polling when the socket drops, then reconnects', async () => { + const first = fakeStream(); + const second = fakeStream(); + let attempt = 0; + const fetchReceipt = vi.fn(async () => null); + const w = createReceiptWatcher({ + connect: () => (attempt++ === 0 ? first.stream : second.stream), + fetchReceipt, + now: () => clock, + reconnectDelayMs: 1_000, + }); + await first.open(); + const pending = w.waitForReceipt(TX, { timeoutMs: 10_000 }); + first.disconnect(); + expect(w.mode()).toBe('polling'); + await tick(300); + expect(fetchReceipt).toHaveBeenCalledTimes(3); + await tick(700); + await second.open(); + expect(w.mode()).toBe('live'); + second.emit('transactionReceipts', [receiptFor(TX)]); + await expect(pending).resolves.toMatchObject({ transactionHash: TX }); + }); + + it('runs polling-only when the socket never opens', async () => { + const fake = fakeStream(); + const w = createReceiptWatcher({ connect: () => fake.stream, fetchReceipt: async () => receiptFor(TX), now: () => clock }); + await fake.fail(); + expect(w.mode()).toBe('polling'); + expect(fake.close).toHaveBeenCalled(); + const pending = w.waitForReceipt(TX, { timeoutMs: 5_000 }); + await tick(100); + await expect(pending).resolves.toMatchObject({ transactionHash: TX }); + }); + + it('rejects with ReceiptTimeoutError and stops polling at the timeout', async () => { + const fetchReceipt = vi.fn(async () => null); + const w = createReceiptWatcher({ connect: () => null, fetchReceipt, now: () => clock }); + const outcome = expect(w.waitForReceipt(TX, { timeoutMs: 250 })).rejects.toMatchObject({ + name: 'ReceiptTimeoutError', + hash: TX, + }); + await tick(250); + await outcome; + const calls = fetchReceipt.mock.calls.length; + await tick(500); + expect(fetchReceipt).toHaveBeenCalledTimes(calls); + }); + + it('close() shuts the socket and lets an in-flight waiter finish over polling', async () => { + const fake = fakeStream(); + let calls = 0; + const fetchReceipt = vi.fn(async () => (++calls >= 2 ? receiptFor(TX) : null)); + const w = createReceiptWatcher({ connect: () => fake.stream, fetchReceipt, now: () => clock }); + await fake.open(); + const pending = w.waitForReceipt(TX, { timeoutMs: 5_000 }); + w.close(); + expect(w.mode()).toBe('closed'); + expect(fake.close).toHaveBeenCalled(); + await tick(1_000); + await expect(pending).resolves.toMatchObject({ transactionHash: TX }); + }); +}); diff --git a/app/vibenet/demos/_shared/receiptWatcher.ts b/app/vibenet/demos/_shared/receiptWatcher.ts new file mode 100644 index 00000000..76a98718 --- /dev/null +++ b/app/vibenet/demos/_shared/receiptWatcher.ts @@ -0,0 +1,289 @@ +// Watches vibenet's block stream so a transaction's inclusion can be reported +// in chain time. Two subscriptions on one WebSocket: `newHeads` keeps the +// newest block (the anchor a broadcast is measured from, and the source of +// each block's Cobalt `timestampMs`), and `transactionReceipts` delivers every +// receipt the moment its block is sealed, so a waiter resolves without polling. +// When the socket is unavailable the watcher polls `eth_getTransactionReceipt` +// over HTTP instead; there is then no anchor, so only block and slot are shown. +// +// Framework-free: no React, no `window`, timers via globalThis, so it runs +// under vitest with a fake stream. + +import type { JsonRpcStream } from '../validity/lib/stream'; +import { quantityToNumber } from './inclusion'; + +type Hex = `0x${string}`; + +export type WatchedHead = { + number: number; + hash: Hex | null; + /** Cobalt millisecond timestamp, or null on chains without it. */ + timestampMs: number | null; +}; + +export type RawReceipt = { + transactionHash?: Hex; + blockHash?: Hex; + blockNumber?: Hex; + status?: Hex; + [key: string]: unknown; +}; + +export class ReceiptTimeoutError extends Error { + readonly hash: Hex; + constructor(hash: Hex) { + super(`Timed out waiting for the receipt of ${hash}.`); + this.name = 'ReceiptTimeoutError'; + this.hash = hash; + } +} + +export type ReceiptWatcherDeps = { + /** Opens the WebSocket; return null to run polling-only (no socket URL, other chain). */ + connect: () => JsonRpcStream | null; + /** HTTP `eth_getTransactionReceipt`; null while the transaction is pending. */ + fetchReceipt: (hash: Hex) => Promise; + /** Clock for head staleness only; never surfaces in a displayed number. */ + now?: () => number; + /** Receipt poll cadence while the socket is down. */ + pollIntervalMs?: number; + /** Receipt poll cadence while live, in case a push is missed. */ + safetyPollMs?: number; + /** A head older than this is no anchor: the socket has stalled. */ + headStaleMs?: number; + /** First reconnect delay; doubles up to ten times this. */ + reconnectDelayMs?: number; + /** How long a pushed receipt is kept for a waiter that registers late. */ + recentReceiptsMs?: number; + /** Heads kept by hash for `headByHash`. */ + headRing?: number; +}; + +export type WatcherMode = 'connecting' | 'live' | 'polling' | 'closed'; + +export type ReceiptWatcher = { + /** The newest head received over the socket, or null when there is none fresh enough to anchor on. */ + latestHead(): WatchedHead | null; + /** A recent head by block hash, for the inclusion block's timestamp. */ + headByHash(hash: Hex): WatchedHead | null; + /** Resolves with the receipt as soon as it is pushed or polled; rejects with ReceiptTimeoutError. */ + waitForReceipt(hash: Hex, opts: { timeoutMs: number }): Promise; + mode(): WatcherMode; + close(): void; +}; + +type Waiter = { + hash: Hex; + settle: (receipt: RawReceipt | null) => void; + pollTimer: ReturnType | undefined; + done: boolean; +}; + +type RawHead = { number?: unknown; hash?: unknown; timestampMs?: unknown }; + +function parseHead(raw: unknown): WatchedHead | null { + if (!raw || typeof raw !== 'object') return null; + const head = raw as RawHead; + const number = quantityToNumber(head.number); + if (number === null) return null; + const hash = typeof head.hash === 'string' && head.hash.startsWith('0x') ? (head.hash as Hex) : null; + return { number, hash, timestampMs: quantityToNumber(head.timestampMs) }; +} + +export function createReceiptWatcher(deps: ReceiptWatcherDeps): ReceiptWatcher { + const now = deps.now ?? (() => Date.now()); + const pollIntervalMs = deps.pollIntervalMs ?? 100; + const safetyPollMs = deps.safetyPollMs ?? 1_000; + const headStaleMs = deps.headStaleMs ?? 1_500; + const baseReconnectMs = deps.reconnectDelayMs ?? 1_000; + const recentReceiptsMs = deps.recentReceiptsMs ?? 30_000; + const headRing = deps.headRing ?? 64; + + let mode: WatcherMode = 'connecting'; + let closed = false; + let stream: JsonRpcStream | null = null; + let reconnectDelay = baseReconnectMs; + let reconnectTimer: ReturnType | undefined; + + let latest: { head: WatchedHead; seenAt: number } | null = null; + const heads = new Map(); + const recent = new Map(); + const waiters = new Map>(); + + const onHead = (raw: unknown) => { + const head = parseHead(raw); + if (!head) return; + latest = { head, seenAt: now() }; + if (head.hash) { + heads.set(head.hash, head); + while (heads.size > headRing) { + const oldest = heads.keys().next().value; + if (oldest === undefined) break; + heads.delete(oldest); + } + } + }; + + const settleWaiters = (hash: Hex, receipt: RawReceipt) => { + const set = waiters.get(hash); + if (!set) return; + waiters.delete(hash); + for (const waiter of set) waiter.settle(receipt); + }; + + const onReceipts = (raw: unknown) => { + if (!Array.isArray(raw)) return; + const seenAt = now(); + for (const [hash, entry] of recent) if (seenAt - entry.seenAt > recentReceiptsMs) recent.delete(hash); + for (const item of raw) { + if (!item || typeof item !== 'object') continue; + const receipt = item as RawReceipt; + const hash = receipt.transactionHash; + if (typeof hash !== 'string') continue; + recent.set(hash, { receipt, seenAt }); + settleWaiters(hash, receipt); + } + }; + + const currentPollDelay = () => (mode === 'live' ? safetyPollMs : pollIntervalMs); + + const rearmWaiters = () => { + for (const set of waiters.values()) { + for (const waiter of set) { + if (waiter.done) continue; + if (waiter.pollTimer !== undefined) clearTimeout(waiter.pollTimer); + waiter.pollTimer = setTimeout(() => void pollOnce(waiter), currentPollDelay()); + } + } + }; + + const pollOnce = async (waiter: Waiter) => { + waiter.pollTimer = undefined; + if (waiter.done) return; + let receipt: RawReceipt | null = null; + try { + receipt = await deps.fetchReceipt(waiter.hash); + } catch { + receipt = null; + } + if (waiter.done) return; + if (receipt) { + waiters.get(waiter.hash)?.delete(waiter); + waiter.settle(receipt); + return; + } + waiter.pollTimer = setTimeout(() => void pollOnce(waiter), currentPollDelay()); + }; + + const onDisconnected = () => { + latest = null; + if (closed) { + mode = 'closed'; + return; + } + mode = 'polling'; + rearmWaiters(); + reconnectTimer = setTimeout(() => { + reconnectTimer = undefined; + connect(); + }, reconnectDelay); + reconnectDelay = Math.min(reconnectDelay * 2, baseReconnectMs * 10); + }; + + const connect = () => { + if (closed) return; + let next: JsonRpcStream | null = null; + try { + next = deps.connect(); + } catch { + next = null; + } + if (!next) { + mode = 'polling'; + return; + } + const s = next; + stream = s; + mode = 'connecting'; + s.setOnClose(() => { + if (stream !== s) return; + stream = null; + onDisconnected(); + }); + void (async () => { + await s.ready; + await s.subscribe(['newHeads'], onHead); + await s.subscribe(['transactionReceipts'], onReceipts); + if (stream !== s || closed) return; + mode = 'live'; + reconnectDelay = baseReconnectMs; + })().catch(() => { + if (stream !== s) return; + stream = null; + s.close(); + onDisconnected(); + }); + }; + + connect(); + + return { + latestHead() { + if (!latest || now() - latest.seenAt > headStaleMs) return null; + return latest.head; + }, + headByHash(hash) { + return heads.get(hash) ?? null; + }, + mode() { + return mode; + }, + waitForReceipt(hash, { timeoutMs }) { + return new Promise((resolve, reject) => { + const hit = recent.get(hash); + if (hit) { + resolve(hit.receipt); + return; + } + let timeoutTimer: ReturnType | undefined; + const waiter: Waiter = { + hash, + done: false, + pollTimer: undefined, + settle: (receipt) => { + if (waiter.done) return; + waiter.done = true; + if (waiter.pollTimer !== undefined) clearTimeout(waiter.pollTimer); + if (timeoutTimer !== undefined) clearTimeout(timeoutTimer); + const set = waiters.get(hash); + if (set) { + set.delete(waiter); + if (set.size === 0) waiters.delete(hash); + } + if (receipt) resolve(receipt); + else reject(new ReceiptTimeoutError(hash)); + }, + }; + const set = waiters.get(hash) ?? new Set(); + set.add(waiter); + waiters.set(hash, set); + timeoutTimer = setTimeout(() => waiter.settle(null), timeoutMs); + waiter.pollTimer = setTimeout(() => void pollOnce(waiter), currentPollDelay()); + }); + }, + close() { + closed = true; + mode = 'closed'; + latest = null; + if (reconnectTimer !== undefined) { + clearTimeout(reconnectTimer); + reconnectTimer = undefined; + } + const s = stream; + stream = null; + s?.close(); + // No more pushes are coming: anything still waiting polls at full speed. + rearmWaiters(); + }, + }; +} diff --git a/app/vibenet/demos/account/components/ActivityLog.tsx b/app/vibenet/demos/account/components/ActivityLog.tsx index e3661352..1f0f6fda 100644 --- a/app/vibenet/demos/account/components/ActivityLog.tsx +++ b/app/vibenet/demos/account/components/ActivityLog.tsx @@ -8,11 +8,24 @@ import { Text } from '../../../../components/ui/Text'; import { VIBENET_EXPLORER_PATH } from '../../../library/config'; import { type ActivityEntry, type StoredAccount, formatTime } from '../library/model'; import { short } from '../shared'; +import type { Inclusion } from '../../_shared/inclusion'; +import { InclusionBadge } from '../../_shared/InclusionBadge'; import { AccountIdentity, Badge } from '../../_shared/primitives'; import { ViewTransactionButton } from '../../_shared/ViewTransactionButton'; const TX_HASH_RE = /^0x[0-9a-fA-F]{64}$/; +/** Inclusion stored on the entry, when the engine observed it land. */ +function inclusionOf(e: ActivityEntry): Inclusion | null { + if (typeof e.blockNumber !== 'number') return null; + return { + blockNumber: e.blockNumber, + blockTimestampMs: e.blockTimestampMs ?? null, + chainMs: e.chainMs ?? null, + blocksAfterSend: e.blocksAfterSend ?? null, + }; +} + export function ActivityLog({ activity, accounts }: { activity: ActivityEntry[]; accounts: StoredAccount[] }) { const reducedMotion = useReducedMotion(); const knownIds = useRef | null>(null); @@ -57,6 +70,7 @@ export function ActivityLog({ activity, accounts }: { activity: ActivityEntry[]; {activity.map((e, i) => { const txHash = e.txHash && TX_HASH_RE.test(e.txHash) ? e.txHash : null; + const inclusion = inclusionOf(e); const acct = e.account ? accounts.find((a) => a.address.toLowerCase() === e.account!.toLowerCase()) : undefined; @@ -106,6 +120,11 @@ export function ActivityLog({ activity, accounts }: { activity: ActivityEntry[]; ))} ) : null} + {inclusion ? ( +
+ +
+ ) : null} @@ -123,6 +142,7 @@ export function ActivityLog({ activity, accounts }: { activity: ActivityEntry[];
{activity.map((e, i) => { const txHash = e.txHash && TX_HASH_RE.test(e.txHash) ? e.txHash : null; + const inclusion = inclusionOf(e); const acct = e.account ? accounts.find((a) => a.address.toLowerCase() === e.account!.toLowerCase()) : undefined; @@ -171,6 +191,12 @@ export function ActivityLog({ activity, accounts }: { activity: ActivityEntry[];
) : null} + {inclusion ? ( +
+ +
+ ) : null} +
{txHash ? ( diff --git a/app/vibenet/demos/account/components/TransactionModal.tsx b/app/vibenet/demos/account/components/TransactionModal.tsx index 829732f4..b475c01c 100644 --- a/app/vibenet/demos/account/components/TransactionModal.tsx +++ b/app/vibenet/demos/account/components/TransactionModal.tsx @@ -38,6 +38,8 @@ import { vibenetApi } from '../../../library/client'; import { VIBENET_EXPLORER_PATH } from '../../../library/config'; import { AccountSwitcher } from '../../_shared/AccountSwitcher'; import { AddressAutocomplete, type AddressBookEntry } from '../../_shared/AddressAutocomplete'; +import type { Inclusion } from '../../_shared/inclusion'; +import { InclusionBadge, InclusionTagline } from '../../_shared/InclusionBadge'; import { Badge, CheckIcon, KindBadge } from '../../_shared/primitives'; import { ViewTransactionButton } from '../../_shared/ViewTransactionButton'; import { DEMO_CHAINS, estimateTxGas, PAYER_URL } from '../library/chains'; @@ -92,6 +94,7 @@ export function TransactionModal({ onClose, preset, applyTarget }: TransactionMo pendingScope, keyChangeCount, broadcast8130, + inclusionFor, signComposed, applyLandedBundle, pendingBundleFor, @@ -131,14 +134,7 @@ export function TransactionModal({ onClose, preset, applyTarget }: TransactionMo const [txStep, setTxStep] = useState<'build' | 'review' | 'submitted'>( applyTarget || preset ? 'review' : 'build', ); - const [result, setResult] = useState<{ - serialized?: Hex; - txHash?: Hex; - by: string; - kind: SignerKind; - gasNote?: string; - pending?: boolean; - } | null>(null); + const [result, setResult] = useState(null); const signableSigners = useMemo( () => [...postChangeOwnerSigners, ...sessionSigners], @@ -193,7 +189,7 @@ export function TransactionModal({ onClose, preset, applyTarget }: TransactionMo gasNote?: string, extraChanges: string[] = [], ) => { - setResult({ serialized, txHash, by: by.label, kind: by.kind, pending, gasNote }); + setResult({ serialized, txHash, by: by.label, kind: by.kind, pending, gasNote, inclusion: inclusionFor(txHash) }); pushActivity({ kind: a.deployed && !pending ? 'transact' : 'create', txHash, @@ -540,7 +536,7 @@ export function TransactionModal({ onClose, preset, applyTarget }: TransactionMo // same presentational shape even though their execution paths differ. const applyResult = applyTarget && configTx && txSigner - ? { txHash: configTx.hash, by: txSigner.label, kind: txSigner.kind } + ? { txHash: configTx.hash, by: txSigner.label, kind: txSigner.kind, inclusion: inclusionFor(configTx.hash) } : null; const submittedResult = applyTarget ? applyResult : result; @@ -1378,6 +1374,8 @@ type SubmittedResult = { kind: SignerKind; gasNote?: string; pending?: boolean; + /** Which 200 ms block it landed in, and how fast; absent while pending. */ + inclusion?: Inclusion; } | null; // Third stage: shown once "Send" is pressed. Renders the in-flight status, then @@ -1442,6 +1440,12 @@ function SubmittedBody({ Broadcast but not yet included — check the explorer for status. ) : null} + {result.inclusion ? ( +
+ + +
+ ) : null} {result.gasNote ? {result.gasNote} : null} {result.txHash ? ( {short(result.txHash)} diff --git a/app/vibenet/demos/account/library/model.ts b/app/vibenet/demos/account/library/model.ts index f8d72fcc..9cdd08a2 100644 --- a/app/vibenet/demos/account/library/model.ts +++ b/app/vibenet/demos/account/library/model.ts @@ -238,6 +238,14 @@ export type ActivityEntry = { serialized?: Hex; txHash?: Hex; account?: Address; + /** Block the tx landed in (Cobalt: one every 200 ms on vibenet). */ + blockNumber?: number; + /** Block time in unix ms from Cobalt's `timestampMs`; null on chains without it. */ + blockTimestampMs?: number | null; + /** Chain time from the block seen at broadcast to the inclusion block; null without an anchor. */ + chainMs?: number | null; + /** The same in blocks. */ + blocksAfterSend?: number | null; }; // --------------------------------------------------------------------------- diff --git a/app/vibenet/demos/account/useAccountEngine.tsx b/app/vibenet/demos/account/useAccountEngine.tsx index e317790e..4c6182a9 100644 --- a/app/vibenet/demos/account/useAccountEngine.tsx +++ b/app/vibenet/demos/account/useAccountEngine.tsx @@ -34,6 +34,7 @@ import { generatePrivateKey, getConfigSequence, getTransactionCount, + getTransactionReceipt, type Hex, http, key, @@ -47,9 +48,9 @@ import { toHex, toP256Signer, toWebAuthnAccount, + parseReceiptFields, toWebAuthnSigner, upgradeableProxyBytecode, - waitForTransactionReceipt, } from '@aa'; import { createContext, @@ -64,7 +65,15 @@ import { import { toast } from 'sonner'; import { vibenetApi } from '../../library/client'; -import { ACCOUNT_RPC_URL } from '../../library/config'; +import { ACCOUNT_RPC_URL, VIBENET_WS_URL } from '../../library/config'; +import { type Inclusion, inclusionFromChain, type SendAnchor } from '../_shared/inclusion'; +import { + createReceiptWatcher, + type RawReceipt, + ReceiptTimeoutError, + type ReceiptWatcher, +} from '../_shared/receiptWatcher'; +import { connectJsonRpcStream } from '../validity/lib/stream'; import { type DemoChain, deploymentFromContracts, estimateTxGas, getDemoChain } from './library/chains'; import { buildPhases, type CallRow, newCallRow, safeGasLimit, valueBearingCallCount } from './library/calls'; import { @@ -608,8 +617,13 @@ function useAccountEngineCore() { }, [sessionPolicyKey, chain.shortName]); // --- helpers ----------------------------------------------------------- + // Entries that name a tx hash pick up its inclusion timing automatically, so + // every surface's activity row can say which 200 ms block the tx landed in. const pushActivity = (e: Omit) => - setActivity((prev) => [{ id: crypto.randomUUID(), ts: Date.now(), ...e }, ...prev]); + setActivity((prev) => [ + { id: crypto.randomUUID(), ts: Date.now(), ...inclusionFor(e.txHash), ...e }, + ...prev, + ]); const updateAccount = useCallback( (id: string, patch: Partial | ((a: StoredAccount) => StoredAccount)) => @@ -797,20 +811,88 @@ function useAccountEngineCore() { ? account.delegate(a.delegate ?? chain.deployment.accounts.default) : (account as ReturnType).create(); + // Inclusion timing per broadcast hash, in chain time: which block (and which + // 200 ms slot) each transaction landed in, and how many blocks after the + // newest head this page had seen when it broadcast. Written by + // awaitInclusion, read by pushActivity and by the surfaces' result views. + // Refs, not state — looked up right after the await, never rendered from. + const inclusions = useRef(new Map()); + const sendAnchors = useRef(new Map()); + const inclusionFor = (txHash: Hex | undefined): Inclusion | undefined => + txHash ? inclusions.current.get(txHash) : undefined; + + // The block watcher: `newHeads` (the anchor a send is measured from, and each + // block's Cobalt `timestampMs`) and `transactionReceipts` (the receipt the + // moment its block is sealed) on one socket, with HTTP receipt polling when + // the socket is down. Opened on mount so the anchor is warm by the first send. + const watcher = useRef(null); + const makeWatcher = useCallback( + () => + createReceiptWatcher({ + connect: () => + chain.shortName === 'vibenet' && VIBENET_WS_URL ? connectJsonRpcStream(VIBENET_WS_URL) : null, + fetchReceipt: (hash) => + makeRpcClient().request({ method: 'eth_getTransactionReceipt', params: [hash] }) as Promise, + }), + [chain.shortName, makeRpcClient], + ); + useEffect(() => { + const w = makeWatcher(); + watcher.current = w; + return () => { + w.close(); + if (watcher.current === w) watcher.current = null; + }; + }, [makeWatcher]); + const ensureWatcher = (): ReceiptWatcher => { + if (!watcher.current) watcher.current = makeWatcher(); + return watcher.current; + }; + // Wait for a broadcast tx to be included and check that it — and every 8130 // phase in it — succeeded. Throws TxPendingError if it is still not included // when the timeout runs out, a plain Error if anything reverted. const awaitInclusion = async (txHash: Hex, timeout = 30_000): Promise => { + const client = makeRpcClient(); + const w = ensureWatcher(); + // The receipt arrives on the socket the moment its block is sealed; over + // HTTP it is polled every 100 ms. This is the whole wait the spinner shows. + let pushed: RawReceipt; try { - const receipt = await waitForTransactionReceipt(makeRpcClient() as never, { hash: txHash, timeout }); - if (receipt.status === '0x0') throw new Error(`Transaction reverted onchain (${txHash}).`); - const phases = receipt.eip8130?.phaseStatuses ?? []; - const failedPhase = phases.findIndex((s: Hex) => s === '0x0'); - if (failedPhase !== -1) throw new Error(`Phase ${failedPhase} reverted (tx ${txHash}).`); + pushed = await w.waitForReceipt(txHash, { timeoutMs: timeout }); } catch (err) { - if ((err as Error)?.message?.includes('timed out')) throw new TxPendingError(txHash); + if (err instanceof ReceiptTimeoutError) throw new TxPendingError(txHash); throw err; } + // Status and 8130 phase results come from the account RPC, whose replica + // can trail the socket's node by a block: a few short retries, then the + // pushed receipt itself. + let receipt: (Record & { eip8130: ReturnType }) | null = null; + for (let attempt = 0; attempt < 5 && !receipt; attempt += 1) { + if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 150)); + receipt = await getTransactionReceipt(client as never, { hash: txHash }).catch(() => null); + } + if (!receipt) receipt = { ...pushed, eip8130: parseReceiptFields(pushed) }; + if (receipt.status === '0x0') throw new Error(`Transaction reverted onchain (${txHash}).`); + const phases = receipt.eip8130?.phaseStatuses ?? []; + const failedPhase = phases.findIndex((s: Hex) => s === '0x0'); + if (failedPhase !== -1) throw new Error(`Phase ${failedPhase} reverted (tx ${txHash}).`); + // Chain facts: the inclusion block's Cobalt `timestampMs`, from the head + // stream when it has the block, else one block read. Timing is decoration, + // so a failed read is dropped. + const blockHash = receipt.blockHash as Hex; + const head = + w.headByHash(blockHash) ?? + ((await client + .request({ method: 'eth_getBlockByHash', params: [blockHash, false] }) + .catch(() => null)) as { timestampMs?: Hex } | null); + const inclusion = inclusionFromChain( + receipt as { blockNumber?: unknown }, + head, + sendAnchors.current.get(txHash)?.anchor ?? null, + ); + if (inclusion) inclusions.current.set(txHash, inclusion); + sendAnchors.current.delete(txHash); return txHash; }; @@ -818,11 +900,21 @@ function useAccountEngineCore() { // timeout (submitted but unconfirmed), a plain Error if any phase reverts. const broadcast8130 = async (signedTx: Hex, onStatus?: (s: 'submitting' | 'confirming') => void): Promise => { const client = makeRpcClient(); + const w = ensureWatcher(); onStatus?.('submitting'); + // The newest block this page has seen is the anchor the inclusion is + // measured from, on the chain's clock: read it before the send leaves. + const latest = w.latestHead(); + const anchor: SendAnchor | null = latest ? { number: latest.number, timestampMs: latest.timestampMs } : null; const txHash = (await client.request({ method: 'eth_sendRawTransaction', params: [signedTx], })) as Hex; + // Anchors outlive a pending timeout (the batch retry re-awaits the same + // hash), so abandoned ones are swept here instead. + const now = Date.now(); + for (const [hash, mark] of sendAnchors.current) if (now - mark.at > 300_000) sendAnchors.current.delete(hash); + sendAnchors.current.set(txHash, { anchor, at: now }); onStatus?.('confirming'); return awaitInclusion(txHash); }; @@ -1116,7 +1208,7 @@ function useAccountEngineCore() { tokenGas?: { token: Address; decimals: number; payer: Signer; fee: bigint }; /** Optional top-level signed app data attached to the transaction. */ metadata?: string; - }): Promise<{ hash: Hex; serialized: Hex; mode: 'self' | 'token' }> => { + }): Promise<{ hash: Hex; serialized: Hex; mode: 'self' | 'token'; inclusion?: Inclusion }> => { if (!acct) throw new Error('Select an account before you continue.'); if (!calls.length) throw new Error('No calls to send.'); const signer = @@ -1151,7 +1243,7 @@ function useAccountEngineCore() { ); const hash = await broadcast8130(serialized); applyLandedBundle(acct, nextSeq, bundle); - return { hash, serialized, mode: tokenGas ? 'token' : 'self' }; + return { hash, serialized, mode: tokenGas ? 'token' : 'self', inclusion: inclusionFor(hash) }; }; // Sign + broadcast from a specific stored account (not necessarily the active @@ -2078,6 +2170,7 @@ function useAccountEngineCore() { // Signing engine (also used by each surface's own Transact flow) broadcast8130, + inclusionFor, signComposed, sendActiveCalls, sendAccountCalls, diff --git a/app/vibenet/demos/b20/B20Demo.tsx b/app/vibenet/demos/b20/B20Demo.tsx index 2d789abd..aec338b0 100644 --- a/app/vibenet/demos/b20/B20Demo.tsx +++ b/app/vibenet/demos/b20/B20Demo.tsx @@ -624,6 +624,7 @@ function B20DemoInner() { onClose={closeModal} wallet={wallet} onSendCalls={sendCalls} + inclusionFor={engine.inclusionFor} onCreated={async (next) => { if (wallet) setRecent(writeRecent(wallet, next)); selectToken(next); @@ -671,6 +672,7 @@ function B20DemoInner() { token={token} assignment={pendingAssign} onSend={send} + inclusionFor={engine.inclusionFor} /> {/* Transfer modal (owns the shared transaction dialog) */} @@ -680,6 +682,7 @@ function B20DemoInner() { token={token} addressBook={addressBook} onSend={send} + inclusionFor={engine.inclusionFor} /> {/* Memos modal (owns the shared transaction dialog) */} @@ -689,6 +692,7 @@ function B20DemoInner() { token={token} addressBook={addressBook} onSend={send} + inclusionFor={engine.inclusionFor} /> {/* Gas Payments modal: send a transaction paying its fee in the token. */} @@ -700,6 +704,7 @@ function B20DemoInner() { recipient={GAS_DEMO_RECIPIENT} fee={token ? formatTokenAmount(tokenGasFee(token.decimals), token.decimals) : ''} onPay={sendGasPayment} + inclusionFor={engine.inclusionFor} /> {/* Announcements modal (owns the shared transaction dialog) */} @@ -710,6 +715,7 @@ function B20DemoInner() { tokenAccess={tokenAccess} wallet={wallet} onSend={send} + inclusionFor={engine.inclusionFor} /> ); diff --git a/app/vibenet/demos/b20/components/AnnouncementModule.tsx b/app/vibenet/demos/b20/components/AnnouncementModule.tsx index 4aa54c1c..3da61580 100644 --- a/app/vibenet/demos/b20/components/AnnouncementModule.tsx +++ b/app/vibenet/demos/b20/components/AnnouncementModule.tsx @@ -6,6 +6,7 @@ import { encodeFunctionData, type Address, type Hex } from 'viem'; import { Text } from '../../../../components/ui/Text'; import { VIBENET_EXPLORER_PATH } from '../../../library/config'; import { walletErrorMessage } from '../../../library/wallet'; +import type { Inclusion } from '../../_shared/inclusion'; import { TransactionModal, type TxStep } from '../../_shared/TransactionModal'; import { client } from '../lib/constants'; import { B20_HELP } from '../lib/glossary'; @@ -25,6 +26,7 @@ export function AnnouncementModule({ tokenAccess, wallet, onSend, + inclusionFor, }: { open: boolean; onClose: () => void; @@ -32,6 +34,8 @@ export function AnnouncementModule({ tokenAccess: TokenAccess; wallet: Address | null; onSend: (label: string, to: Address, data: Hex, action: string) => Promise; + /** Inclusion timing for a landed hash — which 200 ms block, how fast. */ + inclusionFor?: (hash: Hex) => Inclusion | undefined; }) { const [id, setId] = useState(''); const [description, setDescription] = useState(''); @@ -118,7 +122,7 @@ export function AnnouncementModule({ step={step} busy={finalizing} error={error ?? undefined} - result={txHash ? { txHash } : null} + result={txHash ? { txHash, inclusion: inclusionFor?.(txHash) } : null} titles={{ build: 'Publish Announcement', submitted: 'Publish Announcement' }} titleAction={} canProceed={Boolean(isAsset && tokenAccess === 'operator')} diff --git a/app/vibenet/demos/b20/components/AssignPolicyModal.tsx b/app/vibenet/demos/b20/components/AssignPolicyModal.tsx index 685496fe..c51a88ac 100644 --- a/app/vibenet/demos/b20/components/AssignPolicyModal.tsx +++ b/app/vibenet/demos/b20/components/AssignPolicyModal.tsx @@ -7,6 +7,7 @@ import { Text } from '../../../../components/ui/Text'; import { VIBENET_EXPLORER_PATH } from '../../../library/config'; import { walletErrorMessage } from '../../../library/wallet'; import { CallRow, ReviewArrow } from '../../_shared/CallRow'; +import type { Inclusion } from '../../_shared/inclusion'; import { TransactionModal, type TxStep } from '../../_shared/TransactionModal'; import { b20Abi, scopeId } from '../lib/protocol'; import type { TokenInfo } from '../lib/types'; @@ -21,12 +22,15 @@ export function AssignPolicyModal({ token, assignment, onSend, + inclusionFor, }: { open: boolean; onClose: () => void; token: TokenInfo | null; assignment: PendingAssignment | null; onSend: (label: string, to: Address, data: Hex, action: string) => Promise; + /** Inclusion timing for a landed hash — which 200 ms block, how fast. */ + inclusionFor?: (hash: Hex) => Inclusion | undefined; }) { const [step, setStep] = useState('build'); const [finalizing, setFinalizing] = useState(false); @@ -74,7 +78,7 @@ export function AssignPolicyModal({ step={step} busy={finalizing} error={error ?? undefined} - result={txHash ? { txHash } : null} + result={txHash ? { txHash, inclusion: inclusionFor?.(txHash) } : null} titles={{ build: 'Assign Policy', submitted: 'Assign Policy' }} canProceed={Boolean(assignment)} proceedLabel="Assign" diff --git a/app/vibenet/demos/b20/components/DeployModule.tsx b/app/vibenet/demos/b20/components/DeployModule.tsx index e8e1ed13..c7ee0e31 100644 --- a/app/vibenet/demos/b20/components/DeployModule.tsx +++ b/app/vibenet/demos/b20/components/DeployModule.tsx @@ -11,6 +11,7 @@ import { CopyableValue } from '../../../components/CopyableValue'; import { formatTokenAmount, short } from '../../account/shared'; import { VIBENET_EXPLORER_PATH } from '../../../library/config'; import { walletErrorMessage } from '../../../library/wallet'; +import type { Inclusion } from '../../_shared/inclusion'; import { TransactionModal, type TxStep } from '../../_shared/TransactionModal'; import { client, INITIAL_ALLOCATION_MEMO } from '../lib/constants'; import { B20_HELP } from '../lib/glossary'; @@ -42,6 +43,7 @@ export function DeployModule({ onClose, wallet, onSendCalls, + inclusionFor, onCreated, onFirstPayment, }: { @@ -49,6 +51,8 @@ export function DeployModule({ onClose: () => void; wallet: Address | null; onSendCalls: (label: string, calls: Array<{ to: Address; data: Hex }>, action: string) => Promise; + /** Inclusion timing for a landed hash — which 200 ms block, how fast. */ + inclusionFor?: (hash: Hex) => Inclusion | undefined; onCreated: (token: CreatedToken) => Promise; /** Guided flow: after a stablecoin is created, jump to a first payment in it. */ onFirstPayment?: () => void; @@ -182,7 +186,7 @@ export function DeployModule({ step={step} busy={finalizing} error={error ?? undefined} - result={createdToken ? { txHash: createdToken.hash } : null} + result={createdToken ? { txHash: createdToken.hash, inclusion: inclusionFor?.(createdToken.hash) } : null} titles={{ build: 'Create a Token', submitted: 'Create a Token' }} canProceed={Boolean(name && symbol)} proceedLabel="Create Token" diff --git a/app/vibenet/demos/b20/components/GasModule.tsx b/app/vibenet/demos/b20/components/GasModule.tsx index 05093221..edfcef36 100644 --- a/app/vibenet/demos/b20/components/GasModule.tsx +++ b/app/vibenet/demos/b20/components/GasModule.tsx @@ -8,6 +8,7 @@ import { VIBENET_EXPLORER_PATH } from '../../../library/config'; import { short } from '../../account/shared'; import { walletErrorMessage } from '../../../library/wallet'; import { CallRow, ReviewArrow } from '../../_shared/CallRow'; +import type { Inclusion } from '../../_shared/inclusion'; import { TransactionModal, type TxStep } from '../../_shared/TransactionModal'; import type { TokenInfo } from '../lib/types'; import { Notice } from './primitives'; @@ -25,6 +26,7 @@ export function GasModule({ recipient, fee, onPay, + inclusionFor, }: { open: boolean; onClose: () => void; @@ -37,6 +39,8 @@ export function GasModule({ fee: string; /** Sends the demo transaction with its gas paid in the token; resolves to the tx hash. */ onPay: () => Promise; + /** Inclusion timing for a landed hash — which 200 ms block, how fast. */ + inclusionFor?: (hash: Hex) => Inclusion | undefined; }) { const [step, setStep] = useState('build'); const [finalizing, setFinalizing] = useState(false); @@ -80,7 +84,7 @@ export function GasModule({ step={step} busy={finalizing} error={error ?? undefined} - result={txHash ? { txHash } : null} + result={txHash ? { txHash, inclusion: inclusionFor?.(txHash) } : null} titles={{ build: 'Gas Payments', submitted: 'Gas Payments' }} canProceed={Boolean(isStablecoin)} proceedLabel="Send" diff --git a/app/vibenet/demos/b20/components/MemoModule.tsx b/app/vibenet/demos/b20/components/MemoModule.tsx index e8c97732..fb2043ce 100644 --- a/app/vibenet/demos/b20/components/MemoModule.tsx +++ b/app/vibenet/demos/b20/components/MemoModule.tsx @@ -7,6 +7,7 @@ import { Text } from '../../../../components/ui/Text'; import { VIBENET_EXPLORER_PATH } from '../../../library/config'; import { walletErrorMessage } from '../../../library/wallet'; import { AddressAutocomplete, type AddressBookEntry } from '../../_shared/AddressAutocomplete'; +import type { Inclusion } from '../../_shared/inclusion'; import { TransactionModal, type TxStep } from '../../_shared/TransactionModal'; import { B20_HELP } from '../lib/glossary'; import { READ_MEMO_PROMPT } from '../lib/prompts'; @@ -23,12 +24,15 @@ export function MemoModule({ token, addressBook, onSend, + inclusionFor, }: { open: boolean; onClose: () => void; token: TokenInfo | null; addressBook: AddressBookEntry[]; onSend: (label: string, to: Address, data: Hex, action: string) => Promise; + /** Inclusion timing for a landed hash — which 200 ms block, how fast. */ + inclusionFor?: (hash: Hex) => Inclusion | undefined; }) { const [to, setTo] = useState(''); const [value, setValue] = useState(''); @@ -93,7 +97,7 @@ export function MemoModule({ step={step} busy={finalizing} error={error ?? undefined} - result={txHash ? { txHash } : null} + result={txHash ? { txHash, inclusion: inclusionFor?.(txHash) } : null} titles={{ build: 'Send with Memo', submitted: 'Send with Memo' }} titleAction={} canProceed={Boolean(token)} diff --git a/app/vibenet/demos/b20/components/TransferModule.tsx b/app/vibenet/demos/b20/components/TransferModule.tsx index 7ea97bdb..e3b8ab1d 100644 --- a/app/vibenet/demos/b20/components/TransferModule.tsx +++ b/app/vibenet/demos/b20/components/TransferModule.tsx @@ -7,6 +7,7 @@ import { Text } from '../../../../components/ui/Text'; import { VIBENET_EXPLORER_PATH } from '../../../library/config'; import { walletErrorMessage } from '../../../library/wallet'; import { AddressAutocomplete, type AddressBookEntry } from '../../_shared/AddressAutocomplete'; +import type { Inclusion } from '../../_shared/inclusion'; import { TransactionModal, type TxStep } from '../../_shared/TransactionModal'; import { amount, b20Abi, memoToBytes32 } from '../lib/protocol'; import type { TokenInfo } from '../lib/types'; @@ -20,12 +21,15 @@ export function TransferModule({ token, addressBook, onSend, + inclusionFor, }: { open: boolean; onClose: () => void; token: TokenInfo | null; addressBook: AddressBookEntry[]; onSend: (label: string, to: Address, data: Hex, action: string) => Promise; + /** Inclusion timing for a landed hash — which 200 ms block, how fast. */ + inclusionFor?: (hash: Hex) => Inclusion | undefined; }) { const [to, setTo] = useState(''); const [value, setValue] = useState(''); @@ -82,7 +86,7 @@ export function TransferModule({ step={step} busy={finalizing} error={error ?? undefined} - result={txHash ? { txHash } : null} + result={txHash ? { txHash, inclusion: inclusionFor?.(txHash) } : null} titles={{ build: 'Transfer', submitted: 'Transfer' }} canProceed={Boolean(token)} proceedLabel="Send" diff --git a/app/vibenet/demos/catalogue.ts b/app/vibenet/demos/catalogue.ts index 83e0052e..ff2fe1e9 100644 --- a/app/vibenet/demos/catalogue.ts +++ b/app/vibenet/demos/catalogue.ts @@ -36,7 +36,7 @@ export const DEMOS: DemoEntry[] = [ points: [ 'Smart & EOA accounts — deterministic addresses', 'K1 / P-256 / passkey signers', - 'Live balances on Vibenet', + 'Transactions land in the next 200 ms block', ], available: true, }, @@ -50,6 +50,7 @@ export const DEMOS: DemoEntry[] = [ 'Pay gas with your own stablecoin (ERC-8168 token payment)', 'Transaction memos for payment tracking and reconciliation', 'Policies and Asset announcements', + 'Every send confirms in a 200 ms block', ], available: true, }, @@ -67,6 +68,16 @@ export const DEMOS: DemoEntry[] = [ available: true, listed: false, }, + { + // Secret route: /demos/200 (redirects here). Off the grid on purpose. + href: '/vibenet/demos/200', + title: 'Block Runner', + shortTitle: 'Block Runner', + summary: 'A pixel runner paced by vibenet’s 200 ms blocks. Swallow a block to read its number and slot.', + points: ['One block every 200 ms, straight off newHeads', 'Block height follows gas used', 'One button: tap to bite'], + available: true, + listed: false, + }, ]; /** `smart-wallet` -> `Smart Wallet`. Fallback for a route with no catalogue entry. */ diff --git a/app/vibenet/demos/validity/lib/singleton.ts b/app/vibenet/demos/validity/lib/singleton.ts index 97c1827e..281c16d2 100644 --- a/app/vibenet/demos/validity/lib/singleton.ts +++ b/app/vibenet/demos/validity/lib/singleton.ts @@ -123,7 +123,8 @@ async function wait(publicClient: PublicClient, hash: Hex): Promise; + export function headNumber(head: StreamHead): bigint | null { try { return BigInt(head.number); diff --git a/public/audio/block-runner-lofi.mp3 b/public/audio/block-runner-lofi.mp3 new file mode 100644 index 00000000..d1655518 Binary files /dev/null and b/public/audio/block-runner-lofi.mp3 differ diff --git a/scripts/block-runner-music.mjs b/scripts/block-runner-music.mjs new file mode 100644 index 00000000..275d30b0 --- /dev/null +++ b/scripts/block-runner-music.mjs @@ -0,0 +1,469 @@ +#!/usr/bin/env node +// Renders the Block Runner background loop: a 16-bar chiptune lofi track +// at 75 BPM, where one 16th note is exactly 200 ms — the chain's block +// cadence. Everything is synthesized here (pulse, triangle, noise), so the +// only asset the app ships is the encoded MP3. +// +// node scripts/block-runner-music.mjs # writes public/audio/block-runner-lofi.mp3 +// node scripts/block-runner-music.mjs --wav out # keeps the WAV too +// +// Needs ffmpeg with libmp3lame on PATH. + +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync, unlinkSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const SR = 32000; // MP3 supports 32 kHz; plenty for 8-bit content. +const BPM = 75; +const SIXTEENTH = 60 / BPM / 4; // 0.2 s — one block. +const BARS = 16; +const N = Math.round(BARS * 16 * SIXTEENTH * SR); +const SWING = 0.03; // seconds late for every odd 16th: the lofi lean. + +const mix = new Float32Array(N); + +// ---------- helpers ---------- + +const midiHz = (m) => 440 * 2 ** ((m - 69) / 12); + +/** Time (seconds) of 16th-note `k` in bar `bar`, with swing on the off-16ths. */ +const at = (bar, k) => (bar * 16 + k) * SIXTEENTH + (k % 2 ? SWING : 0); + +/** Add a rendered voice into the loop, wrapping past the end so the loop seams cleanly. */ +function add(start, samples, gain = 1) { + const s0 = Math.round(start * SR); + for (let i = 0; i < samples.length; i += 1) { + mix[(s0 + i) % N] += samples[i] * gain; + } +} + +/** ADSR in seconds; sustain is a level. */ +function env(i, len, a, d, s, r) { + const t = i / SR; + const tl = len / SR; + let e; + if (t < a) e = t / a; + else if (t < a + d) e = 1 - (1 - s) * ((t - a) / d); + else e = s; + const rel = tl - t; + if (rel < r) e *= Math.max(0, rel / r); + return e; +} + +// Pulse wave with a duty cycle, optional vibrato, optional pitch slide. +function pulse({ + hz, + dur, + duty = 0.5, + a = 0.004, + d = 0.08, + s = 0.6, + r = 0.05, + vib = 0, + slideTo = 0, +}) { + const len = Math.round(dur * SR); + const out = new Float32Array(len); + let phase = 0; + for (let i = 0; i < len; i += 1) { + const t = i / SR; + let f = hz; + if (slideTo) f = hz + (slideTo - hz) * Math.min(1, t / dur); + if (vib && t > 0.12) + f *= + 1 + + vib * Math.sin(2 * Math.PI * 5.5 * t) * Math.min(1, (t - 0.12) / 0.2); + phase = (phase + f / SR) % 1; + out[i] = (phase < duty ? 1 : -1) * env(i, len, a, d, s, r); + } + return out; +} + +// Triangle wave: the NES bass channel. +function tri({ hz, dur, a = 0.003, d = 0.1, s = 0.7, r = 0.04, slideTo = 0 }) { + const len = Math.round(dur * SR); + const out = new Float32Array(len); + let phase = 0; + for (let i = 0; i < len; i += 1) { + const t = i / SR; + let f = hz; + if (slideTo) f = hz + (slideTo - hz) * Math.min(1, t / dur); + phase = (phase + f / SR) % 1; + // 4-bit stepped triangle like the real chip. + const raw = 1 - 4 * Math.abs(phase - 0.5); + out[i] = (Math.round(raw * 7.5) / 7.5) * env(i, len, a, d, s, r); + } + return out; +} + +// Deterministic noise so every render is byte-identical. +let seed = 0x2f6e2b1; +function rnd() { + seed ^= seed << 13; + seed ^= seed >>> 17; + seed ^= seed << 5; + return ((seed >>> 0) % 100000) / 100000; +} + +function noise({ dur, decay = 0.05, lp = 0 }) { + const len = Math.round(dur * SR); + const out = new Float32Array(len); + let y = 0; + for (let i = 0; i < len; i += 1) { + const t = i / SR; + let v = rnd() * 2 - 1; + if (lp) { + y += lp * (v - y); + v = y; + } + out[i] = v * Math.exp(-t / decay); + } + return out; +} + +// ---------- the song ---------- + +// Bar → chord: bass root (MIDI) and four voicing tones for pad + arp. +const CHORDS = [ + { name: "Am7", root: 45, tones: [57, 60, 64, 67] }, + { name: "Dm7", root: 38, tones: [57, 60, 62, 65] }, + { name: "G7", root: 43, tones: [55, 59, 62, 65] }, + { name: "Cmaj7", root: 48, tones: [55, 59, 60, 64] }, + { name: "Fmaj7", root: 41, tones: [57, 60, 64, 65] }, + { name: "Bm7b5", root: 47, tones: [59, 62, 65, 69] }, + { name: "E7", root: 40, tones: [56, 59, 62, 64] }, + { name: "Am7", root: 45, tones: [57, 60, 64, 67] }, +]; +const chordAt = (bar) => CHORDS[bar % 8]; + +// Melody: per bar, [16th index, MIDI, length in 16ths]. A section then B. +const MELODY = [ + [ + [2, 72, 2], + [4, 74, 2], + [6, 76, 4], + [12, 74, 1], + [13, 72, 3], + ], + [ + [0, 69, 4], + [6, 72, 2], + [8, 74, 6], + ], + [ + [2, 74, 2], + [4, 71, 2], + [6, 67, 4], + [12, 69, 2], + [14, 71, 2], + ], + [ + [0, 72, 6], + [8, 76, 2], + [10, 74, 2], + [12, 72, 4], + ], + [ + [2, 76, 2], + [4, 77, 2], + [6, 76, 2], + [8, 72, 4], + [14, 69, 2], + ], + [ + [0, 71, 4], + [4, 74, 4], + [10, 77, 2], + [12, 76, 4], + ], + [ + [0, 74, 2], + [2, 71, 2], + [4, 68, 4], + [10, 64, 2], + [12, 67, 2], + [14, 68, 2], + ], + [ + [0, 69, 8], + [12, 64, 2], + [14, 67, 2], + ], + [ + [0, 81, 2], + [2, 79, 2], + [4, 76, 4], + [8, 74, 2], + [10, 76, 2], + [12, 72, 4], + ], + [ + [0, 69, 2], + [2, 72, 2], + [4, 74, 6], + [12, 77, 2], + [14, 76, 2], + ], + [ + [0, 74, 4], + [4, 71, 2], + [6, 74, 2], + [8, 79, 4], + [14, 77, 2], + ], + [ + [0, 76, 6], + [8, 72, 2], + [10, 71, 2], + [12, 72, 4], + ], + [ + [2, 81, 2], + [4, 84, 4], + [8, 81, 2], + [10, 79, 2], + [12, 77, 4], + ], + [ + [0, 76, 2], + [2, 74, 2], + [4, 71, 4], + [8, 74, 2], + [10, 77, 2], + [12, 76, 4], + ], + [ + [0, 74, 2], + [2, 71, 2], + [4, 68, 2], + [6, 71, 2], + [8, 76, 4], + [12, 74, 2], + [14, 71, 2], + ], + [ + [0, 69, 6], + [8, 72, 2], + [10, 71, 2], + [12, 67, 3], + ], +]; + +// Length of a note that starts at 16th k and lasts n 16ths, honoring swing on both ends. +const noteDur = (bar, k, n, gap = 0.02) => at(bar, k + n) - at(bar, k) - gap; + +for (let bar = 0; bar < BARS; bar += 1) { + const chord = chordAt(bar); + const next = chordAt(bar + 1); + const bSection = bar >= 8; + + // --- Bass: root, ghost, fifth, root, seventh, root, chromatic approach. + const root = chord.root; + const fifth = root + 7; + const seventh = root + 10; + const approach = bar % 2 ? next.root + 1 : next.root - 1; + const bassLine = [ + [0, root, 3, 1], + [3, root, 1, 0.45], + [6, fifth, 2, 0.9], + [8, root, 2, 1], + [10, seventh, 1, 0.6], + [12, root, 2, 1], + [14, approach, 2, 0.8], + ]; + for (const [k, m, n, v] of bassLine) { + add( + at(bar, k), + tri({ hz: midiHz(m), dur: noteDur(bar, k, n), s: 0.8, r: 0.03 }), + 0.34 * v, + ); + } + + // --- Pad: all four chord tones on a soft 12.5 % pulse, held for the bar. + for (const m of chord.tones) { + add( + at(bar, 0), + pulse({ + hz: midiHz(m), + dur: noteDur(bar, 0, 16, 0.06), + duty: 0.125, + a: 0.08, + d: 0.3, + s: 0.75, + r: 0.12, + }), + 0.045, + ); + } + + // --- Arp: 25 % pulse cycling the chord one tone per 16th — one note per block. + for (let k = 0; k < 16; k += 1) { + const upDown = [0, 1, 2, 3, 2, 1][k % 6]; + const m = chord.tones[upDown] + 12; + const accent = k % 4 === 0 ? 1 : 0.7; + add( + at(bar, k), + pulse({ + hz: midiHz(m), + dur: SIXTEENTH * 0.55, + duty: 0.25, + d: 0.05, + s: 0.5, + r: 0.03, + }), + 0.055 * accent, + ); + } + + // --- Lead: 50 % square with vibrato, plus a tape-style echo three 16ths later. + for (const [k, m, n] of MELODY[bar]) { + const dur = noteDur(bar, k, n, 0.03); + const note = pulse({ + hz: midiHz(m), + dur, + duty: 0.5, + d: 0.12, + s: 0.55, + r: 0.06, + vib: 0.006, + }); + add(at(bar, k), note, 0.2); + add(at(bar, k) + 3 * SIXTEENTH, note, 0.06); + add(at(bar, k) + 6 * SIXTEENTH, note, 0.02); + } + + // --- Drums. + const kicks = bar % 2 ? [0, 7, 10] : [0, 10]; + for (const k of kicks) { + add( + at(bar, k), + tri({ hz: 150, dur: 0.16, slideTo: 42, d: 0.05, s: 0.6, r: 0.04 }), + 0.55, + ); + add(at(bar, k), noise({ dur: 0.02, decay: 0.008 }), 0.18); + } + + const snares = [4, 12]; + if (bar % 4 === 3) snares.push(15); + if (bar === 15) snares.push(14); + for (const k of snares) { + const ghost = k === 15 || k === 14; + const late = 0.012; // laid back + add( + at(bar, k) + late, + noise({ dur: 0.14, decay: 0.045, lp: 0.55 }), + ghost ? 0.14 : 0.4, + ); + add( + at(bar, k) + late, + tri({ hz: 190, dur: 0.06, slideTo: 150, d: 0.03, s: 0.3, r: 0.02 }), + ghost ? 0.1 : 0.28, + ); + } + + for (let k = 0; k < 16; k += 2) { + const open = k === 14 && bar % 4 === 3; + const vol = k % 4 === 0 ? 0.12 : 0.085; + add( + at(bar, k), + noise({ dur: open ? 0.25 : 0.035, decay: open ? 0.09 : 0.012 }), + open ? 0.1 : vol, + ); + } + for (const k of [5, 13]) + add(at(bar, k), noise({ dur: 0.02, decay: 0.008 }), 0.04); + if (bSection && bar % 2 === 0) + add(at(bar, 11), noise({ dur: 0.02, decay: 0.008 }), 0.05); +} + +// ---------- lofi master ---------- + +// Vinyl crackle and hiss. +for (let i = 0; i < N; i += 1) { + if (rnd() < 0.0008) { + const amp = (0.02 + rnd() * 0.05) * (rnd() < 0.5 ? 1 : -1); + mix[i] += amp; + mix[(i + 1) % N] += amp * 0.5; + mix[(i + 2) % N] += amp * 0.2; + } + mix[i] += (rnd() * 2 - 1) * 0.0015; +} + +// Two one-pole lowpasses (≈12 dB/oct) at 6.5 kHz round off the squares like +// an old cassette, a DC-blocking highpass, then a slow tremolo wobble. +const lpk = 1 - Math.exp((-2 * Math.PI * 6500) / SR); +const hpk = 1 - Math.exp((-2 * Math.PI * 30) / SR); +let y1 = 0; +let y2 = 0; +let hp = 0; +for (let pass = 0; pass < 2; pass += 1) { + // Run the filter across the seam twice so the loop start carries the tail's state. + for (let i = 0; i < N; i += 1) { + y1 += lpk * (mix[i] - y1); + y2 += lpk * (y1 - y2); + hp += hpk * (y2 - hp); + if (pass === 1) + mix[i] = (y2 - hp) * (1 + 0.035 * Math.sin((2 * Math.PI * 0.7 * i) / SR)); + } +} + +// Soft clip and normalize. +let peak = 0; +for (let i = 0; i < N; i += 1) { + mix[i] = Math.tanh(mix[i] * 1.15); + peak = Math.max(peak, Math.abs(mix[i])); +} +const norm = 0.89 / peak; + +// ---------- write WAV, encode MP3 ---------- + +const pcm = Buffer.alloc(44 + N * 2); +pcm.write("RIFF", 0); +pcm.writeUInt32LE(36 + N * 2, 4); +pcm.write("WAVE", 8); +pcm.write("fmt ", 12); +pcm.writeUInt32LE(16, 16); +pcm.writeUInt16LE(1, 20); +pcm.writeUInt16LE(1, 22); +pcm.writeUInt32LE(SR, 24); +pcm.writeUInt32LE(SR * 2, 28); +pcm.writeUInt16LE(2, 32); +pcm.writeUInt16LE(16, 34); +pcm.write("data", 36); +pcm.writeUInt32LE(N * 2, 40); +for (let i = 0; i < N; i += 1) + pcm.writeInt16LE(Math.round(mix[i] * norm * 32767), 44 + i * 2); + +const keepWav = process.argv.includes("--wav"); +const outDir = join(process.cwd(), "public", "audio"); +mkdirSync(outDir, { recursive: true }); +const wavPath = keepWav + ? join(outDir, "block-runner-lofi.wav") + : join(tmpdir(), `block-runner-${process.pid}.wav`); +const mp3Path = join(outDir, "block-runner-lofi.mp3"); +writeFileSync(wavPath, pcm); + +execFileSync("ffmpeg", [ + "-y", + "-loglevel", + "error", + "-i", + wavPath, + "-codec:a", + "libmp3lame", + "-b:a", + "48k", + "-ac", + "1", + "-ar", + String(SR), + mp3Path, +]); +if (!keepWav) unlinkSync(wavPath); + +const seconds = (N / SR).toFixed(1); +const kb = (statSync(mp3Path).size / 1024).toFixed(0); +console.log( + `wrote ${mp3Path}: ${seconds}s, ${kb} KB (${BARS} bars @ ${BPM} BPM, 16th = ${SIXTEENTH * 1000} ms)`, +);