Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 

Repository files navigation

Tidebound

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.

The game

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.

The shades

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.

Run it

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, 15 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 tide

Login 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-trip

Properties 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.

Dev / admin mode

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 gate

Both 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.

Five decisions worth knowing

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.

What's published

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"

Footprint

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.

Not built yet

  • Flotsam. subscribeApp over the tidebound app id is already a feed of every published world by anyone. Seeding a deposit from hash(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: seedFromAddress on 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.

Tuning

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors