Every city gets an island
In the first post we grew a city at dusk in the browser with nothing to install. Those cities were deliberately forgetful: press new seed and the one you liked is gone.
This is the same city with a memory, and the memory is public. Below is the sea, with a handful of cities already on it. The island in the middle is yours and follows the sliders. Every island around it is a city somebody kept. Click one and its numbers load into yours. Make one you like, keep it, and it gets an island of its own — alongside everything the next reader keeps.
A kept city is not a model file
Here is the idea that carries this post. A generated city is random, and that seems like a problem for saving: you can't store "what the random numbers happened to be." Except you can, because the randomness was seeded from the start:
jsconst rng = mulberry32(seed);
Every rng() call after that line returns the same sequence for the same
seed, so the city is deterministic: the same plazas, the same tall block
downtown, the same colour on the same corner, every time, on every screen.
A kept city in this app is not a mesh, a glTF, or a screenshot. It is four
numbers and a word: a seed, a density, a height, a time of day, and a
palette name. Give those to a fresh copy of the builder and it grows the
city from scratch, as many times as you like.
Two honest footnotes on "the same city". The numbers fix the recipe, not the view: the camera angle you had when you pressed keep is not one of them, so an island on the sea is the recipe grown fresh, seen from wherever you sail to. And the recipe is exact only while the builder is: change the rule for where plazas fall and every old seed grows a different city. That is why the builder pins its copy of three.js to one release, and why the generator is the one function in the file you treat as frozen. The third post in this series says more about the pin.
That changes what "save" and "share" mean. A model file is megabytes that can only ever be that city. Five values are a few dozen bytes, and they are the recipe — load somebody else's island and you are holding their parameters, not their output. Nudge the height and you have made a variation, not a copy. Every island on the sea is a real three.js city grown from its numbers, which is why the sea is cheap enough to hold dozens of them in one scene, and why clicking one hands you a live builder instead of a thumbnail.
The memory is one hook
Storing five values needs somewhere to put them. That is one line:
jsconst { database, useLiveQuery } = useFireproof("cities");
Keeping a city is one more:
jsawait database.put({ type: "city", ...params, authorHandle, created: Date.now() });
And reading the sea back is the third:
jsconst { docs } = useLiveQuery("created", { descending: true, limit: 60 });
Get posts like this in your inbox
One email field. Real updates. No algorithm required.
That is the entire data layer of this app. useLiveQuery is live in the
sense that matters — when anyone keeps a city, every open copy of the app
re-renders with it, including the one embedded in this page. There is no
fetch, no refresh button, no loading state to write.
It is also local-first, which shows up as an absence: pressing keep does not wait for a server. The city is in your copy of the database the instant you press the button, and it syncs outward when it can. Do this on a train and the sea fills in when you surface.
Two honest lines about being signed out. You can build here all day, and the sea is yours to sail. Keeping is for members: sign in and your city joins the shared sea, stamped with your handle, which is what the section after next is about.
The scene follows the documents
This is the part that is new in 3D, and it is the part worth copying into whatever you build next. A list of documents arrives from the hook. The scene has a list of islands. The two have to agree, and the way to make them agree is not to rebuild the sea every time — it is to reconcile:
jsconst islands = new Map(); // document id → the island group in the scene
function setCities(docs) {
const seen = new Set();
docs.forEach((doc, i) => {
seen.add(doc._id);
let island = islands.get(doc._id);
if (!island) {
island = buildCity(doc, materials);
island.userData.doc = doc;
islands.set(doc._id, island);
scene.add(island);
}
const [x, z] = islandSpot(i);
island.position.set(x, 0, z);
});
for (const [id, island] of islands) {
if (!seen.has(id)) {
scene.remove(island);
islands.delete(id);
}
}
}
Read it as housekeeping. A document the scene has not seen gets an island built and added. A document that is still there keeps the island it already has — three.js is not asked to touch it. A document that has gone takes its island with it. (Cities in this app are kept, never edited, so an island never has to be rebuilt in place; an app whose documents change would compare a revision and rebuild the one island that moved.) The map from id to island is the whole trick: it is what lets a live query that fires every few seconds drive a scene with hundreds of meshes in it without the scene flickering, re-uploading geometry, or losing the camera you were dragging.
The islands sit on a spiral, one step out per document, turned by the golden angle so neighbours never line up:
jsfunction islandSpot(i) {
const r = 13 + Math.sqrt(i) * 6.5;
const a = i * 2.39996;
return [Math.cos(a) * r, Math.sin(a) * r];
}
Newest in the middle, oldest on the horizon. That is a sort order expressed as a place, which is the kind of thing a 3D scene can do that a list cannot.
Clicking an island is one raycast — a line from the camera through the pointer, asked which island it hits first — and then a walk up the hit mesh's parents to the group that carries the document:
jslet o = hit?.object;
while (o && !o.userData.doc) o = o.parent;
if (o) loadParams(o.userData.doc);
Towers are children of islands, so the thing you clicked is a tower and the
thing you meant is its island. The userData bag on the group is where the
document lives; everything else about the island is derived from it.
The bouncer is ten lines
Who gets to add an island to a shared sea? Every vibe ships a server-side access rule, a small function that sees every write before it lands. This app's entire policy:
jsexport default function (doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in to keep a city" };
if (doc.type === "city") {
if (doc.authorHandle !== user.userHandle) {
throw { forbidden: "sign your city with your own handle" };
}
if (oldDoc && oldDoc.authorHandle !== user.userHandle) {
throw { forbidden: "not your city" };
}
return { channels: ["cities"], grant: { public: ["cities"] } };
}
throw { forbidden: "unknown document type" };
}
Read it as house rules: cities go on the public shelf, so anyone can see the sea, even signed out. Every city must be signed with its author's own handle, both when it is kept and on every later change, so nobody can post under your name or hand you a city you did not make; and after that only its author can touch it. The rule runs on the server, so it holds no matter what a client sends. It is the whole difference between a shared world and a scratch pad, and it is a dozen lines you can read.
The same app is a private sketchbook without touching that rule. Deploy it private and the city-level policy above is unchanged — it still says every city is signed and only its author can edit it — while who is in the room at all becomes a separate question, answered by who you invited. Nobody sees the sea but you, or you and the two people you shared it with.
A class world — anyone may look, only your students may add islands — is a
different job, and worth being precise about because it is the one people
get wrong. The grant above controls who can read; swapping public for
a role would hide the sea from visitors without stopping anyone from
adding to it. Restricting writing is a line in the function:
jsctx.requireAccess("class"); // must be in the class
return { channels: ["cities"], grant: { public: ["cities"] } }; // anyone may read
Then the owner adds a student by approving a small grant document, the same way a publication approves an author. So two of the three postures — public sea and private sketchbook — really are the same app with a different setting, and the third costs you one line and a roster.
When you do want the model
Sometimes you want the geometry: a print, a game engine, a friend who works in Blender. Three.js ships an exporter, and it is a few lines:
jsimport { GLTFExporter } from "https://esm.sh/[email protected]/examples/jsm/exporters/GLTFExporter.js";
new GLTFExporter().parse(island, (gltf) => download(JSON.stringify(gltf), "city.gltf"));
Hang that on a button and the reader gets a file every 3D tool can open. It is worth knowing what you traded, though: the file is finished. It is that city, at that height, in that palette, forever. Five values are the seed packet — anyone holding them can grow it denser, at noon instead of dusk, in neon instead of concrete, or nudge the seed until it is theirs. The model is for the printer on your desk. The numbers are for the sea in this post.
Remix it
The archipelago is public, so remixing it hands you all three pieces at once: the builder, the database, and the access rule. The interesting move is to throw away the city and keep everything else. Whatever your scene is — a planet generator, a chair configurator, a molecule, a garden — if you can describe one of its outputs as a handful of numbers, this sea works for it unchanged. Name your parameters, seed your randomness, reconcile the documents into the scene, and the world comes free.
The third post in this series takes the lid off: how three.js gets into the page at all, what a frame of this actually costs, and the same city with a quarter of a million towers in it.
Your world is a remix away
Take the sea and make it yours — public show or private sketchbook.
Start building →