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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ This app uses Vercel Web Analytics. Two things must stay in place:
| `trackExplorerActiveBlockJump(chain, jump)` | `app/internal-explorer/components/ActiveBlockButton.tsx` — zeronet latest/previous active block |
| `trackValidityOrder(side, status)` | `app/vibenet/demos/validity/ValidityDemo.tsx` — conditional swap submit / include / expiry / replace |
| `trackValidityRace(attempt, status)` | `app/vibenet/demos/validity/race-the-agent/RaceTheAgentDemo.tsx` — validity/manual comparison attempts |
| `trackBlockRunnerShare(variant, score)` | `app/vibenet/demos/200/BlockRunner.tsx` — Share on X button on the game-over screen |

Add a helper (and a row here) for a new key journey; remove the helper if you
remove its surface. Confirm the wiring with `grep -rn "analytics/events" app`.
Expand Down
7 changes: 7 additions & 0 deletions app/analytics/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,10 @@ export function trackValidityRace(
): void {
track('validity_race', { attempt, status });
}

// --- Base Runner (200ms) demo ---

// Fired when a player opens the X composer from the game-over screen.
export function trackBlockRunnerShare(variant: 'ate' | 'survived', score: number): void {
track('block_runner_share', { variant, score });
}
18 changes: 15 additions & 3 deletions app/vibenet/demos/200/BlockRunner.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'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
// the spawner. Every new head (one per 200 ms under Denim) 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.
Expand All @@ -17,6 +17,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';

