Whitepaper · v1.0 · 2026

EtherSand:
a sandbox of verifiable physics

A pixel-art cellular automaton with on-chain element ownership, NFT snapshots, and a Uniswap v4 hook that drives global weather, with no backend, no admin, and every contract immutable post-deploy.

Version
1.0
Network
Ethereum mainnet
License
MIT (contracts) / CC-BY-4.0 (docs)

Contents

  1. §1Abstract
  2. §2Introduction
  3. §3Design principles
  4. §4Architecture
  5. §5The cellular automaton engine
  6. §6The 36 elements
  7. §7$SAND token
  8. §8NFTs (Elements + Creations)
  9. §9The Uniswap v4 hook
  10. §10Global weather
  11. §11Treasury & fee distribution
  12. §12Pack mechanics & randomness
  13. §13Trust model
  14. §14Known limitations
  15. §15Roadmap
  16. §16References

§ 1Abstract

EtherSand is a browser-playable falling-sand sandbox where every meaningful asset - element ownership, painted creations, and the global weather - is enforced on Ethereum mainnet. The cellular automaton runs at 60 FPS locally over a 256 × 144 bit-packed grid; the chain layer governs ownership, mystery-pack minting, NFT creation, and a real-time weather feed driven by a custom Uniswap v4 hook. The system has no backend, no signer, no oracle, no admin role. Every contract is immutable post-deploy; the only authorities are address-pinned bindings between contracts that are wired in their constructors and verified by deterministic CREATE2 address prediction.

This document specifies the architecture, the cellular automaton's update model, all 36 element rule functions, the $SAND token economics, the v4 hook's behaviour, the treasury distribution algorithm, and the trust model. It also enumerates known limitations honestly.

tl;dr

EtherSand is two systems married by a bridge: a fast local game (free, fun) and a small set of immutable contracts (cheap, trustless), connected by a Uniswap v4 hook that turns swap activity into shared weather. The team cannot pause, upgrade, mint, or seize anything after the deploy script ends.

§ 2Introduction

Falling-sand games - cellular automata where a 2D grid of cells evolves under simple per-cell rules - have a long pedigree in the open web. The genre rewards depth-from-simple-rules and lends itself to broad experimentation. The major surviving examples (The Powder Toy, Sandspiel, Powder Game) are masterpieces of single-developer iteration, but they share three properties:

  1. The game is the entire medium. There's nothing to own. Your creation lives only as long as the tab.
  2. The element set is curated by an operator. Players can't trade, gift, lock, or stake elements - there's no economic substrate.
  3. Multiplayer state is at best a leaderboard. No pixel any one player paints affects what another player sees.

EtherSand inverts each one. Ownership is on-chain: every element you can paint with is an ERC-1155 token in your wallet. Creations are NFTs: snapshots of your canvas are minted as ERC-721s with on-chain SVG metadata, optionally tradeable with 5% royalties enforced via ERC-2981. Weather is shared: a real-time feed driven by Uniswap v4 swap activity affects every player's local sandbox.

None of this requires the simulation itself to be on chain. Putting 37,000 cells through hundreds of rule evaluations per second on the EVM would cost dozens of ETH per minute. Instead, EtherSand splits the system: the simulation is local, free, instant; the economy is on chain, expensive, slow, and trustlessly verifiable. The boundary is enforced by the contract, which never validates simulation state - it only mints things you can use in the simulation, and only takes value when you choose to mint a permanent record.

§ 3Design principles

Three principles guided every decision.

Principle 1 - Trust nothing

Every authority in the system is an immutable address bound at construction. No Ownable. No AccessControl. No proxy. No Pausable. No upgradeable storage layout. The deployer publishes the contracts and disappears. Every claim, every mint, every distribution function is permissionless: anyone can call any function for themselves.

Principle 2 - Spend gas only on ownership

The simulation is the experience. The chain is the receipt. Cells, ticks, palette swaps, brush strokes - all free. Element ownership, pack opens, NFT mints, fee distribution - all on-chain. Players who never want to mint a creation never need to pay gas after registration; they still get the full game.

Principle 3 - Make weather a public good

