A decentralized 3D block-explorer for the SmartHoldem blockchain. Every wallet becomes a living, procedural planet inside a WebGL universe.
Live demo · Templates · Russian version
| Layer | Technology |
|---|---|
| UI framework | Vue 3.5 (Composition API + <script setup lang="ts">) |
| Bundler | Vite 8 |
| Language | TypeScript |
| Styles | TailwindCSS 3.4 + JetBrains Mono / Unbounded / Outfit |
| State | Pinia |
| Routing | vue-router 4 (history mode) |
| 3D engine | TresJS (@tresjs/core 5 + @tresjs/cientos) on top of Three.js 0.184 |
| Procedural noise | fastnoise-lite (OpenSimplex2, FBm) |
| Network graph | 3d-force-graph |
| Binary buffers | b4a |
| QR codes | qrcode |
| HTTP | axios |
| Icons | @lucide/vue |
| PWA | manifest + service worker (network-first navigation, cache-first static) |
There is no backend in this project. All data is read directly from public SmartHoldem nodes.
/smartholdem-cosmic-sandbox/
├── public/
│ ├── manifest.webmanifest # PWA manifest
│ ├── sw.js # service worker
│ ├── og-default.jpg # 1200x630 default OG card
│ └── icons/ # 192/512/maskable + apple-touch
├── src/
│ ├── App.vue # router resolver + global HUD mount
│ ├── main.ts # createApp + SW registration
│ ├── style.css # Tailwind + design tokens
│ ├── router/index.ts # 4 routes + catch-all > /
│ ├── pages/
│ │ ├── SandboxPage.vue # planet view + photo mode + energon
│ │ └── GalaxyPage.vue # 3d-force-graph network map
│ ├── core/ # WebGL/THREE logic (markRaw'd from Vue)
│ │ ├── dna.ts # SHA-256(address) > PlanetDNA
│ │ ├── biomes.ts # 5 biome palettes
│ │ ├── planetBuilder.ts # IcosahedronGeometry + FBm + assets
│ │ ├── boar.ts # Veipr fauna (1–3 boars)
│ │ ├── flyers.ts # birds / drones / bats
│ │ ├── energon.ts # feeding + raycast deploy
│ │ ├── ships.ts # Rocket / UFO / Asteroid
│ │ ├── spaceBackdrop.ts # nebula + starfield
│ │ └── audio.ts # Web Audio (ambient / ping / boom)
│ ├── stores/cosmos.ts # Pinia: focus, dna, stats, polling
│ ├── services/
│ │ ├── sth.ts # SmartHoldem nodes (failover, /api/wallets)
│ │ └── wallet.ts # Prime Wallet (window.smartholdem)
│ ├── composables/
│ │ ├── useShare.ts # native share / clipboard + capture
│ │ └── usePwaInstall.ts # beforeinstallprompt interceptor
│ ├── components/hud/ # every HTML overlay (TopBar, MobileDock, …)
│ └── utils/
│ ├── capture.ts # photo mode + 1080x1920 stories poster
│ └── format.ts # K/M balance formatter
├── scripts/
│ └── fork-chain.mjs # CLI installer for multi-chain adapters
└── index.html # PWA + OG meta + crawler injection hook
/smartholdem-cosmic-sandbox/templates/services/ # drop-in blockchain adapter templates
├── eth.ts.template # any EVM chain
├── sol.ts.template # Solana
├── tron.ts.template # TRON
└── README.md # adapter contract documentation
cd smartholdem-cosmic-sandbox
yarn install
yarn start # vite --port 3000 --host 0.0.0.0yarn build
# dist/ - static bundle, drop behind any Nginx / CDN| Variable | Description | Default |
|---|---|---|
VITE_STH_NODES |
CSV list of SmartHoldem nodes (failover) | https://node1.smartholdem.io,https://node2.smartholdem.io |
File: //.env. No secrets - only public URLs.
[program:cosmos]
command=yarn start
directory=/
autostart=true
autorestart=trueHot reload works out of the box. A restart is only required when .env changes or new dependencies are installed: sudo supervisorctl restart cosmos.
PlanetDNA is a deterministic object derived from an address via SHA-256:
import b4a from 'b4a'
export async function deriveDNA(seed: string): Promise<PlanetDNA> {
const data = b4a.from(seed, 'utf-8')
const hash = await crypto.subtle.digest('SHA-256', data)
const bytes = new Uint8Array(hash)
return {
biome: bytes[0] % 5, // 0..4
noiseSeed: (bytes[1] << 8) | bytes[2], // uint16
hue: bytes[3] / 255, // 0..1
oceanLevel: 0.3 + (bytes[4] / 255) * 0.25,
amplitude: 0.1 + (bytes[5] / 255) * 0.14,
frequency: 1.1 + (bytes[6] / 255) * 1.7,
ringTilt: ((bytes[7] / 255) * 0.6 - 0.3) + 0.4,
cloudiness: bytes[8] / 255,
bytes,
}
}One address = one planet on every device. Reproducible for sharing.
function makeNoise(dna: PlanetDNA) {
const noise = new FastNoiseLite(dna.noiseSeed)
noise.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2)
noise.SetFractalType(FastNoiseLite.FractalType.FBm)
noise.SetFractalOctaves(5)
noise.SetFractalLacunarity(2.1)
noise.SetFractalGain(0.52)
const f = dna.frequency
return (x: number, y: number, z: number) => noise.GetNoise(x * f, y * f, z * f)
}Geometry: IcosahedronGeometry(1, detail).toNonIndexed() - non-indexed is mandatory so adjacent triangles get their own vertex colours (facet-style low-poly look). LOD slider 4..7 = subdivision 20..64.
State: focusSeed, dna, stats, transactions, neighbors, liveTxs, photoMode, evolving, and more.
Main actions:
init()- restore a Prime Wallet session or bootstrap the guest genesis.randomizeGuest()- randomWILD-XXXXseed with mocked stats.setAddress(address)- fetch wallet + tx + DNA, start polling.connectWallet()-window.smartholdem.getAccount()(single approval).startPolling() / pollOnce()- every 10 s, delta byknownTxIds.
THREE instances must never be reactive (Vue's proxy breaks the scene graph). Everything goes through markRaw():
const planetCache = new Map<string, BuiltPlanet>()
export function getOrBuildPlanet(dna, stats, opts): BuiltPlanet {
const key = `${dna.seedHex}|${Math.round(stats.balanceSTH)}|${stats.txCount}|${opts.detail ?? 42}`
let built = planetCache.get(key)
if (!built) {
built = markRaw(buildPlanet(dna, stats, opts))
planetCache.set(key, built)
if (planetCache.size > 40) planetCache.delete(planetCache.keys().next().value)
}
return built
}LRU capped at 40 planets - a balance between memory and back-navigation speed.
Every call is a direct fetch from the browser (CORS is open on the nodes):
| Endpoint | Description |
|---|---|
GET /api/wallets/{address} |
balance, nonce, publicKey |
GET /api/wallets/{address}/transactions?page=1&limit=N&orderBy=timestamp:desc |
history |
GET /api/wallets/{address}/transactions?orderBy=timestamp:asc&limit=1 |
first transaction (hold-time) |
fetchWallet, fetchTransactions, fetchFirstTxTimestamp all fail over between nodes via nodeGet<T>(path). Timeout: 12 s.
export function isValidSthAddress(addr: string): boolean {
return /^S[1-9A-HJ-NP-Za-km-z]{33}$/.test(addr.trim())
}Length 34, prefix S, Base58 without ambiguous characters (0, O, I, l).
routes: [
{ path: '/', name: 'sandbox', component: SandboxPage },
{ path: '/planet/:address', name: 'planet', component: SandboxPage },
{ path: '/galaxy', name: 'galaxy', component: GalaxyPage },
{ path: '/:pathMatch(.*)*', redirect: '/' }, // catch-all
]Resolver in App.vue (resolvePlanetRoute):
- Valid
:address>store.setAddress()immediately on mount (no guest flash). - Invalid address - fall back to the Prime Wallet session > genesis init,
router.replace('/'), notice. - The TresJS loop is guarded by
!store.dna || store.loadinginSandboxPage.vue- it never crashes onnull.
public/manifest.webmanifest - display: fullscreen with a standalone fallback, icons 192 / 512 / 512-maskable + apple-touch.
public/sw.js - network-first for navigations (with an offline shell), cache-first for static assets. Registered in main.ts:
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {})
})
}The composables/usePwaInstall.ts composable catches beforeinstallprompt, hides the Chrome mini-infobar and exposes a button with data-testid="pwa-install-btn" (mobile) / pwa-install-desktop-btn (desktop). On accepted it shows a success toast and removes the button.
index.html ships a full Open Graph + Twitter Card set. The default og:image is /og-default.jpg (1200×630, neon planet + logo).
For /planet/:address an external edge worker is expected (outside the frontend) that:
- Intercepts requests by User-Agent:
TelegramBot,Twitterbot,facebookexternalhit,Discordbot,Slackbot,Pinterestbot, etc. - Validates the address with
^S[A-HJ-NP-Za-km-z1-9]{33}$. - Fans out to
https://node2.smartholdem.io/api/wallets/{address}with a 1.5 s timeout. - String-replaces the markers
OG:TITLE,OG:DESCRIPTION,OG:IMAGE,OG:URL(see inline docs inindex.html). - Caches the result for 5 min per address.
Without the server hook, crawlers see the default OG tags - that is still valid for the home page and /galaxy.
utils/capture.ts exports:
| Function | Format | Purpose |
|---|---|---|
captureWithWatermark() |
Canvas aspect ratio, JPEG | Classic share image with a corner QR |
watermarkedBlob() |
Same, Blob | Web Share API on mobile |
captureStoriesPoster() |
1080×1920 JPEG | Instagram / Telegram / Twitter Stories |
storiesPosterBlob() |
Same, Blob | Native share with a vertical poster |
Every temporary canvas is released via width = height = 0 after toDataURL / toBlob.
Title and seed strings go through c.measureText() and the font shrinks down to 55 % of the base until the text fits to the left of the QR. On portrait canvases the · WILD WORLD suffix is dropped entirely.
The extension injects window.smartholdem. The wrapper in services/wallet.ts waits for the provider (smartholdem#initialized event) with a ~600 ms timeout. A single getAccount() call > one Prime Wallet modal > signed challenge > session stored in localStorage.
Important: never auto-call getAccount() on mount - otherwise the extension opens the overlay on every load and the UX becomes spammy.
If the extension is missing the app happily runs in Guest Mode on a random seed.
- InstancedMesh for every asset (trees / crystals / spikes / mesas / towers / asteroids in the ring).
- LRU cache for planets (40 slots).
- LOD slider 4..7 (icosahedron subdivision 20..64).
- DPR clamp:
<TresCanvas :dpr="[1, 2]" />. - preserve-drawing-buffer enabled only for Photo Mode (otherwise a canvas-captured buffer would be empty).
- shallowRef for THREE objects in Vue (no deep reactivity).
- Poll throttle: 10 s, delta by
knownTxIds, early break whenfocusSeed !== address.
data-testid attributes are present on every interactive element:
app-root, planet-sandbox-page, search-address-input, search-submit-btn,
connect-wallet-btn, connected-wallet-btn, disconnect-wallet-btn,
pwa-install-btn, pwa-install-desktop-btn, pwa-install-toast,
share-btn, share-toast, photo-mode-btn, capture-photo-btn,
capture-stories-btn, exit-photo-btn, audio-toggle-btn,
replay-evolution-btn, regenerate-planet-btn, energon-btn,
dock-planet-btn, dock-galaxy-btn, dock-photo-btn, dock-share-btn,
dock-audio-btn, mobile-info-fab, mobile-dock, ...
E2E: Playwright. No backend tests (there is no backend).
Test address: SSU6TvycebBMTvc8WHiKTfG9xmX1QSniZu.
Production deploys are done manually on the server, no CI/CD automation:
cd smartholdem-cosmic-sandbox
yarn build # > dist/
rsync -avz dist/ user@server:/var/www/sandbox/Nginx must:
- Serve
dist/statically. - Fall back all unknown routes to
/index.html(SPA). - Serve
sw.js,manifest.webmanifest,icons/*,og-default.jpgwith the correctContent-Type. - (optional) Run the crawler-injection edge worker described in §8.
The palette is a cyberpunk dashboard over low-poly 3D dioramas:
--space-base: #0B0F19
--space-panel: #10182a
--neon-cyan: #00F0FF
--neon-green: #00FF66
--neon-gold: #F2A900
--steel-light: #b3c5d6
--steel-muted: #6c7a87
Fonts: Unbounded (display), Outfit (body), JetBrains Mono (HUD / seed / coords).
P1: Pet Lair feeding, camera transition animations between worlds P2: smart-contract integrations, dynamic OG renderer endpoint, iOS A2HS guide
The Cosmic Sandbox architecture isolates everything chain-specific in one file - src/services/sth.ts. All the other code (DNA, terrain, biomes, boars, photo mode, PWA) is pure WebGL/Vue and has no idea which address it has been handed.
Ready-to-use adapters for the three biggest ecosystems ship in the repo under
/templates/services/{eth,sol,tron}.ts.template. A single command turns
Cosmic Sandbox into a visualiser for the network of your choice:
cd smartholdem-cosmic-sandbox
yarn fork:chain ethereum # or: polygon / arbitrum / base / bsc / optimism
yarn fork:chain solana
yarn fork:chain tronThe scripts/fork-chain.mjs script:
- backs up the current
src/services/sth.ts>sth.ts.bak; - drops the matching template in its place (fully interface-compatible);
- appends the required
VITE_*variables tosmartholdem-cosmic-sandbox/.env.local(idempotently); - prints a demo address for a quick smoke test (e.g.
vitalik.ethfor EVM).
After that just fill in a real RPC key and run yarn dev - planets, ships, photo mode and Stories export keep working without a single change to the 3D code.
Templates live in /templates/services/ (outside smartholdem-cosmic-sandbox/src so tsc does not parse them):
| File | Chain | Native unit | Address format | RPC / API |
|---|---|---|---|---|
eth.ts.template |
Any EVM (ETH, Polygon, Arbitrum, Base, BSC, Optimism) | wei (1e18) | 0x + 40 hex |
JSON-RPC eth_getBalance / eth_getTransactionCount + Etherscan-compatible txlist |
sol.ts.template |
Solana | lamport (1e9) | Base58, 32–44 chars | Pure JSON-RPC (getBalance, getSignaturesForAddress, getTransaction) - no SDK |
tron.ts.template |
TRON | SUN (1e6) | Base58Check, T… 34 chars |
TronGrid v1 REST (/v1/accounts/{addr}, /transactions) |
Every template:
- Strictly matches the
sth.tscontract - same export names, same types, same error-handling style (returnnull/ empty array instead of throwing). The DNA engine reads a template one-to-one. - Has no heavy dependencies - only
axios(already in the project). Noethers/@solana/web3.js/tronweb; everything is raw JSON-RPC / REST so the bundle stays small. - Documents itself in the file header: which
VITE_*variables to read, where to get an RPC key, what the native unit is, links to free public endpoints. - Keeps the
balanceSTH/amountSTH/SMARTOSHInames for interop with the rest of the codebase - these values are interpreted as “native units” (ETH, SOL, TRX) without renaming anything downstream.
smartholdem-cosmic-sandbox/scripts/fork-chain.mjs is a thin CLI installer:
- accepts aliases (
polygon/arbitrum/base/bsc/optimism>ethereum,sol>solana,trx>tron); - takes a one-time backup of
sth.ts>sth.ts.bak(subsequent runs never clobber the original); - appends a
VITE_*block to.env.localwith the marker# cosmic-sandbox:<chain>- a second run will not duplicate it (idempotent); - prints a demo address for a smoke test (
vitalik.eth, a well-known Solana whale, a well-known TRX contract).
Want a new chain (Cosmos, Aptos, Sui, TON, Bitcoin)? Copy any of the three templates, rename the file, swap out the RPC logic and the isValidSthAddress() regex, then add an entry to the CHAINS object inside fork-chain.mjs. Nothing else in the project needs to change.
For an exotic chain (Cosmos, Aptos, Sui, TON…) without going through a template:
- Replace
services/sth.tswithservices/<chain>.tsimplementing the same contract. - Tune
isValidSthAddress()to match the target chain's address format. - (optional) Rebalance
RING_THRESHOLD,MOON_THRESHOLD,FLYER_THRESHOLDfor the new network's economics. - Replace the wallet wrapper in
services/wallet.ts(Prime Wallet > MetaMask / Phantom).
Any adapter must export:
export interface WalletInfo { address: string; balanceSTH: number; nonce: number }
export interface TxInfo { id: string; sender: string; recipient: string; amountSTH: number; timestamp: number }
export function isValidSthAddress(addr: string): boolean
export async function fetchWallet(address: string): Promise<WalletInfo | null>
export async function fetchTransactions(address: string, limit?: number): Promise<{ txs: TxInfo[]; totalCount: number }>
export async function fetchFirstTxTimestamp(address: string): Promise<number>
export function extractCounterparties(txs: TxInfo[], focus: string, max?: number): string[]Keep the balanceSTH field name as is for compatibility with the rest of the code (it is just a number in native units - ETH, SOL, and so on). The same goes for amountSTH.
yarn add etherssrc/services/eth.ts:
import { JsonRpcProvider, formatEther, isAddress } from 'ethers'
const RPCS: string[] = (import.meta.env.VITE_ETH_RPCS || '')
.split(',').map(s => s.trim()).filter(Boolean)
const provider = new JsonRpcProvider(RPCS[0]) // (failover via FallbackProvider)
export interface WalletInfo { address: string; balanceSTH: number; nonce: number }
export interface TxInfo { id: string; sender: string; recipient: string; amountSTH: number; timestamp: number }
export function isValidSthAddress(addr: string): boolean {
return isAddress(addr.trim()) // EIP-55 checksum-tolerant
}
export async function fetchWallet(address: string): Promise<WalletInfo | null> {
try {
const [balance, nonce] = await Promise.all([
provider.getBalance(address),
provider.getTransactionCount(address),
])
return {
address,
balanceSTH: Number(formatEther(balance)),
nonce,
}
} catch { return null }
}
// Ethereum RPC does not expose per-address history without an indexer.
// Use the Etherscan API, Alchemy `alchemy_getAssetTransfers` or Covalent.
export async function fetchTransactions(
address: string,
limit = 30,
): Promise<{ txs: TxInfo[]; totalCount: number }> {
const key = import.meta.env.VITE_ETHERSCAN_KEY
const url = `https://api.etherscan.io/api?module=account&action=txlist&address=${address}` +
`&startblock=0&endblock=99999999&page=1&offset=${limit}&sort=desc&apikey=${key}`
try {
const res = await fetch(url).then(r => r.json())
const txs: TxInfo[] = (res.result || []).map((t: any) => ({
id: t.hash,
sender: t.from,
recipient: t.to,
amountSTH: Number(t.value) / 1e18,
timestamp: Number(t.timeStamp),
}))
return { txs, totalCount: txs.length }
} catch {
return { txs: [], totalCount: 0 }
}
}
export async function fetchFirstTxTimestamp(address: string): Promise<number> {
const key = import.meta.env.VITE_ETHERSCAN_KEY
const url = `https://api.etherscan.io/api?module=account&action=txlist&address=${address}` +
`&page=1&offset=1&sort=asc&apikey=${key}`
try {
const res = await fetch(url).then(r => r.json())
return Number(res.result?.[0]?.timeStamp || 0)
} catch { return 0 }
}
export function extractCounterparties(txs: TxInfo[], focus: string, max = 10): string[] {
const set = new Set<string>()
const f = focus.toLowerCase()
for (const tx of txs) {
const other = (tx.sender.toLowerCase() === f ? tx.recipient : tx.sender).toLowerCase()
if (other && other !== f) set.add(other)
if (set.size >= max) break
}
return [...set]
}
export const SMARTOSHI = 1e18 // wei > ETHKey differences from SmartHoldem:
- EVM address -
0x+ 40 hex characters; useisAddress()from ethers (it accepts both checksummed and lowercase). - wei > ETH - the divisor is
1e18, not1e8. - No native per-address history - you need an Etherscan / Alchemy / Covalent key. This changes UX: add rate-limit handling.
- Wallet wrapper: replace
awaitPrime()with the standardwindow.ethereum.request({ method: 'eth_requestAccounts' }).
.env additions:
VITE_ETH_RPCS=https://eth.llamarpc.com,https://rpc.ankr.com/eth
VITE_ETHERSCAN_KEY=your_key_here
yarn add @solana/web3.jssrc/services/sol.ts:
import { Connection, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js'
const RPCS: string[] = (import.meta.env.VITE_SOL_RPCS || '')
.split(',').map(s => s.trim()).filter(Boolean)
const conn = new Connection(RPCS[0], 'confirmed')
export interface WalletInfo { address: string; balanceSTH: number; nonce: number }
export interface TxInfo { id: string; sender: string; recipient: string; amountSTH: number; timestamp: number }
export function isValidSthAddress(addr: string): boolean {
try {
const pk = new PublicKey(addr.trim())
return PublicKey.isOnCurve(pk.toBytes()) // rejects PDAs, keeps real wallets
} catch { return false }
}
export async function fetchWallet(address: string): Promise<WalletInfo | null> {
try {
const pk = new PublicKey(address)
const lamports = await conn.getBalance(pk)
return {
address,
balanceSTH: lamports / LAMPORTS_PER_SOL,
nonce: 0, // Solana has no EVM-style nonce counter
}
} catch { return null }
}
export async function fetchTransactions(
address: string,
limit = 30,
): Promise<{ txs: TxInfo[]; totalCount: number }> {
try {
const pk = new PublicKey(address)
const sigs = await conn.getSignaturesForAddress(pk, { limit })
// Fetch full tx and parse SystemProgram transfer instructions.
const txs: TxInfo[] = []
for (const s of sigs) {
const tx = await conn.getParsedTransaction(s.signature, { maxSupportedTransactionVersion: 0 })
if (!tx) continue
for (const ix of tx.transaction.message.instructions as any[]) {
if (ix.program === 'system' && ix.parsed?.type === 'transfer') {
txs.push({
id: s.signature,
sender: ix.parsed.info.source,
recipient: ix.parsed.info.destination,
amountSTH: Number(ix.parsed.info.lamports) / LAMPORTS_PER_SOL,
timestamp: s.blockTime ?? 0,
})
break
}
}
}
return { txs, totalCount: sigs.length }
} catch {
return { txs: [], totalCount: 0 }
}
}
export async function fetchFirstTxTimestamp(address: string): Promise<number> {
try {
const pk = new PublicKey(address)
// Paginate to the earliest signature (`before` cursor).
let before: string | undefined
let earliest = 0
for (let i = 0; i < 10; i++) {
const sigs = await conn.getSignaturesForAddress(pk, { limit: 1000, before })
if (!sigs.length) break
const last = sigs[sigs.length - 1]
earliest = last.blockTime ?? earliest
if (sigs.length < 1000) break
before = last.signature
}
return earliest
} catch { return 0 }
}
export function extractCounterparties(txs: TxInfo[], focus: string, max = 10): string[] {
const set = new Set<string>()
for (const tx of txs) {
const other = tx.sender === focus ? tx.recipient : tx.sender
if (other && other !== focus) set.add(other)
if (set.size >= max) break
}
return [...set]
}
export const SMARTOSHI = LAMPORTS_PER_SOL // 1e9Key differences from SmartHoldem:
- Base58 address 32–44 chars - validated through
new PublicKey(). TheisOnCurve()filter cuts off PDA accounts (program-derived addresses) that have no private key and should not be visualised as “wallets”. - lamports > SOL - the divisor is
LAMPORTS_PER_SOL(1e9). - No
nonce- Solana uses a different transaction model; set it to0or map it toslot. - Transaction history -
getSignaturesForAddress()+getParsedTransaction(). That is more RPC-expensive; batches or a Helius / QuickNode indexer will help. - Wallet wrapper: swap
awaitPrime()forwindow.phantom?.solanaor the Solana Wallet Adapter (@solana/wallet-adapter-react- a Vue fork exists).
.env:
VITE_SOL_RPCS=https://api.mainnet-beta.solana.com,https://solana-mainnet.g.alchemy.com/v2/YOUR_KEY
| Module | Changes |
|---|---|
core/dna.ts |
0 - SHA-256 works for any address string |
core/biomes.ts |
0 - biomes can be renamed to match the network's lore |
core/planetBuilder.ts |
0 |
core/boar.ts / flyers.ts / energon.ts |
0 |
core/ships.ts |
possibly retune the Rocket/UFO/Asteroid thresholds |
utils/capture.ts |
change the “SMARTHOLDEM · COSMIC SANDBOX” brand string |
composables/usePwaInstall.ts |
0 |
| Routing | optionally change the /planet/:address regex constraint |
A full port to Ethereum or Solana takes one working day for a single developer. That is the main promise of this architecture: the 3D visualisation is not tied to any particular chain.
This is a reference implementation for the open SmartHoldem blockchain. Pull requests are welcome. Bug reports via issues; for UX proposals please attach a screenshot or a screen recording.
- SmartHoldem Cosmic Sandbox · 2026
- Powered by TechnoL0g
If this project helped you, feel free to buy me a coffee:
- Bitcoin (BTC):
bc1qcvyh4884hq0g0lfttmxtpk5l4ep459wkclwnkp - Litecoin (LTC):
M8GJBdUDNivr9eodU7iCX7rk2Zi392RUCb - SmartHoldem (STH):
SeZLuyhhYf2qxs4ArPJ71oEu3x8EsVw51C