import { Button } from '../../../components/ui/Button';
import { Text } from '../../../components/ui/Text';
import { trackBlockRunnerShare } from '../../../analytics/events';
import { VIBENET_EXPLORER_PATH, VIBENET_RPC_URL, VIBENET_WS_URL } from '../../library/config';
import { connectJsonRpcStream } from '../validity/lib/stream';
import {
Expand Down Expand Up @@ -49,6 +50,7 @@ import {
submitScore,
type BoardEntry,
} from './lib/leaderboard';
import { pickShareVariant, shareText, type ShareVariant, xShareUrl } from './lib/share';
import { Sound } from './lib/sound';
import {
BOSS_CLOSED,
Expand Down Expand Up @@ -646,6 +648,7 @@ export function BlockRunner() {
const [score, setScore] = useState(0);
const [best, setBest] = useState(0);
const [phase, setPhase] = useState<Game['phase']>('ready');
const [shareVariant, setShareVariant] = useState<ShareVariant>('ate');
const [head, setHead] = useState<Head | null>(null);
const [rate, setRate] = useState(0);
const [feed, setFeedState] = useState<'connecting' | 'live' | 'polling' | 'quiet'>('connecting');
Expand Down Expand Up @@ -804,6 +807,11 @@ export function BlockRunner() {
}
}, [submitState]);

const shareOnX = useCallback(() => {
trackBlockRunnerShare(shareVariant, score);
window.open(xShareUrl(shareText(score, shareVariant)), '_blank', 'noopener,noreferrer');
}, [score, shareVariant]);

// Fullscreen: the stage wrapper goes fullscreen; the canvas letterboxes
// inside it with object-contain so the aspect never distorts.
const toggleFullscreen = useCallback(() => {
Expand Down Expand Up @@ -910,6 +918,7 @@ export function BlockRunner() {
if (next.phase === 'running') sound().startMusic();
else sound().stopMusic();
if (next.phase === 'dead') {
setShareVariant(pickShareVariant());
setBest((b) => {
const nb = Math.max(b, next.score);
if (nb !== b) writeBest(nb);
Expand Down Expand Up @@ -1032,10 +1041,13 @@ export function BlockRunner() {
<GameStat label="Best" value={String(best)} />
</div>
</div>
{phase === 'dead' && score > 0 && (score > chainBest || submitState !== '') ? (
{phase === 'dead' && score > 0 ? (
<div className="absolute inset-x-0 bottom-[7%] flex justify-center">
<div className="flex items-center gap-3 rounded-xl border border-bds-blue-60 bg-[#0b1030]/95 px-4 py-3 shadow-lg">
{submitState === 'done' ? (
<Button size="sm" variant="secondary" onClick={shareOnX}>
Share on X
</Button>
{score <= chainBest && submitState === '' ? null : submitState === 'done' ? (
<Text variant="label.regular" className="text-bds-blue-15">
On the board — tap the game to run again.
</Text>
Expand Down
61 changes: 58 additions & 3 deletions app/vibenet/demos/200/lib/game.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest';

import {
blockSalt,
blockSizeFor,
chewTime,
fullGain,
blockLabel,
BOSS_X,
createGame,
Expand Down Expand Up @@ -98,7 +101,9 @@ describe('speedFor', () => {
it('ramps with score and caps', () => {
expect(speedFor(0)).toBe(560);
expect(speedFor(10)).toBe(590);
expect(speedFor(1_000)).toBe(720);
expect(speedFor(60)).toBe(720);
expect(speedFor(160)).toBe(800);
expect(speedFor(1_000)).toBe(960);
});
});

Expand Down Expand Up @@ -285,7 +290,7 @@ describe('fullness / stuffed', () => {
}
expect(g.stuffed).toBe(true);
expect(announced).toBe(true);
expect(g.fullTime).toBeGreaterThan(2);
expect(g.fullTime).toBeGreaterThan(1);
// 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;
Expand Down Expand Up @@ -444,7 +449,7 @@ describe('step', () => {


it('swallowing blocks earns a heart back', () => {
let g = tap({ ...start(createGame()), hearts: 1, heartProgress: 11 });
let g = tap({ ...start(createGame()), hearts: 1, heartProgress: 24 });
g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 200, h: 60 })] };
g = run(g, 0.5);
expect(g.hearts).toBe(2);
Expand All @@ -458,3 +463,53 @@ describe('step', () => {
expect(g.shake).toBe(0);
});
});

describe('rising pressure', () => {
it('chews longer on a fuller belly once the run is under way', () => {
expect(chewTime(0, 0)).toBeCloseTo(0.12);
expect(chewTime(1, 0)).toBeCloseTo(0.12);
expect(chewTime(0, 500)).toBeCloseTo(0.12);
// A full belly late in a run chews past the 200 ms gap between blocks.
expect(chewTime(1, 500)).toBeGreaterThan(0.2);
expect(chewTime(0.5, 120)).toBeGreaterThan(chewTime(0.5, 60));
});

it('makes each FULL need more food than the last', () => {
expect(fullGain(1, 0)).toBeCloseTo(0.11);
expect(fullGain(1, 1)).toBeLessThan(fullGain(1, 0));
expect(fullGain(1, 4)).toBeCloseTo(0.055);
});

it('keeps growing walls after the early difficulty cap', () => {
expect(blockSizeFor(300_000, 300).h).toBeGreaterThan(blockSizeFor(300_000, 60).h);
// Late game bumps every other block, not just every fourth.
expect(blockSizeFor(300_000, 300, 6).w).toBe(48);
expect(blockSizeFor(300_000, 60, 6).w).toBe(32);
});

it('counts FULL modes reached', () => {
let g = tap({ ...start(createGame()), fullness: 0.99 });
g = { ...g, blocks: [blk({ id: 3, x: PLAYER_X + 120, h: 40 })] };
g = run(g, 0.5);
expect(g.stuffed).toBe(true);
expect(g.feasts).toBe(1);
});
});

describe('block salt', () => {
it('is a stable 0..1 value per block number', () => {
const values = Array.from({ length: 1_000 }, (_, i) => blockSalt(3_000_000 + i));
expect(values.every((v) => v >= 0 && v < 1)).toBe(true);
expect(blockSalt(3_000_123)).toBe(blockSalt(3_000_123));
// Spread across the range, not clumped.
expect(values.filter((v) => v < 0.25).length).toBeGreaterThan(150);
expect(values.filter((v) => v > 0.75).length).toBeGreaterThan(150);
});

it('gives identical-gas blocks different heights, inside the block limits', () => {
const heights = Array.from({ length: 200 }, (_, i) => blockSizeFor(210_000, 30, 3_000_000 + i).h);
expect(new Set(heights).size).toBeGreaterThan(20);
expect(Math.min(...heights)).toBeGreaterThanOrEqual(30);
expect(Math.max(...heights)).toBeLessThanOrEqual(112);
});
});
85 changes: 71 additions & 14 deletions app/vibenet/demos/200/lib/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// 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
// Denim) 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
Expand All @@ -23,16 +23,23 @@ export const PLAYER_H = 48;
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. */
/** Seconds of chew (mouth shut, cheeks full) after each swallow, on an empty belly. */
const PUFF_TIME = 0.12;
/** Extra chew at a full belly, as a fraction of PUFF_TIME. Near a full belly a chew
* outlasts the 200 ms gap between blocks, so a full belly makes walls bite. */
const CHEW_SLOWDOWN = 0.9;
/** Score over which the full-belly chew slowdown phases in. */
const CHEW_RAMP = 240;
/** 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;
/** Each FULL already reached this run makes the next one need this much more food. */
const FULL_GROWTH = 0.25;
/** Seconds of FULL mode: too round to hurt, rolling over every block. */
const FULL_TIME = 2.5;
const FULL_TIME = 1.5;
/** How fast he slims back down once full mode ends. */
const DEFLATE_RATE = 2.2;

Expand All @@ -57,9 +64,19 @@ const BLOCK_GRAVITY = 2400;
/** Sprite pixels per block; width = 16 × scale. */
const MIN_BLOCK_H = 30;
const MAX_BLOCK_H = 112;
/** Total height spread (px) of the per-block salt: each wall lands up to half
* this taller or shorter than its gas alone would make it. */
const HEIGHT_SALT = 40;

const BASE_SPEED = 560;
const MAX_SPEED_BONUS = 160;
/** Past the early cap, speed keeps creeping up per block eaten… */
const LATE_SPEED_START = 60;
const LATE_SPEED_PER_BLOCK = 0.8;
/** …to this much more, so long runs keep getting harder instead of flatlining. */
const LATE_SPEED_MAX = 240;
/** Score over which block size keeps growing after the early difficulty cap. */
const LATE_SIZE_SPAN = 240;
const GRAVITY = 2000;
const LABEL_LIFE = 1.2;
const PARTICLE_LIFE = 0.6;
Expand All @@ -69,14 +86,14 @@ 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;
const HEART_REFILL = 25;
/** 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. */
/** Denim millisecond timestamp, or null on chains without it. */
timestampMs: number | null;
gasUsed: number;
};
Expand Down Expand Up @@ -147,6 +164,8 @@ export type Game = {
invuln: number;
/** Bursts banked toward the next heart. */
heartProgress: number;
/** FULL modes reached this run; each one makes the next need more food. */
feasts: number;
nextId: number;
/** Set for one step when something audible happened; the renderer clears it. */
events: GameEvent[];
Expand Down Expand Up @@ -183,14 +202,41 @@ export function createGame(): Game {
hearts: MAX_HEARTS,
invuln: 0,
heartProgress: 0,
feasts: 0,
nextId: 1,
events: [],
};
}

/** Scroll speed: ramps gently with score, dino-style, and caps. */
/** Scroll speed: ramps quickly early, dino-style, then keeps creeping up to a higher cap. */
export function speedFor(score: number): number {
return BASE_SPEED + Math.min(score * 3, MAX_SPEED_BONUS);
const late = Math.min(Math.max(0, score - LATE_SPEED_START) * LATE_SPEED_PER_BLOCK, LATE_SPEED_MAX);
return BASE_SPEED + Math.min(score * 3, MAX_SPEED_BONUS) + late;
}

/** Chew time after a swallow: the fuller the belly, the longer the mouth stays
* busy — an effect that phases in over the first CHEW_RAMP blocks of a run. */
export function chewTime(fullness: number, score = CHEW_RAMP): number {
const ramp = Math.min(1, Math.max(0, score / CHEW_RAMP));
return PUFF_TIME * (1 + CHEW_SLOWDOWN * ramp * Math.min(1, Math.max(0, fullness)));
}

/** Belly gained by a swallow; every FULL already reached makes the next one cost more. */
export function fullGain(weight: number, feasts: number): number {
return (FULL_PER_WEIGHT * weight) / (1 + FULL_GROWTH * feasts);
}

/**
* A stable 0..1 value per block number (a 32-bit integer hash). Seeding from
* the block keeps the salt the same for everyone watching the chain and keeps
* tests deterministic — and it only ever touches size, never spawn timing.
*/
export function blockSalt(blockNumber: number): number {
let x = (blockNumber | 0) ^ 0x9e3779b9;
x = Math.imul(x ^ (x >>> 16), 0x85ebca6b);
x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35);
x ^= x >>> 16;
return (x >>> 0) / 4294967296;
}

/**
Expand All @@ -203,11 +249,18 @@ export function speedFor(score: number): number {
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));
// Late game: past the early cap, walls keep getting taller and wider.
const late = Math.min(1, Math.max(0, score - 60) / LATE_SIZE_SPAN);
const gasH =
MIN_BLOCK_H +
(MAX_BLOCK_H - MIN_BLOCK_H) * Math.min(1, activity * (0.55 + 0.45 * difficulty) + difficulty * 0.25 + late * 0.25);
// Salt: quiet stretches of the chain would otherwise be rows of identical
// walls, so each block gets a stable random nudge on top of its gas height.
let h = Math.round(Math.min(MAX_BLOCK_H, Math.max(MIN_BLOCK_H, gasH + (blockSalt(blockNumber) - 0.5) * HEIGHT_SALT)));
// 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.3 && blockNumber % (late >= 0.5 ? 2 : 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.
Expand All @@ -224,7 +277,7 @@ 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. */
/** The 200 ms slot inside the second, `.000` … `.800`, or null pre-Denim. */
export function slotOf(timestampMs: number | null): string | null {
if (timestampMs === null) return null;
return `.${String(timestampMs % 1000).padStart(3, '0')}`;
Expand Down Expand Up @@ -332,6 +385,7 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G
let score = game.score;
let hearts = game.hearts;
let heartProgress = game.heartProgress;
let feasts = game.feasts;
let invuln = Math.max(0, game.invuln - dt);

// FULL mode leaves afterimages — the unstoppable barrel-along up top.
Expand Down Expand Up @@ -451,17 +505,18 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G
// 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);
fullness = Math.min(1, fullness + fullGain(pulled.weight, feasts));
puffed = chewTime(fullness, score);
if (fullness >= 1) {
stuffed = true;
fullTime = FULL_TIME;
feasts += 1;
events.push('stuffed');
}
} else {
Expand Down Expand Up @@ -501,14 +556,15 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G
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);
fullness = Math.min(1, fullness + fullGain(bonked.weight, feasts));
puffed = chewTime(fullness, score);
if (fullness >= 1) {
stuffed = true;
fullTime = FULL_TIME;
feasts += 1;
events.push('stuffed');
}
labels = [...labels, { x: PLAYER_X + PLAYER_W, y: bonked.y - 14, text: blockLabel(bonked), age: 0 }];
Expand Down Expand Up @@ -594,6 +650,7 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G
hearts,
invuln: dead ? 0 : invuln,
heartProgress,
feasts,
events,
};
}
Loading
Loading