The hook is the only "novel" thing this project introduces beyond standard tokens and NFTs. Its sole purpose is to take swap activity (which already exists for any pool) and convert it into a global mood that every client can render. Weather has no monetary reward, no governance power, no leaderboard tied to it. It exists because shared spectacle is more fun than private spectacle, and because the hook is the cheapest way to coordinate it without a backend.

§ 4Architecture

The system has three layers.

┌──────────────────────────────────────────────────────────────┐
│  PILLAR A - On-chain (Ethereum mainnet)                       │
│                                                               │
│   SandToken    ERC-20 + Permit ($SAND)                        │
│   Elements     ERC-1155 (36 element ids)                      │
│   Creations    ERC-721 + ERC-2981 (snapshot NFTs)             │
│   SandPacks    Mystery-pack opener                            │
│   Treasury     Fee distributor (LPs / Creators / Stakers)     │
│   SandCore     Player registry + weather state                │
│   SandHook     Uniswap v4 hook - drives weather, takes fees   │
└──────────────────────────────────────────────────────────────┘
                            ▲      ▲      ▲
              read via viem │      │      │ writes via wallet
                            │      │      │
                            ▼      ▼      ▼
┌──────────────────────────────────────────────────────────────┐
│  PILLAR B - Browser (HTML/JS)                                 │
│                                                               │
│   60-FPS cellular automaton over 256×144 bit-packed grid      │
│   Double-buffered, bottom-up scan, alternating direction      │
│   36 element rule functions                                   │
│   Render to ImageData → Canvas (4× scale)                     │
│   Brush, line, rect, fill, erase, pause                       │
│   Pack-open animation, weather overlays, audio                │
│   Tutorial, wallet connect, polling                           │
└──────────────────────────────────────────────────────────────┘

The two pillars communicate over three loops:

  1. Local-only loop at 60 Hz: read grid → apply rules → write grid → swap pointers → render. Zero IO.
  2. Slow read loop at 0.033 Hz (every ~30 s): a single multicall to fetch element balances, $SAND balance, weather state, claimable yield. Updates the UI.
  3. Discrete write loop on user action: register(), openPack(tier), mintCreation(rle, title), claimLP(), stakeElement(id, amount), etc. Each is an explicit, opt-in transaction.

Address book

The deploy script computes future addresses for every contract via vm.computeCreateAddress(deployer, nonce + n), mines a salt for the hook so its address has the correct permission bits, then deploys in dependency order. Every constructor takes the addresses it needs as immutable arguments; every require(address(x) == predicted) in the deploy script verifies the wiring before broadcasting.

§ 5The cellular automaton engine

Cell encoding

Every cell is a 32-bit packed integer:

bit  0 .. 7    element id        (0..36)
bit  8 .. 15   age / lifetime    (0..255 ticks)
bit 16 .. 23   temperature       (0..255, ambient = 128)
bit 24 .. 31   element data      (per-element, e.g. captured id for Cloner)

Why packed? A single 32-bit word per cell is cache-friendly. The entire 256 × 144 grid fits in 148 KB; both buffers in 296 KB; well under L2 cache size on every modern CPU. The hot loop never allocates objects, never reads strings, never indexes by Map.

Tick model

Each frame runs one tick = one full pass of every cell. The tick proceeds bottom-up to ensure falling cells (sand, water, etc.) move into already-processed rows rather than racing with their own targets:

function tick() {
  write.set(read);            // baseline copy: every cell stays put unless rule moves it
  const dir = (frame & 1) ? 1 : -1;
  for (let y = H-1; y >= 0; y--) {
    const xStart = dir > 0 ? 0 : W-1;
    const xEnd   = dir > 0 ? W : -1;
    for (let x = xStart; x !== xEnd; x += dir) {
      const c = read[y*W + x];
      const id = c & 0xff;
      RULES[id](c, x, y);     // pure function: reads from `read`, writes to `write`
    }
  }
  [read, write] = [write, read];
  frame++;
}

Direction alternation

Sand piles in naive falling-sand engines develop a 1-cell rightward drift over time because the inner loop is consistently left-to-right. Alternating dir each frame eliminates this artefact averaged over two frames. It's a one-byte change with material aesthetic impact.

