Tweak copy and add links

This commit is contained in:
jwinterm
2026-06-01 11:10:31 -04:00
parent 4b2ae5646c
commit 254e969c36
7 changed files with 165 additions and 167 deletions
+24 -22
View File
@@ -1,55 +1,57 @@
---
title: "Wownerogue: A Provably-Fair Roguelike on Block Time"
date: "2026-06-01"
excerpt: "A browser roguelike where the clock is the blockchain. How the commit-reveal fairness, random block-timing deadline, and Monero payout batching actually work."
excerpt: "A browser roguelike whose round timer is the blockchain. How the commit-reveal fairness, the block-timing deadline, and the Monero payout batching work."
draft: false
---
**Wownerogue** is a multiplayer roguelike that runs in the browser. You drop into a dungeon, and you have until the next block to reach the exit. A monster is hunting you. There is a treasure bag worth grabbing on the way out. That is the whole game, and the catch is in the clock: the deadline is a new block on the Monero or Wownero chain, so it is genuinely random.
**Wownerogue** is a multiplayer roguelike that runs in the browser. You enter a dungeon and have until the next block to reach the exit. One monster pursues you, and an optional treasure bag raises the payout multiplier on escape. The round timer is not a countdown: it is the arrival of the next block on the Monero or Wownero chain.
It runs in two places. [play.wowne.ro](https://play.wowne.ro) is the Wownero instance: free to play, with paid entry feeding a separate leaderboard, and no crypto payouts. [monerogue.app](https://monerogue.app) is the Monero stagenet instance, where escaping pays out stagenet XMR from a house wallet. Stagenet coins are worthless, which makes it a safe place to run the full payment-and-payout loop in the open.
It runs as two instances. [play.wowne.ro](https://play.wowne.ro) runs on Wownero: free to play, with optional paid entry feeding a separate leaderboard, and payouts disabled. [monerogue.app](https://monerogue.app) runs on Monero stagenet, where escaping pays out stagenet XMR from a house wallet. Stagenet coins have no monetary value, which makes that instance a safe environment for exercising the full payment and payout path in public.
<WownerogueLinks />
## The clock is the point
## Block time as the round timer
Most timed games give you a countdown. Wownerogue gives you a block. Monero and Wownero target roughly two-minute block times, but proof-of-work timing is a Poisson process: the next block might land in fifteen seconds or in five minutes, and nothing on the server can predict which. Your run starts when a block arrives, and a later block ends it.
Block intervals are exponentially distributed. Monero targets a 120-second block; Wownero targets 300 seconds. The target is only a mean: the next block can arrive in fifteen seconds or in several multiples of the target, and the server cannot predict the interval. A round begins on one block and ends on a later one, so the deadline is non-deterministic by construction.
I had one early instinct to "fix" this by adding a minimum fairness floor so nobody got a fifteen-second round. That was wrong, and I tore it out. The random deadline is not a bug to smooth over: it is the entire reason the game is interesting. The only timing guard left is a two-second grace at the very start, so a block landing in the same instant your run begins cannot kill you before you have moved.
That non-determinism is the design, not a defect. An early build added a minimum-time floor so that no round could be shorter than a set number of seconds. It defeats the premise and was removed. The only remaining timing guard is a two-second grace at round start, which keeps a block that lands in the same instant as the start from ending the round before the first move is possible.
## Provably fair, the boring honest way
The two chains' different intervals have a balance consequence. A 300-second Wownero deadline is far more forgiving than a 120-second Monero deadline for an identical dungeon. Difficulty (dungeon dimensions, monster aggression, spawn distance) is a per-instance configuration value, so the two windows can and should be tuned independently rather than sharing one preset.
A game that holds money has to prove it is not cooking the board after you pay. Wownerogue uses a standard commit-reveal:
## Commit-reveal fairness
1. Before your run, the server generates a 256-bit seed and shows you `SHA-256(seed)`: the commitment. You can copy it.
2. The seed drives a deterministic RNG that lays out the whole dungeon: walls, the exit, the treasure, and the monster's behaviour.
3. When the run ends, the server reveals the seed.
4. You hash the revealed seed and confirm it equals the commitment you were shown up front.
A game that holds funds has to show the board is fixed before the player commits. Wownerogue uses commit-reveal:
Because the commitment is published before you move, the server cannot reroll a dungeon it does not like once the round is live. Feed the seed into the verification page and it regenerates the exact board you played, then prints a layout fingerprint so you can compare. No trust required, just a hash.
1. Before the round, the server generates a 256-bit seed and publishes `SHA-256(seed)`, the commitment.
2. The seed drives a deterministic RNG that generates the dungeon: walls, exit, treasure, and monster behaviour.
3. At the end of the round, the server reveals the seed.
4. The client hashes the revealed seed and checks it against the commitment shown earlier.
Because the commitment is published before any input, the server cannot substitute a different dungeon once the round is live. The verification endpoint takes a seed, regenerates the dungeon, and emits a layout fingerprint for comparison. Verification depends on a hash function, not on trust in the operator.
## Two instances, one codebase
The split between the two sites is config, not forks. The same server runs both; environment variables decide the currency, the network, whether payouts are on, and the branding. Mainnet Wownero runs free-to-play with payouts switched off. Stagenet Monero runs with payouts on. That meant being careful that a single flag actually gated the money path end to end, and not just part of it: an early version of the payout toggle disabled credits payouts but not direct ones, which is exactly the kind of gap you do not want in code that moves funds.
The two sites are the same server under different configuration, not separate forks. Environment variables select the currency, the network, the payout toggle, and the branding. The Wownero instance runs free-to-play with payouts off; the Monero instance runs with payouts on. The payout toggle has to gate the entire money path: an earlier revision disabled credit-based payouts but not direct ones, a partial gate that is unacceptable in code that moves funds. It now gates both.
## Paying out without tripping over Monero
## Payout batching under Monero output locks
The interesting backend problem is payouts. When a Monero wallet spends, the change output (and the spent output) get locked for about ten blocks. Fire payouts naively and you will try to spend funds that are still locked, and the transfer fails.
The non-trivial backend constraint is payout sequencing. When a Monero wallet spends, the change output and the spent outputs are locked for roughly ten blocks. Issuing payouts without accounting for this attempts to spend locked outputs, and the transfer fails.
So payouts batch, and the batcher checks unlocked balance before it sends. If the wallet does not have enough unlocked balance to cover the batch right now, it does not fail the payouts: it defers them back to pending and retries on the next block tick and the next interval. Insufficient-funds errors are treated as safe to retry, because they happen before anything is broadcast. Anything ambiguous (an error that might mean the transaction already went out) gets parked for manual review instead of being auto-retried, so a flaky RPC response can never cause a double payout.
Payouts are batched, and the batcher checks unlocked balance before sending. If unlocked balance does not cover the batch, the payouts are not failed: they are returned to pending and retried on the next block and the next interval. Insufficient-funds errors are classified as safe to retry because they occur before broadcast. Errors that are ambiguous about whether a transaction was broadcast are routed to manual review instead of auto-retried, so an inconclusive RPC response cannot produce a double payout.
## Keeping the free board honest
## Leaderboard integrity on free play
Free play is the obvious abuse target: spin up sockets, farm the leaderboard. Scores are computed server-side from real game state, so you cannot just post a number. The rate limiter already capped new connections and game starts per IP, but nothing stopped one IP from holding a pile of sockets open, so I added a per-IP concurrent-socket cap. Not bulletproof against a determined botnet, but it raises the cost of the cheap attacks well past worthwhile.
Free play is the obvious target for inflation: open sockets, replay, pad the board. Scores are computed server-side from authoritative game state, so a client cannot submit an arbitrary score. The rate limiter caps new connections and game starts per IP. It did not cap how many sockets one IP could hold open concurrently, so a per-IP concurrent-socket cap was added. That does not stop a distributed botnet, but it removes the cheapest single-host attack.
## Stack
Node.js and Socket.io for real-time game state, chat, and live spectating. PostgreSQL for everything durable, with money handled in integer atomic units and idempotent, transactional writes. Monero and Wownero wallet-RPC for payments and payouts. The client is plain JavaScript with a tile renderer; no framework, because the game is a grid and a render loop.
Node.js and Socket.io for real-time game state, chat, and spectating. PostgreSQL for durable state, with monetary values stored as integer atomic units and written in idempotent transactions. Monero and Wownero wallet-RPC for payments and payouts. The client is plain JavaScript over a tile renderer; the game is a grid and a render loop, so it does not need a framework.
The whole thing is MIT-licensed and public: [github.com/Such-Software/wownerogue](https://github.com/Such-Software/wownerogue). If you want to see exactly how the fairness check, the block-timing loop, or the payout batching work, the answer is in there rather than in this post.
The server and client are MIT-licensed: [github.com/Such-Software/wownerogue](https://github.com/Such-Software/wownerogue).
## Play it
<WownerogueLinks />
Queue up, escape before the block lands, and check your seed afterward. If you escape with the bag on the Monero instance, the stagenet XMR is real (and worthless, which is the point).
Queue, escape before the next block, and verify the seed afterward. On the Monero instance, escaping with the bag pays out stagenet XMR.
+17 -17
View File
@@ -19,19 +19,6 @@ export const metadata: Metadata = {
};
const apps = [
{
name: "Such Moon Launch",
tagline: "Vry rocket. Much landing. Wow!",
icon: "/images/products/such-moon-launch.png",
color: "text-amber-600 dark:text-amber-400",
borderColor: "border-amber-500/20",
href: "/products/such-moon-launch",
links: [
// null = render disabled "Coming Soon" button; flip to real URLs once live.
{ platform: "google" as const, href: null },
{ platform: "apple" as const, href: null },
],
},
{
name: "Vegan IQ",
tagline: "Plant-based trivia backed by real data",
@@ -68,20 +55,33 @@ const apps = [
{ platform: "apple" as const, href: "https://apps.apple.com/us/app/suchoice/id6759626658" },
],
},
{
name: "Such Moon Launch",
tagline: "Vry rocket. Much landing. Wow!",
icon: "/images/products/such-moon-launch.png",
color: "text-amber-600 dark:text-amber-400",
borderColor: "border-amber-500/20",
href: "/products/such-moon-launch",
links: [
// Google Play live; Apple in review (null renders a disabled "Coming Soon").
{ platform: "google" as const, href: "https://play.google.com/store/apps/details?id=com.suchsoftware.suchmoonlaunch" },
{ platform: "apple" as const, href: null },
],
},
];
export default function LinksPage() {
return (
<main className="relative min-h-screen flex flex-col items-center bg-background text-foreground">
<Header />
<div id="main-content" className="z-10 w-full max-w-lg mx-auto py-20 px-4 pb-24 md:pb-20">
<div id="main-content" className="z-10 w-full max-w-3xl mx-auto py-20 px-4 pb-24 md:pb-20">
<h1 className="text-3xl font-bold mb-2 text-center">Such Software</h1>
<p className="text-muted-foreground text-center mb-10">Our apps</p>
<div className="space-y-8">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{apps.map((app, i) => (
<ScrollReveal key={app.name} delay={i * 0.08}>
<div className={`rounded-xl border ${app.borderColor} bg-card p-6`}>
<ScrollReveal key={app.name} delay={(i % 2) * 0.08}>
<div className={`h-full rounded-xl border ${app.borderColor} bg-card p-6`}>
<div className="flex items-center gap-4 mb-3">
<Image
src={app.icon}
+14 -14
View File
@@ -18,20 +18,6 @@ export const metadata: Metadata = {
};
const products = [
{
slug: "wownerogue",
title: "Wownerogue",
description: "A provably-fair multiplayer roguelike synced to Monero and Wownero block times. Play free in the browser, escape the dungeon, earn on-chain payouts.",
color: "orange",
icon: "/images/products/privacy-labs.png",
},
{
slug: "neroswap",
title: "Neroswap",
description: "Aggregated DEX & CEX orderbook data. Compare Monero, Wownero, and Bitcoin prices across 6 exchanges in real time.",
color: "yellow",
icon: "/images/products/neroswap.png",
},
{
slug: "webshops",
title: "Webshops",
@@ -81,6 +67,20 @@ const products = [
color: "amber",
icon: "/images/products/such-moon-launch.png",
},
{
slug: "wownerogue",
title: "Wownerogue",
description: "A provably-fair multiplayer roguelike synced to Monero and Wownero block times. Play free in the browser, escape the dungeon, earn on-chain payouts.",
color: "orange",
icon: "/images/products/privacy-labs.png",
},
{
slug: "neroswap",
title: "Neroswap",
description: "Aggregated DEX & CEX orderbook data. Compare Monero, Wownero, and Bitcoin prices across 6 exchanges in real time.",
color: "yellow",
icon: "/images/products/neroswap.png",
},
];
const colorClasses: Record<string, string> = {
+18 -14
View File
@@ -117,20 +117,24 @@ export default function WowneroguePage() {
<h2 className="text-2xl font-bold mb-6">Two Worlds</h2>
<div className="grid md:grid-cols-2 gap-6 mb-12">
<Card className="glass-card border-orange-500/20">
<h3 className="text-xl font-bold mb-2">play.wowne.ro</h3>
<p className="text-muted-foreground text-sm">
The Wownero mainnet instance: free to play, with paid entry for the Hall of Champions.
No crypto payouts, all the tension. The friendly place to learn the game.
</p>
</Card>
<Card className="glass-card border-orange-500/20">
<h3 className="text-xl font-bold mb-2">monerogue.app</h3>
<p className="text-muted-foreground text-sm">
The Monero stagenet instance, where escaping pays out stagenet XMR from a house wallet.
Stagenet coins have no real value, so it is a safe sandbox for the full payout loop.
</p>
</Card>
<a href="https://play.wowne.ro" target="_blank" rel="noopener noreferrer">
<Card className="glass-card border-orange-500/20 hover:border-orange-500/50 transition-colors cursor-pointer h-full">
<h3 className="text-xl font-bold mb-2 text-orange-600 dark:text-orange-400">play.wowne.ro &rarr;</h3>
<p className="text-muted-foreground text-sm">
The Wownero mainnet instance: free to play, with paid entry for the Hall of Champions.
No crypto payouts, all the tension. The friendly place to learn the game.
</p>
</Card>
</a>
<a href="https://monerogue.app" target="_blank" rel="noopener noreferrer">
<Card className="glass-card border-orange-500/20 hover:border-orange-500/50 transition-colors cursor-pointer h-full">
<h3 className="text-xl font-bold mb-2 text-orange-600 dark:text-orange-400">monerogue.app &rarr;</h3>
<p className="text-muted-foreground text-sm">
The Monero stagenet instance, where escaping pays out stagenet XMR from a house wallet.
Stagenet coins have no real value, so it is a safe sandbox for the full payout loop.
</p>
</Card>
</a>
</div>
<h2 className="text-2xl font-bold mb-6">How a Run Works</h2>
@@ -44,7 +44,7 @@ export const FounderSignature = () => {
He previously co-founded <span className="text-foreground font-semibold">Patrn LLC</span> (art101.io), a series of art-history NFT drops, and <span className="text-foreground font-semibold">Tabby Labs</span>, which was developing a cryptocurrency wallet with an interactive world inside.
</p>
<p className="text-muted-foreground leading-relaxed mt-4">
He most recently taught engineering at <span className="text-foreground font-semibold">Cecil College</span> and senior-level materials engineering (microfabrication, nanomaterials) at <span className="text-foreground font-semibold">Frostburg State University</span>. Active in cryptocurrency since 2014; <a href="https://github.com/jwinterm" target="_blank" rel="noopener noreferrer" className="text-foreground font-semibold underline decoration-primary/50 hover:text-primary transition-colors">GitHub</a> for the receipts.
He most recently taught engineering at <span className="text-foreground font-semibold">Cecil College</span> and senior-level materials engineering (microfabrication, nanomaterials) at <span className="text-foreground font-semibold">Frostburg State University</span>. Active in cryptocurrency since 2014, with a public track record on <a href="https://github.com/jwinterm" target="_blank" rel="noopener noreferrer" className="text-foreground font-semibold underline decoration-primary/50 hover:text-primary transition-colors">GitHub</a>.
</p>
</div>
</div>
@@ -2,103 +2,97 @@ import { Card } from "@repo/ui/components/card";
import Image from "next/image";
import Link from "next/link";
// Literal class strings per color so Tailwind's JIT keeps them (no dynamic interpolation).
const cardBg: Record<string, string> = {
orange: "!bg-orange-50/95 dark:!bg-orange-950/95 !border-orange-500/20",
yellow: "!bg-yellow-50/95 dark:!bg-yellow-950/95 !border-yellow-500/20",
amber: "!bg-amber-50/95 dark:!bg-amber-950/95 !border-amber-500/20",
emerald: "!bg-emerald-50/95 dark:!bg-emerald-950/95 !border-emerald-500/20",
blue: "!bg-blue-50/95 dark:!bg-blue-950/95 !border-blue-500/20",
green: "!bg-green-50/95 dark:!bg-green-950/95 !border-green-500/20",
};
const titleColor: Record<string, string> = {
orange: "text-orange-700 dark:text-orange-400",
yellow: "text-yellow-700 dark:text-yellow-400",
amber: "text-amber-700 dark:text-amber-400",
emerald: "text-emerald-700 dark:text-emerald-400",
blue: "text-blue-700 dark:text-blue-400",
green: "text-green-700 dark:text-green-400",
};
const imgBorder: Record<string, string> = {
orange: "border-orange-500/10",
yellow: "border-yellow-500/10",
amber: "border-amber-500/10",
emerald: "border-emerald-500/10",
blue: "border-blue-500/10",
green: "border-green-500/10",
};
const spanClass: Record<number, string> = {
1: "md:col-span-1",
2: "md:col-span-2",
};
type Item = {
name: string;
tagline: string;
color: keyof typeof titleColor;
span: 1 | 2;
image: string;
alt: string;
href: string;
external?: boolean;
};
// Order matters: spans 2,1,1,2,2,1 tile a 3-column grid with no gaps while alternating the
// wide side row to row for a balanced bento look.
const items: Item[] = [
{ name: "Wownerogue", tagline: "Provably-fair roguelike, synced to crypto block times.", color: "orange", span: 2, image: "/images/products/privacy-labs.png", alt: "Screenshot of the Wownerogue roguelike dungeon-crawler", href: "/products/wownerogue" },
{ name: "Neroswap", tagline: "DEX & CEX orderbook aggregator.", color: "yellow", span: 1, image: "/images/products/neroswap.png", alt: "Screenshot of the Neroswap orderbook aggregator", href: "https://neroswap.com", external: true },
{ name: "Smirk Wallet", tagline: "Non-custodial browser wallet with social tipping.", color: "amber", span: 1, image: "/images/products/smirk-wallet.png", alt: "Smirk Wallet browser extension", href: "https://smirk.cash", external: true },
{ name: "Occupy Wallets", tagline: "Custom e-commerce storefront we build and host for an art brand.", color: "emerald", span: 2, image: "/images/products/occupy-wallets.png", alt: "Occupy Wallets online store and gallery", href: "https://occupywallets.art", external: true },
{ name: "Bauhaus Echo", tagline: "Visual memory puzzle game.", color: "blue", span: 2, image: "/images/products/bauhaus-echo.png", alt: "Bauhaus Echo visual memory puzzle game", href: "/products/bauhaus-echo" },
{ name: "Vegan IQ", tagline: "Plant-based trivia game.", color: "green", span: 1, image: "/images/products/vegan-iq.png", alt: "Vegan IQ plant-based trivia game", href: "/products/vegan-iq" },
];
function BentoCard({ item, delay }: { item: Item; delay: number }) {
const inner = (
<Card className={`h-full glass-card group min-h-[300px] rounded-[2.5rem] ${cardBg[item.color]} cursor-pointer`}>
<div className="absolute inset-x-0 top-0 z-20 p-6 bg-gradient-to-b from-background/95 to-transparent pointer-events-none">
<h3 className={`text-xl font-bold ${titleColor[item.color]}`}>{item.name}</h3>
<p className="text-sm mt-1 text-foreground font-semibold">{item.tagline}</p>
</div>
<div className={`absolute inset-x-4 bottom-4 top-24 rounded-lg overflow-hidden z-10 bg-black/5 dark:bg-white/5 border ${imgBorder[item.color]} shadow-inner`}>
<Image
src={item.image}
alt={item.alt}
fill
className="object-contain p-2 opacity-60 transition-transform duration-1000 group-hover:scale-105"
sizes={item.span === 2 ? "(max-width: 768px) 100vw, 66vw" : "(max-width: 768px) 100vw, 33vw"}
/>
</div>
</Card>
);
const className = `bento-item ${spanClass[item.span]}`;
const style = { animationDelay: `${delay}ms` };
return item.external ? (
<a href={item.href} target="_blank" rel="noopener noreferrer" className={className} style={style}>
{inner}
</a>
) : (
<Link href={item.href} className={className} style={style}>
{inner}
</Link>
);
}
export const PortfolioGrid = () => {
return (
<section className="grid grid-cols-1 md:grid-cols-4 auto-rows-[300px] gap-6 w-full max-w-7xl mx-auto p-4 z-10 relative">
{/* Row 1: Wownerogue (2x1) */}
<Link href="/products/wownerogue" className="bento-item md:col-span-2" style={{ animationDelay: "0ms" }}>
<Card className="h-full glass-card group min-h-[300px] rounded-[2.5rem] !bg-orange-50/95 dark:!bg-orange-950/95 !border-orange-500/20 cursor-pointer">
<div className="absolute inset-x-0 top-0 z-20 p-6 bg-gradient-to-b from-background/95 to-transparent pointer-events-none">
<h3 className="text-xl font-bold text-orange-700 dark:text-orange-400">Wownerogue</h3>
<p className="text-sm mt-1 text-foreground font-semibold">Provably-fair multiplayer roguelike, synced to Monero and Wownero blocks.</p>
</div>
<div className="absolute inset-x-4 bottom-4 top-24 rounded-lg overflow-hidden z-10 bg-black/5 dark:bg-white/5 border border-orange-500/10 shadow-inner">
<Image
src="/images/products/privacy-labs.png"
alt="Screenshot of the Wownerogue and Monerogue roguelike dungeon-crawler game"
fill
className="object-contain p-2 opacity-60 transition-transform duration-1000 group-hover:scale-105"
sizes="(max-width: 768px) 100vw, 50vw"
/>
</div>
</Card>
</Link>
{/* Row 1: Smirk Wallet (2x1) */}
<Link href="https://smirk.cash" target="_blank" rel="noopener noreferrer" className="bento-item md:col-span-2" style={{ animationDelay: "75ms" }}>
<Card className="h-full glass-card group min-h-[300px] rounded-[2.5rem] !bg-amber-50/95 dark:!bg-amber-950/95 !border-amber-500/20 cursor-pointer">
<div className="absolute inset-x-0 top-0 z-20 p-6 bg-gradient-to-b from-background/95 to-transparent pointer-events-none">
<h3 className="text-xl font-bold text-amber-700 dark:text-amber-400">Smirk Wallet</h3>
<p className="text-sm mt-1 text-foreground font-semibold">Non-custodial browser wallet with encrypted social tipping.</p>
</div>
<div className="absolute inset-x-4 bottom-4 top-24 rounded-lg overflow-hidden z-10 bg-black/5 dark:bg-white/5 border border-amber-500/10 shadow-inner flex items-center justify-center">
<Image
src="/images/products/smirk-wallet.png"
alt="Screenshot of Smirk Wallet browser extension showing multi-currency support for BTC, LTC, XMR, WOW, and GRIN"
fill
className="object-contain p-4 opacity-60 transition-transform duration-1000 group-hover:scale-110"
sizes="(max-width: 768px) 100vw, 50vw"
/>
</div>
</Card>
</Link>
{/* Row 2: Neroswap (2x1) */}
<Link href="https://neroswap.com" target="_blank" rel="noopener noreferrer" className="bento-item md:col-span-2" style={{ animationDelay: "150ms" }}>
<Card className="h-full glass-card group min-h-[300px] rounded-[2.5rem] !bg-yellow-50/95 dark:!bg-yellow-950/95 !border-yellow-500/20 cursor-pointer">
<div className="absolute inset-x-0 top-0 z-20 p-6 bg-gradient-to-b from-background/95 to-transparent pointer-events-none">
<h3 className="text-xl font-bold text-yellow-700 dark:text-yellow-400">Neroswap</h3>
<p className="text-sm mt-1 text-foreground font-semibold">DEX &amp; CEX orderbook aggregator.</p>
</div>
<div className="absolute inset-x-4 bottom-4 top-24 rounded-lg overflow-hidden z-10 bg-black/5 dark:bg-white/5 border border-yellow-500/10 shadow-inner">
<Image
src="/images/products/neroswap.png"
alt="Screenshot of Neroswap DEX and CEX orderbook aggregator interface"
fill
className="object-cover opacity-50 transition-transform duration-1000 group-hover:scale-105"
sizes="(max-width: 768px) 100vw, 50vw"
/>
</div>
</Card>
</Link>
{/* Row 2: Bauhaus Echo (1x1) */}
<Link href="/products/bauhaus-echo" className="bento-item md:col-span-1" style={{ animationDelay: "225ms" }}>
<Card className="h-full glass-card group min-h-[300px] rounded-[2.5rem] !bg-blue-50/95 dark:!bg-blue-950/95 !border-blue-500/20 cursor-pointer">
<div className="absolute inset-x-0 top-0 z-20 p-6 bg-gradient-to-b from-background/95 to-transparent pointer-events-none">
<h3 className="text-xl font-bold text-blue-700 dark:text-blue-400">Bauhaus Echo</h3>
<p className="text-sm mt-1 text-foreground font-semibold">Visual memory puzzle game.</p>
</div>
<div className="absolute inset-x-4 bottom-4 top-24 rounded-lg overflow-hidden z-10 bg-black/5 dark:bg-white/5 border border-blue-500/10 shadow-inner">
<Image
src="/images/products/bauhaus-echo.png"
alt="Screenshot of Bauhaus Echo visual memory puzzle game with geometric tiles"
fill
className="object-contain p-2 opacity-50 transition-transform duration-1000 group-hover:scale-110"
sizes="(max-width: 768px) 100vw, 25vw"
/>
</div>
</Card>
</Link>
{/* Row 2: Occupy Wallets (1x1) */}
<Link href="https://occupywallets.art" target="_blank" rel="noopener noreferrer" className="bento-item md:col-span-1" style={{ animationDelay: "300ms" }}>
<Card className="h-full glass-card group min-h-[300px] rounded-[2.5rem] cursor-pointer">
<div className="absolute inset-0 z-20 p-6 flex flex-col justify-center items-center text-center pointer-events-none">
<h3 className="text-2xl font-bold text-zinc-900 dark:text-white drop-shadow-sm bg-white/60 dark:bg-black/60 backdrop-blur-3xl rounded-xl px-6 py-2">Occupy Wallets</h3>
<p className="text-zinc-900 dark:text-zinc-200 font-bold drop-shadow-sm bg-white/60 dark:bg-black/60 backdrop-blur-3xl rounded-xl px-4 py-2 mt-4 text-sm">High-fidelity gallery for fiscal graffiti.</p>
</div>
<div className="absolute inset-4 rounded-xl overflow-hidden z-10 bg-zinc-200/50 dark:bg-zinc-800/50">
<Image
src="/images/products/occupy-wallets.png"
alt="Screenshot of Occupy Wallets gallery displaying illustrated artwork with political and economic commentary"
fill
className="object-contain p-4 opacity-60 transition-transform duration-1000 group-hover:scale-105"
sizes="(max-width: 768px) 100vw, 25vw"
/>
</div>
</Card>
</Link>
<section className="grid grid-cols-1 md:grid-cols-3 auto-rows-[300px] gap-6 w-full max-w-7xl mx-auto p-4 z-10 relative">
{items.map((item, i) => (
<BentoCard key={item.name} item={item} delay={i * 75} />
))}
</section>
);
};
+2 -4
View File
@@ -72,13 +72,11 @@ export function SuchoiceStoreButtons() {
}
export function SuchMoonLaunchStoreButtons() {
// Both stores submitted 2026-05-18; pending review. Flip null → real URLs
// once the app is live (Apple assigns the appstore.com /id<NUM> at first
// approval; Google URL is predictable as id=com.suchsoftware.suchmoonlaunch).
// Google Play is live; Apple still in review (flip null → real URL once approved).
return (
<StoreButtons
links={[
{ platform: "google", href: null },
{ platform: "google", href: "https://play.google.com/store/apps/details?id=com.suchsoftware.suchmoonlaunch" },
{ platform: "apple", href: null },
]}
extraLinks={[