From 3d41c1caad94cfe08df90f99021937a78b58362d Mon Sep 17 00:00:00 2001 From: Soheima M Date: Thu, 3 Sep 2026 10:52:45 +0200 Subject: [PATCH 01/11] feat(demos): show 200 ms block inclusion timing on transaction panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vibenet runs Denim (one block every 200 ms, with a millisecond timestampMs on each block). Every sent transaction in the Accounts and Tokens demos now carries an inclusion badge on the success view and its Activity row: "Landed in 359 ms · block 148,646 · .200". Latency is the block's own timestamp minus the send time, so it reports chain latency rather than client polling; the trailing slot is the 200 ms position inside the second. The account engine records the send time before broadcast, polls receipts at the block cadence, and reads timestampMs from the landed block. A short direct receipt pre-poll runs ahead of the vendored waiter, which otherwise spent a second round trip per loop and turned a 300 ms inclusion into ~1.4 s of spinner. Validity's receipt poll drops from 1 s to 150 ms for the same reason. --- app/vibenet/demos/_shared/InclusionBadge.tsx | 41 ++++++++++ .../demos/_shared/TransactionModal.tsx | 6 +- app/vibenet/demos/_shared/inclusion.test.ts | 78 +++++++++++++++++++ app/vibenet/demos/_shared/inclusion.ts | 65 ++++++++++++++++ .../demos/account/components/ActivityLog.tsx | 21 +++++ .../account/components/TransactionModal.tsx | 19 +++-- app/vibenet/demos/account/library/model.ts | 6 ++ .../demos/account/useAccountEngine.tsx | 63 ++++++++++++++- app/vibenet/demos/b20/B20Demo.tsx | 6 ++ .../b20/components/AnnouncementModule.tsx | 6 +- .../b20/components/AssignPolicyModal.tsx | 6 +- .../demos/b20/components/DeployModule.tsx | 6 +- .../demos/b20/components/GasModule.tsx | 6 +- .../demos/b20/components/MemoModule.tsx | 6 +- .../demos/b20/components/TransferModule.tsx | 6 +- app/vibenet/demos/catalogue.ts | 3 +- app/vibenet/demos/validity/lib/singleton.ts | 3 +- 17 files changed, 324 insertions(+), 23 deletions(-) create mode 100644 app/vibenet/demos/_shared/InclusionBadge.tsx create mode 100644 app/vibenet/demos/_shared/inclusion.test.ts create mode 100644 app/vibenet/demos/_shared/inclusion.ts diff --git a/app/vibenet/demos/_shared/InclusionBadge.tsx b/app/vibenet/demos/_shared/InclusionBadge.tsx new file mode 100644 index 00000000..2c8c878d --- /dev/null +++ b/app/vibenet/demos/_shared/InclusionBadge.tsx @@ -0,0 +1,41 @@ +// "Landed in 412 ms · block 136,522 · .400" — the inclusion line shown wherever +// a demo reports a sent transaction. Base's Denim upgrade produces a block +// every 200 ms and stamps each with a millisecond timestamp, so a tx can name +// the slot it landed in. Vibenet runs Denim today; on chains without it the +// slot is omitted and only latency + block remain. + +import { cn } from '../../../components/ui/cn'; +import { type Inclusion, latencyLabel, slotLabel } from './inclusion'; + +const SEP = ( + +); + +export function InclusionBadge({ inclusion, className }: { inclusion: Inclusion; className?: string }) { + const slot = slotLabel(inclusion.blockTimestampMs); + return ( + + Landed in {latencyLabel(inclusion.inclusionMs)} + {SEP} + 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..d10b7dfa 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 } 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,7 @@ 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..dcbbc192 --- /dev/null +++ b/app/vibenet/demos/_shared/inclusion.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; + +import { formatInclusion, inclusionFromBlock, 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('inclusionFromBlock', () => { + it('measures latency from the block clock when Denim timestampMs is present', () => { + // Sent at .718, block stamped at the next .000 → 282 ms, regardless of how + // late the receipt was observed (1.4 s here, mostly polling round trips). + const inclusion = inclusionFromBlock( + { number: '0x2140a', timestampMs: '0x1a066162f08' }, + 1_788_419_124_718, + 1_788_419_126_120, + ); + expect(inclusion).toEqual({ blockNumber: 136_202, blockTimestampMs: 1_788_419_125_000, inclusionMs: 282 }); + }); + + it('falls back to observed time when the block has no millisecond timestamp (pre-Denim)', () => { + const inclusion = inclusionFromBlock({ number: '0x10' }, 0, 250); + expect(inclusion).toEqual({ blockNumber: 16, blockTimestampMs: null, inclusionMs: 250 }); + }); + + it('returns null without a block number', () => { + expect(inclusionFromBlock(null, 0, 1)).toBeNull(); + expect(inclusionFromBlock({}, 0, 1)).toBeNull(); + }); + + it('never reports negative latency when clocks skew', () => { + expect(inclusionFromBlock({ number: '0x1' }, 500, 400)?.inclusionMs).toBe(0); + expect(inclusionFromBlock({ number: '0x1', timestampMs: '0x64' }, 500, 900)?.inclusionMs).toBe(0); + }); +}); + +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 Denim metadata', () => { + expect(slotLabel(null)).toBeNull(); + }); +}); + +describe('latencyLabel', () => { + it('uses milliseconds under a second and seconds above', () => { + expect(latencyLabel(412)).toBe('412 ms'); + expect(latencyLabel(999.6)).toBe('1000 ms'); + expect(latencyLabel(1_840)).toBe('1.8 s'); + }); +}); + +describe('formatInclusion', () => { + it('joins latency, block, and slot', () => { + expect( + formatInclusion({ blockNumber: 136_522, blockTimestampMs: 1_788_419_191_400, inclusionMs: 412 }), + ).toBe('Landed in 412 ms · block 136,522 · .400'); + }); + + it('drops the slot when there is no millisecond timestamp', () => { + expect(formatInclusion({ blockNumber: 16, blockTimestampMs: null, inclusionMs: 1_500 })).toBe( + 'Landed in 1.5 s · block 16', + ); + }); +}); diff --git a/app/vibenet/demos/_shared/inclusion.ts b/app/vibenet/demos/_shared/inclusion.ts new file mode 100644 index 00000000..b7358ddd --- /dev/null +++ b/app/vibenet/demos/_shared/inclusion.ts @@ -0,0 +1,65 @@ +// Inclusion timing for a broadcast transaction. Base's Denim upgrade (200 ms +// blocks) adds a millisecond `timestampMs` to every block, so a transaction can +// say which 200 ms slot it landed in, not just which second. Vibenet runs +// Denim today; on chains without it `blockTimestampMs` is null and only the +// wall-clock latency is shown. + +export type Inclusion = { + /** Block the transaction was included in. */ + blockNumber: number; + /** Block time in unix milliseconds from the Denim `timestampMs` field, or null pre-Denim. */ + blockTimestampMs: number | null; + /** + * Milliseconds from broadcast to inclusion. With Denim metadata this is the + * block's own millisecond timestamp minus the send time — the chain's latency, + * not the client's polling. Without it, it falls back to when the receipt was + * observed. + */ + inclusionMs: number; +}; + +/** 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; +} + +/** Build an Inclusion from the raw block a receipt landed in. */ +export function inclusionFromBlock( + block: { number?: unknown; timestampMs?: unknown } | null | undefined, + submittedAt: number, + observedAt: number, +): Inclusion | null { + const blockNumber = quantityToNumber(block?.number); + if (blockNumber === null) return null; + const blockTimestampMs = quantityToNumber(block?.timestampMs); + // Prefer the block's own clock: receipt polling adds round trips that would + // otherwise be charged to the chain. Clamped, since the two clocks can skew. + const landedAt = blockTimestampMs ?? observedAt; + return { + blockNumber, + blockTimestampMs, + inclusionMs: Math.max(0, landedAt - submittedAt), + }; +} + +/** The 200 ms slot inside the second: `.000`, `.200`, … or null without Denim metadata. */ +export function slotLabel(blockTimestampMs: number | null): string | null { + if (blockTimestampMs === null) return null; + return `.${String(blockTimestampMs % 1000).padStart(3, '0')}`; +} + +/** Latency for display: `412 ms` under a second, `1.8 s` above. */ +export function latencyLabel(inclusionMs: number): string { + if (inclusionMs < 1000) return `${Math.round(inclusionMs)} ms`; + return `${(inclusionMs / 1000).toFixed(1)} s`; +} + +/** One-line summary: `Landed in 412 ms · block 136,522 · .200`. */ +export function formatInclusion(inclusion: Inclusion): string { + const parts = [`Landed in ${latencyLabel(inclusion.inclusionMs)}`, `block ${inclusion.blockNumber.toLocaleString()}`]; + const slot = slotLabel(inclusion.blockTimestampMs); + if (slot) parts.push(slot); + return parts.join(' · '); +} diff --git a/app/vibenet/demos/account/components/ActivityLog.tsx b/app/vibenet/demos/account/components/ActivityLog.tsx index e3661352..17f0e861 100644 --- a/app/vibenet/demos/account/components/ActivityLog.tsx +++ b/app/vibenet/demos/account/components/ActivityLog.tsx @@ -8,11 +8,19 @@ 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 timing stored on the entry, when the engine observed it land. */ +function inclusionOf(e: ActivityEntry): Inclusion | null { + if (typeof e.blockNumber !== 'number' || typeof e.inclusionMs !== 'number') return null; + return { blockNumber: e.blockNumber, blockTimestampMs: e.blockTimestampMs ?? null, inclusionMs: e.inclusionMs }; +} + export function ActivityLog({ activity, accounts }: { activity: ActivityEntry[]; accounts: StoredAccount[] }) { const reducedMotion = useReducedMotion(); const knownIds = useRef | null>(null); @@ -57,6 +65,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 +115,11 @@ export function ActivityLog({ activity, accounts }: { activity: ActivityEntry[]; ))} ) : null} + {inclusion ? ( +
+ +
+ ) : null} @@ -123,6 +137,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 +186,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..fac1593e 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 } 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,7 @@ 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..a358bed9 100644 --- a/app/vibenet/demos/account/library/model.ts +++ b/app/vibenet/demos/account/library/model.ts @@ -238,6 +238,12 @@ export type ActivityEntry = { serialized?: Hex; txHash?: Hex; account?: Address; + /** Block the tx landed in (Denim: one every 200 ms on vibenet). */ + blockNumber?: number; + /** Block time in unix ms from Denim's `timestampMs`; null on chains without it. */ + blockTimestampMs?: number | null; + /** Wall-clock ms from broadcast to the receipt being observed. */ + inclusionMs?: number; }; // --------------------------------------------------------------------------- diff --git a/app/vibenet/demos/account/useAccountEngine.tsx b/app/vibenet/demos/account/useAccountEngine.tsx index e317790e..46f7e887 100644 --- a/app/vibenet/demos/account/useAccountEngine.tsx +++ b/app/vibenet/demos/account/useAccountEngine.tsx @@ -65,6 +65,7 @@ import { toast } from 'sonner'; import { vibenetApi } from '../../library/client'; import { ACCOUNT_RPC_URL } from '../../library/config'; +import { type Inclusion, inclusionFromBlock } from '../_shared/inclusion'; import { type DemoChain, deploymentFromContracts, estimateTxGas, getDemoChain } from './library/chains'; import { buildPhases, type CallRow, newCallRow, safeGasLimit, valueBearingCallCount } from './library/calls'; import { @@ -96,6 +97,22 @@ import { import { actorPairs, randomHex32, sortActors, toStoredActor } from './library/derive'; import { useAccounts } from './useAccounts'; +// Resolve as soon as eth_getTransactionReceipt returns non-null, polling at the +// chain's 200 ms block cadence. Gives up quietly at `budget` — the caller's +// full waiter takes over from there. +async function pollReceiptExists( + client: { request: (args: { method: 'eth_getTransactionReceipt'; params: [Hex] }) => Promise }, + hash: Hex, + budget: number, +): Promise { + const deadline = Date.now() + budget; + while (Date.now() < deadline) { + const receipt = await client.request({ method: 'eth_getTransactionReceipt', params: [hash] }).catch(() => null); + if (receipt) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + const fundAccount = (address: Address) => Promise.all([ vibenetApi.faucet.drip({ address }), @@ -608,8 +625,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,12 +819,33 @@ function useAccountEngineCore() { ? account.delegate(a.delegate ?? chain.deployment.accounts.default) : (account as ReturnType).create(); + // Inclusion timing per broadcast hash: which block (and which 200 ms slot) + // each transaction landed in, and how long that took from broadcast. Written + // by awaitInclusion, read by pushActivity and by the surfaces' result views. + // A ref, not state — it is looked up right after the await, never rendered + // from directly. + const inclusions = useRef(new Map()); + const submittedAt = useRef(new Map()); + const inclusionFor = (txHash: Hex | undefined): Inclusion | undefined => + txHash ? inclusions.current.get(txHash) : undefined; + // 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. + // + // Vibenet produces a block every 200 ms (Denim), so the receipt is polled at + // a matching cadence — the default 4 s interval would hide the whole point. const awaitInclusion = async (txHash: Hex, timeout = 30_000): Promise => { + const client = makeRpcClient(); + let receipt: Awaited>; try { - const receipt = await waitForTransactionReceipt(makeRpcClient() as never, { hash: txHash, timeout }); + // The vendored waiter spends a second round trip per loop on + // eth_getTransactionByHash (expiry detection), which turns a 300 ms + // inclusion into ~1.4 s of spinner. Watch for the receipt directly first; + // the waiter then returns on its first poll, keeping its expiry and + // phase-status semantics for the slow path. + await pollReceiptExists(client, txHash, Math.min(timeout, 3_000)); + receipt = await waitForTransactionReceipt(client as never, { hash: txHash, timeout, pollingInterval: 150 }); if (receipt.status === '0x0') throw new Error(`Transaction reverted onchain (${txHash}).`); const phases = receipt.eip8130?.phaseStatuses ?? []; const failedPhase = phases.findIndex((s: Hex) => s === '0x0'); @@ -811,6 +854,15 @@ function useAccountEngineCore() { if ((err as Error)?.message?.includes('timed out')) throw new TxPendingError(txHash); throw err; } + const observedAt = Date.now(); + // One extra read for the block's Denim `timestampMs`; the receipt only + // carries the block hash. Timing is decoration, so a failed read is dropped. + const block = (await client + .request({ method: 'eth_getBlockByHash', params: [receipt.blockHash as Hex, false] }) + .catch(() => null)) as { number?: Hex; timestampMs?: Hex } | null; + const inclusion = inclusionFromBlock(block, submittedAt.current.get(txHash) ?? observedAt, observedAt); + if (inclusion) inclusions.current.set(txHash, inclusion); + submittedAt.current.delete(txHash); return txHash; }; @@ -819,10 +871,12 @@ function useAccountEngineCore() { const broadcast8130 = async (signedTx: Hex, onStatus?: (s: 'submitting' | 'confirming') => void): Promise => { const client = makeRpcClient(); onStatus?.('submitting'); + const sentAt = Date.now(); const txHash = (await client.request({ method: 'eth_sendRawTransaction', params: [signedTx], })) as Hex; + submittedAt.current.set(txHash, sentAt); onStatus?.('confirming'); return awaitInclusion(txHash); }; @@ -1116,7 +1170,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 +1205,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 +2132,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..02b6e36d 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, }, diff --git a/app/vibenet/demos/validity/lib/singleton.ts b/app/vibenet/demos/validity/lib/singleton.ts index 97c1827e..89b924ee 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 Date: Thu, 3 Sep 2026 14:43:10 +0200 Subject: [PATCH 02/11] added 200ms game demo --- app/vibenet/demos/200/BlockRunner.tsx | 780 +++++++++++++++++++++ app/vibenet/demos/200/layout.tsx | 13 + app/vibenet/demos/200/lib/game.test.ts | 343 +++++++++ app/vibenet/demos/200/lib/game.ts | 505 +++++++++++++ app/vibenet/demos/200/lib/selfplay.test.ts | 109 +++ app/vibenet/demos/200/lib/sound.ts | 143 ++++ app/vibenet/demos/200/lib/sprites.ts | 346 +++++++++ app/vibenet/demos/200/page.tsx | 22 + app/vibenet/demos/catalogue.ts | 10 + 9 files changed, 2271 insertions(+) create mode 100644 app/vibenet/demos/200/BlockRunner.tsx create mode 100644 app/vibenet/demos/200/layout.tsx create mode 100644 app/vibenet/demos/200/lib/game.test.ts create mode 100644 app/vibenet/demos/200/lib/game.ts create mode 100644 app/vibenet/demos/200/lib/selfplay.test.ts create mode 100644 app/vibenet/demos/200/lib/sound.ts create mode 100644 app/vibenet/demos/200/lib/sprites.ts create mode 100644 app/vibenet/demos/200/page.tsx diff --git a/app/vibenet/demos/200/BlockRunner.tsx b/app/vibenet/demos/200/BlockRunner.tsx new file mode 100644 index 00000000..40ed6109 --- /dev/null +++ b/app/vibenet/demos/200/BlockRunner.tsx @@ -0,0 +1,780 @@ +'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 Denim) is spat out as a block +// by the boss on the right edge. Jump over blocks, land on them, shoot them to +// reveal their number and 200 ms slot, and hold a dash for speed and double +// score. Hitting a block's side ends the run. +// +// 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_RPC_URL, VIBENET_WS_URL } from '../../library/config'; +import { connectJsonRpcStream } from '../validity/lib/stream'; +import { + BOSS_X, + blockLabel, + createGame, + fire, + GROUND_Y, + HEIGHT, + jump, + MAX_HEARTS, + PLAYER_H, + PLAYER_X, + restart, + setDash, + setFire, + slotOf, + spawnBlock, + step, + WIDTH, + type Game, + type Head, +} from './lib/game'; +import { Sound } from './lib/sound'; +import { + BOSS_CLOSED, + BOSS_OPEN, + BUSTER_SHOT, + CLOUD_BIG, + CLOUD_SMALL, + CRATE_BODY, + CRATE_FACE, + CRATE_TOP, + DARK, + drawSprite, + HEART, + HEART_EMPTY, + LIGHT, + type Palette, + RUNNER_DASH, + RUNNER_DEAD, + RUNNER_JUMP, + RUNNER_RUN, + spriteHeight, +} from './lib/sprites'; + +const BEST_KEY = 'block-runner:best'; +const SCALE = 3; + +// Active palette; `render` sets it from the page theme before drawing. +let P: Palette = LIGHT; + +/** Follows the site theme: the data-theme attribute, else the system setting. */ +function paletteForTheme(): Palette { + const attr = document.documentElement.getAttribute('data-theme'); + if (attr === 'dark') return DARK; + if (attr === 'light') return LIGHT; + return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? DARK : LIGHT; +} + +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 clouds crawl, +// hills drift, posts on the rail whip past. +function hash(i: number): number { + return ((i * 2654435761) >>> 0) % 1000; +} + +function drawSky(ctx: CanvasRenderingContext2D, distance: number): void { + ctx.fillStyle = P.sky; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + + // Far clouds at 12% of scroll speed. + const cloudPeriod = 360; + const off = distance * 0.12; + const first = Math.floor(off / cloudPeriod) - 1; + for (let i = first; i < first + 5; i += 1) { + const x = i * cloudPeriod - off + (hash(i) % 160); + const y = 20 + (hash(i + 7) % 80); + if (x < -80 || x > WIDTH + 80) continue; + drawSprite(ctx, hash(i) % 2 ? CLOUD_BIG : CLOUD_SMALL, x, y, SCALE, P); + } + + // Hills at 35% of scroll speed: a rolling pixel skyline in 12 px columns. + const hillOff = distance * 0.35; + const col = 12; + const hfirst = Math.floor(hillOff / col) - 1; + for (let i = hfirst; i < hfirst + WIDTH / col + 3; i += 1) { + const x = i * col - hillOff; + const h = Math.round(34 + 22 * Math.sin(i * 0.35) + 10 * Math.sin(i * 0.11 + 2) + (hash(i) % 4)); + ctx.fillStyle = P.hills; + ctx.fillRect(Math.round(x), GROUND_Y - h, col, h); + ctx.fillStyle = P.k; + ctx.fillRect(Math.round(x), GROUND_Y - h, col, 3); + } +} + +function drawGround(ctx: CanvasRenderingContext2D, distance: number, dashing: boolean): void { + // Pavement: a light slab with a thin edge, and a kerb line every 112 px — + // one per 200 ms at base speed — so the chain's cadence is painted on the + // floor. No hard outlines here; the ground should read as surface, not frame. + 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.railEdge; + ctx.fillRect(0, GROUND_Y + 13, WIDTH, 2); + ctx.fillStyle = P.ground; + ctx.fillRect(0, GROUND_Y + 15, WIDTH, HEIGHT - GROUND_Y - 15); + + const period = 112; + const off = distance % period; + ctx.fillStyle = P.railEdge; + for (let x = -off; x < WIDTH; x += period) { + ctx.fillRect(Math.round(x), GROUND_Y + 3, 3, 10); + } + // Foreground streaks at 1.6× scroll speed: the fastest layer, the one that + // reads as speed. Longer while dashing. + const streakOff = (distance * 1.6) % 120; + ctx.fillStyle = P.B; + for (let x = -streakOff; x < WIDTH; x += 120) { + ctx.fillRect(Math.round(x), GROUND_Y + 26, dashing ? 56 : 24, 3); + } +} + +function drawBlock(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, damage = 0): 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); + drawCracks(ctx, x, y, w, h, damage, 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); + drawCracks(ctx, x, y, w, h, damage, scale); +} + +// Cracks for each hit taken: dark zig-zags from the top edge, one per hit. +function drawCracks(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, damage: number, scale: number): void { + if (damage <= 0) return; + ctx.fillStyle = P.k; + for (let i = 0; i < damage; i += 1) { + let cx = x + Math.round(w * (0.25 + 0.5 * ((i * 0.618) % 1))); + for (let cy = y + scale; cy < y + h * 0.6; cy += scale) { + ctx.fillRect(cx, cy, scale, scale); + cx += ((cy / scale + i) % 3 === 0 ? -scale : (cy / scale) % 2 === 0 ? scale : 0); + } + } +} + +function render(ctx: CanvasRenderingContext2D, game: Game, frame: number, feedQuiet: boolean, pal: Palette): void { + P = pal; + ctx.imageSmoothingEnabled = false; + const dashing = game.dash.active; + + 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, dashing); + + // Speed lines while dashing. + if (dashing) { + 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.k; + ctx.font = 'bold 12px var(--font-mono, ui-monospace, monospace)'; + 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, b.maxHp - b.hp); + + // Afterimages: blue silhouettes fading behind the runner while dashing. + for (const a of game.afterimages) { + ctx.globalAlpha = 0.45 * (1 - a.age / 0.28); + const trail = a.age * 260; + drawSprite(ctx, RUNNER_DASH, PLAYER_X - trail, a.y, SCALE, P, P.c); + } + ctx.globalAlpha = 1; + + // Runner. + const runner = + game.phase === 'dead' + ? RUNNER_DEAD + : !game.player.grounded + ? RUNNER_JUMP + : dashing + ? RUNNER_DASH + : game.phase === 'running' + ? RUNNER_RUN[Math.floor(frame / (dashing ? 3 : 5)) % 3] + : RUNNER_RUN[1]; + // Blink while invulnerable after a hit. + if (game.invuln <= 0 || Math.floor(frame / 4) % 2 === 0) { + drawSprite(ctx, runner, PLAYER_X, game.player.y, SCALE, P); + } + + // Muzzle flash right after a shot. + if (game.fireCooldown > 0.1) { + ctx.fillStyle = P.y; + ctx.fillRect(PLAYER_X + 44, game.player.y + 24, 10, 10); + ctx.fillStyle = P.w; + ctx.fillRect(PLAYER_X + 47, game.player.y + 27, 4, 4); + } + + // Buster shots. + for (const bl of game.bullets) drawSprite(ctx, BUSTER_SHOT, bl.x, bl.y, SCALE, P); + + // 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 var(--font-mono, ui-monospace, monospace)'; + 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(); + + // Dash meter, HUD-fixed (not shaken). + const meterW = 120; + ctx.fillStyle = P.railEdge; + ctx.fillRect(16, 16, meterW + 6, 14); + ctx.fillStyle = P.rail; + ctx.fillRect(19, 19, meterW, 8); + ctx.fillStyle = game.dash.spent ? P.o : dashing ? P.y : P.b; + ctx.fillRect(19, 19, Math.round(meterW * game.dash.energy), 8); + ctx.fillStyle = P.hud; + ctx.font = 'bold 10px var(--font-mono, ui-monospace, monospace)'; + ctx.textAlign = 'left'; + ctx.fillText('DASH', 16, 44); + + // 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, 14, 2, P); + } + + // Overlays. + if (game.phase !== 'running') { + ctx.fillStyle = P.B; + ctx.fillRect(WIDTH / 2 - 250, 70, 500, 96); + ctx.fillStyle = P.D; + ctx.fillRect(WIDTH / 2 - 250, 166, 500, 4); + ctx.fillStyle = P.w; + ctx.textAlign = 'center'; + ctx.font = 'bold 20px var(--font-mono, ui-monospace, monospace)'; + const title = game.phase === 'dead' ? 'GAME OVER' : 'BLOCK RUNNER'; + ctx.fillText(title, WIDTH / 2, 106); + ctx.font = '12px var(--font-mono, ui-monospace, monospace)'; + ctx.fillStyle = P.c; + const hint = + game.phase === 'dead' + ? 'Space or R to run again' + : 'Space jump · hold X to shoot · hold Shift to dash · 3 hearts · one block every 200 ms'; + ctx.fillText(hint, WIDTH / 2, 134); + if (game.phase === 'dead') { + ctx.fillStyle = P.y; + ctx.fillText(`${game.score} blocks shot`, WIDTH / 2, 152); + } + } +} + +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 [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; + headTimes.current = [...headTimes.current.filter((t) => now - t < 2000), now]; + setRate(headTimes.current.length / 2); + 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(gameRef.current.dash.active); + 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'); + else if (lastHeadAt.current) setFeed((f) => (f === 'quiet' ? 'live' : f)); + }, 500); + + return () => { + cancelled = true; + closeStream?.(); + stopPoll?.(); + window.clearInterval(quiet); + }; + }, [apply, setFeed]); + + // Input. + const doJump = useCallback(() => apply(jump(gameRef.current)), [apply]); + const doFire = useCallback(() => apply(fire(gameRef.current)), [apply]); + const doFireHeld = useCallback((held: boolean) => apply(setFire(gameRef.current, held)), [apply]); + const doRestart = useCallback(() => apply(restart(gameRef.current)), [apply]); + const doDash = useCallback((held: boolean) => apply(setDash(gameRef.current, held)), [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.code === 'ShiftLeft' || e.code === 'ShiftRight' || e.code === 'ArrowRight') { + e.preventDefault(); + if (gameRef.current.phase !== 'dead') doDash(true); + return; + } + if (e.repeat) return; + if (e.code === 'Space' || e.code === 'ArrowUp') { + e.preventDefault(); + if (gameRef.current.phase === 'dead') doRestart(); + else doJump(); + } else if (e.code === 'KeyX' || e.code === 'Enter') { + e.preventDefault(); + if (gameRef.current.phase === 'dead') doRestart(); + else if (gameRef.current.phase === 'ready') doFire(); + else doFireHeld(true); + } 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 === 'KeyM') { + const next = !sound().muted; + sound().setMuted(next); + setMuted(next); + } + }; + const onKeyUp = (e: KeyboardEvent) => { + if (e.code === 'ShiftLeft' || e.code === 'ShiftRight' || e.code === 'ArrowRight') doDash(false); + if (e.code === 'KeyX' || e.code === 'Enter') doFireHeld(false); + }; + const onBlur = () => { + doDash(false); + doFireHeld(false); + }; + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + window.addEventListener('blur', onBlur); + return () => { + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', onKeyUp); + window.removeEventListener('blur', onBlur); + }; + }, [doJump, doFire, doFireHeld, doRestart, doDash]); + + // 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 pal = paletteForTheme(); + let palAt = 0; + let shownScore = -1; + let shownPhase: Game['phase'] | null = null; + + const tick = (now: number) => { + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + lastFrameAt.current = now; + frameRef.current += 1; + if (now - palAt > 500) { + pal = paletteForTheme(); + palAt = now; + } + if (frameRef.current === 1) { + setBest(readBest()); + setMuted(sound().muted); + } + + const next = step(gameRef.current, dt); + gameRef.current = next; + + for (const ev of next.events) { + if (ev === 'jump') sound().jump(); + else if (ev === 'shoot') sound().shoot(); + else if (ev === 'burst') sound().burst(); + else if (ev === 'hit') sound().hit(); + 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 === 'dash-on') sound().dashOn(); + else if (ev === 'dash-off') sound().dashOff(); + 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 === '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', pal); + }; + + 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); + }; + }, []); + + const onPointerDown = (e: React.PointerEvent) => { + e.preventDefault(); + sound().unlock(); + if (gameRef.current.phase === 'dead') return doRestart(); + // Touch: tapping the picture jumps; the buttons under it shoot and dash. + // Mouse: click shoots (hold to keep firing). + if (e.pointerType === 'touch') return doJump(); + if (gameRef.current.phase === 'ready') doFire(); + else doFireHeld(true); + }; + const onPointerUp = () => { + doDash(false); + doFireHeld(false); + }; + + const slot = head ? slotOf(head.timestampMs) : null; + const feedLabel = + feed === 'live' ? 'live · newHeads' : feed === 'polling' ? 'polling · 200 ms' : feed === 'quiet' ? 'chain quiet' : 'connecting'; + + return ( +
+
+
+ + Latest block + +
+ {head ? head.number.toLocaleString() : '———'} + {slot ? {slot} : null} +
+
+ + + +
+ + + +
+
+ + e.preventDefault()} + className="w-full select-none rounded-xl bg-bds-blue-5 [image-rendering:pixelated] dark:bg-bds-blue-100" + style={{ aspectRatio: `${WIDTH} / ${HEIGHT}`, touchAction: 'none', background: P.sky, WebkitTouchCallout: 'none' }} + aria-label={`Block Runner. ${phase === 'running' ? `Score ${score}.` : 'Press space to start.'}`} + role="img" + /> + + {/* Touch controls: shown only on coarse pointers. Held buttons map to the + same held-key semantics as the keyboard. */} +
+ { + sound().unlock(); + if (gameRef.current.phase === 'dead') doRestart(); + else doJump(); + }} + /> + { + sound().unlock(); + if (gameRef.current.phase === 'dead') return doRestart(); + if (gameRef.current.phase === 'ready') doFire(); + else doFireHeld(true); + }} + onUp={() => doFireHeld(false)} + /> + { + sound().unlock(); + doDash(true); + }} + onUp={() => doDash(false)} + /> +
+ + + 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. Shoot one to read its number and slot + {head ? `, like ${blockLabel(head)}` : ''}. Space jumps, hold X to shoot, hold Shift to dash for double + score, R or Space restarts after a run, M mutes. On a phone, tap the picture to jump and use the buttons. + +
+ ); +} + +function TouchButton({ + label, + hint, + onDown, + onUp, +}: { + label: string; + hint: string; + onDown: () => void; + onUp?: () => void; +}) { + return ( + + ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + {value} +
+ ); +} diff --git a/app/vibenet/demos/200/layout.tsx b/app/vibenet/demos/200/layout.tsx new file mode 100644 index 00000000..b1126dee --- /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. Shoot 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..93f6e91a --- /dev/null +++ b/app/vibenet/demos/200/lib/game.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it } from 'vitest'; + +import { + blockSizeFor, + blockLabel, + BOSS_X, + createGame, + DASH_MULTIPLIER, + fire, + GROUND_Y, + hitsFor, + jump, + MAX_HEARTS, + MOUTH_Y, + PLAYER_H, + PLAYER_X, + restart, + RESTART_GRACE, + setDash, + setFire, + slotOf, + spawnBlock, + speedFor, + start, + step, + STEP_UP, + 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, + hp: 1, + maxHp: 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, caps, and the dash multiplies it', () => { + expect(speedFor(0)).toBe(560); + expect(speedFor(10)).toBe(590); + expect(speedFor(1_000)).toBe(720); + expect(speedFor(0, true)).toBe(560 * DASH_MULTIPLIER); + }); +}); + +describe('spawnBlock', () => { + it('ignores heads until the run starts', () => { + expect(spawnBlock(createGame(), head(1)).blocks).toHaveLength(0); + }); + + it('spits a block from the boss mouth and opens its mouth once running', () => { + const g = spawnBlock(start(createGame()), head(148_646)); + expect(g.blocks).toHaveLength(1); + expect(g.blocks[0].x).toBeGreaterThanOrEqual(BOSS_X); + expect(g.blocks[0].number).toBe(148_646); + expect(g.blocks[0].landed).toBe(false); + expect(g.blocks[0].y + g.blocks[0].h).toBeLessThanOrEqual(MOUTH_Y); + expect(g.bossMouth).toBeGreaterThan(0); + }); + + 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('jump / fire / dash', () => { + it('a restart keeps held keys so a player mashing Space with X down comes back firing', () => { + let g = setFire(start(createGame()), true); + g = setDash(g, true); + g = start(g); + expect(g.fireHeld).toBe(true); + expect(g.dash.held).toBe(true); + expect(g.score).toBe(0); + }); + + it('start the run from the ready screen', () => { + expect(jump(createGame()).phase).toBe('running'); + expect(fire(createGame()).phase).toBe('running'); + expect(setDash(createGame(), true).phase).toBe('running'); + }); + + it('jump only works from the ground and emits a jump event', () => { + const g = jump(start(createGame())); + expect(g.player.vy).toBeLessThan(0); + expect(g.events).toContain('jump'); + const again = jump({ ...g, events: [] }); + expect(again.events).toHaveLength(0); + }); + + it('fire respects the cooldown', () => { + const g = fire(start(createGame())); + expect(g.bullets).toHaveLength(1); + expect(fire(g).bullets).toHaveLength(1); + const cooled = run(g, 0.2); + expect(fire(cooled).fireCooldown).toBeGreaterThan(0); + }); + + it('held fire keeps shooting at the cooldown rate', () => { + let g = setFire(start(createGame()), true); + expect(g.bullets).toHaveLength(1); + g = run(g, 0.5); + // Bullets leave the screen quickly, so count shoot events instead. + let shots = 0; + let h = setFire(start(createGame()), true); + for (let t = 0; t < 1; t += 1 / 120) { + h = step(h, 1 / 120, zeroRng); + shots += h.events.filter((e) => e === 'shoot').length; + } + expect(shots).toBeGreaterThanOrEqual(5); + expect(shots).toBeLessThanOrEqual(7); + expect(setFire(h, false).fireHeld).toBe(false); + }); + + it('dash drains energy while held, regenerates when released, and moves further', () => { + const idle = run(start(createGame()), 1); + const dashed = run(setDash(start(createGame()), true), 1); + expect(dashed.distance).toBeGreaterThan(idle.distance * 1.5); + expect(dashed.dash.energy).toBeLessThan(1); + expect(dashed.dash.active).toBe(true); + expect(dashed.afterimages.length).toBeGreaterThan(0); + const rested = run(setDash(dashed, false), 1); + expect(rested.dash.energy).toBeGreaterThan(dashed.dash.energy); + expect(rested.dash.active).toBe(false); + }); + + it('dash runs dry, stays off until the meter refills a bit, then works again', () => { + const dry = run(setDash(start(createGame()), true), 2.5); + expect(dry.dash.active).toBe(false); + expect(dry.dash.spent).toBe(true); + expect(dry.dash.energy).toBeLessThan(0.35); + // Still holding: no flicker while it refills. + const refilling = run(dry, 0.5); + expect(refilling.dash.active).toBe(false); + // Past the restart threshold it kicks back in. + const back = run(refilling, 0.5); + expect(back.dash.active).toBe(true); + expect(back.dash.spent).toBe(false); + }); +}); + +describe('hitsFor', () => { + it('wider blocks take more shots', () => { + expect(hitsFor(32)).toBe(1); + expect(hitsFor(48)).toBe(2); + expect(hitsFor(64)).toBe(3); + }); + + it('a two-hit block chips on the first shot and bursts on the second', () => { + let g = start(createGame()); + g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 300, h: 60, hp: 2, maxHp: 2 })] }; + g = fire(g); + g = run(g, 0.3); + expect(g.blocks).toHaveLength(1); + expect(g.blocks[0].hp).toBe(1); + expect(g.score).toBe(0); + expect(g.particles.some((p) => p.kind === 'spark')).toBe(true); + g = fire(g); + g = run(g, 0.3); + expect(g.blocks).toHaveLength(0); + expect(g.score).toBe(1); + }); +}); + +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('a jump comes back down, lands, and kicks up dust', () => { + let g = jump(start(createGame())); + g = run(g, 1); + expect(g.player.grounded).toBe(true); + expect(g.player.y).toBe(GROUND_Y - PLAYER_H); + expect(g.particles.some((p) => p.kind === 'dust') || g.time > 0.6).toBe(true); + }); + + it('a bullet destroys a block, scores, shakes, and reveals its label', () => { + let g = start(createGame()); + g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 300, h: 60, number: 148_646, timestampMs: 1_788_419_137_200 })] }; + g = fire(g); + g = run(g, 0.5); + expect(g.blocks).toHaveLength(0); + expect(g.score).toBe(1); + expect(g.labels[0]?.text).toBe('148,646 · .200'); + expect(g.particles.some((p) => p.kind === 'shard')).toBe(true); + }); + + it('a hit while dashing scores double', () => { + let g = setDash(start(createGame()), true); + g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 300, h: 60 })] }; + g = fire(g); + g = run(g, 0.5); + expect(g.score).toBe(2); + }); + + 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('a small step is climbed instead of crashed into', () => { + let g = start(createGame()); + g = { ...g, blocks: [blk({ x: PLAYER_X + 80, h: STEP_UP - 2 })] }; + g = run(g, 0.12); + expect(g.hearts).toBe(MAX_HEARTS); + expect(g.phase).toBe('running'); + expect(g.player.y).toBeLessThan(GROUND_Y - PLAYER_H); + expect(g.player.grounded).toBe(true); + }); + + it('bursting blocks earns a heart back', () => { + let g = { ...start(createGame()), hearts: 1, heartProgress: 11 }; + g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 300, h: 60 })] }; + g = fire(g); + g = run(g, 0.5); + expect(g.hearts).toBe(2); + }); + + it('landing on top of a block is safe and supports the player', () => { + let g = start(createGame()); + const platform = blk({ x: PLAYER_X - 24, h: 60 }); + g = { ...g, blocks: [platform], player: { y: GROUND_Y - 60 - PLAYER_H - 20, vy: 0, grounded: false } }; + for (let t = 0; t < 0.4; t += 1 / 120) { + g = step({ ...g, blocks: [{ ...platform }] }, 1 / 120, zeroRng); + } + expect(g.phase).toBe('running'); + expect(g.player.grounded).toBe(true); + expect(g.player.y).toBe(GROUND_Y - 60 - PLAYER_H); + }); + + 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..12ebd83e --- /dev/null +++ b/app/vibenet/demos/200/lib/game.ts @@ -0,0 +1,505 @@ +// 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 +// Denim) becomes one block that the boss on the right edge spits out. The +// player auto-runs, jumps over or lands on blocks, shoots them to reveal their +// number and 200 ms slot, and can hold a dash for speed and double score. +// Hitting a block's side ends the run, like the offline dino game. + +export const WIDTH = 800; +export const HEIGHT = 360; +export const GROUND_Y = 300; + +export const PLAYER_X = 96; +export const PLAYER_W = 42; +export const PLAYER_H = 54; + +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; +export const DASH_MULTIPLIER = 1.8; +const DASH_DRAIN = 0.5; +const DASH_REGEN = 0.3; +const DASH_RESTART = 0.35; +const GRAVITY = 2000; +const JUMP_VY = -700; +const BULLET_SPEED = 1100; +export const BULLET_W = 18; +export const BULLET_H = 15; +const FIRE_COOLDOWN = 0.14; +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; +/** A block top this close above the feet is climbed, not crashed into. */ +export const STEP_UP = 22; +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; + /** Denim 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; + /** Hits left; wider blocks take more. Bursts at zero. */ + hp: number; + maxHp: number; + number: number; + timestampMs: number | null; +}; + +export type Bullet = { x: number; y: number }; +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 }; + dash: { + held: boolean; + active: boolean; + energy: number; + /** True after the meter ran dry; the dash cannot restart until it refills a bit. */ + spent: boolean; + sinceImage: number; + }; + blocks: Block[]; + bullets: Bullet[]; + 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; + fireCooldown: number; + /** Hold to fire: the step fires whenever the cooldown allows. */ + fireHeld: 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 = 'jump' | 'shoot' | 'burst' | 'hit' | 'hurt' | 'die' | 'land' | 'dash-on' | 'dash-off' | '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 }, + dash: { held: false, active: false, energy: 1, spent: false, sinceImage: 0 }, + blocks: [], + bullets: [], + labels: [], + particles: [], + afterimages: [], + shake: 0, + bossMouth: 0, + fireCooldown: 0, + fireHeld: false, + sinceDeath: 0, + hearts: MAX_HEARTS, + invuln: 0, + heartProgress: 0, + nextId: 1, + events: [], + }; +} + +/** Scroll speed: ramps gently with score, dino-style, caps, and the dash multiplies it. */ +export function speedFor(score: number, dashing = false): number { + const base = BASE_SPEED + Math.min(score * 3, MAX_SPEED_BONUS); + return dashing ? base * DASH_MULTIPLIER : base; +} + +/** + * 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) }; +} + +/** Hits to burst a block: one per 16 px of width beyond the smallest. */ +export function hitsFor(w: number): number { + return Math.max(1, Math.round(w / 16) - 1); +} + +/** 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')}`; +} + +export function blockLabel(block: Pick): string { + const slot = slotOf(block.timestampMs); + return slot ? `${block.number.toLocaleString()} · ${slot}` : block.number.toLocaleString(); +} + +/** A new head arrived: the boss spits a block. Ignored unless running. */ +export function spawnBlock(game: Game, head: Head): Game { + if (game.phase !== 'running') return game; + const { w, h } = blockSizeFor(head.gasUsed, game.score, head.number, game.time); + const block: Block = { + id: game.nextId, + x: SPAWN_X, + y: MOUTH_Y - h, + vy: SPIT_VY, + landed: false, + w, + h, + hp: hitsFor(w), + maxHp: hitsFor(w), + number: head.number, + timestampMs: head.timestampMs, + }; + return { ...game, nextId: game.nextId + 1, blocks: [...game.blocks, block], bossMouth: BOSS_MOUTH_OPEN }; +} + +export function start(game: Game): Game { + // Keys still held through a restart keep working: a player mashing Space + // with X down should come back firing. + return { + ...createGame(), + phase: 'running', + nextId: game.nextId, + fireHeld: game.fireHeld, + dash: { ...createGame().dash, held: game.dash.held }, + }; +} + +/** + * 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); +} + +export function jump(game: Game): Game { + if (game.phase === 'ready') return start(game); + if (game.phase !== 'running' || !game.player.grounded) return game; + return { + ...game, + player: { ...game.player, vy: JUMP_VY, grounded: false }, + events: [...game.events, 'jump'], + }; +} + +export function fire(game: Game): Game { + if (game.phase === 'ready') return start(game); + if (game.phase !== 'running' || game.fireCooldown > 0) return game; + const bullet: Bullet = { x: PLAYER_X + PLAYER_W - 6, y: game.player.y + 24 }; + return { + ...game, + bullets: [...game.bullets, bullet], + fireCooldown: FIRE_COOLDOWN, + events: [...game.events, 'shoot'], + }; +} + +/** Hold to keep firing at the cooldown rate; the first shot fires immediately. */ +export function setFire(game: Game, held: boolean): Game { + if (game.fireHeld === held) return game; + const next = { ...game, fireHeld: held }; + return held ? fire(next) : next; +} + +/** Hold to dash. The step decides whether there is energy to actually go faster. */ +export function setDash(game: Game, held: boolean): Game { + if (game.phase === 'ready' && held) return { ...start(game), dash: { ...createGame().dash, held: true } }; + if (game.dash.held === held) return game; + return { ...game, dash: { ...game.dash, held } }; +} + +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 sparks(x: number, y: number, rng: () => number): Particle[] { + const out: Particle[] = []; + for (let i = 0; i < 5; i += 1) { + out.push({ x, y, vx: -80 - rng() * 160, vy: -120 + rng() * 240, age: 0, kind: 'spark' }); + } + 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); + + // Held fire: shoot again as soon as the cooldown allows. + let fireCooldown = Math.max(0, game.fireCooldown - dt); + let fired = game.bullets; + if (game.fireHeld && fireCooldown === 0) { + fired = [...fired, { x: PLAYER_X + PLAYER_W - 6, y: game.player.y + 24 }]; + fireCooldown = FIRE_COOLDOWN; + events.push('shoot'); + } + + // Dash: drains while held, regenerates otherwise. A meter that ran dry stays + // spent until it refills a little (or the key is released), so it does not + // flicker on and off at zero. + let energy = game.dash.energy; + let spent = game.dash.spent; + if (!game.dash.held || energy >= DASH_RESTART) spent = false; + const dashing = game.dash.held && !spent && energy > 0; + if (dashing) { + energy = Math.max(0, energy - DASH_DRAIN * dt); + if (energy === 0) spent = true; + } else { + energy = Math.min(1, energy + DASH_REGEN * dt); + } + if (dashing && !game.dash.active) events.push('dash-on'); + if (!dashing && game.dash.active) events.push('dash-off'); + let sinceImage = game.dash.sinceImage + dt; + let afterimages = game.afterimages; + if (dashing && sinceImage >= AFTERIMAGE_EVERY) { + sinceImage = 0; + afterimages = [...afterimages, { y: game.player.y, age: 0, frame: Math.floor(game.time * 12) % 3 }]; + } + + const speed = speedFor(score, dashing); + + // 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); + + // Bullets fly right; a hit removes the block and reveals it. + 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'); + } + const bullets: Bullet[] = []; + for (const bullet of fired) { + const nx = bullet.x + BULLET_SPEED * dt; + const hit = blocks.find( + (b) => nx + BULLET_W >= b.x && bullet.x <= b.x + b.w && bullet.y + BULLET_H >= b.y && bullet.y <= b.y + b.h, + ); + if (hit && hit.hp > 1) { + // Chip it: the block stays, a little lighter. + blocks = blocks.map((b) => (b.id === hit.id ? { ...b, hp: b.hp - 1 } : b)); + particles = [...particles, ...sparks(nx + BULLET_W, bullet.y + BULLET_H / 2, rng)]; + events.push('hit'); + } else if (hit) { + blocks = blocks.filter((b) => b.id !== hit.id); + score += dashing ? 2 : 1; + heartProgress += 1; + labels = [...labels, { x: hit.x + hit.w / 2, y: hit.y - 14, text: blockLabel(hit), age: 0 }]; + particles = [...particles, ...shards(hit.x + hit.w / 2, hit.y + hit.h / 2, rng)]; + shake = Math.max(shake, 4); + events.push('burst'); + } else if (nx < BOSS_X + 20) { + bullets.push({ x: nx, y: bullet.y }); + } + } + + // 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) { + const overlapsX = right > b.x && left < b.x + b.w; + if (!overlapsX) continue; + const top = b.y; + if (prevBottom <= top + 1 && vy >= 0) { + // Landing on it. + floor = Math.min(floor, top); + } else if (prevBottom - top <= STEP_UP && vy >= 0) { + // A small step: climb it, like walking up stairs. + floor = Math.min(floor, top); + } else if (y + PLAYER_H > top + 1) { + bonked = bonked ?? b; + } + } + 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'); + } + let grounded = 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 }, + dash: { held: game.dash.held, active: dashing && !dead, energy, spent, sinceImage }, + blocks, + bullets, + labels, + particles, + afterimages, + shake, + bossMouth: Math.max(0, game.bossMouth - dt), + fireCooldown, + hearts, + invuln: dead ? 0 : invuln, + heartProgress, + events, + }; +} 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..5b4c19f4 --- /dev/null +++ b/app/vibenet/demos/200/lib/selfplay.test.ts @@ -0,0 +1,109 @@ +// 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 (a wall of crates) or trivial (the +// bot never dies), these numbers move and the test says so. + +import { describe, expect, it } from 'vitest'; + +import { + createGame, + jump, + PLAYER_H, + PLAYER_W, + PLAYER_X, + restart, + setDash, + setFire, + 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; + +/** + * Hold X, jump late (90 px) when a landed block is about to hit, and only dash + * when nothing is within 320 px. Careful rather than clever. + */ +const simpleBot: Bot = (g) => { + let next = setFire(g, true); + const bottom = next.player.y + PLAYER_H; + const ahead = next.blocks.filter((b) => b.landed && b.x + b.w > PLAYER_X && b.y < bottom - 2); + const threat = ahead.find((b) => b.x < PLAYER_X + PLAYER_W + 90); + const near = ahead.find((b) => b.x < PLAYER_X + PLAYER_W + 320); + if (threat && next.player.grounded) next = jump(next); + next = setDash(next, next.dash.energy > 0.5 && !near); + return next; +}; + +/** Never jumps, never shoots: the baseline that must die quickly. */ +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('a simple bot survives well past the first few blocks and scores', () => { + const r = play(simpleBot, 60, 1); + expect(r.longestRun).toBeGreaterThan(8); + expect(r.bestScore).toBeGreaterThan(20); + }); + + it('is still a game: the simple bot does die sometimes over a long session', () => { + const r = play(simpleBot, 120, 2); + expect(r.deaths).toBeGreaterThan(0); + }); + + it('doing nothing loses within a few seconds', () => { + const r = play(idleBot, 20, 3); + expect(r.deaths).toBeGreaterThan(2); + expect(r.longestRun).toBeLessThan(6); + }); +}); diff --git a/app/vibenet/demos/200/lib/sound.ts b/app/vibenet/demos/200/lib/sound.ts new file mode 100644 index 00000000..0e7db825 --- /dev/null +++ b/app/vibenet/demos/200/lib/sound.ts @@ -0,0 +1,143 @@ +// Tiny synthesized sound set — no audio files. Everything is a short +// oscillator or noise burst through one gain node, so it stays under a few +// hundred bytes of code and never blocks on a network fetch. +// +// 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'; + +export class Sound { + private ctx: AudioContext | null = null; + private master: GainNode | null = null; + 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. */ + } + } + + /** + * 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) this.ensure(); + } + + 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. Higher while dashing. */ + tick(dashing = false): void { + this.tone('triangle', dashing ? 1800 : 1200, dashing ? 1300 : 900, 0.03, 0.25); + } + + dashOn(): void { + this.noise(0.12, 0.35); + this.tone('sawtooth', 200, 900, 0.18, 0.35); + } + + dashOff(): void { + this.tone('sawtooth', 700, 250, 0.12, 0.25); + } + + jump(): void { + this.tone('square', 320, 640, 0.09, 0.6); + } + + shoot(): void { + this.tone('square', 900, 300, 0.05, 0.5); + } + + /** A shot chipping a block that is not done yet. */ + hit(): void { + this.tone('square', 700, 380, 0.04, 0.35); + } + + burst(): void { + this.noise(0.09, 0.7); + this.tone('square', 500, 1100, 0.06, 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); + } + + 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..e7fbf3c7 --- /dev/null +++ b/app/vibenet/demos/200/lib/sprites.ts @@ -0,0 +1,346 @@ +// 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; +}; + +export const LIGHT: Palette = { + k: '#111111', // gray-100: outlines + w: '#ffffff', // gray-0 + B: '#0052ff', // blue-60: Base blue + D: '#003ec1', // blue-80 + b: '#4684ff', // blue-30 + c: '#b0caff', // blue-10 + s: '#ffffff', // visor + r: '#5e5e5e', // gray-60: crate fill + R: '#3a3a3a', // gray-80: crate shade + p: '#f5f8ff', // blue-0 + y: '#ffe436', // yellow-20 + o: '#f48c4c', // orange-30 + g: '#dadada', // gray-15 + d: '#9a9a9a', // gray-40 + sky: '#d3e1ff', // blue-5 + hills: '#73a2ff', // blue-20 + rail: '#ffffff', + railEdge: '#dadada', // gray-15 + ground: '#efefef', // gray-10: pavement continues down + hud: '#5e5e5e', // gray-60 +}; + +export const DARK: Palette = { + ...LIGHT, + B: '#266eff', // blue-40 reads better on navy + D: '#0052ff', + r: '#6f6f6f', // gray-50 + R: '#525252', // gray-70 + sky: '#00184d', // blue-100 + hills: '#002982', // blue-90 + rail: '#3a3a3a', // gray-80 + railEdge: '#525252', // gray-70 + ground: '#262626', // gray-90 + hud: '#ffffff', +}; + +// The runner: a blue robot in the Mega Man silhouette. 14 × 18, drawn at 3×. +// Three run frames plus jump, dash, and a dead pose. +export const RUNNER_RUN: Sprite[] = [ + [ + '.....kkkkk....', + '....kBBBBBk...', + '...kBBbbbBBk..', + '...kBbkskbBk..', + '...kBksssskk..', + '...kBkskkskk..', + '....kksssk....', + '.....kBBBk....', + '...kkBBBBBkk..', + '..kBBkBBBkBBk.', + '..kBBkBBBkBBk.', + '...kkkBBBkkk..', + '.....kBBBk....', + '....kBBkBBk...', + '...kBBk.kBBk..', + '..kBBk...kBBk.', + '.kBBBk....kBBk', + '.kkkk.....kkkk', + ], + [ + '.....kkkkk....', + '....kBBBBBk...', + '...kBBbbbBBk..', + '...kBbkskbBk..', + '...kBksssskk..', + '...kBkskkskk..', + '....kksssk....', + '.....kBBBk....', + '...kkBBBBBkk..', + '..kBBkBBBkBBk.', + '..kBBkBBBkBBk.', + '...kkkBBBkkk..', + '.....kBBBk....', + '.....kBBBk....', + '....kBBBBBk...', + '....kBBkBBk...', + '....kBBkBBk...', + '....kkk.kkk...', + ], + [ + '.....kkkkk....', + '....kBBBBBk...', + '...kBBbbbBBk..', + '...kBbkskbBk..', + '...kBksssskk..', + '...kBkskkskk..', + '....kksssk....', + '.....kBBBk....', + '...kkBBBBBkk..', + '..kBBkBBBkBBk.', + '..kBBkBBBkBBk.', + '...kkkBBBkkk..', + '.....kBBBk....', + '....kBBkBBk...', + '...kBBk.kBBk..', + '...kBBk..kBBk.', + '..kBBk....kBk.', + '..kkkk....kkk.', + ], +]; + +export const RUNNER_JUMP: Sprite = [ + '.....kkkkk....', + '....kBBBBBk...', + '...kBBbbbBBk..', + '...kBbkskbBk..', + '...kBksssskk..', + '...kBkskkskk..', + '....kksssk....', + '..kkkkBBBkkkk.', + '.kBBkBBBBBkBBk', + '.kBBkBBBBBkBBk', + '..kkkBBBBBkkk.', + '.....kBBBk....', + '....kBBBBBk...', + '...kBBk.kBBk..', + '..kBBk...kBBk.', + '..kBBk...kBBk.', + '..kkkk...kkkk.', + '..............', +]; + +// Leaning forward with a trailing edge — shown while dashing. +export const RUNNER_DASH: Sprite = [ + '..............', + '......kkkkk...', + '.....kBBBBBk..', + '....kBBbbbBBk.', + '....kBbkskbBk.', + '....kBksssskk.', + '....kBkskkskk.', + '.....kksssk...', + '..kkkkBBBBk...', + '.kBBBBBBBBBk..', + '.kBBkBBBBBBBk.', + '..kkkBBBBkBBk.', + '.....kBBBkkk..', + '....kBBkBBBk..', + '...kBBk.kBBBk.', + '..kBBk...kBBk.', + '.kBBk.....kkk.', + '.kkkk.........', +]; + +export const RUNNER_DEAD: Sprite = [ + '.....kkkkk....', + '....kBBBBBk...', + '...kBBbbbBBk..', + '...kBbkkkbBk..', + '...kBkskskkk..', + '...kBksksskk..', + '....kkskksk...', + '.....kBBBk....', + '...kkBBBBBkk..', + '..kBBkBBBkBBk.', + '..kBBkBBBkBBk.', + '...kkkBBBkkk..', + '.....kBBBk....', + '....kBBkBBk...', + '....kBBkBBk...', + '....kBBkBBk...', + '....kBBkBBk...', + '....kkk.kkk...', +]; + +// 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..', +]; + +export const CLOUD_BIG: Sprite = [ + '.......kkkkk........', + '.....kkwwwwwkk......', + '...kkwwwwwwwwwkkkk..', + '..kwwwwwwwwwwwwwwwk.', + '.kwwwwwwwwwwwwwwwwwk', + 'kwwwwwwwwwwwwwwwwwwk', + 'kwwwwwwwwwwwwwwwwwwk', + '.kkkkkkkkkkkkkkkkkk.', +]; + +export const CLOUD_SMALL: Sprite = [ + '...kkkk....', + '.kkwwwwkk..', + 'kwwwwwwwwkk', + 'kwwwwwwwwwk', + '.kkkkkkkkk.', +]; + +// 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 const BUSTER_SHOT: Sprite = ['.kkkk.', 'kBBBBk', 'kBwwBk', 'kBBBBk', '.kkkk.']; + +export const DUST: Sprite = ['.ww.', 'wwww', '.ww.']; + +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 spriteWidth(sprite: Sprite, scale: number): number { + return (sprite[0]?.length ?? 0) * 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..f4aa4494 --- /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 Denim 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/catalogue.ts b/app/vibenet/demos/catalogue.ts index 02b6e36d..673257bd 100644 --- a/app/vibenet/demos/catalogue.ts +++ b/app/vibenet/demos/catalogue.ts @@ -68,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. Shoot a block to read its number and slot.', + points: ['One block every 200 ms, straight off newHeads', 'Block height follows gas used', 'Space jumps, X shoots'], + available: true, + listed: false, + }, ]; /** `smart-wallet` -> `Smart Wallet`. Fallback for a route with no catalogue entry. */ From 31c02d5e4ed1dc491ffdf34b23efd31c63a38d53 Mon Sep 17 00:00:00 2001 From: Soheima M Date: Thu, 3 Sep 2026 15:17:54 +0200 Subject: [PATCH 03/11] added 200ms copy in transactions --- app/vibenet/demos/_shared/InclusionBadge.tsx | 15 +++++++++++++++ app/vibenet/demos/_shared/TransactionModal.tsx | 9 +++++++-- .../demos/account/components/TransactionModal.tsx | 9 +++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/app/vibenet/demos/_shared/InclusionBadge.tsx b/app/vibenet/demos/_shared/InclusionBadge.tsx index 2c8c878d..b651a35c 100644 --- a/app/vibenet/demos/_shared/InclusionBadge.tsx +++ b/app/vibenet/demos/_shared/InclusionBadge.tsx @@ -5,8 +5,23 @@ // slot is omitted and only latency + block remain. import { cn } from '../../../components/ui/cn'; +import { Text } from '../../../components/ui/Text'; import { type Inclusion, latencyLabel, slotLabel } from './inclusion'; +/** + * The line under the badge in a transaction result: the claim, in words. Only + * for blocks that carry Denim'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; + return ( + + Brought to you by 200 ms blocks on Base. + + ); +} + const SEP = ( {renderSuccess ? renderSuccess() : Transaction submitted} - {result?.inclusion ? : null} + {result?.inclusion ? ( +
+ + +
+ ) : null}
); } diff --git a/app/vibenet/demos/account/components/TransactionModal.tsx b/app/vibenet/demos/account/components/TransactionModal.tsx index fac1593e..b475c01c 100644 --- a/app/vibenet/demos/account/components/TransactionModal.tsx +++ b/app/vibenet/demos/account/components/TransactionModal.tsx @@ -39,7 +39,7 @@ 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 } from '../../_shared/InclusionBadge'; +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'; @@ -1440,7 +1440,12 @@ function SubmittedBody({ Broadcast but not yet included — check the explorer for status. ) : null} - {result.inclusion ? : null} + {result.inclusion ? ( +
+ + +
+ ) : null} {result.gasNote ? {result.gasNote} : null} {result.txHash ? ( {short(result.txHash)} From 7db19ba2a307071d1c2e00ba59ab5592f31455b9 Mon Sep 17 00:00:00 2001 From: Soheima M Date: Thu, 3 Sep 2026 15:39:02 +0200 Subject: [PATCH 04/11] added 200ms shimmer --- app/globals.css | 39 ++++++++++++++++++++ app/vibenet/demos/_shared/InclusionBadge.tsx | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) 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/_shared/InclusionBadge.tsx b/app/vibenet/demos/_shared/InclusionBadge.tsx index b651a35c..6888f694 100644 --- a/app/vibenet/demos/_shared/InclusionBadge.tsx +++ b/app/vibenet/demos/_shared/InclusionBadge.tsx @@ -33,7 +33,7 @@ export function InclusionBadge({ inclusion, className }: { inclusion: Inclusion; return ( Date: Thu, 3 Sep 2026 18:52:49 +0200 Subject: [PATCH 05/11] added character updated game logic --- app/vibenet/demos/200/BlockRunner.tsx | 490 +++++++++++---------- app/vibenet/demos/200/GULPY-SPRITE-SPEC.md | 70 +++ app/vibenet/demos/200/glutton-sheet-v2.png | Bin 0 -> 13643 bytes app/vibenet/demos/200/layout.tsx | 2 +- app/vibenet/demos/200/lib/game.test.ts | 305 +++++++------ app/vibenet/demos/200/lib/game.ts | 326 +++++++------- app/vibenet/demos/200/lib/selfplay.test.ts | 49 +-- app/vibenet/demos/200/lib/sound.ts | 44 +- app/vibenet/demos/200/lib/sprites.ts | 212 ++------- app/vibenet/demos/catalogue.ts | 4 +- 10 files changed, 736 insertions(+), 766 deletions(-) create mode 100644 app/vibenet/demos/200/GULPY-SPRITE-SPEC.md create mode 100644 app/vibenet/demos/200/glutton-sheet-v2.png diff --git a/app/vibenet/demos/200/BlockRunner.tsx b/app/vibenet/demos/200/BlockRunner.tsx index 40ed6109..7222d680 100644 --- a/app/vibenet/demos/200/BlockRunner.tsx +++ b/app/vibenet/demos/200/BlockRunner.tsx @@ -1,10 +1,13 @@ '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 Denim) is spat out as a block -// by the boss on the right edge. Jump over blocks, land on them, shoot them to -// reveal their number and 200 ms slot, and hold a dash for speed and double -// score. Hitting a block's side ends the run. +// 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 Denim) is spat out as a +// block by the boss on the right edge. The player is a round Base-blue glutton: +// hold ONE button — Space, X, or a finger anywhere on the picture — to inhale, +// and the nearest block drags into its mouth and is swallowed, scoring its +// weight. 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 @@ -20,16 +23,15 @@ import { BOSS_X, blockLabel, createGame, - fire, GROUND_Y, HEIGHT, - jump, MAX_HEARTS, PLAYER_H, + PLAYER_W, PLAYER_X, + INHALE_RANGE, restart, - setDash, - setFire, + setInhale, slotOf, spawnBlock, step, @@ -37,41 +39,73 @@ import { type Game, type Head, } from './lib/game'; +import gluttonSheet from './glutton-sheet-v2.png'; import { Sound } from './lib/sound'; import { BOSS_CLOSED, BOSS_OPEN, - BUSTER_SHOT, - CLOUD_BIG, - CLOUD_SMALL, CRATE_BODY, CRATE_FACE, CRATE_TOP, - DARK, drawSprite, HEART, HEART_EMPTY, - LIGHT, + NIGHT, + NIGHT_FAR, + NIGHT_HORIZON, type Palette, - RUNNER_DASH, - RUNNER_DEAD, - RUNNER_JUMP, - RUNNER_RUN, spriteHeight, } from './lib/sprites'; const BEST_KEY = 'block-runner:best'; const SCALE = 3; -// Active palette; `render` sets it from the page theme before drawing. -let P: Palette = LIGHT; +// 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; +} -/** Follows the site theme: the data-theme attribute, else the system setting. */ -function paletteForTheme(): Palette { - const attr = document.documentElement.getAttribute('data-theme'); - if (attr === 'dark') return DARK; - if (attr === 'light') return LIGHT; - return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? DARK : LIGHT; +function drawGlutton(ctx: CanvasRenderingContext2D, frame: number, x: number, y: number, swell = 1): 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 * SCALE * swell; + const h = G_H * SCALE * swell; + const dx = x - ((G_W - 16) / 2) * SCALE - (w - G_W * SCALE) / 2; + const dy = y - (h - G_H * SCALE); + 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 }; @@ -102,70 +136,146 @@ function writeBest(score: number): void { } // Deterministic scenery from a seed so nothing allocates per frame beyond a -// few small objects. Three parallax layers sell the speed: far clouds crawl, -// hills drift, posts on the rail whip past. +// 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; } -function drawSky(ctx: CanvasRenderingContext2D, distance: number): void { +// 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; - // Far clouds at 12% of scroll speed. - const cloudPeriod = 360; - const off = distance * 0.12; - const first = Math.floor(off / cloudPeriod) - 1; - for (let i = first; i < first + 5; i += 1) { - const x = i * cloudPeriod - off + (hash(i) % 160); - const y = 20 + (hash(i + 7) % 80); - if (x < -80 || x > WIDTH + 80) continue; - drawSprite(ctx, hash(i) % 2 ? CLOUD_BIG : CLOUD_SMALL, x, y, SCALE, P); + // 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); } - // Hills at 35% of scroll speed: a rolling pixel skyline in 12 px columns. - const hillOff = distance * 0.35; - const col = 12; - const hfirst = Math.floor(hillOff / col) - 1; - for (let i = hfirst; i < hfirst + WIDTH / col + 3; i += 1) { - const x = i * col - hillOff; - const h = Math.round(34 + 22 * Math.sin(i * 0.35) + 10 * Math.sin(i * 0.11 + 2) + (hash(i) % 4)); + // 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(Math.round(x), GROUND_Y - h, col, h); - ctx.fillStyle = P.k; - ctx.fillRect(Math.round(x), GROUND_Y - h, col, 3); + 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); } } -function drawGround(ctx: CanvasRenderingContext2D, distance: number, dashing: boolean): void { - // Pavement: a light slab with a thin edge, and a kerb line every 112 px — - // one per 200 ms at base speed — so the chain's cadence is painted on the - // floor. No hard outlines here; the ground should read as surface, not frame. +/** 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 var(--font-mono, ui-monospace, monospace)'; + 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.railEdge; - ctx.fillRect(0, GROUND_Y + 13, WIDTH, 2); ctx.fillStyle = P.ground; - ctx.fillRect(0, GROUND_Y + 15, WIDTH, HEIGHT - GROUND_Y - 15); + ctx.fillRect(0, GROUND_Y + 13, WIDTH, HEIGHT - GROUND_Y - 13); const period = 112; const off = distance % period; - ctx.fillStyle = P.railEdge; + ctx.fillStyle = P.d; for (let x = -off; x < WIDTH; x += period) { ctx.fillRect(Math.round(x), GROUND_Y + 3, 3, 10); } - // Foreground streaks at 1.6× scroll speed: the fastest layer, the one that - // reads as speed. Longer while dashing. + // 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.B; + ctx.fillStyle = P.y; for (let x = -streakOff; x < WIDTH; x += 120) { - ctx.fillRect(Math.round(x), GROUND_Y + 26, dashing ? 56 : 24, 3); + ctx.fillRect(Math.round(x), GROUND_Y + 30, barreling ? 56 : 26, 4); } } -function drawBlock(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, damage = 0): void { +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; @@ -178,7 +288,6 @@ function drawBlock(ctx: CanvasRenderingContext2D, x: number, y: number, w: numbe ctx.restore(); ctx.fillStyle = P.k; ctx.fillRect(x, y + h - scale, w, scale); - drawCracks(ctx, x, y, w, h, damage, scale); return; } drawSprite(ctx, CRATE_FACE, x, y, scale, P); @@ -192,26 +301,11 @@ function drawBlock(ctx: CanvasRenderingContext2D, x: number, y: number, w: numbe ctx.restore(); } drawSprite(ctx, CRATE_TOP.slice(0, 1), x, y + h - scale, scale, P); - drawCracks(ctx, x, y, w, h, damage, scale); } -// Cracks for each hit taken: dark zig-zags from the top edge, one per hit. -function drawCracks(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, damage: number, scale: number): void { - if (damage <= 0) return; - ctx.fillStyle = P.k; - for (let i = 0; i < damage; i += 1) { - let cx = x + Math.round(w * (0.25 + 0.5 * ((i * 0.618) % 1))); - for (let cy = y + scale; cy < y + h * 0.6; cy += scale) { - ctx.fillRect(cx, cy, scale, scale); - cx += ((cy / scale + i) % 3 === 0 ? -scale : (cy / scale) % 2 === 0 ? scale : 0); - } - } -} - -function render(ctx: CanvasRenderingContext2D, game: Game, frame: number, feedQuiet: boolean, pal: Palette): void { - P = pal; +function render(ctx: CanvasRenderingContext2D, game: Game, frame: number, feedQuiet: boolean, happyFrames: number): void { ctx.imageSmoothingEnabled = false; - const dashing = game.dash.active; + const barreling = game.stuffed; ctx.save(); if (game.shake > 0) { @@ -220,10 +314,10 @@ function render(ctx: CanvasRenderingContext2D, game: Game, frame: number, feedQu } drawSky(ctx, game.distance); - drawGround(ctx, game.distance, dashing); + drawGround(ctx, game.distance, barreling); - // Speed lines while dashing. - if (dashing) { + // 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)); @@ -236,50 +330,66 @@ function render(ctx: CanvasRenderingContext2D, game: Game, frame: number, feedQu 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.k; + ctx.fillStyle = P.w; ctx.font = 'bold 12px var(--font-mono, ui-monospace, monospace)'; 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, b.maxHp - b.hp); + for (const b of game.blocks) drawBlock(ctx, Math.round(b.x), Math.round(b.y), b.w, b.h); - // Afterimages: blue silhouettes fading behind the runner while dashing. + // Afterimages: fading ghosts while he barrels along FULL. for (const a of game.afterimages) { - ctx.globalAlpha = 0.45 * (1 - a.age / 0.28); + ctx.globalAlpha = 0.35 * (1 - a.age / 0.28); const trail = a.age * 260; - drawSprite(ctx, RUNNER_DASH, PLAYER_X - trail, a.y, SCALE, P, P.c); + drawGlutton(ctx, GLUTTON.dash, PLAYER_X - trail, a.y); } ctx.globalAlpha = 1; - // Runner. - const runner = - game.phase === 'dead' - ? RUNNER_DEAD - : !game.player.grounded - ? RUNNER_JUMP - : dashing - ? RUNNER_DASH - : game.phase === 'running' - ? RUNNER_RUN[Math.floor(frame / (dashing ? 3 : 5)) % 3] - : RUNNER_RUN[1]; - // Blink while invulnerable after a hit. - if (game.invuln <= 0 || Math.floor(frame / 4) % 2 === 0) { - drawSprite(ctx, runner, PLAYER_X, game.player.y, SCALE, P); + // 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; } - // Muzzle flash right after a shot. - if (game.fireCooldown > 0.1) { - ctx.fillStyle = P.y; - ctx.fillRect(PLAYER_X + 44, game.player.y + 24, 10, 10); - ctx.fillStyle = P.w; - ctx.fillRect(PLAYER_X + 47, game.player.y + 27, 4, 4); + // 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); } - // Buster shots. - for (const bl of game.bullets) drawSprite(ctx, BUSTER_SHOT, bl.x, bl.y, SCALE, P); - // Particles. for (const p of game.particles) { ctx.globalAlpha = 1 - p.age / 0.6; @@ -308,18 +418,20 @@ function render(ctx: CanvasRenderingContext2D, game: Game, frame: number, feedQu ctx.restore(); - // Dash meter, HUD-fixed (not shaken). + // Belly gauge, HUD-fixed (not shaken): fills as he eats; during FULL it + // shows the time left and drains to empty — then he must eat again. const meterW = 120; + const fillFrac = game.stuffed ? game.fullTime / 2.5 : game.fullness; ctx.fillStyle = P.railEdge; ctx.fillRect(16, 16, meterW + 6, 14); ctx.fillStyle = P.rail; ctx.fillRect(19, 19, meterW, 8); - ctx.fillStyle = game.dash.spent ? P.o : dashing ? P.y : P.b; - ctx.fillRect(19, 19, Math.round(meterW * game.dash.energy), 8); + ctx.fillStyle = game.stuffed ? P.y : P.b; + ctx.fillRect(19, 19, Math.round(meterW * Math.max(0, Math.min(1, fillFrac))), 8); ctx.fillStyle = P.hud; ctx.font = 'bold 10px var(--font-mono, ui-monospace, monospace)'; ctx.textAlign = 'left'; - ctx.fillText('DASH', 16, 44); + ctx.fillText(game.stuffed ? 'FULL!' : 'BELLY', 16, 44); // Hearts: pixel hearts next to the meter; empty ones are outlined. for (let i = 0; i < MAX_HEARTS; i += 1) { @@ -341,12 +453,12 @@ function render(ctx: CanvasRenderingContext2D, game: Game, frame: number, feedQu ctx.fillStyle = P.c; const hint = game.phase === 'dead' - ? 'Space or R to run again' - : 'Space jump · hold X to shoot · hold Shift to dash · 3 hearts · one block every 200 ms'; + ? 'Press any key to run again' + : 'Hold Space (or touch) to eat · every block hits if you don’t · one every 200 ms'; ctx.fillText(hint, WIDTH / 2, 134); if (game.phase === 'dead') { ctx.fillStyle = P.y; - ctx.fillText(`${game.score} blocks shot`, WIDTH / 2, 152); + ctx.fillText(`${game.score} blocks eaten`, WIDTH / 2, 152); } } } @@ -400,7 +512,7 @@ export function BlockRunner() { // 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(gameRef.current.dash.active); + if (next !== gameRef.current) sound().tick(); apply(next); }; @@ -469,11 +581,8 @@ export function BlockRunner() { }, [apply, setFeed]); // Input. - const doJump = useCallback(() => apply(jump(gameRef.current)), [apply]); - const doFire = useCallback(() => apply(fire(gameRef.current)), [apply]); - const doFireHeld = useCallback((held: boolean) => apply(setFire(gameRef.current, held)), [apply]); + const doInhale = useCallback((held: boolean) => apply(setInhale(gameRef.current, held)), [apply]); const doRestart = useCallback(() => apply(restart(gameRef.current)), [apply]); - const doDash = useCallback((held: boolean) => apply(setDash(gameRef.current, held)), [apply]); useEffect(() => { const isTyping = (e: KeyboardEvent) => { @@ -483,21 +592,11 @@ export function BlockRunner() { const onKeyDown = (e: KeyboardEvent) => { if (isTyping(e)) return; sound().unlock(); - if (e.code === 'ShiftLeft' || e.code === 'ShiftRight' || e.code === 'ArrowRight') { - e.preventDefault(); - if (gameRef.current.phase !== 'dead') doDash(true); - return; - } if (e.repeat) return; - if (e.code === 'Space' || e.code === 'ArrowUp') { - e.preventDefault(); - if (gameRef.current.phase === 'dead') doRestart(); - else doJump(); - } else if (e.code === 'KeyX' || e.code === 'Enter') { + if (e.code === 'Space' || e.code === 'KeyX' || e.code === 'Enter') { e.preventDefault(); if (gameRef.current.phase === 'dead') doRestart(); - else if (gameRef.current.phase === 'ready') doFire(); - else doFireHeld(true); + else doInhale(true); } else if (e.code === 'KeyR') { // Same as Space on the game-over screen; never resets a live run. if (gameRef.current.phase === 'dead') doRestart(); @@ -508,13 +607,9 @@ export function BlockRunner() { } }; const onKeyUp = (e: KeyboardEvent) => { - if (e.code === 'ShiftLeft' || e.code === 'ShiftRight' || e.code === 'ArrowRight') doDash(false); - if (e.code === 'KeyX' || e.code === 'Enter') doFireHeld(false); - }; - const onBlur = () => { - doDash(false); - doFireHeld(false); + if (e.code === 'Space' || e.code === 'KeyX' || e.code === 'Enter') doInhale(false); }; + const onBlur = () => doInhale(false); window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp); window.addEventListener('blur', onBlur); @@ -523,7 +618,7 @@ export function BlockRunner() { window.removeEventListener('keyup', onKeyUp); window.removeEventListener('blur', onBlur); }; - }, [doJump, doFire, doFireHeld, doRestart, doDash]); + }, [doInhale, doRestart]); // Main loop. requestAnimationFrame when the tab is visible; a 60 Hz timer // takes over when the browser stops issuing frames (hidden or throttled), @@ -540,20 +635,16 @@ export function BlockRunner() { let last = performance.now(); let raf = 0; - let pal = paletteForTheme(); - let palAt = 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 (now - palAt > 500) { - pal = paletteForTheme(); - palAt = now; - } if (frameRef.current === 1) { setBest(readBest()); setMuted(sound().muted); @@ -562,17 +653,15 @@ export function BlockRunner() { 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 === 'jump') sound().jump(); - else if (ev === 'shoot') sound().shoot(); - else if (ev === 'burst') sound().burst(); - else if (ev === 'hit') sound().hit(); + 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 === 'dash-on') sound().dashOn(); - else if (ev === 'dash-off') sound().dashOff(); else if (ev === 'thud') sound().thud(); } if (next.score !== shownScore) { @@ -591,7 +680,7 @@ export function BlockRunner() { } } - render(ctx, next, frameRef.current, feedRef.current === 'quiet', pal); + render(ctx, next, frameRef.current, feedRef.current === 'quiet', Math.max(0, happyUntil - frameRef.current)); }; let lastRafAt = 0; @@ -621,16 +710,10 @@ export function BlockRunner() { e.preventDefault(); sound().unlock(); if (gameRef.current.phase === 'dead') return doRestart(); - // Touch: tapping the picture jumps; the buttons under it shoot and dash. - // Mouse: click shoots (hold to keep firing). - if (e.pointerType === 'touch') return doJump(); - if (gameRef.current.phase === 'ready') doFire(); - else doFireHeld(true); - }; - const onPointerUp = () => { - doDash(false); - doFireHeld(false); + // One action everywhere: press (and hold) to inhale. + doInhale(true); }; + const onPointerUp = () => doInhale(false); const slot = head ? slotOf(head.timestampMs) : null; const feedLabel = @@ -649,7 +732,7 @@ export function BlockRunner() { - +
e.preventDefault()} - className="w-full select-none rounded-xl bg-bds-blue-5 [image-rendering:pixelated] dark:bg-bds-blue-100" + className="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}.` : 'Press space to start.'}`} + aria-label={`Block Runner. ${phase === 'running' ? `Score ${score}.` : 'Press space or touch to start eating.'}`} role="img" /> - {/* Touch controls: shown only on coarse pointers. Held buttons map to the - same held-key semantics as the keyboard. */} -
- { - sound().unlock(); - if (gameRef.current.phase === 'dead') doRestart(); - else doJump(); - }} - /> - { - sound().unlock(); - if (gameRef.current.phase === 'dead') return doRestart(); - if (gameRef.current.phase === 'ready') doFire(); - else doFireHeld(true); - }} - onUp={() => doFireHeld(false)} - /> - { - sound().unlock(); - doDash(true); - }} - onUp={() => doDash(false)} - /> -
- 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. Shoot one to read its number and slot - {head ? `, like ${blockLabel(head)}` : ''}. Space jumps, hold X to shoot, hold Shift to dash for double - score, R or Space restarts after a run, M mutes. On a phone, tap the picture to jump and use the buttons. + it lands, one every 200 ms. Block height follows gas used. Swallow one to read its number and slot + {head ? `, like ${blockLabel(head)}` : ''}. Hold Space, X, or a finger on the picture to inhale — the + nearest block drags in and is swallowed, and heavy walls drag 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. R restarts after a run, M + mutes.
); } -function TouchButton({ - label, - hint, - onDown, - onUp, -}: { - label: string; - hint: string; - onDown: () => void; - onUp?: () => void; -}) { - return ( - - ); -} - function Stat({ label, value }: { label: string; value: string }) { return (
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/glutton-sheet-v2.png b/app/vibenet/demos/200/glutton-sheet-v2.png new file mode 100644 index 0000000000000000000000000000000000000000..747de98470049e9d9370e341bd17fadee578f7d4 GIT binary patch literal 13643 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 & Pick): Block => ({ vy: 0, landed: true, w: 48, - hp: 1, - maxHp: 1, + weight: 1, number: 1, timestampMs: null, ...over, @@ -98,11 +94,10 @@ describe('blockSizeFor', () => { }); describe('speedFor', () => { - it('ramps with score, caps, and the dash multiplies it', () => { + it('ramps with score and caps', () => { expect(speedFor(0)).toBe(560); expect(speedFor(10)).toBe(590); expect(speedFor(1_000)).toBe(720); - expect(speedFor(0, true)).toBe(560 * DASH_MULTIPLIER); }); }); @@ -134,101 +129,184 @@ describe('spawnBlock', () => { }); }); -describe('jump / fire / dash', () => { - it('a restart keeps held keys so a player mashing Space with X down comes back firing', () => { - let g = setFire(start(createGame()), true); - g = setDash(g, true); +describe('inhale', () => { + it('a restart keeps held keys so a mashing player comes back inhaling', () => { + let g = setInhale(start(createGame()), true); g = start(g); - expect(g.fireHeld).toBe(true); - expect(g.dash.held).toBe(true); + expect(g.inhaling).toBe(true); expect(g.score).toBe(0); }); it('start the run from the ready screen', () => { - expect(jump(createGame()).phase).toBe('running'); - expect(fire(createGame()).phase).toBe('running'); - expect(setDash(createGame(), true).phase).toBe('running'); + expect(setInhale(createGame(), true).phase).toBe('running'); }); - it('jump only works from the ground and emits a jump event', () => { - const g = jump(start(createGame())); - expect(g.player.vy).toBeLessThan(0); - expect(g.events).toContain('jump'); - const again = jump({ ...g, events: [] }); - expect(again.events).toHaveLength(0); +}); + +describe('weightFor / swallowing', () => { + it('wider blocks are heavier', () => { + expect(weightFor(32)).toBe(1); + expect(weightFor(48)).toBe(2); + expect(weightFor(64)).toBe(2); }); - it('fire respects the cooldown', () => { - const g = fire(start(createGame())); - expect(g.bullets).toHaveLength(1); - expect(fire(g).bullets).toHaveLength(1); - const cooled = run(g, 0.2); - expect(fire(cooled).fireCooldown).toBeGreaterThan(0); + it('inhaling drags the nearest block in and swallows it, scoring its weight', () => { + let g = setInhale(start(createGame()), true); + 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(2); + expect(g.labels[0]?.text).toBe('148,646 · .200'); + expect(g.puffed).toBeGreaterThan(0); }); - it('held fire keeps shooting at the cooldown rate', () => { - let g = setFire(start(createGame()), true); - expect(g.bullets).toHaveLength(1); - g = run(g, 0.5); - // Bullets leave the screen quickly, so count shoot events instead. - let shots = 0; - let h = setFire(start(createGame()), true); - for (let t = 0; t < 1; t += 1 / 120) { - h = step(h, 1 / 120, zeroRng); - shots += h.events.filter((e) => e === 'shoot').length; - } - expect(shots).toBeGreaterThanOrEqual(5); - expect(shots).toBeLessThanOrEqual(7); - expect(setFire(h, false).fireHeld).toBe(false); - }); - - it('dash drains energy while held, regenerates when released, and moves further', () => { - const idle = run(start(createGame()), 1); - const dashed = run(setDash(start(createGame()), true), 1); - expect(dashed.distance).toBeGreaterThan(idle.distance * 1.5); - expect(dashed.dash.energy).toBeLessThan(1); - expect(dashed.dash.active).toBe(true); - expect(dashed.afterimages.length).toBeGreaterThan(0); - const rested = run(setDash(dashed, false), 1); - expect(rested.dash.energy).toBeGreaterThan(dashed.dash.energy); - expect(rested.dash.active).toBe(false); - }); - - it('dash runs dry, stays off until the meter refills a bit, then works again', () => { - const dry = run(setDash(start(createGame()), true), 2.5); - expect(dry.dash.active).toBe(false); - expect(dry.dash.spent).toBe(true); - expect(dry.dash.energy).toBeLessThan(0.35); - // Still holding: no flicker while it refills. - const refilling = run(dry, 0.5); - expect(refilling.dash.active).toBe(false); - // Past the restart threshold it kicks back in. - const back = run(refilling, 0.5); - expect(back.dash.active).toBe(true); - expect(back.dash.spent).toBe(false); + it('a heavy wall drags in slower than a light block', () => { + const timeToEat = (weight: number) => { + let g = setInhale(start(createGame()), true); + 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)); }); -}); -describe('hitsFor', () => { - it('wider blocks take more shots', () => { - expect(hitsFor(32)).toBe(1); - expect(hitsFor(48)).toBe(2); - expect(hitsFor(64)).toBe(3); + it('blocks beyond inhale range are left alone', () => { + let g = setInhale(start(createGame()), true); + 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('a two-hit block chips on the first shot and bursts on the second', () => { - let g = start(createGame()); - g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 300, h: 60, hp: 2, maxHp: 2 })] }; - g = fire(g); + it('contact while inhaling is a meal, not a crash', () => { + let g = setInhale(start(createGame()), true); + 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(2); + }); + + it('released inhale stops the pull', () => { + let g = setInhale(start(createGame()), true); + g = setInhale(g, false); + g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 200, h: 60 })] }; + g = step(g, 1 / 120, zeroRng); expect(g.blocks).toHaveLength(1); - expect(g.blocks[0].hp).toBe(1); - expect(g.score).toBe(0); - expect(g.particles.some((p) => p.kind === 'spark')).toBe(true); - g = fire(g); + expect(g.inhaling).toBe(false); + }); +}); + +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('each swallow fills the belly; heavier blocks fill it more', () => { + const eat = (weight: number) => { + let g = setInhale(start(createGame()), true); + 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 = setInhale(start(createGame()), true); + 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 = { ...setInhale(start(createGame()), true), fullness: 1, stuffed: true, fullTime: 0.2 }; + g = run(g, 1); + expect(g.fullness).toBe(0); + expect(g.stuffed).toBe(false); + // Hungry again: a block within range gets eaten. + g = { ...g, blocks: [blk({ id: 11, x: PLAYER_X + 200, h: 60, weight: 1 })] }; g = run(g, 0.3); - expect(g.blocks).toHaveLength(0); - expect(g.score).toBe(1); + 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 keeps his footing over walls until he reaches the road', () => { + let g = { ...start(createGame()), fullness: 0.01, stuffed: true, fullTime: 0 }; + g = { ...g, player: { y: GROUND_Y - 96 - PLAYER_H, vy: 0, grounded: true }, blocks: [blk({ x: PLAYER_X - 10, h: 96, weight: 2 })] }; + g = run(g, 0.2); + expect(g.hearts).toBe(MAX_HEARTS); + expect(g.descending || g.player.y + PLAYER_H >= GROUND_Y - 1).toBe(true); + // Once the wall passes he lands on the road and is a normal runner again. + g = { ...g, blocks: [] }; + g = run(g, 0.6); + expect(g.descending).toBe(false); + expect(g.player.y).toBe(GROUND_Y - PLAYER_H); + }); +}); + +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); }); }); @@ -245,32 +323,14 @@ describe('restart', () => { }); describe('step', () => { - it('a jump comes back down, lands, and kicks up dust', () => { - let g = jump(start(createGame())); + 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); - expect(g.particles.some((p) => p.kind === 'dust') || g.time > 0.6).toBe(true); - }); - - it('a bullet destroys a block, scores, shakes, and reveals its label', () => { - let g = start(createGame()); - g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 300, h: 60, number: 148_646, timestampMs: 1_788_419_137_200 })] }; - g = fire(g); - g = run(g, 0.5); - expect(g.blocks).toHaveLength(0); - expect(g.score).toBe(1); - expect(g.labels[0]?.text).toBe('148,646 · .200'); - expect(g.particles.some((p) => p.kind === 'shard')).toBe(true); }); - it('a hit while dashing scores double', () => { - let g = setDash(start(createGame()), true); - g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 300, h: 60 })] }; - g = fire(g); - g = run(g, 0.5); - expect(g.score).toBe(2); - }); it('running into a block side costs a heart, crumbles the block, and grants a moment of invulnerability', () => { let g = start(createGame()); @@ -304,35 +364,14 @@ describe('step', () => { expect(g.phase).toBe('dead'); }); - it('a small step is climbed instead of crashed into', () => { - let g = start(createGame()); - g = { ...g, blocks: [blk({ x: PLAYER_X + 80, h: STEP_UP - 2 })] }; - g = run(g, 0.12); - expect(g.hearts).toBe(MAX_HEARTS); - expect(g.phase).toBe('running'); - expect(g.player.y).toBeLessThan(GROUND_Y - PLAYER_H); - expect(g.player.grounded).toBe(true); - }); - it('bursting blocks earns a heart back', () => { - let g = { ...start(createGame()), hearts: 1, heartProgress: 11 }; - g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 300, h: 60 })] }; - g = fire(g); + it('swallowing blocks earns a heart back', () => { + let g = setInhale({ ...start(createGame()), hearts: 1, heartProgress: 11 }, true); + g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 200, h: 60 })] }; g = run(g, 0.5); expect(g.hearts).toBe(2); }); - it('landing on top of a block is safe and supports the player', () => { - let g = start(createGame()); - const platform = blk({ x: PLAYER_X - 24, h: 60 }); - g = { ...g, blocks: [platform], player: { y: GROUND_Y - 60 - PLAYER_H - 20, vy: 0, grounded: false } }; - for (let t = 0; t < 0.4; t += 1 / 120) { - g = step({ ...g, blocks: [{ ...platform }] }, 1 / 120, zeroRng); - } - expect(g.phase).toBe('running'); - expect(g.player.grounded).toBe(true); - expect(g.player.y).toBe(GROUND_Y - 60 - PLAYER_H); - }); 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); diff --git a/app/vibenet/demos/200/lib/game.ts b/app/vibenet/demos/200/lib/game.ts index 12ebd83e..893ebd60 100644 --- a/app/vibenet/demos/200/lib/game.ts +++ b/app/vibenet/demos/200/lib/game.ts @@ -3,17 +3,36 @@ // // The chain is the spawner: every new vibenet head (one per 200 ms under // Denim) becomes one block that the boss on the right edge spits out. The -// player auto-runs, jumps over or lands on blocks, shoots them to reveal their -// number and 200 ms slot, and can hold a dash for speed and double score. -// Hitting a block's side ends the run, like the offline dino game. +// 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, +// scoring its weight and 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 = 42; -export const PLAYER_H = 54; +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; +/** 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; export const BOSS_X = WIDTH - 104; const SPAWN_X = BOSS_X + 8; @@ -27,23 +46,12 @@ const MAX_BLOCK_H = 112; const BASE_SPEED = 560; const MAX_SPEED_BONUS = 160; -export const DASH_MULTIPLIER = 1.8; -const DASH_DRAIN = 0.5; -const DASH_REGEN = 0.3; -const DASH_RESTART = 0.35; const GRAVITY = 2000; -const JUMP_VY = -700; -const BULLET_SPEED = 1100; -export const BULLET_W = 18; -export const BULLET_H = 15; -const FIRE_COOLDOWN = 0.14; 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; -/** A block top this close above the feet is climbed, not crashed into. */ -export const STEP_UP = 22; export const MAX_HEARTS = 3; const HURT_INVULN = 1.1; /** Bursts needed to earn a heart back. */ @@ -68,14 +76,12 @@ export type Block = { landed: boolean; w: number; h: number; - /** Hits left; wider blocks take more. Bursts at zero. */ - hp: number; - maxHp: number; + /** Suction weight: heavy walls pull in slower and score more. */ + weight: number; number: number; timestampMs: number | null; }; -export type Bullet = { x: number; y: number }; 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 }; @@ -87,16 +93,9 @@ export type Game = { distance: number; score: number; player: { y: number; vy: number; grounded: boolean }; - dash: { - held: boolean; - active: boolean; - energy: number; - /** True after the meter ran dry; the dash cannot restart until it refills a bit. */ - spent: boolean; - sinceImage: number; - }; + /** Afterimage cadence timer while FULL (he barrels along up there). */ + sinceImage: number; blocks: Block[]; - bullets: Bullet[]; labels: Label[]; particles: Particle[]; afterimages: Afterimage[]; @@ -104,9 +103,22 @@ export type Game = { shake: number; /** Seconds the boss mouth stays open after spitting a block. */ bossMouth: number; - fireCooldown: number; - /** Hold to fire: the step fires whenever the cooldown allows. */ - fireHeld: boolean; + /** Hold to inhale: suction is on while this is held. */ + inhaling: boolean; + /** 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: keeps his footing on tall blocks until he touches road. */ + descending: boolean; /** Seconds since the run ended; a restart needs a deliberate press after a beat. */ sinceDeath: number; hearts: number; @@ -119,7 +131,7 @@ export type Game = { events: GameEvent[]; }; -export type GameEvent = 'jump' | 'shoot' | 'burst' | 'hit' | 'hurt' | 'die' | 'land' | 'dash-on' | 'dash-off' | 'thud' | 'heart'; +export type GameEvent = 'inhale-on' | 'inhale-off' | 'gulp' | 'stuffed' | 'hurt' | 'die' | 'land' | 'thud' | 'heart'; export function createGame(): Game { return { @@ -128,16 +140,21 @@ export function createGame(): Game { distance: 0, score: 0, player: { y: GROUND_Y - PLAYER_H, vy: 0, grounded: true }, - dash: { held: false, active: false, energy: 1, spent: false, sinceImage: 0 }, + sinceImage: 0, blocks: [], - bullets: [], labels: [], particles: [], afterimages: [], shake: 0, bossMouth: 0, - fireCooldown: 0, - fireHeld: false, + inhaling: false, + puffed: 0, + mouthOpen: 0, + runDust: 0, + fullness: 0, + stuffed: false, + fullTime: 0, + descending: false, sinceDeath: 0, hearts: MAX_HEARTS, invuln: 0, @@ -147,10 +164,9 @@ export function createGame(): Game { }; } -/** Scroll speed: ramps gently with score, dino-style, caps, and the dash multiplies it. */ -export function speedFor(score: number, dashing = false): number { - const base = BASE_SPEED + Math.min(score * 3, MAX_SPEED_BONUS); - return dashing ? base * DASH_MULTIPLIER : base; +/** 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); } /** @@ -179,9 +195,9 @@ export function blockSizeFor(gasUsed: number, score = 0, blockNumber = 0, time = return { w: 16 * scale, h: Math.max(h, 12 * scale) }; } -/** Hits to burst a block: one per 16 px of width beyond the smallest. */ -export function hitsFor(w: number): number { - return Math.max(1, Math.round(w / 16) - 1); +/** 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-Denim. */ @@ -207,8 +223,7 @@ export function spawnBlock(game: Game, head: Head): Game { landed: false, w, h, - hp: hitsFor(w), - maxHp: hitsFor(w), + weight: weightFor(w), number: head.number, timestampMs: head.timestampMs, }; @@ -216,14 +231,13 @@ export function spawnBlock(game: Game, head: Head): Game { } export function start(game: Game): Game { - // Keys still held through a restart keep working: a player mashing Space - // with X down should come back firing. + // Keys still held through a restart keep working: a player mashing the + // button comes back inhaling. return { ...createGame(), phase: 'running', nextId: game.nextId, - fireHeld: game.fireHeld, - dash: { ...createGame().dash, held: game.dash.held }, + inhaling: game.inhaling, }; } @@ -237,42 +251,17 @@ export function restart(game: Game): Game { return start(game); } -export function jump(game: Game): Game { - if (game.phase === 'ready') return start(game); - if (game.phase !== 'running' || !game.player.grounded) return game; +/** Hold to inhale; the step drags the nearest block in range to the mouth. */ +export function setInhale(game: Game, held: boolean): Game { + if (game.phase === 'ready' && held) return { ...start(game), inhaling: true }; + if (game.inhaling === held) return game; return { ...game, - player: { ...game.player, vy: JUMP_VY, grounded: false }, - events: [...game.events, 'jump'], + inhaling: held && game.phase === 'running', + events: game.phase === 'running' ? [...game.events, held ? 'inhale-on' : 'inhale-off'] : game.events, }; } -export function fire(game: Game): Game { - if (game.phase === 'ready') return start(game); - if (game.phase !== 'running' || game.fireCooldown > 0) return game; - const bullet: Bullet = { x: PLAYER_X + PLAYER_W - 6, y: game.player.y + 24 }; - return { - ...game, - bullets: [...game.bullets, bullet], - fireCooldown: FIRE_COOLDOWN, - events: [...game.events, 'shoot'], - }; -} - -/** Hold to keep firing at the cooldown rate; the first shot fires immediately. */ -export function setFire(game: Game, held: boolean): Game { - if (game.fireHeld === held) return game; - const next = { ...game, fireHeld: held }; - return held ? fire(next) : next; -} - -/** Hold to dash. The step decides whether there is energy to actually go faster. */ -export function setDash(game: Game, held: boolean): Game { - if (game.phase === 'ready' && held) return { ...start(game), dash: { ...createGame().dash, held: true } }; - if (game.dash.held === held) return game; - return { ...game, dash: { ...game.dash, held } }; -} - function shards(x: number, y: number, rng: () => number): Particle[] { const out: Particle[] = []; for (let i = 0; i < 12; i += 1) { @@ -283,14 +272,6 @@ function shards(x: number, y: number, rng: () => number): Particle[] { return out; } -function sparks(x: number, y: number, rng: () => number): Particle[] { - const out: Particle[] = []; - for (let i = 0; i < 5; i += 1) { - out.push({ x, y, vx: -80 - rng() * 160, vy: -120 + rng() * 240, age: 0, kind: 'spark' }); - } - return out; -} - function dust(x: number, y: number, rng: () => number): Particle[] { const out: Particle[] = []; for (let i = 0; i < 5; i += 1) { @@ -334,38 +315,15 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G let heartProgress = game.heartProgress; let invuln = Math.max(0, game.invuln - dt); - // Held fire: shoot again as soon as the cooldown allows. - let fireCooldown = Math.max(0, game.fireCooldown - dt); - let fired = game.bullets; - if (game.fireHeld && fireCooldown === 0) { - fired = [...fired, { x: PLAYER_X + PLAYER_W - 6, y: game.player.y + 24 }]; - fireCooldown = FIRE_COOLDOWN; - events.push('shoot'); - } - - // Dash: drains while held, regenerates otherwise. A meter that ran dry stays - // spent until it refills a little (or the key is released), so it does not - // flicker on and off at zero. - let energy = game.dash.energy; - let spent = game.dash.spent; - if (!game.dash.held || energy >= DASH_RESTART) spent = false; - const dashing = game.dash.held && !spent && energy > 0; - if (dashing) { - energy = Math.max(0, energy - DASH_DRAIN * dt); - if (energy === 0) spent = true; - } else { - energy = Math.min(1, energy + DASH_REGEN * dt); - } - if (dashing && !game.dash.active) events.push('dash-on'); - if (!dashing && game.dash.active) events.push('dash-off'); - let sinceImage = game.dash.sinceImage + dt; + // FULL mode leaves afterimages — the unstoppable barrel-along up top. + let sinceImage = game.sinceImage + dt; let afterimages = game.afterimages; - if (dashing && sinceImage >= AFTERIMAGE_EVERY) { + 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, dashing); + const speed = speedFor(score); // Scroll blocks; airborne ones fall until they thud onto the rail. Drop the // ones that left the screen. @@ -385,7 +343,6 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G }) .filter((b) => b.x + b.w > -4); - // Bullets fly right; a hit removes the block and reveals it. const fx = decay(game.labels, game.particles, afterimages); let { labels, particles } = fx; afterimages = fx.afterimages; @@ -395,30 +352,68 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G shake = Math.max(shake, Math.min(3, t.w / 24)); events.push('thud'); } - const bullets: Bullet[] = []; - for (const bullet of fired) { - const nx = bullet.x + BULLET_SPEED * dt; - const hit = blocks.find( - (b) => nx + BULLET_W >= b.x && bullet.x <= b.x + b.w && bullet.y + BULLET_H >= b.y && bullet.y <= b.y + b.h, - ); - if (hit && hit.hp > 1) { - // Chip it: the block stays, a little lighter. - blocks = blocks.map((b) => (b.id === hit.id ? { ...b, hp: b.hp - 1 } : b)); - particles = [...particles, ...sparks(nx + BULLET_W, bullet.y + BULLET_H / 2, rng)]; - events.push('hit'); - } else if (hit) { - blocks = blocks.filter((b) => b.id !== hit.id); - score += dashing ? 2 : 1; + // 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 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 = game.inhaling && !chewing ? game.mouthOpen + dt : 0; + let target: Block | null = null; + const mouthReach = game.player.y + PLAYER_H + 6; + if (game.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 += pulled.weight; heartProgress += 1; - labels = [...labels, { x: hit.x + hit.w / 2, y: hit.y - 14, text: blockLabel(hit), age: 0 }]; - particles = [...particles, ...shards(hit.x + hit.w / 2, hit.y + hit.h / 2, rng)]; - shake = Math.max(shake, 4); - events.push('burst'); - } else if (nx < BOSS_X + 20) { - bullets.push({ x: nx, y: bullet.y }); + 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; + 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; @@ -429,20 +424,39 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G 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 const overlapsX = right > b.x && left < b.x + b.w; if (!overlapsX) continue; const top = b.y; - if (prevBottom <= top + 1 && vy >= 0) { - // Landing on it. - floor = Math.min(floor, top); - } else if (prevBottom - top <= STEP_UP && vy >= 0) { - // A small step: climb it, like walking up stairs. + // Consistency rule: every block is a collision unless eaten. Blocks are + // only walkable in FULL mode (he rolls over everything) and during the + // descent right after it (he keeps his footing until he is back on the + // street, so the power-up cannot strand him inside a wall). + const walkable = stuffed || descending; + if (walkable && vy >= 0) { floor = Math.min(floor, top); } else if (y + PLAYER_H > top + 1) { bonked = bonked ?? b; } } - if (bonked && invuln <= 0) { + if (bonked && game.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 += bonked.weight; + heartProgress += 1; + puffed = PUFF_TIME; + mouthOpen = 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); @@ -465,7 +479,20 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G 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'); @@ -488,15 +515,20 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G distance: game.distance + speed * dt, score, player: { y, vy, grounded }, - dash: { held: game.dash.held, active: dashing && !dead, energy, spent, sinceImage }, + sinceImage, blocks, - bullets, labels, particles, afterimages, shake, bossMouth: Math.max(0, game.bossMouth - dt), - fireCooldown, + puffed, + mouthOpen, + runDust, + fullness, + stuffed, + fullTime, + descending, hearts, invuln: dead ? 0 : invuln, heartProgress, diff --git a/app/vibenet/demos/200/lib/selfplay.test.ts b/app/vibenet/demos/200/lib/selfplay.test.ts index 5b4c19f4..0ddff8ff 100644 --- a/app/vibenet/demos/200/lib/selfplay.test.ts +++ b/app/vibenet/demos/200/lib/selfplay.test.ts @@ -1,19 +1,14 @@ // 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 (a wall of crates) or trivial (the -// bot never dies), these numbers move and the test says so. +// tuning change makes the lane unwinnable or trivial, these numbers move and +// the test says so. import { describe, expect, it } from 'vitest'; import { createGame, - jump, - PLAYER_H, - PLAYER_W, - PLAYER_X, restart, - setDash, - setFire, + setInhale, spawnBlock, start, step, @@ -35,22 +30,10 @@ function mulberry32(seed: number): () => number { type Bot = (g: Game) => Game; -/** - * Hold X, jump late (90 px) when a landed block is about to hit, and only dash - * when nothing is within 320 px. Careful rather than clever. - */ -const simpleBot: Bot = (g) => { - let next = setFire(g, true); - const bottom = next.player.y + PLAYER_H; - const ahead = next.blocks.filter((b) => b.landed && b.x + b.w > PLAYER_X && b.y < bottom - 2); - const threat = ahead.find((b) => b.x < PLAYER_X + PLAYER_W + 90); - const near = ahead.find((b) => b.x < PLAYER_X + PLAYER_W + 320); - if (threat && next.player.grounded) next = jump(next); - next = setDash(next, next.dash.energy > 0.5 && !near); - return next; -}; +/** Holds the button forever: eats, goes FULL, cruises, deflates, repeats. */ +const holdBot: Bot = (g) => setInhale(g, true); -/** Never jumps, never shoots: the baseline that must die quickly. */ +/** Never inhales: the baseline that must die once real walls arrive. */ const idleBot: Bot = (g) => g; function play(bot: Bot, seconds: number, seed: number) { @@ -90,20 +73,16 @@ function play(bot: Bot, seconds: number, seed: number) { } describe('self-play balance', () => { - it('a simple bot survives well past the first few blocks and scores', () => { - const r = play(simpleBot, 60, 1); - expect(r.longestRun).toBeGreaterThan(8); - expect(r.bestScore).toBeGreaterThan(20); + it('the eat → FULL → shrink cycle keeps a run alive and scoring', () => { + const r = play(holdBot, 60, 1); + expect(r.longestRun).toBeGreaterThan(15); + expect(r.bestScore).toBeGreaterThan(40); }); - it('is still a game: the simple bot does die sometimes over a long session', () => { - const r = play(simpleBot, 120, 2); - expect(r.deaths).toBeGreaterThan(0); - }); - it('doing nothing loses within a few seconds', () => { - const r = play(idleBot, 20, 3); - expect(r.deaths).toBeGreaterThan(2); - expect(r.longestRun).toBeLessThan(6); + 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 index 0e7db825..8e4b6835 100644 --- a/app/vibenet/demos/200/lib/sound.ts +++ b/app/vibenet/demos/200/lib/sound.ts @@ -85,36 +85,21 @@ export class Sound { src.start(); } - /** Quiet click on every new head — the chain's 200 ms metronome. Higher while dashing. */ - tick(dashing = false): void { - this.tone('triangle', dashing ? 1800 : 1200, dashing ? 1300 : 900, 0.03, 0.25); + /** Quiet click on every new head — the chain's 200 ms metronome. */ + tick(): void { + this.tone('triangle', 1200, 900, 0.03, 0.25); } - dashOn(): void { - this.noise(0.12, 0.35); - this.tone('sawtooth', 200, 900, 0.18, 0.35); + /** Suction kicking in: a rising whoosh. */ + inhaleOn(): void { + this.noise(0.22, 0.3); + this.tone('sawtooth', 140, 520, 0.28, 0.3); } - dashOff(): void { - this.tone('sawtooth', 700, 250, 0.12, 0.25); - } - - jump(): void { - this.tone('square', 320, 640, 0.09, 0.6); - } - - shoot(): void { - this.tone('square', 900, 300, 0.05, 0.5); - } - - /** A shot chipping a block that is not done yet. */ - hit(): void { - this.tone('square', 700, 380, 0.04, 0.35); - } - - burst(): void { - this.noise(0.09, 0.7); - this.tone('square', 500, 1100, 0.06, 0.4); + /** 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. */ @@ -127,6 +112,13 @@ export class Sound { 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); diff --git a/app/vibenet/demos/200/lib/sprites.ts b/app/vibenet/demos/200/lib/sprites.ts index e7fbf3c7..cac99fe5 100644 --- a/app/vibenet/demos/200/lib/sprites.ts +++ b/app/vibenet/demos/200/lib/sprites.ts @@ -19,171 +19,36 @@ export type Palette = { hud: string; }; -export const LIGHT: Palette = { - k: '#111111', // gray-100: outlines - w: '#ffffff', // gray-0 - B: '#0052ff', // blue-60: Base blue - D: '#003ec1', // blue-80 +// 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: '#b0caff', // blue-10 - s: '#ffffff', // visor - r: '#5e5e5e', // gray-60: crate fill - R: '#3a3a3a', // gray-80: crate shade - p: '#f5f8ff', // blue-0 - y: '#ffe436', // yellow-20 + 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', // gray-15 - d: '#9a9a9a', // gray-40 - sky: '#d3e1ff', // blue-5 - hills: '#73a2ff', // blue-20 - rail: '#ffffff', - railEdge: '#dadada', // gray-15 - ground: '#efefef', // gray-10: pavement continues down - hud: '#5e5e5e', // gray-60 + 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', }; -export const DARK: Palette = { - ...LIGHT, - B: '#266eff', // blue-40 reads better on navy - D: '#0052ff', - r: '#6f6f6f', // gray-50 - R: '#525252', // gray-70 - sky: '#00184d', // blue-100 - hills: '#002982', // blue-90 - rail: '#3a3a3a', // gray-80 - railEdge: '#525252', // gray-70 - ground: '#262626', // gray-90 - hud: '#ffffff', -}; - -// The runner: a blue robot in the Mega Man silhouette. 14 × 18, drawn at 3×. -// Three run frames plus jump, dash, and a dead pose. -export const RUNNER_RUN: Sprite[] = [ - [ - '.....kkkkk....', - '....kBBBBBk...', - '...kBBbbbBBk..', - '...kBbkskbBk..', - '...kBksssskk..', - '...kBkskkskk..', - '....kksssk....', - '.....kBBBk....', - '...kkBBBBBkk..', - '..kBBkBBBkBBk.', - '..kBBkBBBkBBk.', - '...kkkBBBkkk..', - '.....kBBBk....', - '....kBBkBBk...', - '...kBBk.kBBk..', - '..kBBk...kBBk.', - '.kBBBk....kBBk', - '.kkkk.....kkkk', - ], - [ - '.....kkkkk....', - '....kBBBBBk...', - '...kBBbbbBBk..', - '...kBbkskbBk..', - '...kBksssskk..', - '...kBkskkskk..', - '....kksssk....', - '.....kBBBk....', - '...kkBBBBBkk..', - '..kBBkBBBkBBk.', - '..kBBkBBBkBBk.', - '...kkkBBBkkk..', - '.....kBBBk....', - '.....kBBBk....', - '....kBBBBBk...', - '....kBBkBBk...', - '....kBBkBBk...', - '....kkk.kkk...', - ], - [ - '.....kkkkk....', - '....kBBBBBk...', - '...kBBbbbBBk..', - '...kBbkskbBk..', - '...kBksssskk..', - '...kBkskkskk..', - '....kksssk....', - '.....kBBBk....', - '...kkBBBBBkk..', - '..kBBkBBBkBBk.', - '..kBBkBBBkBBk.', - '...kkkBBBkkk..', - '.....kBBBk....', - '....kBBkBBk...', - '...kBBk.kBBk..', - '...kBBk..kBBk.', - '..kBBk....kBk.', - '..kkkk....kkk.', - ], -]; - -export const RUNNER_JUMP: Sprite = [ - '.....kkkkk....', - '....kBBBBBk...', - '...kBBbbbBBk..', - '...kBbkskbBk..', - '...kBksssskk..', - '...kBkskkskk..', - '....kksssk....', - '..kkkkBBBkkkk.', - '.kBBkBBBBBkBBk', - '.kBBkBBBBBkBBk', - '..kkkBBBBBkkk.', - '.....kBBBk....', - '....kBBBBBk...', - '...kBBk.kBBk..', - '..kBBk...kBBk.', - '..kBBk...kBBk.', - '..kkkk...kkkk.', - '..............', -]; - -// Leaning forward with a trailing edge — shown while dashing. -export const RUNNER_DASH: Sprite = [ - '..............', - '......kkkkk...', - '.....kBBBBBk..', - '....kBBbbbBBk.', - '....kBbkskbBk.', - '....kBksssskk.', - '....kBkskkskk.', - '.....kksssk...', - '..kkkkBBBBk...', - '.kBBBBBBBBBk..', - '.kBBkBBBBBBBk.', - '..kkkBBBBkBBk.', - '.....kBBBkkk..', - '....kBBkBBBk..', - '...kBBk.kBBBk.', - '..kBBk...kBBk.', - '.kBBk.....kkk.', - '.kkkk.........', -]; +/** Far tower silhouettes: violet, half-dissolved into the sky. */ +export const NIGHT_FAR = '#241a4d'; -export const RUNNER_DEAD: Sprite = [ - '.....kkkkk....', - '....kBBBBBk...', - '...kBBbbbBBk..', - '...kBbkkkbBk..', - '...kBkskskkk..', - '...kBksksskk..', - '....kkskksk...', - '.....kBBBk....', - '...kkBBBBBkk..', - '..kBBkBBBkBBk.', - '..kBBkBBBkBBk.', - '...kkkBBBkkk..', - '.....kBBBk....', - '....kBBkBBk...', - '....kBBkBBk...', - '....kBBkBBk...', - '....kBBkBBk...', - '....kkk.kkk...', -]; +/** 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 = [ @@ -271,25 +136,6 @@ export const BOSS_OPEN: Sprite = [ '..kkkkkkkkkkkkkkkkkkkkkkkkkkkk..', ]; -export const CLOUD_BIG: Sprite = [ - '.......kkkkk........', - '.....kkwwwwwkk......', - '...kkwwwwwwwwwkkkk..', - '..kwwwwwwwwwwwwwwwk.', - '.kwwwwwwwwwwwwwwwwwk', - 'kwwwwwwwwwwwwwwwwwwk', - 'kwwwwwwwwwwwwwwwwwwk', - '.kkkkkkkkkkkkkkkkkk.', -]; - -export const CLOUD_SMALL: Sprite = [ - '...kkkk....', - '.kkwwwwkk..', - 'kwwwwwwwwkk', - 'kwwwwwwwwwk', - '.kkkkkkkkk.', -]; - // Hearts for the HUD, 9 × 8 at 2×. Filled is Base blue; empty is an outline. export const HEART: Sprite = [ '.kk...kk.', @@ -313,10 +159,6 @@ export const HEART_EMPTY: Sprite = [ '....k....', ]; -export const BUSTER_SHOT: Sprite = ['.kkkk.', 'kBBBBk', 'kBwwBk', 'kBBBBk', '.kkkk.']; - -export const DUST: Sprite = ['.ww.', 'wwww', '.ww.']; - export function drawSprite( ctx: CanvasRenderingContext2D, sprite: Sprite, @@ -337,10 +179,6 @@ export function drawSprite( } } -export function spriteWidth(sprite: Sprite, scale: number): number { - return (sprite[0]?.length ?? 0) * scale; -} - export function spriteHeight(sprite: Sprite, scale: number): number { return sprite.length * scale; } diff --git a/app/vibenet/demos/catalogue.ts b/app/vibenet/demos/catalogue.ts index 673257bd..71ed635a 100644 --- a/app/vibenet/demos/catalogue.ts +++ b/app/vibenet/demos/catalogue.ts @@ -73,8 +73,8 @@ export const DEMOS: DemoEntry[] = [ href: '/vibenet/demos/200', title: 'Block Runner', shortTitle: 'Block Runner', - summary: 'A pixel runner paced by vibenet’s 200 ms blocks. Shoot a block to read its number and slot.', - points: ['One block every 200 ms, straight off newHeads', 'Block height follows gas used', 'Space jumps, X shoots'], + 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: hold to inhale'], available: true, listed: false, }, From ff7ae1c2a790064b36179e9c9df296e9a49ad667 Mon Sep 17 00:00:00 2001 From: Soheima M Date: Thu, 3 Sep 2026 19:17:48 +0200 Subject: [PATCH 06/11] fix(200-demo): EATEN counts real blocks, not weighted score Every swallow adds exactly one; heavy walls keep their reward by filling the belly twice as fast. Comments and tests updated to match. --- app/vibenet/demos/200/BlockRunner.tsx | 4 ++-- app/vibenet/demos/200/lib/game.test.ts | 14 +++++++++++--- app/vibenet/demos/200/lib/game.ts | 12 +++++++----- app/vibenet/demos/200/lib/selfplay.test.ts | 2 +- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/app/vibenet/demos/200/BlockRunner.tsx b/app/vibenet/demos/200/BlockRunner.tsx index 7222d680..4763c654 100644 --- a/app/vibenet/demos/200/BlockRunner.tsx +++ b/app/vibenet/demos/200/BlockRunner.tsx @@ -4,8 +4,8 @@ // 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: // hold ONE button — Space, X, or a finger anywhere on the picture — to inhale, -// and the nearest block drags into its mouth and is swallowed, scoring its -// weight. Heavy walls drag slower while the chain keeps coming. Any block +// and the nearest block drags into its mouth and is swallowed — 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. // diff --git a/app/vibenet/demos/200/lib/game.test.ts b/app/vibenet/demos/200/lib/game.test.ts index b1717f25..910434f5 100644 --- a/app/vibenet/demos/200/lib/game.test.ts +++ b/app/vibenet/demos/200/lib/game.test.ts @@ -150,12 +150,12 @@ describe('weightFor / swallowing', () => { expect(weightFor(64)).toBe(2); }); - it('inhaling drags the nearest block in and swallows it, scoring its weight', () => { + it('inhaling drags the nearest block in and swallows it, counting one eaten', () => { let g = setInhale(start(createGame()), true); 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(2); + expect(g.score).toBe(1); expect(g.labels[0]?.text).toBe('148,646 · .200'); expect(g.puffed).toBeGreaterThan(0); }); @@ -190,7 +190,7 @@ describe('weightFor / swallowing', () => { expect(g.hearts).toBe(MAX_HEARTS); expect(g.phase).toBe('running'); expect(g.blocks).toHaveLength(0); - expect(g.score).toBeGreaterThanOrEqual(2); + expect(g.score).toBeGreaterThanOrEqual(1); }); it('released inhale stops the pull', () => { @@ -219,6 +219,14 @@ describe('fullness / stuffed', () => { expect(g.player.y).toBe(GROUND_Y - PLAYER_H); }); + it('a heavy wall still counts as ONE eaten, not its weight', () => { + let g = setInhale(start(createGame()), true); + 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 = setInhale(start(createGame()), true); diff --git a/app/vibenet/demos/200/lib/game.ts b/app/vibenet/demos/200/lib/game.ts index 893ebd60..3617d4f3 100644 --- a/app/vibenet/demos/200/lib/game.ts +++ b/app/vibenet/demos/200/lib/game.ts @@ -4,8 +4,8 @@ // The chain is the spawner: every new vibenet head (one per 200 ms under // 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, -// scoring its weight and revealing its number and 200 ms slot. Busy walls are +// 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 — @@ -76,7 +76,7 @@ export type Block = { landed: boolean; w: number; h: number; - /** Suction weight: heavy walls pull in slower and score more. */ + /** Suction weight: heavy walls pull in slower and fill the belly faster. */ weight: number; number: number; timestampMs: number | null; @@ -397,7 +397,9 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G 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 += pulled.weight; + // 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 }]; @@ -444,7 +446,7 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G // 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 += bonked.weight; + score += 1; heartProgress += 1; puffed = PUFF_TIME; mouthOpen = 0; diff --git a/app/vibenet/demos/200/lib/selfplay.test.ts b/app/vibenet/demos/200/lib/selfplay.test.ts index 0ddff8ff..d8db6242 100644 --- a/app/vibenet/demos/200/lib/selfplay.test.ts +++ b/app/vibenet/demos/200/lib/selfplay.test.ts @@ -76,7 +76,7 @@ describe('self-play balance', () => { it('the eat → FULL → shrink cycle keeps a run alive and scoring', () => { const r = play(holdBot, 60, 1); expect(r.longestRun).toBeGreaterThan(15); - expect(r.bestScore).toBeGreaterThan(40); + expect(r.bestScore).toBeGreaterThan(30); }); From 0508767148d4c914a002f20b8d214837caa0748a Mon Sep 17 00:00:00 2001 From: Soheima M Date: Thu, 3 Sep 2026 19:33:43 +0200 Subject: [PATCH 07/11] fix(200-demo): steady blocks/s readout Measure the span between oldest and newest head in a 3 s window instead of counting heads per fixed bucket, which made a steady 200 ms cadence flicker between 5.0 and 5.5. --- app/vibenet/demos/200/BlockRunner.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/vibenet/demos/200/BlockRunner.tsx b/app/vibenet/demos/200/BlockRunner.tsx index 4763c654..f1b8a82a 100644 --- a/app/vibenet/demos/200/BlockRunner.tsx +++ b/app/vibenet/demos/200/BlockRunner.tsx @@ -505,8 +505,13 @@ export function BlockRunner() { if (cancelled) return; const now = performance.now(); lastHeadAt.current = now; - headTimes.current = [...headTimes.current.filter((t) => now - t < 2000), now]; - setRate(headTimes.current.length / 2); + // 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. From 3629efd2219cf15ba27996fab35f898e80e03131 Mon Sep 17 00:00:00 2001 From: Soheima M Date: Thu, 3 Sep 2026 21:29:19 +0200 Subject: [PATCH 08/11] added full screen --- app/vibenet/demos/200/BlockRunner.tsx | 445 +++++++++++++++-- .../demos/200/contract/BlockRunnerScores.json | 107 ++++ .../demos/200/contract/BlockRunnerScores.sol | 51 ++ app/vibenet/demos/200/contract/deploy.mjs | 31 ++ app/vibenet/demos/200/lib/game.test.ts | 144 ++++-- app/vibenet/demos/200/lib/game.ts | 128 +++-- app/vibenet/demos/200/lib/leaderboard.test.ts | 27 + app/vibenet/demos/200/lib/leaderboard.ts | 113 +++++ app/vibenet/demos/200/lib/selfplay.test.ts | 10 +- app/vibenet/demos/200/lib/sound.ts | 85 +++- app/vibenet/demos/200/page.tsx | 2 +- public/audio/block-runner-lofi.mp3 | Bin 0 -> 307844 bytes scripts/block-runner-music.mjs | 469 ++++++++++++++++++ 13 files changed, 1476 insertions(+), 136 deletions(-) create mode 100644 app/vibenet/demos/200/contract/BlockRunnerScores.json create mode 100644 app/vibenet/demos/200/contract/BlockRunnerScores.sol create mode 100644 app/vibenet/demos/200/contract/deploy.mjs create mode 100644 app/vibenet/demos/200/lib/leaderboard.test.ts create mode 100644 app/vibenet/demos/200/lib/leaderboard.ts create mode 100644 public/audio/block-runner-lofi.mp3 create mode 100644 scripts/block-runner-music.mjs diff --git a/app/vibenet/demos/200/BlockRunner.tsx b/app/vibenet/demos/200/BlockRunner.tsx index f1b8a82a..2a3c4257 100644 --- a/app/vibenet/demos/200/BlockRunner.tsx +++ b/app/vibenet/demos/200/BlockRunner.tsx @@ -3,8 +3,8 @@ // 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 Denim) is spat out as a // block by the boss on the right edge. The player is a round Base-blue glutton: -// hold ONE button — Space, X, or a finger anywhere on the picture — to inhale, -// and the nearest block drags into its mouth and is swallowed — one eaten. +// 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. @@ -17,7 +17,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '../../../components/ui/Button'; import { Text } from '../../../components/ui/Text'; -import { VIBENET_RPC_URL, VIBENET_WS_URL } from '../../library/config'; +import { VIBENET_EXPLORER_PATH, VIBENET_RPC_URL, VIBENET_WS_URL } from '../../library/config'; import { connectJsonRpcStream } from '../validity/lib/stream'; import { BOSS_X, @@ -29,9 +29,10 @@ import { PLAYER_H, PLAYER_W, PLAYER_X, + fullMeter, INHALE_RANGE, restart, - setInhale, + tap, slotOf, spawnBlock, step, @@ -40,6 +41,14 @@ import { 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, @@ -96,15 +105,15 @@ function glutton(): HTMLImageElement { return gluttonImg; } -function drawGlutton(ctx: CanvasRenderingContext2D, frame: number, x: number, y: number, swell = 1): void { +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 * SCALE * swell; - const h = G_H * SCALE * swell; - const dx = x - ((G_W - 16) / 2) * SCALE - (w - G_W * SCALE) / 2; - const dy = y - (h - G_H * SCALE); + 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)); } @@ -303,6 +312,181 @@ function drawBlock(ctx: CanvasRenderingContext2D, x: number, y: number, w: numbe drawSprite(ctx, CRATE_TOP.slice(0, 1), x, y + h - scale, scale, P); } +/** Chunky pixel text: rendered small offscreen with an outline, scaled up + * with smoothing off. Cached per string+style. */ +const chunkyCache = new Map(); +function chunkyText( + ctx: CanvasRenderingContext2D, + text: string, + cx: number, + top: number, + scale: number, + fill: string, + outline: string, +): number { + const key = `${text}|${fill}|${outline}`; + let off = chunkyCache.get(key); + if (!off) { + const measure = document.createElement('canvas').getContext('2d')!; + measure.font = 'bold 11px ui-monospace, monospace'; + const tw = Math.ceil(measure.measureText(text).width); + off = document.createElement('canvas'); + off.width = tw + 4; + off.height = 16; + const o = off.getContext('2d')!; + o.font = 'bold 11px ui-monospace, monospace'; + o.textBaseline = 'top'; + o.fillStyle = outline; + for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1], [1, 1], [-1, 1]] as const) o.fillText(text, 2 + dx, 2 + dy); + o.fillStyle = fill; + o.fillText(text, 2, 2); + chunkyCache.set(key, off); + } + const w = off.width * scale; + ctx.imageSmoothingEnabled = false; + ctx.drawImage(off, Math.round(cx - w / 2), Math.round(top), w, off.height * scale); + return w; +} + +/** 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 var(--font-mono, ui-monospace, monospace)'; + 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 = 34; + 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 tw = chunkyText(ctx, dead ? 'GAME OVER' : 'BLOCK RUNNER', WIDTH / 2, titleY, 3, P.w, '#1a3fb8'); + 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) { + chunkyText(ctx, `${game.score} BLOCKS EATEN`, WIDTH / 2, y + 74, 2, P.y, '#5c4a00'); + 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 var(--font-mono, ui-monospace, monospace)'; + 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) { + chunkyText(ctx, dead ? '\u25b6 TAP TO RUN AGAIN' : '\u25b6 TAP TO START', WIDTH / 2, by + 5, 2, P.y, '#5c4a00'); + } + + // 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; @@ -419,9 +603,12 @@ function render(ctx: CanvasRenderingContext2D, game: Game, frame: number, feedQu ctx.restore(); // Belly gauge, HUD-fixed (not shaken): fills as he eats; during FULL it - // shows the time left and drains to empty — then he must eat again. + // 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 ? game.fullTime / 2.5 : game.fullness; + const fillFrac = game.stuffed ? fullMeter(game) : game.fullness; ctx.fillStyle = P.railEdge; ctx.fillRect(16, 16, meterW + 6, 14); ctx.fillStyle = P.rail; @@ -437,29 +624,12 @@ function render(ctx: CanvasRenderingContext2D, game: Game, frame: number, feedQu for (let i = 0; i < MAX_HEARTS; i += 1) { drawSprite(ctx, i < game.hearts ? HEART : HEART_EMPTY, 150 + i * 22, 14, 2, P); } + } - // Overlays. + // Overlays: an 8-bit dialog — layered pixel border, drop shadow, accent + // bar — instead of a flat slab. if (game.phase !== 'running') { - ctx.fillStyle = P.B; - ctx.fillRect(WIDTH / 2 - 250, 70, 500, 96); - ctx.fillStyle = P.D; - ctx.fillRect(WIDTH / 2 - 250, 166, 500, 4); - ctx.fillStyle = P.w; - ctx.textAlign = 'center'; - ctx.font = 'bold 20px var(--font-mono, ui-monospace, monospace)'; - const title = game.phase === 'dead' ? 'GAME OVER' : 'BLOCK RUNNER'; - ctx.fillText(title, WIDTH / 2, 106); - ctx.font = '12px var(--font-mono, ui-monospace, monospace)'; - ctx.fillStyle = P.c; - const hint = - game.phase === 'dead' - ? 'Press any key to run again' - : 'Hold Space (or touch) to eat · every block hits if you don’t · one every 200 ms'; - ctx.fillText(hint, WIDTH / 2, 134); - if (game.phase === 'dead') { - ctx.fillStyle = P.y; - ctx.fillText(`${game.score} blocks eaten`, WIDTH / 2, 152); - } + drawDialog(ctx, game, frame); } } @@ -470,6 +640,15 @@ export function BlockRunner() { 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'); @@ -585,8 +764,65 @@ export function BlockRunner() { }; }, [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 doInhale = useCallback((held: boolean) => apply(setInhale(gameRef.current, held)), [apply]); + const doTap = useCallback(() => apply(tap(gameRef.current)), [apply]); const doRestart = useCallback(() => apply(restart(gameRef.current)), [apply]); useEffect(() => { @@ -601,29 +837,23 @@ export function BlockRunner() { if (e.code === 'Space' || e.code === 'KeyX' || e.code === 'Enter') { e.preventDefault(); if (gameRef.current.phase === 'dead') doRestart(); - else doInhale(true); + 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); } }; - const onKeyUp = (e: KeyboardEvent) => { - if (e.code === 'Space' || e.code === 'KeyX' || e.code === 'Enter') doInhale(false); - }; - const onBlur = () => doInhale(false); window.addEventListener('keydown', onKeyDown); - window.addEventListener('keyup', onKeyUp); - window.addEventListener('blur', onBlur); return () => { window.removeEventListener('keydown', onKeyDown); - window.removeEventListener('keyup', onKeyUp); - window.removeEventListener('blur', onBlur); }; - }, [doInhale, doRestart]); + }, [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), @@ -676,6 +906,8 @@ export function BlockRunner() { 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); @@ -708,6 +940,7 @@ export function BlockRunner() { return () => { window.cancelAnimationFrame(raf); window.clearInterval(fallback); + soundRef.current?.stopMusic(0.2); }; }, []); @@ -715,10 +948,9 @@ export function BlockRunner() { e.preventDefault(); sound().unlock(); if (gameRef.current.phase === 'dead') return doRestart(); - // One action everywhere: press (and hold) to inhale. - doInhale(true); + // One action everywhere: a tap is a bite. + doTap(); }; - const onPointerUp = () => doInhale(false); const slot = head ? slotOf(head.timestampMs) : null; const feedLabel = @@ -765,29 +997,132 @@ export function BlockRunner() { > {muted ? 'Unmute' : 'Mute'} +
+
e.preventDefault()} - className="mx-auto w-full max-w-[800px] select-none rounded-xl bg-[#12093a] [image-rendering:pixelated]" + 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}.` : 'Press space or touch to start eating.'}`} + aria-label={`Block Runner. ${phase === 'running' ? `Score ${score}.` : 'Tap space or the screen to start eating.'}`} role="img" /> + {immersive && !isFullscreen ? ( +
+ + + +
+ ) : null} +
+ +
+
+ Onchain high scores +
+ {phase === 'dead' && score > 0 && score > chainBest ? ( + + ) : 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)}` : ''}. Hold Space, X, or a finger on the picture to inhale — the - nearest block drags in and is swallowed, and heavy walls drag 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. R restarts after a run, M - mutes. + {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. ); 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/lib/game.test.ts b/app/vibenet/demos/200/lib/game.test.ts index 910434f5..7141de98 100644 --- a/app/vibenet/demos/200/lib/game.test.ts +++ b/app/vibenet/demos/200/lib/game.test.ts @@ -5,6 +5,7 @@ import { blockLabel, BOSS_X, createGame, + fullMeter, INHALE_RANGE, GROUND_Y, MAX_HEARTS, @@ -14,7 +15,7 @@ import { restart, RESTART_GRACE, PLAYER_W, - setInhale, + tap, slotOf, spawnBlock, speedFor, @@ -106,16 +107,39 @@ describe('spawnBlock', () => { expect(spawnBlock(createGame(), head(1)).blocks).toHaveLength(0); }); - it('spits a block from the boss mouth and opens its mouth once running', () => { - const g = spawnBlock(start(createGame()), head(148_646)); + 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); + 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.blocks[0].y + g.blocks[0].h).toBeLessThanOrEqual(MOUTH_Y); 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; @@ -130,15 +154,8 @@ describe('spawnBlock', () => { }); describe('inhale', () => { - it('a restart keeps held keys so a mashing player comes back inhaling', () => { - let g = setInhale(start(createGame()), true); - g = start(g); - expect(g.inhaling).toBe(true); - expect(g.score).toBe(0); - }); - it('start the run from the ready screen', () => { - expect(setInhale(createGame(), true).phase).toBe('running'); + expect(tap(createGame()).phase).toBe('running'); }); }); @@ -151,7 +168,7 @@ describe('weightFor / swallowing', () => { }); it('inhaling drags the nearest block in and swallows it, counting one eaten', () => { - let g = setInhale(start(createGame()), true); + 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); @@ -162,7 +179,7 @@ describe('weightFor / swallowing', () => { it('a heavy wall drags in slower than a light block', () => { const timeToEat = (weight: number) => { - let g = setInhale(start(createGame()), true); + 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) { @@ -175,7 +192,7 @@ describe('weightFor / swallowing', () => { }); it('blocks beyond inhale range are left alone', () => { - let g = setInhale(start(createGame()), true); + 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); @@ -184,7 +201,7 @@ describe('weightFor / swallowing', () => { }); it('contact while inhaling is a meal, not a crash', () => { - let g = setInhale(start(createGame()), true); + 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); @@ -193,13 +210,32 @@ describe('weightFor / swallowing', () => { expect(g.score).toBeGreaterThanOrEqual(1); }); - it('released inhale stops the pull', () => { - let g = setInhale(start(createGame()), true); - g = setInhale(g, false); + 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); }); }); @@ -220,7 +256,7 @@ describe('fullness / stuffed', () => { }); it('a heavy wall still counts as ONE eaten, not its weight', () => { - let g = setInhale(start(createGame()), true); + 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); @@ -229,7 +265,7 @@ describe('fullness / stuffed', () => { it('each swallow fills the belly; heavier blocks fill it more', () => { const eat = (weight: number) => { - let g = setInhale(start(createGame()), true); + 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; @@ -239,7 +275,7 @@ describe('fullness / stuffed', () => { }); it('eating to the max enters FULL mode: no more eating, and a power fanfare', () => { - let g = setInhale(start(createGame()), true); + 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; @@ -268,12 +304,12 @@ describe('fullness / stuffed', () => { }); it('FULL mode times out, he shrinks back to empty, and is hungry again', () => { - let g = { ...setInhale(start(createGame()), true), fullness: 1, stuffed: true, fullTime: 0.2 }; + 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 block within range gets eaten. - g = { ...g, blocks: [blk({ id: 11, x: PLAYER_X + 200, h: 60, weight: 1 })] }; + // 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); }); @@ -297,17 +333,51 @@ describe('blocks are never road', () => { expect(hurt).toBe(true); }); - it('after FULL ends he keeps his footing over walls until he reaches the road', () => { + 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 }; - g = { ...g, player: { y: GROUND_Y - 96 - PLAYER_H, vy: 0, grounded: true }, blocks: [blk({ x: PLAYER_X - 10, h: 96, weight: 2 })] }; - g = run(g, 0.2); - expect(g.hearts).toBe(MAX_HEARTS); - expect(g.descending || g.player.y + PLAYER_H >= GROUND_Y - 1).toBe(true); - // Once the wall passes he lands on the road and is a normal runner again. - g = { ...g, blocks: [] }; - g = run(g, 0.6); - expect(g.descending).toBe(false); - expect(g.player.y).toBe(GROUND_Y - PLAYER_H); + // 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); }); }); @@ -374,7 +444,7 @@ describe('step', () => { it('swallowing blocks earns a heart back', () => { - let g = setInhale({ ...start(createGame()), hearts: 1, heartProgress: 11 }, true); + 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); diff --git a/app/vibenet/demos/200/lib/game.ts b/app/vibenet/demos/200/lib/game.ts index 3617d4f3..4ce762a7 100644 --- a/app/vibenet/demos/200/lib/game.ts +++ b/app/vibenet/demos/200/lib/game.ts @@ -27,6 +27,8 @@ const PULL_SPEED = 1300; 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. */ @@ -34,6 +36,18 @@ 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. */ @@ -96,6 +110,11 @@ export type Game = { /** 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[]; @@ -103,8 +122,10 @@ export type Game = { shake: number; /** Seconds the boss mouth stays open after spitting a block. */ bossMouth: number; - /** Hold to inhale: suction is on while this is held. */ + /** 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. */ @@ -117,7 +138,7 @@ export type Game = { stuffed: boolean; /** Seconds of FULL mode remaining before he shrinks back to empty. */ fullTime: number; - /** After FULL ends: keeps his footing on tall blocks until he touches road. */ + /** 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; @@ -131,7 +152,7 @@ export type Game = { events: GameEvent[]; }; -export type GameEvent = 'inhale-on' | 'inhale-off' | 'gulp' | 'stuffed' | 'hurt' | 'die' | 'land' | 'thud' | 'heart'; +export type GameEvent = 'inhale-on' | 'gulp' | 'stuffed' | 'hurt' | 'die' | 'land' | 'thud' | 'heart'; export function createGame(): Game { return { @@ -142,12 +163,15 @@ export function createGame(): Game { 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, @@ -211,23 +235,14 @@ export function blockLabel(block: Pick): string return slot ? `${block.number.toLocaleString()} · ${slot}` : block.number.toLocaleString(); } -/** A new head arrived: the boss spits a block. Ignored unless running. */ +/** + * 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; - const { w, h } = blockSizeFor(head.gasUsed, game.score, head.number, game.time); - const block: Block = { - id: game.nextId, - x: SPAWN_X, - y: MOUTH_Y - h, - vy: SPIT_VY, - landed: false, - w, - h, - weight: weightFor(w), - number: head.number, - timestampMs: head.timestampMs, - }; - return { ...game, nextId: game.nextId + 1, blocks: [...game.blocks, block], bossMouth: BOSS_MOUTH_OPEN }; + return { ...game, pending: [...game.pending, head] }; } export function start(game: Game): Game { @@ -237,7 +252,6 @@ export function start(game: Game): Game { ...createGame(), phase: 'running', nextId: game.nextId, - inhaling: game.inhaling, }; } @@ -251,14 +265,19 @@ export function restart(game: Game): Game { return start(game); } -/** Hold to inhale; the step drags the nearest block in range to the mouth. */ -export function setInhale(game: Game, held: boolean): Game { - if (game.phase === 'ready' && held) return { ...start(game), inhaling: true }; - if (game.inhaling === held) return 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: held && game.phase === 'running', - events: game.phase === 'running' ? [...game.events, held ? 'inhale-on' : 'inhale-off'] : game.events, + inhaling: true, + inhaleTime: INHALE_BURST, + events: game.inhaling ? game.events : [...game.events, 'inhale-on'], }; } @@ -343,6 +362,35 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G }) .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; @@ -357,6 +405,8 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G // 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 @@ -378,10 +428,10 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G } // 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 = game.inhaling && !chewing ? game.mouthOpen + dt : 0; + let mouthOpen = inhaling && !chewing ? game.mouthOpen + dt : 0; let target: Block | null = null; const mouthReach = game.player.y + PLAYER_H + 6; - if (game.inhaling && !chewing && !stuffed) { + 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 @@ -406,6 +456,8 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G 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; @@ -427,21 +479,22 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G 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 (he rolls over everything) and during the - // descent right after it (he keeps his footing until he is back on the - // street, so the power-up cannot strand him inside a wall). - const walkable = stuffed || descending; - if (walkable && vy >= 0) { + // 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 && game.inhaling && !stuffed && puffed <= 0) { + 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. @@ -450,6 +503,8 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G 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; @@ -522,8 +577,13 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G labels, particles, afterimages, + pending, + spawnClock, + nextId, shake, - bossMouth: Math.max(0, game.bossMouth - dt), + bossMouth, + inhaling, + inhaleTime, puffed, mouthOpen, runDust, 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..35786c07 --- /dev/null +++ b/app/vibenet/demos/200/lib/leaderboard.ts @@ -0,0 +1,113 @@ +// 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'); + await client.waitForTransactionReceipt({ hash, pollingInterval: 200, timeout: 30_000 }); + return hash; +} diff --git a/app/vibenet/demos/200/lib/selfplay.test.ts b/app/vibenet/demos/200/lib/selfplay.test.ts index d8db6242..853120c9 100644 --- a/app/vibenet/demos/200/lib/selfplay.test.ts +++ b/app/vibenet/demos/200/lib/selfplay.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest'; import { createGame, restart, - setInhale, + tap, spawnBlock, start, step, @@ -30,8 +30,8 @@ function mulberry32(seed: number): () => number { type Bot = (g: Game) => Game; -/** Holds the button forever: eats, goes FULL, cruises, deflates, repeats. */ -const holdBot: Bot = (g) => setInhale(g, true); +/** 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; @@ -73,8 +73,8 @@ function play(bot: Bot, seconds: number, seed: number) { } describe('self-play balance', () => { - it('the eat → FULL → shrink cycle keeps a run alive and scoring', () => { - const r = play(holdBot, 60, 1); + 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); }); diff --git a/app/vibenet/demos/200/lib/sound.ts b/app/vibenet/demos/200/lib/sound.ts index 8e4b6835..4c1802fb 100644 --- a/app/vibenet/demos/200/lib/sound.ts +++ b/app/vibenet/demos/200/lib/sound.ts @@ -1,15 +1,25 @@ -// Tiny synthesized sound set — no audio files. Everything is a short -// oscillator or noise burst through one gain node, so it stays under a few -// hundred bytes of code and never blocks on a network fetch. +// 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() { @@ -27,6 +37,8 @@ export class Sound { } catch { /* Persistence is a convenience. */ } + if (muted) this.haltMusic(0.05); + else if (this.musicWanted) this.startMusic(); } /** @@ -35,7 +47,72 @@ export class Sound { * before the game loop gets to play anything. */ unlock(): void { - if (!this.muted) this.ensure(); + 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 { diff --git a/app/vibenet/demos/200/page.tsx b/app/vibenet/demos/200/page.tsx index f4aa4494..1b111509 100644 --- a/app/vibenet/demos/200/page.tsx +++ b/app/vibenet/demos/200/page.tsx @@ -3,7 +3,7 @@ import { Text } from '../../../components/ui/Text'; export default function BlockRunnerPage() { return ( -
+
Vibenet · 200 ms blocks diff --git a/public/audio/block-runner-lofi.mp3 b/public/audio/block-runner-lofi.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..d1655518444a48bc6586b05237aa590a32addf4d GIT binary patch literal 307844 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

    ;s2-r|>J|8Msowo^MSfDHjSVhnQ9WhG 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%0Dk`X3HqMpW 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

    7=sfkijCZWP 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 WCRsZIQUU^TJS!jg{|f#e#`z!Z3&L~& literal 0 HcmV?d00001 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)`, +); From bf4b025599e6956d83f7a5ad93057583e142b31e Mon Sep 17 00:00:00 2001 From: Soheima M Date: Thu, 3 Sep 2026 21:46:54 +0200 Subject: [PATCH 09/11] post score prompt, in-game stats, doto font --- app/vibenet/demos/200/BlockRunner.tsx | 194 ++++++++++++++++---------- 1 file changed, 122 insertions(+), 72 deletions(-) diff --git a/app/vibenet/demos/200/BlockRunner.tsx b/app/vibenet/demos/200/BlockRunner.tsx index 2a3c4257..c78c365c 100644 --- a/app/vibenet/demos/200/BlockRunner.tsx +++ b/app/vibenet/demos/200/BlockRunner.tsx @@ -253,7 +253,7 @@ function drawNeonSign(ctx: CanvasRenderingContext2D, cx: number, roofY: number): ctx.globalAlpha = 0.35; ctx.fillRect(x - 4, y - 4, w + 8, h + 8); // glow ctx.globalAlpha = 1; - ctx.font = 'bold 11px var(--font-mono, ui-monospace, monospace)'; + ctx.font = `bold 11px ${dotoFamily()}`; ctx.textAlign = 'center'; ctx.fillStyle = P.B; ctx.fillText('BASE', cx, y + 12); @@ -312,40 +312,24 @@ function drawBlock(ctx: CanvasRenderingContext2D, x: number, y: number, w: numbe drawSprite(ctx, CRATE_TOP.slice(0, 1), x, y + h - scale, scale, P); } -/** Chunky pixel text: rendered small offscreen with an outline, scaled up - * with smoothing off. Cached per string+style. */ -const chunkyCache = new Map(); -function chunkyText( - ctx: CanvasRenderingContext2D, - text: string, - cx: number, - top: number, - scale: number, - fill: string, - outline: string, -): number { - const key = `${text}|${fill}|${outline}`; - let off = chunkyCache.get(key); - if (!off) { - const measure = document.createElement('canvas').getContext('2d')!; - measure.font = 'bold 11px ui-monospace, monospace'; - const tw = Math.ceil(measure.measureText(text).width); - off = document.createElement('canvas'); - off.width = tw + 4; - off.height = 16; - const o = off.getContext('2d')!; - o.font = 'bold 11px ui-monospace, monospace'; - o.textBaseline = 'top'; - o.fillStyle = outline; - for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1], [1, 1], [-1, 1]] as const) o.fillText(text, 2 + dx, 2 + dy); - o.fillStyle = fill; - o.fillText(text, 2, 2); - chunkyCache.set(key, off); +// 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'; } - const w = off.width * scale; - ctx.imageSmoothingEnabled = false; - ctx.drawImage(off, Math.round(cx - w / 2), Math.round(top), w, off.height * scale); - return w; + 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. */ @@ -358,7 +342,7 @@ function keysLine(ctx: CanvasRenderingContext2D, cx: number, y: number): void { ['F', P.b], [' full screen', P.g], ]; - ctx.font = 'bold 12px var(--font-mono, ui-monospace, monospace)'; + 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; @@ -376,7 +360,7 @@ function drawDialog(ctx: CanvasRenderingContext2D, game: Game, frame: number): v const w = 660; const h = dead ? 190 : 232; const x = Math.round((WIDTH - w) / 2); - const y = 34; + const y = 62; const METAL = '#8d95a6'; const METAL_DARK = '#4a5164'; const METAL_LIGHT = '#c3cad9'; @@ -420,7 +404,12 @@ function drawDialog(ctx: CanvasRenderingContext2D, game: Game, frame: number): v // Title with arrow flourishes. const titleY = y + 14; - const tw = chunkyText(ctx, dead ? 'GAME OVER' : 'BLOCK RUNNER', WIDTH / 2, titleY, 3, P.w, '#1a3fb8'); + 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); @@ -430,7 +419,9 @@ function drawDialog(ctx: CanvasRenderingContext2D, game: Game, frame: number): v ctx.textAlign = 'center'; if (dead) { - chunkyText(ctx, `${game.score} BLOCKS EATEN`, WIDTH / 2, y + 74, 2, P.y, '#5c4a00'); + 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. @@ -439,7 +430,7 @@ function drawDialog(ctx: CanvasRenderingContext2D, game: Game, frame: number): v [() => 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 var(--font-mono, ui-monospace, monospace)'; + ctx.font = `12px ${monoFamily()}`; ctx.textAlign = 'left'; lines.forEach(([icon, text], i) => { icon(); @@ -471,7 +462,10 @@ function drawDialog(ctx: CanvasRenderingContext2D, game: Game, frame: number): v 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) { - chunkyText(ctx, dead ? '\u25b6 TAP TO RUN AGAIN' : '\u25b6 TAP TO START', WIDTH / 2, by + 5, 2, P.y, '#5c4a00'); + 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). @@ -515,7 +509,7 @@ function render(ctx: CanvasRenderingContext2D, game: Game, frame: number, feedQu drawSprite(ctx, game.bossMouth > 0 ? BOSS_OPEN : BOSS_CLOSED, BOSS_X, bossY, SCALE, P); if (feedQuiet) { ctx.fillStyle = P.w; - ctx.font = 'bold 12px var(--font-mono, ui-monospace, monospace)'; + ctx.font = `bold 12px ${dotoFamily()}`; ctx.textAlign = 'center'; ctx.fillText('zzz', BOSS_X + 48, bossY - 8); } @@ -588,7 +582,7 @@ function render(ctx: CanvasRenderingContext2D, game: Game, frame: number, feedQu ctx.globalAlpha = 1; // Revealed labels, with a dark plate so they read over anything. - ctx.font = 'bold 13px var(--font-mono, ui-monospace, monospace)'; + ctx.font = `bold 13px ${dotoFamily()}`; ctx.textAlign = 'center'; for (const l of game.labels) { ctx.globalAlpha = Math.max(0, 1 - l.age / 1.2); @@ -610,19 +604,19 @@ function render(ctx: CanvasRenderingContext2D, game: Game, frame: number, feedQu const meterW = 120; const fillFrac = game.stuffed ? fullMeter(game) : game.fullness; ctx.fillStyle = P.railEdge; - ctx.fillRect(16, 16, meterW + 6, 14); + ctx.fillRect(16, 322, meterW + 6, 14); ctx.fillStyle = P.rail; - ctx.fillRect(19, 19, meterW, 8); + ctx.fillRect(19, 325, meterW, 8); ctx.fillStyle = game.stuffed ? P.y : P.b; - ctx.fillRect(19, 19, Math.round(meterW * Math.max(0, Math.min(1, fillFrac))), 8); + ctx.fillRect(19, 325, Math.round(meterW * Math.max(0, Math.min(1, fillFrac))), 8); ctx.fillStyle = P.hud; - ctx.font = 'bold 10px var(--font-mono, ui-monospace, monospace)'; + ctx.font = `bold 10px ${dotoFamily()}`; ctx.textAlign = 'left'; - ctx.fillText(game.stuffed ? 'FULL!' : 'BELLY', 16, 44); + 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, 14, 2, P); + drawSprite(ctx, i < game.hearts ? HEART : HEART_EMPTY, 150 + i * 22, 320, 2, P); } } @@ -823,7 +817,10 @@ export function BlockRunner() { // Input. const doTap = useCallback(() => apply(tap(gameRef.current)), [apply]); - const doRestart = useCallback(() => apply(restart(gameRef.current)), [apply]); + const doRestart = useCallback(() => { + setSubmitState((st) => (st === 'done' || st === 'error' ? '' : st)); + apply(restart(gameRef.current)); + }, [apply]); useEffect(() => { const isTyping = (e: KeyboardEvent) => { @@ -958,19 +955,7 @@ export function BlockRunner() { return (

    -
    -
    - - Latest block - -
    - {head ? head.number.toLocaleString() : '———'} - {slot ? {slot} : null} -
    -
    - - - +
    + {/* 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 ? ( -
    +
diff --git a/app/vibenet/demos/_shared/InclusionBadge.tsx b/app/vibenet/demos/_shared/InclusionBadge.tsx index 6888f694..d9bf28e3 100644 --- a/app/vibenet/demos/_shared/InclusionBadge.tsx +++ b/app/vibenet/demos/_shared/InclusionBadge.tsx @@ -1,23 +1,25 @@ -// "Landed in 412 ms · block 136,522 · .400" — the inclusion line shown wherever -// a demo reports a sent transaction. Base's Denim upgrade produces a block +// "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. Vibenet runs Denim today; on chains without it the -// slot is omitted and only latency + block remain. +// 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 { type Inclusion, latencyLabel, slotLabel } from './inclusion'; +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 Denim's millisecond timestamp, so it never shows on a + * 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 ( - Brought to you by 200 ms blocks on Base. + {blocks ? `Sealed ${blocks} after broadcast. ` : ''}Brought to you by 200 ms blocks on Base. ); } @@ -28,6 +30,13 @@ 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 ( @@ -36,15 +45,17 @@ export function InclusionBadge({ inclusion, className }: { inclusion: Inclusion; 'inclusion-shimmer inline-flex shrink-0 items-center gap-1.5 rounded-full bg-bds-blue-0 px-2.5 py-1 text-[11px] font-normal leading-none tracking-[0px] text-bds-blue-60 dark:text-base-blue', className, )} - title={ - slot - ? `Block time minus send time: ${latencyLabel(inclusion.inclusionMs)}. Included in the ${slot} block of its second (one every 200 ms).` - : `Receipt seen ${latencyLabel(inclusion.inclusionMs)} after broadcast` - } + title={badgeTitle(inclusion, slot)} > - Landed in {latencyLabel(inclusion.inclusionMs)} - {SEP} - block {inclusion.blockNumber.toLocaleString()} + {inclusion.chainMs !== null ? ( + <> + Landed in {latencyLabel(inclusion.chainMs)} + {SEP} + block {inclusion.blockNumber.toLocaleString()} + + ) : ( + Block {inclusion.blockNumber.toLocaleString()} + )} {slot ? ( <> {SEP} diff --git a/app/vibenet/demos/_shared/inclusion.test.ts b/app/vibenet/demos/_shared/inclusion.test.ts index dcbbc192..179b9df3 100644 --- a/app/vibenet/demos/_shared/inclusion.test.ts +++ b/app/vibenet/demos/_shared/inclusion.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { formatInclusion, inclusionFromBlock, latencyLabel, quantityToNumber, slotLabel } from './inclusion'; +import { + blocksAfterSendLabel, + formatInclusion, + inclusionFromChain, + latencyLabel, + quantityToNumber, + slotLabel, +} from './inclusion'; describe('quantityToNumber', () => { it('parses hex quantities', () => { @@ -15,31 +22,76 @@ describe('quantityToNumber', () => { }); }); -describe('inclusionFromBlock', () => { - it('measures latency from the block clock when Denim timestampMs is present', () => { - // Sent at .718, block stamped at the next .000 → 282 ms, regardless of how - // late the receipt was observed (1.4 s here, mostly polling round trips). - const inclusion = inclusionFromBlock( - { number: '0x2140a', timestampMs: '0x1a066162f08' }, - 1_788_419_124_718, - 1_788_419_126_120, - ); - expect(inclusion).toEqual({ blockNumber: 136_202, blockTimestampMs: 1_788_419_125_000, inclusionMs: 282 }); +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('falls back to observed time when the block has no millisecond timestamp (pre-Denim)', () => { - const inclusion = inclusionFromBlock({ number: '0x10' }, 0, 250); - expect(inclusion).toEqual({ blockNumber: 16, blockTimestampMs: null, inclusionMs: 250 }); + 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('returns null without a block number', () => { - expect(inclusionFromBlock(null, 0, 1)).toBeNull(); - expect(inclusionFromBlock({}, 0, 1)).toBeNull(); + 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('never reports negative latency when clocks skew', () => { - expect(inclusionFromBlock({ number: '0x1' }, 500, 400)?.inclusionMs).toBe(0); - expect(inclusionFromBlock({ number: '0x1', timestampMs: '0x64' }, 500, 900)?.inclusionMs).toBe(0); + 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(); }); }); @@ -50,29 +102,46 @@ describe('slotLabel', () => { expect(slotLabel(1_788_419_137_800)).toBe('.800'); }); - it('is absent without Denim metadata', () => { + it('is absent without Cobalt metadata', () => { expect(slotLabel(null)).toBeNull(); }); }); describe('latencyLabel', () => { - it('uses milliseconds under a second and seconds above', () => { - expect(latencyLabel(412)).toBe('412 ms'); - expect(latencyLabel(999.6)).toBe('1000 ms'); + 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, inclusionMs: 412 }), - ).toBe('Landed in 412 ms · block 136,522 · .400'); + 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, inclusionMs: 1_500 })).toBe( - 'Landed in 1.5 s · block 16', + 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 index b7358ddd..4b706f4f 100644 --- a/app/vibenet/demos/_shared/inclusion.ts +++ b/app/vibenet/demos/_shared/inclusion.ts @@ -1,23 +1,33 @@ -// Inclusion timing for a broadcast transaction. Base's Denim upgrade (200 ms -// blocks) adds a millisecond `timestampMs` to every block, so a transaction can -// say which 200 ms slot it landed in, not just which second. Vibenet runs -// Denim today; on chains without it `blockTimestampMs` is null and only the -// wall-clock latency is shown. +// 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 Denim `timestampMs` field, or null pre-Denim. */ + /** Block time in unix milliseconds from the Cobalt `timestampMs` field, or null without it. */ blockTimestampMs: number | null; /** - * Milliseconds from broadcast to inclusion. With Denim metadata this is the - * block's own millisecond timestamp minus the send time — the chain's latency, - * not the client's polling. Without it, it falls back to when the receipt was - * observed. + * 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). */ - inclusionMs: number; + 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; @@ -25,40 +35,62 @@ export function quantityToNumber(value: unknown): number | null { return Number.isSafeInteger(parsed) ? parsed : null; } -/** Build an Inclusion from the raw block a receipt landed in. */ -export function inclusionFromBlock( - block: { number?: unknown; timestampMs?: unknown } | null | undefined, - submittedAt: number, - observedAt: number, +/** 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(block?.number); + const blockNumber = quantityToNumber(receipt?.blockNumber); if (blockNumber === null) return null; - const blockTimestampMs = quantityToNumber(block?.timestampMs); - // Prefer the block's own clock: receipt polling adds round trips that would - // otherwise be charged to the chain. Clamped, since the two clocks can skew. - const landedAt = blockTimestampMs ?? observedAt; - return { - blockNumber, - blockTimestampMs, - inclusionMs: Math.max(0, landedAt - submittedAt), - }; + 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 Denim metadata. */ +/** 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: `412 ms` under a second, `1.8 s` above. */ -export function latencyLabel(inclusionMs: number): string { - if (inclusionMs < 1000) return `${Math.round(inclusionMs)} ms`; - return `${(inclusionMs / 1000).toFixed(1)} s`; +/** 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 412 ms · block 136,522 · .200`. */ +/** 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 = [`Landed in ${latencyLabel(inclusion.inclusionMs)}`, `block ${inclusion.blockNumber.toLocaleString()}`]; + 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 17f0e861..1f0f6fda 100644 --- a/app/vibenet/demos/account/components/ActivityLog.tsx +++ b/app/vibenet/demos/account/components/ActivityLog.tsx @@ -15,10 +15,15 @@ import { ViewTransactionButton } from '../../_shared/ViewTransactionButton'; const TX_HASH_RE = /^0x[0-9a-fA-F]{64}$/; -/** Inclusion timing stored on the entry, when the engine observed it land. */ +/** Inclusion stored on the entry, when the engine observed it land. */ function inclusionOf(e: ActivityEntry): Inclusion | null { - if (typeof e.blockNumber !== 'number' || typeof e.inclusionMs !== 'number') return null; - return { blockNumber: e.blockNumber, blockTimestampMs: e.blockTimestampMs ?? null, inclusionMs: e.inclusionMs }; + 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[] }) { diff --git a/app/vibenet/demos/account/library/model.ts b/app/vibenet/demos/account/library/model.ts index a358bed9..9cdd08a2 100644 --- a/app/vibenet/demos/account/library/model.ts +++ b/app/vibenet/demos/account/library/model.ts @@ -238,12 +238,14 @@ export type ActivityEntry = { serialized?: Hex; txHash?: Hex; account?: Address; - /** Block the tx landed in (Denim: one every 200 ms on vibenet). */ + /** Block the tx landed in (Cobalt: one every 200 ms on vibenet). */ blockNumber?: number; - /** Block time in unix ms from Denim's `timestampMs`; null on chains without it. */ + /** Block time in unix ms from Cobalt's `timestampMs`; null on chains without it. */ blockTimestampMs?: number | null; - /** Wall-clock ms from broadcast to the receipt being observed. */ - inclusionMs?: number; + /** 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 46f7e887..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,8 +65,15 @@ import { import { toast } from 'sonner'; import { vibenetApi } from '../../library/client'; -import { ACCOUNT_RPC_URL } from '../../library/config'; -import { type Inclusion, inclusionFromBlock } from '../_shared/inclusion'; +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 { @@ -97,22 +105,6 @@ import { import { actorPairs, randomHex32, sortActors, toStoredActor } from './library/derive'; import { useAccounts } from './useAccounts'; -// Resolve as soon as eth_getTransactionReceipt returns non-null, polling at the -// chain's 200 ms block cadence. Gives up quietly at `budget` — the caller's -// full waiter takes over from there. -async function pollReceiptExists( - client: { request: (args: { method: 'eth_getTransactionReceipt'; params: [Hex] }) => Promise }, - hash: Hex, - budget: number, -): Promise { - const deadline = Date.now() + budget; - while (Date.now() < deadline) { - const receipt = await client.request({ method: 'eth_getTransactionReceipt', params: [hash] }).catch(() => null); - if (receipt) return; - await new Promise((resolve) => setTimeout(resolve, 100)); - } -} - const fundAccount = (address: Address) => Promise.all([ vibenetApi.faucet.drip({ address }), @@ -819,50 +811,88 @@ function useAccountEngineCore() { ? account.delegate(a.delegate ?? chain.deployment.accounts.default) : (account as ReturnType).create(); - // Inclusion timing per broadcast hash: which block (and which 200 ms slot) - // each transaction landed in, and how long that took from broadcast. Written - // by awaitInclusion, read by pushActivity and by the surfaces' result views. - // A ref, not state — it is looked up right after the await, never rendered - // from directly. + // 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 submittedAt = 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. - // - // Vibenet produces a block every 200 ms (Denim), so the receipt is polled at - // a matching cadence — the default 4 s interval would hide the whole point. const awaitInclusion = async (txHash: Hex, timeout = 30_000): Promise => { const client = makeRpcClient(); - let receipt: Awaited>; + 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 { - // The vendored waiter spends a second round trip per loop on - // eth_getTransactionByHash (expiry detection), which turns a 300 ms - // inclusion into ~1.4 s of spinner. Watch for the receipt directly first; - // the waiter then returns on its first poll, keeping its expiry and - // phase-status semantics for the slow path. - await pollReceiptExists(client, txHash, Math.min(timeout, 3_000)); - receipt = await waitForTransactionReceipt(client as never, { hash: txHash, timeout, pollingInterval: 150 }); - 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; } - const observedAt = Date.now(); - // One extra read for the block's Denim `timestampMs`; the receipt only - // carries the block hash. Timing is decoration, so a failed read is dropped. - const block = (await client - .request({ method: 'eth_getBlockByHash', params: [receipt.blockHash as Hex, false] }) - .catch(() => null)) as { number?: Hex; timestampMs?: Hex } | null; - const inclusion = inclusionFromBlock(block, submittedAt.current.get(txHash) ?? observedAt, observedAt); + // 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); - submittedAt.current.delete(txHash); + sendAnchors.current.delete(txHash); return txHash; }; @@ -870,13 +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'); - const sentAt = Date.now(); + // 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; - submittedAt.current.set(txHash, sentAt); + // 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); }; diff --git a/app/vibenet/demos/validity/lib/singleton.ts b/app/vibenet/demos/validity/lib/singleton.ts index 89b924ee..281c16d2 100644 --- a/app/vibenet/demos/validity/lib/singleton.ts +++ b/app/vibenet/demos/validity/lib/singleton.ts @@ -123,7 +123,7 @@ async function wait(publicClient: PublicClient, hash: Hex): Promise; + export function headNumber(head: StreamHead): bigint | null { try { return BigInt(head.number);