Double-buffer race-freedom

Every rule reads from read and writes to write. Reading write mid-tick is forbidden. Before any move, the rule checks (write[target] & ID_M) === E_EMPTY to avoid two cells claiming the same destination. The pre-tick write.set(read) baseline guarantees that any cell whose rule chose not to move stays in place.

Render path

An offscreen 256 × 144 canvas holds an ImageData buffer; we walk the grid once per frame and write a pre-multiplied 0xAABBGGRR word per cell from a 32-element Uint32Array palette LUT. Then a single drawImage(offscreen, 0, 0, W*4, H*4) upscales onto the visible 1024 × 576 canvas with imageSmoothingEnabled = false. Total render cost: ~3 ms per frame on 5-year-old laptops.

§ 6The 36 elements

Every element has a hand-written rule function. None reuses a template. The full catalogue:

IDNameTierBehaviour summary
1Empty-Air. No-op.
2WallCommonImmovable. Blocks all movement.
3SandCommonFalls. Slides down-left or down-right when blocked.
4WaterCommonFalls then spreads horizontally up to 4 cells.
5StoneCommonImmovable. Eroded by Acid; melts in extreme heat.
6WoodCommonImmovable. Catches fire after 4 ticks of adjacent flame.
7FireCommonRises. 30-tick lifetime. Ignites flammables. Dies in water.
8SmokeCommonRises. Fades after 60 ticks.
9SteamCommonRises fast. Condenses to water when cold.
10PlantCommonGrows on adjacent water (1% per tick). Burns to Fire.
11OilCommonFalls and spreads. Floats on water. Highly flammable.
12AcidCommonFalls. Mutual annihilation with sand, stone, wood, plant, metal.
13LavaUncommonFalls slowly. Cools to Stone on water (+ Steam). Glasses sand.
14IceUncommonMelts in heat. Freezes adjacent water.
15SaltUncommonFalls. Melts ice. Dissolves in water.
16AshUncommonFalls light. Fades after 200 ticks.
17MudUncommonSand+Water. Dries to Sand if hot, hardens to Stone if hotter.
18GlassUncommonSand+Lava. Immovable. Shatters under metal momentum.
19SnowUncommonFalls slowly. Melts to water in heat.
20MetalUncommonImmovable. Conducts electricity. Melts at extreme heat.
21ElectricityUncommon1-tick lifetime. Chains through metal. Ignites oil/gunpowder.
22GunpowderUncommonFalls. Explodes (radius 4) on fire/electricity contact.
23ClonerRareCaptures the first element to touch it. Replicates forever.
24VinesRareClimbs adjacent walls/stone/wood. Flammable.
25CrystalRareGrows on adjacent Water+Salt. Immovable once formed.
26AntimatterRareMutual annihilation with any non-empty cell.
27QuantumRareRandom sand/water/gas/fire behaviour each tick.
28Gravity-FlipRareFalls UP. Inverts gravity for cells in radius 5.
29MercuryRareHeavier than water. Conducts. Toxic to plants in radius 2.
30HeliumRareRises fast. Bubbles through water.
31Black HoleLegendaryPulls non-empty cells in radius 8. 200-tick lifetime.
32Phoenix FireLegendaryNever dies. Ignites any adjacent flammable. Rises.
33Gold SpawnerLegendaryDrops Gold particle every 30 ticks.
34Time SlowLegendaryCells in radius 6 update every 3 frames. 500-tick lifetime.
35PhotonLegendaryMoves 4 cells/tick. Ignites oil, melts ice, passes glass.
36Alchemist ClonerLegendaryLP-reward only. Cloner with custom captured element.

Interaction matrix

Every interaction between elements is explicit. There is no "fire just burns wood" implicit rule - wood's update function checks all four orthogonal neighbours for FIRE, LAVA, or PHOENIX-FIRE; counts them; advances its own age proportionally; and ignites once age exceeds 4. The interaction matrix is fully spelled out in /docs/PHYSICS.md and verified by gameplay testing.

§ 7$SAND token

$SAND is an ERC-20 with EIP-2612 permit. Minimal surface, deflationary by construction.

