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.
Contents
- §1Abstract
- §2Introduction
- §3Design principles
- §4Architecture
- §5The cellular automaton engine
- §6The 36 elements
- §7$SAND token
- §8NFTs (Elements + Creations)
- §9The Uniswap v4 hook
- §10Global weather
- §11Treasury & fee distribution
- §12Pack mechanics & randomness
- §13Trust model
- §14Known limitations
- §15Roadmap
- §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.
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:
- The game is the entire medium. There's nothing to own. Your creation lives only as long as the tab.
- The element set is curated by an operator. Players can't trade, gift, lock, or stake elements - there's no economic substrate.
- 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:
- Local-only loop at 60 Hz: read grid → apply rules → write grid → swap pointers → render. Zero IO.
- Slow read loop at 0.033 Hz (every ~30 s): a single
multicallto fetch element balances, $SAND balance, weather state, claimable yield. Updates the UI. - 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:
| ID | Name | Tier | Behaviour summary |
|---|---|---|---|
| 1 | Empty | - | Air. No-op. |
| 2 | Wall | Common | Immovable. Blocks all movement. |
| 3 | Sand | Common | Falls. Slides down-left or down-right when blocked. |
| 4 | Water | Common | Falls then spreads horizontally up to 4 cells. |
| 5 | Stone | Common | Immovable. Eroded by Acid; melts in extreme heat. |
| 6 | Wood | Common | Immovable. Catches fire after 4 ticks of adjacent flame. |
| 7 | Fire | Common | Rises. 30-tick lifetime. Ignites flammables. Dies in water. |
| 8 | Smoke | Common | Rises. Fades after 60 ticks. |
| 9 | Steam | Common | Rises fast. Condenses to water when cold. |
| 10 | Plant | Common | Grows on adjacent water (1% per tick). Burns to Fire. |
| 11 | Oil | Common | Falls and spreads. Floats on water. Highly flammable. |
| 12 | Acid | Common | Falls. Mutual annihilation with sand, stone, wood, plant, metal. |
| 13 | Lava | Uncommon | Falls slowly. Cools to Stone on water (+ Steam). Glasses sand. |
| 14 | Ice | Uncommon | Melts in heat. Freezes adjacent water. |
| 15 | Salt | Uncommon | Falls. Melts ice. Dissolves in water. |
| 16 | Ash | Uncommon | Falls light. Fades after 200 ticks. |
| 17 | Mud | Uncommon | Sand+Water. Dries to Sand if hot, hardens to Stone if hotter. |
| 18 | Glass | Uncommon | Sand+Lava. Immovable. Shatters under metal momentum. |
| 19 | Snow | Uncommon | Falls slowly. Melts to water in heat. |
| 20 | Metal | Uncommon | Immovable. Conducts electricity. Melts at extreme heat. |
| 21 | Electricity | Uncommon | 1-tick lifetime. Chains through metal. Ignites oil/gunpowder. |
| 22 | Gunpowder | Uncommon | Falls. Explodes (radius 4) on fire/electricity contact. |
| 23 | Cloner | Rare | Captures the first element to touch it. Replicates forever. |
| 24 | Vines | Rare | Climbs adjacent walls/stone/wood. Flammable. |
| 25 | Crystal | Rare | Grows on adjacent Water+Salt. Immovable once formed. |
| 26 | Antimatter | Rare | Mutual annihilation with any non-empty cell. |
| 27 | Quantum | Rare | Random sand/water/gas/fire behaviour each tick. |
| 28 | Gravity-Flip | Rare | Falls UP. Inverts gravity for cells in radius 5. |
| 29 | Mercury | Rare | Heavier than water. Conducts. Toxic to plants in radius 2. |
| 30 | Helium | Rare | Rises fast. Bubbles through water. |
| 31 | Black Hole | Legendary | Pulls non-empty cells in radius 8. 200-tick lifetime. |
| 32 | Phoenix Fire | Legendary | Never dies. Ignites any adjacent flammable. Rises. |
| 33 | Gold Spawner | Legendary | Drops Gold particle every 30 ticks. |
| 34 | Time Slow | Legendary | Cells in radius 6 update every 3 frames. 500-tick lifetime. |
| 35 | Photon | Legendary | Moves 4 cells/tick. Ignites oil, melts ice, passes glass. |
| 36 | Alchemist Cloner | Legendary | LP-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.
| Name | EtherSand |
|---|---|
| Symbol | SAND |
| Decimals | 18 |
| Initial supply | 32,000,000 SAND |
| Hard cap | 100,000,000 SAND |
| Mint authority | Treasury, SandPacks (immutable) |
| Burn authority | Anyone (own balance only) |
| Permit | EIP-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 TWAP | Sell SAND fee | Buy 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:
- Whale sell (≥ 50,000 SAND single-tx): trigger
STORMwithACID_RAIN, lasting 240 blocks (~48 minutes). - Whale buy (≥ 50,000 SAND): trigger
SUNNYwithGOLDEN_FALL.
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:
| Pool | Share | Share basis |
|---|---|---|
| LP | 50% | SAND-equivalent of v4 position size |
| Creators | 25% | Number of Creation NFTs held |
| Stakers | 20% | Rarity-weighted score (rare ×1, legendary ×4) |
| Burn | 5% | 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:
| Tier | Cost | Common | Uncommon | Rare | Legendary |
|---|---|---|---|---|---|
| Common | 100 SAND | 70% | 25% | 4.9% | 0.1% |
| Rare | 500 SAND | 0% | 60% | 35% | 5% |
| Legendary | 2,500 SAND | 0% | 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:
- Reverting and replaying inside the same block: same prevrandao, same sender, same packCounter (revert doesn't increment) → identical roll. Cannot improve outcome.
- Opening twice in the same block: packCounter increments → different seed → independent roll.
- Cross-block: prevrandao changes, equivalent to fresh entropy.
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:
| Authority | Power | Mutable post-deploy? |
|---|---|---|
| Deployer (you) | Initial mint of 32M SAND, deploy script execution | No, but deployer is a normal EOA without contract privileges after deploy |
| SandToken minter | Mint new SAND | No (immutable: Treasury, SandPacks) |
| Elements minter | Mint element ERC-1155s | No (immutable: SandPacks, Treasury, SandCore) |
| Weather writer | Update WeatherState | No (immutable: SandHook only) |
| Royalty receiver | Receive 5% royalty | No (immutable: Treasury) |
| Pool fee receiver | Receive afterSwap tax | No (immutable: Treasury) |
| Treasury distributor | Distribute to LP/Creator/Staker pools | Permissionless - anyone can call claim functions for themselves |
| Anything else | None | - |
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.
- 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.
- 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.
- 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.
- 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.
- TWAP buffer warmup - for the first hour after deploy, mood always defaults to CALM (count < 5 samples). Acceptable; documented.
- Bank-run threshold is naïve - uses absolute
liquidityDelta > 50,000 etherinstead 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. - 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. - 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.mdbut not wired by default. - Royalty enforcement is advisory - ERC-2981 is honoured by major marketplaces but ignored by some. Royalty income is best-effort.
- 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:
- v1.0 - initial launch (this document)
- v1.1 - multi-canvas slots + shareable URLs
- v1.2 - high-res 384 × 216 mode + Web Worker
- v1.3 - communal canvases with auto-mint to top painters
- v1.4 - element evolution / on-chain recipes
- v2.0 - quarterly seasonal element drops
§ 16References
- The Powder Toy. https://powdertoy.co.uk
- Sandspiel. https://sandspiel.club
- Uniswap v4 Whitepaper. Adams, H. et al., 2024.
- EIP-1155: Multi Token Standard. https://eips.ethereum.org/EIPS/eip-1155
- EIP-2981: NFT Royalty Standard. https://eips.ethereum.org/EIPS/eip-2981
- EIP-2612: Permit Extension for ERC-20. https://eips.ethereum.org/EIPS/eip-2612
- OpenZeppelin Contracts v5. github.com/OpenZeppelin/openzeppelin-contracts
- Foundry Book. https://book.getfoundry.sh