A fully on-chain multiplayer explore & escape game. Land on an uncharted planet, navigate a hex grid shrouded in fog, recover ancient relics, and make it back alive. Every action, dice roll, and card draw lives on-chain -- no hidden server state, no trust required.
Built on the Lucky Machines Game Core framework with Chainlink VRF for provably fair randomness.
Project tracking, report cards, rename notes, and ops notes live in the private coordinator repo: https://github.com/LuckyMachines/xenovoya-coordinator
- 1st place winner of Polygon Gaming prize @ Chainlink Spring 22 Hackathon
- Recipient of Chainlink Top Quality prize @ Chainlink Spring 22 Hackathon
- Fully on-chain -- all game state, randomness, and resolution happen in smart contracts. No backend server, no hidden information asymmetry.
- Cooperative tension -- players share a board but compete for relics. Help each other survive, or race ahead and leave them behind.
- Emergent strategy -- fog of war, random events, and inventory management mean no two games play the same way.
- Verifiable fairness -- supports Chainlink VRF for provably random dice rolls, card draws, and event triggers. Mock VRF available for testing and low-cost deployments.
1-4 players land on an unexplored planet represented by a 10x10 hexagonal grid. The goal: explore the map, collect relics, and escape back to the landing site before your stats run out.
- Land -- all players start at the landing site. The rest of the grid is hidden under fog of war.
- Explore -- on your turn, choose an action: Move to a new hex, Dig for artifacts, Camp to set up a safe point, Rest to recover stats, Help another player, or Flee back to the landing site.
- Discover -- moving into unexplored hexes reveals terrain (Jungle, Plains, Desert, Mountain) and triggers card draws: events, ambushes, treasures, and relics.
- Survive -- your character has three stats: Movement, Agility, and Dexterity. Events and combat drain them. If any stat hits zero, you're out.
- Escape -- return to the landing site with relics in your inventory to win. But don't wait too long -- the planet gets more dangerous at night.
- Day/Night cycle -- day phases let players move and explore freely. Night phases trigger random events, enemies, and disasters.
- Card decks -- 5 on-chain decks (Event, Ambush, Treasure, Land, Relic) are shuffled and drawn during gameplay. What you draw changes everything.
- Items & inventory -- find shields, campsites, and tools. Equip items in your left/right hand slots. Manage limited inventory space.
- Combat -- enemies appear during exploration and at night. Agility determines your odds. Failing costs stats.
- Relics -- scattered across the map. Dig to find them. Collect enough and escape to win.
| Players | 1-4 per game |
| Board | 10x10 hex grid with fog of war |
| Terrain | Jungle, Plains, Desert, Mountain, Landing, Relic |
| Actions | Move, Dig, Camp, Rest, Help, Flee |
| Stats | Movement, Agility, Dexterity |
| Randomness | AutoLoop VRF (recommended), Mock VRF, or Chainlink VRF v2 |
| Contracts | 25 Solidity contracts |
25 Solidity contracts organized by role:
| Contract | Purpose |
|---|---|
| XenovoyaController | Player action submission (move, explore, rest, help) |
| XenovoyaGameplay | Main gameplay logic and turn processing |
| XenovoyaGameplayUpdates | Gameplay state mutation helpers |
| XenovoyaStateUpdate | Phase transitions and state advancement |
| XenovoyaQueue | Pending game update queue for automation |
| Contract | Purpose |
|---|---|
| XenovoyaBoard | 10x10 hex grid extending Game Core's HexGrid |
| XenovoyaZone | Zone logic for hex tiles |
| XenovoyaRules | Ruleset defining movement, capacity, and game parameters |
| Contract | Purpose |
|---|---|
| CharacterCard | Player character stats (movement, agility, dexterity) |
| TokenInventory | Per-player token/item tracking |
| GameToken | ERC-1155 token contract for game state tokens |
| CardDeck | On-chain shuffled card deck with draw mechanics |
DayNight, Disaster, Enemy, Item, PlayerStatus, Relic -- each tracks a different aspect of game state per zone or player.
Event, Ambush, Treasure, Land, Relic -- each deck is populated from scripts/onchain-data.json and drawn during gameplay.
| Contract | Purpose |
|---|---|
| RollDraw | Dice rolling and card drawing logic |
| RelicManagement | Relic placement and collection |
| RandomnessConsumer | Chainlink VRF / mock VRF randomness |
| VRFVerifier | ECVRF proof verification library |
| AutoLoopVRFCompatible | Abstract base for VRF-enabled AutoLoop contracts |
| RandomIndices | Random index generation for shuffling |
| StateUpdateHelpers | Shared state update utilities |
| StringToUint | String-to-number conversion for coordinate parsing |
| Utilities | General-purpose utility functions |
| GameWallets | Player wallet management for gas subsidization |
| Contract | Purpose |
|---|---|
| GameSummary | High-level game state queries |
| PlayerSummary | Per-player state queries |
| PlayZoneSummary | Per-zone state queries |
| GameEvents | Centralized event emission |
| GameSetup | Game initialization and configuration queries |
Xenovoya requires two things to keep running: automation (advancing game phases) and randomness (card draws, dice rolls, events). Both are pluggable. Three randomness modes are supported:
Use the same-engine simulator to tune balance and outcomes against the local Solidity contracts:
npm run local:solo
npm run sim:balanced
npm run sim:golden
npm run sim:baseline
npm run sim:compare -- --changed="movement tuning"
npm run sim:autotune:dry
npm run sim:autotune
npm run scenario:create -- "solo artifact hunt with high stat pressure"
npm run scenario:run -- --id=solo-artifact-hunt
npm run setup:explain -- --id=solo-artifact-hunt
npm run autopilot:dry -- "solo artifact hunting should feel risky but rewarding"
npm run oracle:latest
npm run oracle:scenario -- --id=solo-artifact-hunt
npm run memory:build
npm run memory:query -- "what do we know about artifact hunting?"
npm run time-machine:scenario -- --id=solo-artifact-hunt
npm run lab:entry -- --id=solo-artifact-hunt
npm run feel:scenario -- --id=solo-artifact-hunt
npm run tutor:next -- --markdown
npm run bridge:build -- --markdown
npm run growth:report -- --markdown
npm run fun:report -- --markdownThen open http://localhost:5502/simulator. The runner writes reports to reports/simulator/latest-report.json and app/public/simulator/latest-report.json, scores them against simulator.tuning.json, runs the Fun Debugger life-score/experiment analysis, applies supported Scenario Setup Forge starting conditions, evaluates the Gameplay Oracle's design verdicts, can auto-tune safe knobs from simulator.balance.json, can save plain-English design scenarios from simulator.scenarios.json, can run Scenario Autopilot to plan, diagnose, patch, rerun, compare, and memo a gameplay intent, can build Playable Design Memory so past evidence stays queryable, can build Scenario Time Machine reports to compare scenario progress over time, can write Scenario Lab Notebook entries so each scenario cycle leaves a durable design journal, can score Player Feeling Black Box arcs so input feel has first-class evidence, can build Scenario Self-Driving Tutor lessons that rank what to work on next, and can build a Scenario Evidence Bridge so public routes feature only evidence-backed scenarios.
See docs/gameplay-simulator.md for strategies and engine-path details, docs/scenario-setup-forge.md for scenario setup support, docs/scenario-autopilot.md for the orchestration loop, docs/gameplay-oracle.md for Oracle scoring and daily tuning workflows, docs/playable-design-memory.md for memory queries and report outputs, docs/scenario-time-machine.md for scenario trend reports, docs/scenario-lab-notebook.md for design journal entries and decisions, docs/player-feeling-black-box.md for felt-control arcs, docs/scenario-self-driving-tutor.md for ordered gameplay lessons, and docs/scenario-evidence-bridge.md for public route readiness.
Public growth routes start at http://localhost:5502/play, with /challenge, /scenarios, /progress, /devlog, and /create-scenario covering the shareable run loop. See docs/growth-loop.md for metrics, routes, and release checks.
Fun-first public run tuning is documented in docs/fun-loop.md. It adds action previews, barks, event cards, artifacts, roles, badges, run titles, and npm run fun:report quality gates.
Scenario Evidence Bridge is documented in docs/scenario-evidence-bridge.md. It writes ignored reports/bridge/ and app/public/bridge/ JSON, then /play, /challenge, /scenarios, /progress, /devlog, and /create-scenario use readiness to explain what is featured and what still needs evidence.
The recommended approach. The worker generates an ECVRF proof off-chain, passes it to progressLoop(), and the contract verifies the proof on-chain — all in one transaction. Cheaper, faster, and cryptographically verifiable without Chainlink fees.
# One-time setup
node scripts/register-vrf-key.mjs # Register worker's VRF public key
node scripts/enable-autoloop-vrf.mjs # Enable VRF mode on contracts
# Run the worker
USE_AUTOLOOP_VRF=true node scripts/xenovoya-worker.mjsHow it works:
- Worker polls
shouldProgressLoop()— no randomness check needed (randomness comes with the proof) - Worker computes
seed = keccak256(gameplayAddress, loopID)and generates an ECVRF proof - Worker wraps the proof + game data into a VRF envelope and calls
progressLoop(vrfEnvelope) - On-chain:
progressLoop()verifies the ECVRF proof, extracts randomness, writes it to Queue, then processes the turn - GameSetup still uses Mock VRF (one-time cold path, not worth VRF overhead)
The simplest setup for testing and development. Uses blockhash-based pseudo-randomness:
node scripts/xenovoya-worker.mjs- Polls
shouldProgressLoop()on Controller, Gameplay, and GameSetup contracts every 5 seconds - Calls
progressLoop()to advance game phases when ready - Fulfills mock VRF randomness requests on-chain (separate transaction)
- No external subscriptions or token balances needed
Chainlink VRF v2 for provably fair randomness without running your own worker:
- Create and fund a Chainlink VRF subscription
- Call
setVRFSubscriptionID(subscriptionId)on GameSetup and Queue contracts - Add the contract addresses as consumers on your VRF subscription
The AutomationCompatibleInterface (checkUpkeep/performUpkeep) is implemented on both XenovoyaController and XenovoyaGameplay, so Chainlink Automation can also replace the worker for phase advancement.
AutoLoop VRF mode:
Player submits action → Controller tracks submission
↓
All players submit OR 10-min timeout
↓
Controller.shouldProgressLoop() → true
↓
Worker calls progressLoop() → Queue enters Processing phase
↓
Gameplay.shouldProgressLoop() → true (no randomness wait)
↓
Worker generates ECVRF proof for seed(address, loopID)
↓
Worker calls progressLoop(vrfEnvelope)
↓
On-chain: verify proof → extract randomness → write to Queue → process turn
↓
New queue created for next turn
Mock VRF mode:
Player submits action → Controller tracks submission
↓
All players submit OR 10-min timeout
↓
Controller.shouldProgressLoop() → true
↓
Worker calls progressLoop() → Queue enters Processing phase
↓
Queue requests randomness (mock VRF)
↓
Worker fulfills mock randomness → stored in Queue
↓
Gameplay.shouldProgressLoop() → true
↓
Worker calls progressLoop() → processes actions, advances state
↓
New queue created for next turn
To switch an existing deployment from Mock VRF to AutoLoop VRF:
# 1. Register the worker's VRF public key on Gameplay
node scripts/register-vrf-key.mjs
# 2. Enable AutoLoop VRF on Queue and Gameplay contracts
node scripts/enable-autoloop-vrf.mjs
# 3. Start the worker with VRF mode
USE_AUTOLOOP_VRF=true node scripts/xenovoya-worker.mjsTo switch back to Mock VRF:
node scripts/enable-autoloop-vrf.mjs --disable
node scripts/xenovoya-worker.mjsEstimated costs per game round and per full game (10-round average) on Ethereum mainnet at current prices (Feb 2026: ETH ~$1,850, LINK ~$8.20, gas ~0.5 gwei).
| AutoLoop VRF | AutoLoop (Mock VRF) | Chainlink VRF + Automation | |
|---|---|---|---|
| VRF cost per round | Included in progressLoop (~50K extra gas for proof verification) = ~$0.00005 | Gas only: ~400K gas = ~$0.0004 | Gas + 20% LINK premium. ~$0.002 |
| Transactions per round | 3 (GameSetup VRF + Controller loop + Gameplay VRF loop) | 5 (2 VRF fulfills + 3 loops) | 5 (Chainlink handles VRF + automation) |
| Automation cost per round | Gas only: ~1.1M gas = ~$0.001 | Gas only: ~1.1M gas = ~$0.001 | Gas + LINK premium. ~$0.003 |
| Total per round | ~$0.001 | ~$0.0014 | ~$0.005 |
| Per 10-round game | ~$0.01 | ~$0.014 | ~$0.05 |
| Per 100 games | ~$1.00 | ~$1.40 | ~$5.00 |
| Requires LINK tokens | No | No | Yes |
| Requires VRF subscription | No | No | Yes |
| Requires running a worker | Yes | Yes | No |
| Provably fair randomness | Yes (ECVRF proof on-chain) | No (blockhash) | Yes (Chainlink VRF proof) |
Bottom line: AutoLoop VRF gives you provably fair randomness at the lowest cost. Mock VRF is cheapest for testing. Chainlink is hands-off but costs ~5x more.
# Build contracts
forge build
# Run the SPA
cd app && npm install && npm run dev
# Run the automation worker
node scripts/xenovoya-worker.mjsRun the entire stack locally with a single command -- Anvil chain, deployed contracts, populated card decks, a seeded game, the automation worker, and the Vite frontend:
npm run localThis takes about 60 seconds to boot. When you see the Local Stack Running banner, open http://localhost:5502 in your browser.
- Starts an Anvil local chain on port 9955 (chain ID 31337)
- Deploys all 25 contracts via
forge script - Populates all 5 card decks from
onchain-data.json - Seeds an open 2-player game so you can join immediately
- Writes
app/.env.localwith the deployed addresses - Starts the automation worker (2s poll interval)
- Starts the Vite dev server on port 5502
Add a custom network in MetaMask:
| Field | Value |
|---|---|
| Network name | Anvil Local |
| RPC URL | http://127.0.0.1:9955 |
| Chain ID | 31337 |
| Currency symbol | ETH |
Import the Anvil default account (pre-funded with 10,000 ETH):
Private key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
Address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
| Env var | Default | Description |
|---|---|---|
ANVIL_PORT |
9955 |
Port for the Anvil RPC server |
# Use a different port
ANVIL_PORT=8545 npm run localPress Ctrl+C to cleanly shut down Anvil, the worker, and the dev server.
app/.env.localis gitignored (covered by the.env.*pattern) and is overwritten on each run- The existing
npm run workerandnpm run devcommands still work unchanged for Sepolia
- Foundry
- Node.js 18+
- Sepolia ETH for deployment
# 1. Create .env at repo root
cp .env.example .env
# Edit .env with your PRIVATE_KEY and SEPOLIA_RPC_URL
# 2. Deploy all contracts + wire + register tokens + create grid
forge script script/DeployXenovoya.s.sol --rpc-url sepolia --broadcast
# 3. Update deployments.json with new addresses from output
# 4. Populate card decks (reads from scripts/onchain-data.json)
node scripts/populate-decks.mjs
# 5. Update app/.env with new VITE_* addresses
# 6. Verify deployment
node scripts/check-xenovoya-status.mjsFull deployment costs approximately 0.00014 ETH on Sepolia. Deploys in mock VRF mode by default (no Chainlink subscription needed).
The SPA in app/ is built with React 19, Vite, Wagmi 2, viem, and TailwindCSS. Dark expedition-themed UI with custom colors and fonts (Barlow Condensed for headings, JetBrains Mono for data).
/-- Expedition Console. Browse active games, create new expeditions (select 1-4 players), join open games. Game cards show ID, player count, and registration status./game/:gameId-- Routes between three views based on game state:- Lobby -- Crew manifest showing registered players, join/start buttons
- Expedition Bench -- Full game UI (see below)
- Game Over -- Final results and winner
The main gameplay screen is a dashboard layout:
- Top bar -- Day/Night badge, current phase, day counter
- Hex grid (left 2/3) -- SVG hexagonal grid with fog of war. Terrain tiles (Jungle, Plains, Desert, Mountain, Landing, Relic) reveal as players explore. Player position markers, landing site highlight, movement path overlay, and terrain legend.
- Expedition Crew (right 1/3) -- Player dossiers showing address, current zone, three stat bars (Movement, Agility, Dexterity), active/inactive status, and action-submitted indicators
- Action Console -- Tabbed interface with 6 actions: Move, Camp, Dig, Rest, Help, Flee
- Turn Results -- Displays action outcomes and card draws after each phase
- Event Log -- Real-time feed of on-chain game events
The SPA reads from 8 contracts (Board, Controller, GameSummary, PlayerSummary, GameEvents, Queue, GameSetup, GameRegistry) via 15+ custom Wagmi hooks with polling intervals (3-10s). Writes go through the Controller for game actions and the Board for game creation/registration. All contract events are watched in real-time.
Built-in Field Manual modal with 4 tabs: Overview, Actions, Terrain types, and How to Play tutorial.
cd app && npm install && npm run devVITE_RPC_URL # Sepolia RPC endpoint
VITE_WALLETCONNECT_PROJECT_ID # WalletConnect project ID (optional)
VITE_BOARD_ADDRESS # XenovoyaBoard contract
VITE_CONTROLLER_ADDRESS # XenovoyaController contract
VITE_GAME_SUMMARY_ADDRESS # GameSummary contract
VITE_PLAYER_SUMMARY_ADDRESS # PlayerSummary contract
VITE_GAME_EVENTS_ADDRESS # GameEvents contract
VITE_GAME_REGISTRY_ADDRESS # GameRegistry contract
VITE_GAME_QUEUE_ADDRESS # XenovoyaQueue contract
VITE_GAME_SETUP_ADDRESS # GameSetup contract
xenovoya/
├── contracts/ # 25 Solidity contracts
├── script/
│ └── DeployXenovoya.s.sol # Foundry deploy script
├── scripts/
│ ├── xenovoya-worker.mjs # Automation worker
│ ├── populate-decks.mjs # Populate card decks on-chain
│ ├── check-xenovoya-status.mjs # Verify deployment status
│ ├── enable-mock-vrf.mjs # Enable mock VRF mode
│ ├── enable-autoloop-vrf.mjs # Enable/disable AutoLoop VRF mode
│ ├── register-vrf-key.mjs # Register worker VRF public key
│ ├── run-local-stack.mjs # Local full-stack orchestrator
│ ├── ecvrf-prover.mjs # ECVRF proof generation library
│ ├── pull-onchain-data.mjs # Pull game data from chain
│ ├── onchain-data.json # Card deck + token data
│ └── xenovoya-worker.env.example
├── app/ # React SPA (Vite + Wagmi + TailwindCSS)
│ ├── src/
│ ├── index.html
│ ├── vite.config.js
│ └── package.json
├── abi/ # Contract ABIs for frontend
├── lib/ # Git submodules (forge-std, game-core, OpenZeppelin)
├── deployments.json # Deployed contract addresses by network
├── foundry.toml
└── remappings.txt
| Script | Description |
|---|---|
xenovoya-worker.mjs |
Automation worker -- polls for pending games, fulfills VRF, advances phases |
run-local-stack.mjs |
Boots full local stack: Anvil + contracts + decks + worker + frontend |
populate-decks.mjs |
Populates all 5 card decks from onchain-data.json |
check-xenovoya-status.mjs |
Reads on-chain state to verify deployment is correctly wired |
enable-mock-vrf.mjs |
Enables mock VRF mode on deployed contracts |
enable-autoloop-vrf.mjs |
Enables/disables AutoLoop VRF mode on deployed contracts |
register-vrf-key.mjs |
Registers worker's VRF public key on Gameplay contract |
ecvrf-prover.mjs |
ECVRF proof generation library (used by worker in VRF mode) |
pull-onchain-data.mjs |
Pulls current game/card data from deployed contracts |
Owner: 0x98609e60FDd4C5fB656a4C4A7D229c515bDE139b
Key addresses:
- Board:
0xE1baE692be42980760C661c31338EEcfed2D9e33 - Controller:
0x5B5F1Fecc66FDc4c5028e7Ca7510aa8870A0Dd36 - Game Queue:
0x77c5886c1e2be7E4100C31607D4E1EBF3965B484
See deployments.json for all contract addresses.
Legacy env vars and deployment keys from the old naming scheme were not kept during this rename; use the XENOVOYA_* names everywhere.
GPL-3.0