+
+ One full block. Signing happens first, then the countdown starts immediately before broadcast.
+
+
+ >
+ )}
+
+
+
+ How it works
+
+
01 Sign and prepare the transaction.
+
02 Position your cursor during 3–2–1.
+
03 Hit the target before the receipt arrives.
+
+
+
+
+
+ );
+}
+
+export function ReflexGame() {
+ return (
+
+
+
+ );
+}
diff --git a/app/vibenet/demos/reflex/layout.tsx b/app/vibenet/demos/reflex/layout.tsx
new file mode 100644
index 0000000..f1c6a64
--- /dev/null
+++ b/app/vibenet/demos/reflex/layout.tsx
@@ -0,0 +1,14 @@
+import type { Metadata } from 'next';
+import type { ReactNode } from 'react';
+
+// Unlisted: reached from the inclusion line in transaction popups, not the
+// catalogue grid, nav, or sitemap.
+export const metadata: Metadata = {
+ title: 'Reflex · Vibenet',
+ description: 'Race your reaction time against a real transaction landing in a native 200 ms Vibenet block.',
+ robots: { index: false, follow: false },
+};
+
+export default function ReflexLayout({ children }: { children: ReactNode }) {
+ return <>{children}>;
+}
diff --git a/app/vibenet/demos/reflex/lib/game.test.ts b/app/vibenet/demos/reflex/lib/game.test.ts
new file mode 100644
index 0000000..24c257c
--- /dev/null
+++ b/app/vibenet/demos/reflex/lib/game.test.ts
@@ -0,0 +1,33 @@
+import { TransactionRejectedRpcError } from 'viem';
+import { describe, expect, it } from 'vitest';
+
+import { outcomeCopy, raceErrorMessage, reflexOutcome } from './game';
+
+describe('reflexOutcome', () => {
+ it('awards the run to the human only when the click is earlier', () => {
+ expect(reflexOutcome(199, 200)).toBe('human');
+ expect(reflexOutcome(201, 200)).toBe('block');
+ });
+
+ it('calls an exact same-millisecond finish a tie', () => {
+ expect(reflexOutcome(200, 200)).toBe('tie');
+ expect(outcomeCopy('tie').title).toBe('Dead heat.');
+ });
+});
+
+describe('raceErrorMessage', () => {
+ // What the node returns when a stored account has no ETH, e.g. after a reset.
+ const rejected = (reason: string) => new TransactionRejectedRpcError(new Error(reason));
+
+ it('explains an unfunded account instead of the generic -32003 text', () => {
+ const message = raceErrorMessage(rejected('insufficient funds for gas * price + value: have 0 want 67000000000000'));
+ expect(message).not.toBe('Transaction creation failed.');
+ expect(message).toMatch(/no ETH/);
+ });
+
+ it("passes through the node's reason for other rejections", () => {
+ expect(raceErrorMessage(rejected('nonce too low: next nonce 4, tx nonce 3'))).toBe(
+ 'nonce too low: next nonce 4, tx nonce 3',
+ );
+ });
+});
diff --git a/app/vibenet/demos/reflex/lib/game.ts b/app/vibenet/demos/reflex/lib/game.ts
new file mode 100644
index 0000000..21e9d42
--- /dev/null
+++ b/app/vibenet/demos/reflex/lib/game.ts
@@ -0,0 +1,47 @@
+export type ReflexOutcome = 'human' | 'block' | 'tie';
+
+/** Compare two durations measured from the same broadcast callback. */
+export function reflexOutcome(reactionMs: number, inclusionMs: number): ReflexOutcome {
+ if (reactionMs < inclusionMs) return 'human';
+ if (reactionMs > inclusionMs) return 'block';
+ return 'tie';
+}
+
+export function outcomeCopy(outcome: ReflexOutcome): { title: string; detail: string } {
+ if (outcome === 'human') {
+ return {
+ title: 'You beat the block.',
+ detail: 'You hit the target before this page observed the transaction receipt.',
+ };
+ }
+ if (outcome === 'tie') {
+ return {
+ title: 'Dead heat.',
+ detail: 'Your click and the transaction receipt arrived in the same millisecond.',
+ };
+ }
+ return {
+ title: 'The block beat you.',
+ detail: 'Vibenet included the transaction before you hit the target.',
+ };
+}
+
+/**
+ * Below this, top the account up before a race. A Reflex transaction costs
+ * about 67k gas at 1 gwei (~0.000067 ETH); the faucet drips 0.1 ETH.
+ */
+export const MIN_RACE_BALANCE_WEI = 1_000_000_000_000_000n; // 0.001 ETH
+
+/**
+ * The node reports every txpool rejection as JSON-RPC -32003, which viem
+ * renders as the generic "Transaction creation failed." The node's reason is
+ * in `details`; translate the one a player can act on and surface the rest.
+ */
+export function raceErrorMessage(cause: unknown): string {
+ const e = cause as { shortMessage?: string; details?: string; message?: string };
+ const reason = e?.details || e?.shortMessage || e?.message || String(cause);
+ if (/insufficient funds/i.test(reason)) {
+ return 'This account has no ETH on Vibenet to pay for gas (the network may have been reset). Try again to top it up from the faucet.';
+ }
+ return reason;
+}
diff --git a/app/vibenet/demos/reflex/page.tsx b/app/vibenet/demos/reflex/page.tsx
new file mode 100644
index 0000000..86e7902
--- /dev/null
+++ b/app/vibenet/demos/reflex/page.tsx
@@ -0,0 +1,5 @@
+import { ReflexGame } from './ReflexGame';
+
+export default function ReflexPage() {
+ return ;
+}
diff --git a/llms.config.mjs b/llms.config.mjs
index c7e6cc5..268690f 100644
--- a/llms.config.mjs
+++ b/llms.config.mjs
@@ -8,5 +8,6 @@
import { disabledRouteGlobs } from './deploy.config.mjs';
export default {
- exclude: disabledRouteGlobs(),
+ // Reflex is an easter egg reached from transaction popups, so keep it out of the indexes.
+ exclude: [...disabledRouteGlobs(), '/vibenet/demos/reflex'],
};