NameEtherSand
SymbolSAND
Decimals18
Initial supply32,000,000 SAND
Hard cap100,000,000 SAND
Mint authorityTreasury, SandPacks (immutable)
Burn authorityAnyone (own balance only)
PermitEIP-2612 supported

The deployer receives the entire 32M initial supply. Use is restricted by social contract: seed liquidity on the SAND/ETH pool, fund the first-week airdrop to early players, retain a small reserve for partnerships. There is no team allocation, no investor allocation, no vesting schedule - because there are no insiders.

The hard cap of 100M is enforced in SandToken.mint():

function mint(address to, uint256 amount) external {
    if (msg.sender != treasury && msg.sender != packs) revert NotMinter();
    if (totalSupply() + amount > HARD_CAP) revert CapExceeded();
    _mint(to, amount);
}

Both treasury and packs are immutable storage variables set in the constructor. There is no setter to change them. There is no role to grant. There is no admin to bypass them.

§ 8NFTs (Elements + Creations)

Elements (ERC-1155)

36 element types are represented as fungible-but-typed ERC-1155 tokens. Token id 1 (Empty) is reserved and never minted. Ids 2–36 cover the 35 paintable elements; the Alchemist Cloner (id 36) is mintable only by the Treasury via the LP-threshold callback. Common elements (ids 2–12) drop at registration. Uncommon (13–22), Rare (23–30), and Legendary (31–35, excluding 36) drop from packs.

Metadata is fully on-chain. uri(id) returns a base64-encoded JSON with a base64-encoded SVG that renders a 32 × 32 pixel-art tile in the element's palette colour. There is no IPFS dependency, no centralized metadata host, no off-chain pinning service.

Creations (ERC-721 + ERC-2981)

A Creation is an ERC-721 NFT representing a snapshot of the player's 256 × 144 cellular automaton canvas at the moment of mint. The canvas is encoded as run-length-encoded bytes (each cell's element id is one byte; runs of identical cells are stored as count + value pairs) and embedded directly in contract storage. Mint cost is a flat 50 SAND (25 burnt, 25 to Treasury).

The on-chain SVG render is intentionally quarter-resolution (64 × 36 sample, 4 × 4 rects) to keep tokenURI() gas-bounded; the full-resolution render is left to clients. ERC-2981 royalty is 5% to Treasury, which forwards 60% of received royalties to the original creator at distribution time.

§ 9The Uniswap v4 hook

Permission encoding

Uniswap v4 requires hook contracts to encode their permissions in the lower 14 bits of their address. SandHook needs four permissions:

beforeSwap            = bit 7  = 0x0080
afterSwap             = bit 6  = 0x0040
afterAddLiquidity     = bit 10 = 0x0400
beforeRemoveLiquidity = bit 9  = 0x0200
                                ──────
PERMISSION_FLAGS      =          0x06C0

The hook's deployed address must satisfy uint160(hookAddr) & 0x3FFF == 0x06C0. We mine a CREATE2 salt that produces a matching address. The expected number of brute-force attempts is 2^14 = 16,384, mined in ~80 ms locally.

beforeSwap - dynamic LP fee

On every swap, the hook reads the current sqrtPriceX96 and compares it to a 1-hour TWAP maintained in an embedded 60-bucket circular buffer. The result classifies the market into one of five moods:

MoodΔ vs TWAPSell SAND feeBuy SAND fee
Storm< -20%1.00%0.30%
Cloudy-20%..-5%0.30%0.30%
Calm-5%..+5%0.30%0.30%
Clear+5%..+20%0.30%0.30%
Sunny> +20%0.30%0.10%

Fees lean against the trend: storms tax sellers, sunny weather rewards buyers. The override is applied via the LP_FEE_OVERRIDE_FLAG (0x400000), which only works on dynamic-fee pools. The pool must therefore be initialized with fee = 0x800000.

afterSwap - treasury tax + weather trigger

After every swap, the hook takes 0.25% of the SAND-side volume as a treasury tax (provided the hook holds enough SAND from prior LP inflows). It also detects whale-sized trades:

The weather trigger writes a new WeatherState struct into SandCore via the immutable hook→core authorization. The struct includes a stormSeed derived from keccak256(block.prevrandao, swapper), which clients use to seed local pseudo-random storm spawn columns.

