An island per wallet, published to the Fangorn network.
Your address is the seed. Walk the island it generates, harvest what grows there, build with what you gather — and publish before the tide turns, because the sea takes everything you didn't.
A published world is a signed, content-addressed graph under
tidebound:<your address>:<world>. Anyone can read it back from the chain — no
account, no server of ours, no API key.
browser (three.js) server (:8787) Arbitrum Sepolia
Privy login ──► verify JWT (jose)
seed → terrain ──► worlds/<owner>/*.json ──► DataRegistry
Date.now() → tide ◄─► same tide, same rules (one root per publisher)
WASD / E / X / 1-4 actions + inventory
wallet signs ────────────────────────────────────────► settlement tx
@fangorn-network/sdk ──► IPFS (blocks)
trystero (WebRTC) ◄──► other players (no signalling server of ours)
| Piece | File | Job |
|---|---|---|
| The island | src/island.js | Seed → hex grid, terraces, resources, build rules. Deterministic; runs on both sides. |
| The tide | src/tide.js | Clock → water level, phase, and the deadline. Pure, no state, no imports. |
| The view | src/scene.js | three.js render + pointer-lock movement. Authoritative over nothing. |
| The shades | src/peers.js | WebRTC presence over trystero. Positions only. |
| The gate | src/login.jsx | Privy login, or the dev bypass. The only React in the project. |
| The rules | server/index.js | Auth, working state on disk, action validation, publish prepare/settle. |
| The graph | server/graph.js | World state ⇄ Fangorn vertices and edges. |
One cycle of the tide is the whole loop.
Low water. The most ground you will ever have. Two kinds of resource are out there. Standing ones — stone on the high ground, wood below it — are a function of the seed alone: they never move, and they stay until you take them, so learning your own island is worth something. Flotsam is whatever the last tide left on the beach.
Build with it. Walls and posts stack four high (a post is an open pillar, so its ground floor is walk-through), and tearing anything down returns the material.
The flood. The water climbs about five terraces. Low ground goes under, and drowned tiles can't be stood on, harvested, or built on. The richest ground is the lowest, so it's the first thing you lose. Get cut off and you're swimming — fourteen seconds of that and the tide puts you back at spawn.
Dikes are the answer to the flood. A dike is exactly one terrace tall and you stand on top of it, so a stack raises the ground: four courses on a beach tile clears high water and that tile stays yours all cycle. You can only climb one terrace at a time, so a tall stack needs a ramp of shorter ones beside it — or a swim to haul out onto. That's the building puzzle.
Sand only ever arrives with the tide. It never grows on dry land, so the material for a seawall has to be fetched out of the exact zone the seawall exists to defend, in the window before the water returns. Running short is the intended pressure to go looking somewhere else.
High water. Dark, the fog closes to a few metres, and you are not alone (below). Nothing you can fight; it's a stretch of the cycle to be somewhere defensible rather than out on a sandbar.
The turn. At low water the number ticks. This is not a reset — the island is a place you live on. Two things happen:
- It takes what stood in its path. Anything below high water, on ground you hadn't raised on dikes, is gone. Everything above high water is untouched, permanently. That's the whole cost of building on the beach.
- It brings something back. The old flotsam is swept away and a fresh scatter lands in the intertidal band.
Your inventory is yours forever, harvested trees stay harvested, and your walls are where you left them. The only things the sea can reach are the ones standing in it.
Autosave (a checkbox, off by default) publishes once you stop making changes or thirty seconds before the turn, whichever comes first.
Every island is generated in the same coordinate space with a different seed. So a peer's position on their island is a real position on yours. At low water you're alone; as the water rises, other people's islands come through yours and there are figures on your beach who are standing somewhere else. They can't be interacted with and they don't acknowledge you.
That's deliberately the entire protocol: a shade is a rumour, not a game object. Positions only — no inventory, no actions, nothing another client can act on — so a hostile peer can lie about where it's standing and that is the whole blast radius. Everything with consequences still goes through the server.
The watchers are seed-derived: pairs of eyes out on the water, in the same place every tide, closing in as it rises and stopping just short of the shore. They never do anything.
Needs Node ≥ 20.19, pnpm, a throwaway EVM key (no funds), a Pinata gateway domain, and a Privy app id.
pnpm install
cp .env.example .env # ETH_PRIVATE_KEY, PINATA_GATEWAY, VITE_PRIVY_APP_ID
pnpm dev # API (:8787) + Vite (:5173)Open http://localhost:5173, log in, click to look. WASD to walk, shift to
run, E to harvest, 1–5 to build on the tile you're facing (wall, dike,
hearth, post, cairn), X to tear the top off a stack, esc for the cursor.
Nothing is on-chain until you hit Publish and sign (or leave autosave on).
A full tide is 4 minutes. Shorten it to iterate; the server and every client stay in step because the period is sent in the world payload:
TIDE_PERIOD_MS=45000 pnpm dev:server # a 45-second tideLogin is Privy (email or wallet); an email login gets an embedded wallet, and that wallet is your island — the address seeds the terrain and owns the namespace. It also decides how autosave feels: an embedded wallet signs silently, an injected one like MetaMask pops a confirmation every time. React is pulled in for the Privy modal and nothing else; the game itself is vanilla three.js and never re-renders.
pnpm test # 29 tests: island, tide, graph round-tripProperties are asserted across 40 seeds, not one, and several earned their keep
immediately. Some seeds put open water at the origin (spawning you in the sea
with the whole island unreachable). An absolute height cutoff for stone left 19%
of islands with none at all. And the tide's tests are what say whether
TIDE_HIGH is set sanely: no island may drown completely, every spawn must stay
dry at high water, and every island must still lose real ground to the flood.
Skips login entirely and plays as the ETH_PRIVATE_KEY wallet. Because that
identity is one the server can sign for, publishing works with no browser
wallet in the loop — which is what makes the whole loop testable headlessly.
DEV_LOGIN=1 # server: accept sessions with no token
VITE_DEV_LOGIN=1 # client: skip the Privy gateBoth halves are required, and that's the safety property: the client flag alone grants nothing, because the server decides independently whether to honour an unauthenticated session. Dev mode serves exactly one address (the service wallet's) and refuses every other, announces itself in the boot log, and is the only path on which the server will sign a publish. Never set it in production — anyone who can reach the server becomes the service wallet.
The island is a number. Terrain, coastline, and every tree and rock come out of one 32-bit seed folded from the wallet address. Nothing about the shape of the world is ever stored or sent — the client regenerates it. That's why a published world is a handful of vertices instead of a heightmap, and why the server can validate a harvest without loading anything.
The tide is a number too. Water level, phase, darkness, and the deadline are
one pure function of Date.now(). There is no scheduler, no background job, and
not a single tide message on the wire: the server and every browser derive the
same sea the same way, exactly as they derive the same island from a seed. A
world nobody opened for a week is just a world whose tide turned unwatched — it
reconciles on the next load. (A cosine, not a sawtooth, so the water is
continuous across the turn rather than dropping five metres on the tick.)
Everything is a hex. Continuous noise is still the source of truth, but every tile quantises it to a fixed terrace height, so the island is stacked plates rather than a smooth surface. A tile is a real place: you stand on one, harvest from one, build on one, publish one. Movement compares surfaces rather than tile tops, which is what lets wading off a beach, swimming, and hauling back out all be the same rule as walking up a terrace — while a cliff is still a cliff.
The client renders, the server rules. The browser sends what you did ("harvested node 7, standing here"), never what you have. The server credits your ledger only after agreeing the node exists, is in reach, is above water and is untaken, so a patched client can't mint. Reach checks run against a waterline a hair below the real one — smaller than a terrace, so it never opens a drowned tile, but enough that honest play near the edge isn't punished for latency.
The server can't publish for you. It builds the commit and pins the blocks with a keyless service wallet, then hands the browser an unsigned settlement tx. Your world's on-chain head moves only when your own wallet sends it. Same self-custodial split as fangornmd. The single exception is dev mode, which is why it's opt-in on both sides and loud at boot.
The island's seed and your delta on it — nothing else:
world:home tag "world" { seed, collected[], inventory }
├─in_world─ item:home:<id> tag "item" { kind, q, r, level }
└─in_world─ item:home:<id>
Items carry axial tile coords plus their height in the stack — small integers, no rounding question. Their world-space height isn't published because it's derivable from the seed.
The store is append-only, so every payload carries path (identity) and
updatedAt (ordering); worldFromGraph folds the history back to current state.
Unchanged payloads keep their timestamp, re-derive the same CID, and are never
re-uploaded. Nothing can be removed from such a store, so tearing down a
published structure publishes a tombstone — a newer version of the same path
carrying razed: true — and the ordering guarantee is what stops a razed wall
reappearing on every reader's copy.
Read anyone's island back — this is the whole point of publishing:
curl "localhost:8787/api/published?owner=0x…&world=home"A world on disk is ~250 bytes, and only the authored fields are written —
inventory and washed are recomputed on every load, so persisting them would
just create a staler second copy. Reads don't write at all: turnTide is a pure
function of the stored state plus the clock, so a tide that turns while nobody
is acting reconciles identically on the next load.
The binding constraint is memory, not disk. Terrain caches are pure functions of
a seed, but reachableTiles is ~600 tile objects per island, and uncapped that
is one permanent copy per player the process has ever served. They're bounded
LRUs now (64 islands for the flood fill, 128 for the cheaper ones); a miss costs
about a millisecond, far less than the disk and IPFS work around it. Cost is flat
from 5,000 players to 20,000.
razed is cleared once its tombstones are committed. The append-only history
keeps them and latestByPath keeps finding them, so holding the ids locally
afterwards only grows a list that never shrinks.
What this deliberately doesn't do yet: local-filesystem state means one server
instance. The next step is treating the working copy as a cache over Fangorn —
rebuild on miss from readNamespace, evict any world whose state matches its
published head (reconstructible at zero loss), and use subscribe(owner, ns) as
the invalidation signal so a publish from another device can't leave a stale
copy. Storage then scales with unpublished work rather than with players.
- Flotsam.
subscribeAppover thetideboundapp id is already a feed of every published world by anyone. Seeding a deposit fromhash(address + tideNumber)would pick which of other players' items wash up on your beach when the water recedes — a read of the feed, not new infrastructure. The tide that would carry it now exists; the mixing doesn't. - Boats and the archipelago. The next thing to build. An island is just a
seed, so unpopulated islands cost nothing to invent:
seedFromAddresson anything that isn't an address gives a whole sea of them. A boat (wood) lets you leave, which is what makes sand scarcity mean something. Visiting a player's island can only ever mean visiting their published snapshot — one more reason the publish button matters. - Doing something with a shade. They're presence-only today. The cheap real version is leaving something rather than interacting live: a cairn you build is already published, so a shade could read it. Anything synchronous means trusting a peer, which the current design deliberately never does.
- Drowning costs something. Right now the sea just puts you back at spawn. Losing the tide's unspent inventory would need a server-side action; it's a real consequence and it isn't wired.
- Binding address → Privy user server-side. The token proves a live session; the address riding alongside it is asserted by the client. Publishing is still safe (the settlement signature is the real authority), but one logged-in user can currently stage into another's working tree. Closing it needs the Privy app secret to resolve token → linked wallet.
The numbers that decide how the game feels, and where they live:
| Constant | File | Now | What it changes |
|---|---|---|---|
TIDE_PERIOD_MS |
tide.js | 4 min | How long a cycle — the game's whole pacing |
TIDE_LOW / TIDE_HIGH |
tide.js | −1.5 / 5.0 | How much island the flood takes |
DROWN_S |
scene.js | 14 | How forgiving open water is |
MAX_STACK |
island.js | 4 | How tall a building — or a seawall — gets |
NODE_COUNT / NODE_SPACING |
island.js | 60 / 2 | How dense the permanent island is |
FLOTSAM_COUNT |
island.js | 16 | How much each tide leaves on the beach |
STONE_SHARE |
island.js | 0.4 | Stone vs wood among standing resources |
STRUCTURES.dike.cost |
island.js | 2 sand + 1 stone | Courses of seawall per tide |
AUTOSAVE_IDLE_MS |
main.js | 45 s | How eagerly autosave spends gas |
pnpm test is what tells you when one of these has gone too far: the tide suite
fails if TIDE_HIGH drowns an island or floods a spawn.