afterAddLiquidity / beforeRemoveLiquidity

On liquidity adds, the hook notifies Treasury of the new LP's SAND-equivalent. If the deposit ≥ 5,000 SAND-equivalent, an Alchemist Cloner badge (id 36) is minted - one per address ever. On removes > 5,000 SAND-equiv (a "bank run"), Treasury sets a 50-block cooldown during which claimLP() reverts. The cooldown is a soft brake, not a hard freeze.

§ 10Global weather

The weather system is the cleanest expression of the hook's purpose. SandCore.WeatherState:

struct WeatherState {
    uint8  mood;         // 0=STORM, 1=CLOUDY, 2=CALM, 3=CLEAR, 4=SUNNY, 5=HEATWAVE
    uint8  stormType;    // 0=NONE, 1=ACID_RAIN, 2=METEOR_SHOWER, 3=GOLDEN_FALL,
                         // 4=SNOWFALL, 5=AURORA
    uint64 untilBlock;   // weather active until this block
    uint64 setAtBlock;
    bytes32 stormSeed;   // PRNG seed for client-side spawn locations
}

Clients poll SandCore.getWeather() every 30 seconds. While untilBlock > currentBlock, the simulation engine runs a per-tick spawn function gated on frame % N === 0 (rate varies by storm type). Spawn columns are derived from the stormSeed so all viewers of the same weather event see roughly the same storm pattern.

Weather is purely cosmetic from an economic standpoint. The hook never disburses funds based on weather. There is no leaderboard tied to weather. Triggering a storm costs the trader the gas + slippage of their swap and yields nothing financial. This makes the hook unattractive to MEV - exactly as intended.

§ 11Treasury & fee distribution

Treasury accumulates fees from three sources: hook fees (afterSwap), pack revenue (50% of pack price), and creation royalties (received via ERC-2981 forwarding). It distributes via a MasterChef-style accumulator pattern across four pools:

PoolShareShare basis
LP50%SAND-equivalent of v4 position size
Creators25%Number of Creation NFTs held
Stakers20%Rarity-weighted score (rare ×1, legendary ×4)
Burn5%Sent to 0xdEaD

The accumulator pattern works as follows. For each pool we maintain accPerShare (cumulative per-share reward, scaled by 1e18) and totalShares. When fees come in:

accPerShare += amount * 1e18 / totalShares;

Each user has shares[user] and snapshot[user]. Pending reward = shares[user] * (accPerShare - snapshot[user]) / 1e18. On any share change (LP add/remove, NFT transfer, stake/unstake), we settle the pending reward to the user, update their share, and snapshot the current accPerShare.

Why MasterChef and not snapshots?

Snapshot-based distribution requires either off-chain coordination (which we forbid) or per-event pause windows (which feel terrible). The accumulator pattern handles continuous deposits with O(1) gas per user-action, never requires iteration over the user list, and is well-tested in DeFi.

Stakeable elements

Only Rare (ids 23–30) and Legendary (ids 31–36) elements are stakeable. The staking vault is non-custodial in spirit - the ERC-1155 token is transferred to Treasury, but staked balances and unstake rights are tracked per-user and per-id. Unstaking always succeeds (no lock period). Yield only accrues while staked, settled lazily on stake/unstake/claim.

§ 12Pack mechanics & randomness

SandPacks supports three tiers with the following economics and probability tables:

TierCostCommonUncommonRareLegendary
Common100 SAND70%25%4.9%0.1%
Rare500 SAND0%60%35%5%
Legendary2,500 SAND0%0%70%30%

Every pack rolls 5 elements with independent probability draws. 50% of the SAND cost is burnt; 50% goes to Treasury.

Randomness

Each pack open generates its seed:

seed = keccak256(block.prevrandao, msg.sender, packCounter)

Then for each of the 5 rolls:

roll_i = keccak256(seed, i)

Anti-grinding

block.prevrandao is constant within a block. packCounter is a global incrementing nonce. So:

Sequencer manipulation

A malicious block builder could in theory choose which prevrandao to use and which transactions to include. The XOR with msg.sender bounds the attacker's bias to a few bits per opener. For high-stakes mints, players can split opens across multiple blocks. We do not consider this a critical risk for a cosmetic-element game economy.

§ 13Trust model

The trust model is enumerable. Every authority in the system, listed exhaustively:

AuthorityPowerMutable post-deploy?
Deployer (you)Initial mint of 32M SAND, deploy script executionNo, but deployer is a normal EOA without contract privileges after deploy
SandToken minterMint new SANDNo (immutable: Treasury, SandPacks)
Elements minterMint element ERC-1155sNo (immutable: SandPacks, Treasury, SandCore)
Weather writerUpdate WeatherStateNo (immutable: SandHook only)
Royalty receiverReceive 5% royaltyNo (immutable: Treasury)
Pool fee receiverReceive afterSwap taxNo (immutable: Treasury)
Treasury distributorDistribute to LP/Creator/Staker poolsPermissionless - anyone can call claim functions for themselves
Anything elseNone-

There is no Ownable. There is no AccessControl. There is no Pausable. There is no upgrade proxy. There is no role-grant function. There is no setter that takes an address and rewires authority.

Every cross-contract authorization is verified by the deploy script via require(address(x) == predicted) after each new. If any address mispredicts, the entire deploy reverts.

§ 14Known limitations

An honest list of things a real audit or playtest would flag.

  1. MEV on pack opens - block builders can choose to include or exclude pack-open transactions to bias prevrandao. With XOR(msg.sender, packCounter), the bias is bounded but real. A truly fair on-chain RNG would need Chainlink VRF or commit-reveal; both add gas + latency we judged not worth it for a cosmetic-element game.
  2. Whale storm-trigger as cosmetic - intentional design but worth flagging: a coordinated whale-sell to summon STORM could be a fun community moment or a griefing vector if storms ever gain utility. v1 keeps storms purely decorative.
  3. Creation-mint storage cost - full 36,864-cell snapshot can RLE-compress to 1–10 KB on real art. Worst case (random palette per cell) is 73,728 bytes which would cost ~16M gas. The mint test uses uniform-fill RLE so its measured gas is misleadingly low. Real users see 250–500k gas.
  4. Hook tax can starve - for SAND→ETH swaps, the hook never receives SAND directly to tax. The 0.25% only flows when the hook holds SAND from prior LP-add inflows. Without LP activity, the tax is 0.
  5. TWAP buffer warmup - for the first hour after deploy, mood always defaults to CALM (count < 5 samples). Acceptable; documented.
  6. Bank-run threshold is naïve - uses absolute liquidityDelta > 50,000 ether instead of % of pool. With a small pool every withdrawal looks like a bank run; with a huge pool, real bank runs slip past. Should be live-pool-relative; future work.
  7. Creator-yield share dynamics - accumulator updates correctly on transfer via notifyCreationTransfer, but rapid back-and-forth transfers within a single block could in theory truncate a small amount of accrued yield. Edge case; not financially meaningful.
  8. Single-threaded engine - 384×216 mode at 50%+ active cells will drop frames on midrange hardware. A Web Worker stub is documented in /docs/PHYSICS.md but not wired by default.
  9. Royalty enforcement is advisory - ERC-2981 is honoured by major marketplaces but ignored by some. Royalty income is best-effort.
  10. Weather seed determinism is loose - clients seed a local PRNG from stormSeed, but local frame counters drift. Storms are visually similar, not pixel-identical, across viewers.

§ 15Roadmap

See the homepage roadmap section. Summary:

§ 16References

  1. The Powder Toy. https://powdertoy.co.uk
  2. Sandspiel. https://sandspiel.club
  3. Uniswap v4 Whitepaper. Adams, H. et al., 2024.
  4. EIP-1155: Multi Token Standard. https://eips.ethereum.org/EIPS/eip-1155
  5. EIP-2981: NFT Royalty Standard. https://eips.ethereum.org/EIPS/eip-2981
  6. EIP-2612: Permit Extension for ERC-20. https://eips.ethereum.org/EIPS/eip-2612
  7. OpenZeppelin Contracts v5. github.com/OpenZeppelin/openzeppelin-contracts
  8. Foundry Book. https://book.getfoundry.sh