SOURCE / PINNED RELEASE
Made of little things.
Napcraft
- Release
- ee47ac6a25df…
- Author-recorded commit
- 410dea87e109…
- License
- LICENSE
- Author’s source reference
- nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-52f9e22f5ce
Archive hash verified: 9e6e0cabb18d0d46…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import './styles.css';
import { cvm, webrtc, storage, identity } from '@napplet/sdk';
import backend from '../.napplet-space/soy-backend.json';
import {
backendClient,
BackendCallError,
} from '../docs/examples/backend-client';
import type {
BackendIntent,
BackendTarget,
} from '../docs/examples/backend-client';
import { runtimeHasDomain } from './domain-availability.js';
import { Scene } from './scene';
import { MATERIALS, practiceWorld, cell, top } from './world';
import type { World } from './world';
import { SharedEditor, validJournal } from './shared-editor';
import type { EditJournal } from './shared-editor';
import { mountWorldList, mountListingControl } from './directory-ui';
const FOLLOW_HOST_THEME = false;
void FOLLOW_HOST_THEME;
const $ = <T extends HTMLElement = HTMLElement>(id: string) =>
document.getElementById(id) as T;
const scene = new Scene($<HTMLCanvasElement>('scene'), practiceWorld());
const api = backendClient({ cvm }),
moduleRef = { napplet: backend.napplet, name: 'worlds' };
async function readSavedWorld(t: BackendTarget) {
for (let attempt=0;;attempt++) {
try { return await api.invoke<World>(api.intent(t,'readWorld',{})); }
catch (error) {
// Queries can conflict with another player's transaction. A fresh query is
// safe; this retry never changes or resubmits a world-creation intent.
if (!(error instanceof BackendCallError) || error.code !== 'CONFLICT' || attempt>=2) throw error;
await new Promise(resolve=>setTimeout(resolve,150*(attempt+1)));
}
}
}
type Recent = { code: string; name: string };
type KeptSave = { id: string; savedAt: number; intent?: BackendIntent; journal?: EditJournal; code?: string };
let keptSaves: KeptSave[] = [];
let target: BackendTarget | undefined,
recents: Recent[] = [],
pending: BackendIntent | undefined;
let mode: 'lobby' | 'practice' | 'shared' = 'lobby',
material = 4,
busy = false,
session = '',
seq = 0,
sending = false,
lastSnapshot = -1;
let liveState = 'Waiting for friends';
let playerActor = '';
let spawnFresh = false;
let pollTimer = 0,
sendTimer = 0,
toastTimer = 0,
identitySub: { close: () => void } | undefined,
identityKey = '',
epoch = 0;
const protocol = 'napcraft-presence-v1';
const connectedPeers = new Set<string>();
let editor: SharedEditor | undefined;
let editJournal: EditJournal | undefined;
async function persistEdits(journal: EditJournal | null) {
editJournal = journal ?? undefined;
if (runtimeHasDomain('storage'))
await storage.setItem('napcraft-edit-journal-v1', JSON.stringify(journal));
}
function unfinished() { return Boolean(pending || editor?.hasWork || editJournal); }
async function recoverEdits() {
if (editor) { editor.retry(); closePanel(); return; }
if (busy || !editJournal) return;
setBusy(true);
try {
const journal = editJournal;
const t = parseCode(codeFor(journal.target));
if (!validJournal(journal,t)) throw new Error('Invalid recovery data. Keep your world code and contact the creator.');
const r = await readSavedWorld(t);
await enter(t,r.result,r.revision,journal);
} catch (error) { showError(error); }
finally { setBusy(false); }
}
async function hintSaved() {
if (!session || !connectedPeers.size || !target) return;
try { await webrtc.send(session,{v:1,kind:'saved',instance:target.instance,release:target.release}); } catch {}
}
scene.player.color = crypto.getRandomValues(new Uint8Array(1))[0] % 6;
function toast(message: string) {
$('toast').textContent = message;
$('toast').hidden = false;
clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => ($('toast').hidden = true), 4200);
}
function status(message: string, error = false) {
$('saveStatus').textContent = message;
$('saveStatus').classList.toggle('warning', error);
}
function showPanel(html: string) {
scene.keys.clear();
$('panel').classList.remove('directory-panel');
$('overlay').hidden = false;
$('panel').innerHTML = html;
const close = $('close');
if (close) close.onclick = closePanel;
setTimeout(
() => $('panel').querySelector<HTMLElement>('input,button')?.focus(),
20,
);
}
function closePanel() {
if (mode === 'lobby') return;
$('overlay').hidden = true;
scene.canvas.focus();
}
const closer = () =>
mode === 'lobby'
? ''
: '<button id="close" class="close" aria-label="Close dialog">×</button>';
function escape(value: string) {
const span = document.createElement('span');
span.textContent = value;
return span.innerHTML;
}
function codeFor(t: BackendTarget) {
return (
'NC1-' +
btoa(JSON.stringify([t.release, t.instance]))
.replaceAll('+', '-')
.replaceAll('/', '_')
.replace(/=+$/, '')
);
}
function parseCode(value: string): BackendTarget {
try {
if (value.length > 300 || !value.startsWith('NC1-')) throw 0;
const [release, instance] = JSON.parse(
atob(value.trim().slice(4).replaceAll('-', '+').replaceAll('_', '/')),
);
if (
!/^[a-f0-9]{64}$/.test(release) ||
typeof instance !== 'string' ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(instance)
)
throw 0;
return { module: moduleRef, release, instance };
} catch {
throw new Error(
'That world code looks incomplete. Paste the entire NC1- code.',
);
}
}
async function saveLocal(required = false) {
if (!runtimeHasDomain('storage')) {
if (mode === 'shared')
toast(
'Keep your world code to return. This host has no recent-world storage.',
);
return;
}
try {
await storage.setItem('napcraft-worlds-v1', JSON.stringify(recents));
await storage.setItem(
'napcraft-pending-v1',
JSON.stringify(pending ?? null),
);
} catch (error) {
if (required) throw new Error('Could not keep your recovery request. Retry when host storage is available.');
toast('World is online; recent-world saving failed. Keep the world code.');
}
}
async function loadLocal() {
recents = [];
pending = undefined;
editJournal = undefined;
keptSaves = [];
if (runtimeHasDomain('storage'))
try {
const raw = await storage.getItem('napcraft-worlds-v1');
const entries = raw ? JSON.parse(String(raw)) : [];
recents = Array.isArray(entries)
? entries
.filter(
(x) => typeof x?.name === 'string' && typeof x?.code === 'string',
)
.slice(0, 8)
: [];
const journal = await storage.getItem('napcraft-edit-journal-v1');
if (journal) {
const value = JSON.parse(String(journal));
if (value?.target?.module?.napplet === backend.napplet && value.target.module.name === 'worlds' && validJournal(value,value.target)) editJournal = value;
}
const p = await storage.getItem('napcraft-pending-v1');
if (p) {
const saved = JSON.parse(String(p));
if (
saved?.target?.module?.napplet === backend.napplet &&
typeof saved.requestId === 'string'
)
pending = saved;
}
const kept = JSON.parse(String(await storage.getItem('napcraft-kept-saves-v1') || '[]'));
if (Array.isArray(kept)) keptSaves = kept.filter(k => k && typeof k.id === 'string' &&
(k.intent?.target?.module?.napplet === backend.napplet || k.journal?.target?.module?.napplet === backend.napplet)).slice(0,8);
} catch {
/* Optional local history is not authoritative world state. */
}
}
function lobby() {
showPanel(
`${closer()}<span class="stamp">WELCOME TO NAPCRAFT</span><div class="lobby-heading"><h1>Find your little<br>corner of the world.</h1><button id="create" class="primary">✦ Create a world</button></div>${unfinished() || editor?.uncertain ? '<button id="recover" class="wide">Review unfinished save</button>' : ''}${keptSaves.length ? '<button id="kept" class="wide">Kept recovery records</button>' : ''}<section id="publicWorlds" aria-label="Public worlds"></section><p id="error" class="error" role="status"></p><div class="secondary-row"><button id="join">↗ Join with a code</button><button id="practice">Try a practice island</button></div>${recents.length?'<h3 class="recents-heading">Your recent worlds</h3>':''}<div id="recents"></div><p class="footnote">Sign in to create or list a world. Friends can join as guests.<br>Listings are shared invitations, not live player counts.</p>`,
);
$('panel').classList.add('directory-panel');
mountWorldList($('publicWorlds'),code=>joinWorld(code),identityKey);
$('create').onclick = () => createDialog();
$('join').onclick = joinDialog;
$('practice').onclick = () => void startPractice();
if ($('recover')) $('recover').onclick = () => recoveryDialog();
if ($('kept')) $('kept').onclick = keptDialog;
for (const recent of recents) {
const b = document.createElement('button');
b.className = 'recent';
b.textContent = `▧ ${recent.name} →`;
$('recents').append(b);
b.onclick = () => void joinWorld(recent.code);
}
}
function createDialog(separate = false) {
if (unfinished()) { recoveryDialog(); return; }
const unresolved = keptSaves.find(k => k.intent?.operation === 'createWorld' && !k.code);
if (unresolved && !separate) { recoveryDialog(unresolved); return; }
showPanel(
`${closer()}<span class="stamp">A FRESH START</span><h2>Your world awaits.</h2><label for="name">Give your island a name</label><input id="name" maxlength="48" value="Our little island"/><button id="confirmCreate" class="primary wide">Create shared world</button><p class="footnote">Your host will ask to use your signed-in account. Anyone with the code can build here. Worlds are public; the code is not a password.</p><p id="error" class="error"></p><button id="back" class="linkish">← Back</button>`,
);
if (unresolved) {
const notice=document.createElement('label'); notice.className='recovery-consent';
notice.innerHTML='<input id="separateWorld" type="checkbox"/> I want a separate world. My older request may already have created one; keep its recovery record.';
$('confirmCreate').before(notice);
$<HTMLInputElement>('name').value='A different little island';
}
$('confirmCreate').onclick = () => void createWorld();
$('back').onclick = lobby;
}
function joinDialog() {
showPanel(
`${closer()}<span class="stamp">BETTER TOGETHER</span><h2>Meet on the island.</h2><label for="code">Paste a world code</label><input id="code" class="code" placeholder="NC1-…" autocomplete="off" spellcheck="false"/><button id="confirmJoin" class="primary wide">Join world</button><p id="error" class="error"></p><p class="footnote">Keep this code: it works again after everyone leaves. Anyone with it can build.</p><button id="back" class="linkish">← Back</button>`,
);
$('confirmJoin').onclick = () =>
void joinWorld($<HTMLInputElement>('code').value.trim());
$('back').onclick = lobby;
}
function showError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
if ($('error')) $('error').textContent = message;
else toast(message);
status('Connection needs attention', true);
}
async function keepRecovery(intent?: BackendIntent, journal?: EditJournal) {
if (!runtimeHasDomain('storage')) throw new Error('This host cannot keep recovery records. Copy the details before leaving; your pending request stays here.');
const id = intent?.requestId || journal?.active?.intent.requestId || `edits-${journal?.target.instance}`;
const old = keptSaves.find(k=>k.id===id);
if (old) return old;
if (keptSaves.length >= 8) throw new Error('Eight recovery records are already kept. Copy these details and resolve an earlier record first.');
const record: KeptSave = {id,savedAt:Date.now(),...(intent?{intent:structuredClone(intent)}:{}),...(journal?{journal:structuredClone(journal)}:{})};
const next=[record,...keptSaves];
await storage.setItem('napcraft-kept-saves-v1',JSON.stringify(next));
keptSaves=next;
return record;
}
function keptDialog() {
showPanel(`${closer()}<span class="stamp">NOTHING THROWN AWAY</span><h2>Kept recovery records.</h2><p>These copies are never sent automatically. Recover a missing world with its code, or deliberately choose a separate new world.</p><div id="records"></div><button id="back" class="linkish">← World menu</button>`);
for (const record of keptSaves) {
const b=document.createElement('button'); b.className='recent';
b.textContent=record.code?'Recovered world · View record':record.intent?.operation==='createWorld'?'Unresolved world creation':'Block recovery copy';
b.onclick=()=>recoveryDialog(record); $('records').append(b);
}
$('back').onclick=lobby;
}
function recoveryDialog(record?: KeptSave) {
const intent=record ? record.intent : pending, journal=record ? record.journal : editJournal;
const creation=intent?.operation==='createWorld';
const expired=Boolean(intent && intent.expiresAt <= Date.now()/1000);
const name=creation ? String((intent.input as {name?:string})?.name || 'Your world') : 'Your blocks';
const text=creation ? expired ?
'This older request can no longer be safely retried. Your world might already exist, but its code never arrived. The original request is kept; creating a replacement could make a duplicate.' :
'The world code has not arrived yet. Check your host or signer for an approval prompt. Retrying uses the exact same saved request, so a lost reply will not create a second world.' :
`${journal?.queue.length || 1} block or setting change(s) need review. We will check the saved world and retry only when it is safe. A recovery copy is kept before reviewing older work.`;
showPanel(`${closer()}<span class="stamp">LET’S PICK UP WHERE YOU LEFT OFF</span><h2>${record?.code?'Recovered world.':creation?'Let’s find your world.':'Your unfinished save.'}</h2><p><b>${escape(name)}</b></p><p>${record?.code?'This request was linked to the world code you recognized. Its original details are retained.':text}</p>${!record && (!creation || !expired)?'<button id="retrySave" class="primary wide">'+(creation?'Retry saved request':'Review and retry safely')+'</button>':''}${creation && !record?.code?'<label for="recoveredCode">Already have this world’s code?</label><input id="recoveredCode" class="code" placeholder="NC1-…" autocomplete="off"/><button id="checkCode" class="wide">Check recovered world</button>':''}${creation&&expired&&!record?'<button id="keepSave" class="wide">Keep request and continue</button>':''}<p id="error" class="error" role="status"></p><details><summary>Recovery details to keep or share with support</summary><textarea id="recoveryDetails" class="code" readonly rows="6">${escape(JSON.stringify(record || {intent,journal},null,2))}</textarea></details><p class="footnote">${record?'You can join an existing world or use practice from World menu.':'Nothing is discarded when you close this panel.'}</p><button id="back" class="linkish">← World menu</button>`);
$('back').onclick=lobby;
if (record?.intent?.operation==='createWorld' && !record.code) {
const different=document.createElement('button'); different.id='differentWorld'; different.className='wide';
different.textContent='Create a different world…';
different.onclick=()=>createDialog(true); $('back').before(different);
}
if ($('retrySave')) $('retrySave').onclick=()=>void (async()=>{
if (busy) return;
if (creation) { await retryPending(); return; }
try {
if (journal || intent) await keepRecovery(intent,journal);
await (pending ? retryPending() : recoverEdits());
} catch(error) { showError(error); }
})();
if ($('keepSave')) $('keepSave').onclick=()=>void (async()=>{
if (busy || !intent || pending !== intent) return;
try {
await keepRecovery(intent);
await storage.setItem('napcraft-pending-v1','null');
pending=undefined; lobby(); toast('Request kept. You can join another island or practice while its creation is unresolved.');
} catch(error) { showError(error); }
})();
if ($('checkCode')) $('checkCode').onclick=()=>void (async()=>{
if (busy || !intent) return;
setBusy(true);
try {
const code=$<HTMLInputElement>('recoveredCode').value.trim(), t=parseCode(code);
if (t.release !== intent.target.release) throw new Error('That code is from a different world release. Keep this request and check the original world code.');
const r=await readSavedWorld(t);
showPanel(`${closer()}<h2>Is this your missing world?</h2><p><b>${escape(r.result.name)}</b><br>${r.result.edits} saved edits.</p><p>Only continue if you recognize this as the world you were creating. We will retain the original request and open this code.</p><button id="useRecovered" class="primary wide">Yes, open this world</button><button id="back" class="wide">Keep looking</button><p id="error" class="error"></p>`);
$('back').onclick=()=>recoveryDialog(record);
$('useRecovered').onclick=()=>void (async()=>{
if (busy) return;
setBusy(true);
try {
const saved=record || await keepRecovery(intent);
const next=keptSaves.map(k=>k.id===saved.id?{...k,code}:k);
await storage.setItem('napcraft-kept-saves-v1',JSON.stringify(next)); keptSaves=next;
if (pending===intent) {await storage.setItem('napcraft-pending-v1','null');pending=undefined;}
recents=[{code,name:r.result.name},...recents.filter(v=>v.code!==code)].slice(0,8);await saveLocal();
await enter(t,r.result,r.revision);
} catch(error) {showError(error);} finally {setBusy(false);}
})();
} catch(error) {showError(error);} finally {setBusy(false);}
})();
}
function setBusy(value: boolean) {
busy = value;
for (const id of ['confirmCreate', 'confirmJoin', 'place', 'remove', 'retrySave', 'checkCode', 'keepSave', 'useRecovered'])
if ($(id)) $<HTMLButtonElement>(id).disabled = value;
for (const [id,label] of [['confirmCreate','Create shared world'],['retrySave','Retry saved request']] as const) {
if ($(id) && (value || id==='confirmCreate')) $(id).textContent=value?'Waiting for approval or reply…':label;
}
}
async function invoke(intent: BackendIntent) {
return api.invoke<World>(intent);
}
async function createWorld() {
if (busy) return;
if (unfinished()) {
toast('Resolve the unfinished save before creating another world.');
return;
}
if (keptSaves.some(k=>k.intent?.operation==='createWorld'&&!k.code) && !$<HTMLInputElement>('separateWorld')?.checked) {
showError(new Error('Confirm that you want a separate world. Your earlier request will remain in Kept recovery records.'));
return;
}
setBusy(true);
try {
const desc = await api.describe(moduleRef);
if (desc.disabled || !desc.active)
throw new Error(
'Shared world hosting is not active yet. Try practice, or retry shortly.',
);
pending = api.intent(
{ module: moduleRef, release: desc.active },
'createWorld',
{ name: $<HTMLInputElement>('name').value.trim() || 'Our little island' },
);
await saveLocal(true);
await finishPending();
} catch (error) {
if (pending) recoveryDialog();
showError(error);
} finally {
setBusy(false);
}
}
async function finishPending() {
if (!pending) return;
const intent = pending;
const generation = epoch;
if (intent.expiresAt <= Date.now() / 1000 || intent.operation === 'editBlock') {
if (intent.target.instance) {
const fresh = await readSavedWorld(intent.target);
if (generation !== epoch) return;
pending = undefined;
await saveLocal();
await enter(intent.target, fresh.result, fresh.revision);
toast(
intent.operation === 'editBlock' ? 'Older pending save refreshed. Review your last block before editing again.' :
'Retry expired. World refreshed; check your last block before editing again.',
);
return;
}
throw new Error(
'Creation response expired before its world code was recovered. Do not create a duplicate without checking with the provider.',
);
}
const result = await invoke(intent);
if (generation !== epoch) return;
const t = {
module: moduleRef,
release: result.release,
instance: result.instance,
};
const code = codeFor(t);
recents = [
{ code, name: result.result.name },
...recents.filter((r) => r.code !== code),
].slice(0, 8);
// Keep the original intent until the recovered code has been stored. If that
// write fails, an exact retry can still retrieve the same creation receipt.
await saveLocal(true);
pending = undefined;
await saveLocal();
await enter(t, result.result, result.revision);
void hintSaved();
status('✓ All blocks saved');
}
async function retryPending() {
if (busy || !pending) return;
setBusy(true);
try {
await finishPending();
} catch (error) {
recoveryDialog();
showError(error);
} finally {
setBusy(false);
}
}
async function joinWorld(code: string) {
if (busy) return;
setBusy(true);
try {
if (unfinished())
throw new Error('Retry your unfinished save before changing worlds.');
const t = parseCode(code);
const result = await readSavedWorld(t);
recents = [
{ code, name: result.result.name },
...recents.filter((r) => r.code !== code),
].slice(0, 8);
await saveLocal();
await enter(t, result.result, result.revision);
} catch (error) {
showError(error);
} finally {
setBusy(false);
}
}
function applyWorld(world: World, revision: number) {
if (!world || !Array.isArray(world.blocks) || world.blocks.length !== 3888)
throw new Error('Invalid world snapshot.');
if (revision < lastSnapshot) return;
scene.world = world;
lastSnapshot = revision;
$('worldName').textContent = world.name;
$('app').dataset.revision = String(revision);
status(world.locked ? 'Building paused by owner' : '✓ All blocks saved');
}
async function stopSession() {
epoch++;
editor?.dispose();
editor = undefined;
scene.pendingCells.clear();
clearInterval(pollTimer);
clearInterval(sendTimer);
scene.peers.clear();
connectedPeers.clear();
spawnFresh = false;
const old = session;
session = '';
if (old)
try {
await webrtc.close(old, 'Leaving world');
} catch {}
scene.keys.clear();
}
async function enter(t: BackendTarget, world: World, revision: number, journal?: EditJournal) {
const sameWorld = mode === 'shared' && target?.instance === t.instance && target?.release === t.release;
await stopSession();
target = t;
mode = 'shared';
lastSnapshot = -1;
applyWorld(world, revision);
const generation = epoch;
// Query the pinned release, not the current default: old worlds keep their rules.
let compact = false;
try {
const description = await api.describe(moduleRef,t.release);
const schemas = description.schemas as {operations?:Record<string,unknown>} | undefined;
compact = Boolean(schemas?.operations?.editBlocks && schemas.operations.readUpdates);
} catch { /* Legacy operations work on both releases; retain the existing world. */ }
if (generation !== epoch) return;
const transport = await cvm.registry.call('soy.rooms.v1','soy_session',{}, {timeoutMs:15000});
const actor = (transport.structuredContent as {actor?:unknown} | undefined)?.actor;
if (transport.isError || typeof actor !== 'string' || !/^[a-f0-9]{64}$/.test(actor))
throw new Error('Could not verify your player session. Rejoin before saving blocks.');
if (generation !== epoch) return;
playerActor = actor;
if (!sameWorld) {
scene.player = {
x: 6.5 + (parseInt(actor.slice(0,4),16)%5)*1.3,
z: 8.5 + (parseInt(actor.slice(4,8),16)%4)*1.3,
color: parseInt(actor.slice(8,12),16)%6,
name: 'You',
};
spawnFresh = true;
}
editor = new SharedEditor(t,world,revision,compact,api,{
persist:persistEdits,
saved:() => { void hintSaved(); },
notice:toast,
change(view) {
if (generation !== epoch) return;
scene.world = view.world;
scene.pendingCells = view.cells;
scene.confirmedEdits = view.confirmedEdits;
$('app').dataset.revision = String(view.revision);
$('app').dataset.pending = String(view.pending);
$('app').dataset.sync = view.state;
const message = view.state === 'retry' || view.state === 'refreshing' ? view.message || 'Refreshing saved blocks…' :
view.pending ? `${view.pending} block${view.pending === 1 ? '' : 's'} pending · Saving…` :
view.world.locked ? 'Building paused by owner' : '✓ All blocks saved';
status(message,view.state === 'retry');
},
},journal,actor);
editor.start();
activate();
$('share').hidden = false;
$('worldMode').textContent = 'Shared island · Creative';
try {
const result = await webrtc.open({
scope: { type: 'room', room: `napcraft-${t.instance}` },
channel: 'garden',
protocol,
});
if (generation !== epoch) { await webrtc.close(result.session.id,'World changed'); return; }
session = result.session.id;
liveState = 'Waiting for friends';
} catch (error) {
liveState = 'Live players unavailable';
$('worldMode').textContent = 'Shared · Live players unavailable';
toast(
`Blocks are saved. Live players unavailable: ${error instanceof Error ? error.message : 'Connection denied'}`,
);
}
pollTimer = window.setInterval(() => { if (!document.hidden) void refresh(); }, 2600);
sendTimer = window.setInterval(() => void sendPresence(), 110);
}
async function startPractice() {
if (unfinished()) {
toast('Retry your unfinished save before changing worlds.');
return;
}
await stopSession();
target = undefined;
mode = 'practice';
scene.world = practiceWorld();
scene.player = { x: 8, z: 11, color: 0, name: 'You' };
$('worldName').textContent = 'Practice island';
$('worldMode').textContent = 'Creative · Temporary';
$('share').hidden = true;
status('Practice only · Changes are not saved');
activate();
}
function activate() {
scene.active = true;
$('playHud').hidden = false;
$('overlay').hidden = true;
scene.canvas.focus();
}
async function refresh() {
if (mode === 'shared' && !busy && !pending) editor?.sync();
}
function edit(remove: boolean) {
if (busy || mode === 'lobby' || !$('overlay').hidden) return;
spawnFresh = false;
if (pending) { toast('Your last save needs a retry. Open World menu.'); return; }
if (scene.world.locked) { toast('The owner has paused building.'); return; }
const {x,z} = scene.selected, y = top(scene.world,x,z)+(remove?0:1);
if (y < 2) { toast('Keep the island’s foundation.'); return; }
if (y >= 12) { toast('You reached the sky limit — 12 blocks high.'); return; }
const expected = scene.world.blocks[cell(x,y,z)], block = remove?0:material;
if (mode === 'practice') {
scene.world.blocks[cell(x,y,z)] = block; scene.world.edits++;
} else if (!editor?.enqueue({x,y,z,expected,block})) return;
scene.burst(x,y,z,remove?expected:material);
}
async function sendPresence() {
if (!session || sending || !connectedPeers.size) return;
sending = true;
const generation = epoch;
try {
await webrtc.send(session, {
v: 1,
seq: ++seq,
x: scene.player.x,
z: scene.player.z,
color: scene.player.color,
});
} catch {
if (generation === epoch) liveState = 'Live connection interrupted';
} finally {
sending = false;
}
}
let rtcSub: { close: () => void } | undefined;
try {
rtcSub = webrtc.onEvent((event) => {
if (event.sessionId !== session) return;
if (event.type === 'message') {
const p = event.payload as Record<string, unknown>;
if (p?.v === 1 && p.kind === 'saved') {
if (connectedPeers.has(event.from) && p.instance === target?.instance && p.release === target?.release)
editor?.sync(true);
return;
}
if (
!p ||
!connectedPeers.has(event.from) ||
p.v !== 1 ||
!Number.isSafeInteger(p.seq) ||
typeof p.x !== 'number' ||
typeof p.z !== 'number' ||
!Number.isFinite(p.x) ||
!Number.isFinite(p.z) ||
p.x < 0 ||
p.x >= 18 ||
p.z < 0 ||
p.z >= 18 ||
!Number.isInteger(p.color) ||
Number(p.color) < 0 ||
Number(p.color) > 5
)
return;
const old = scene.peers.get(event.from);
if (old && Number(p.seq) <= old.seq) return;
if (!old && scene.peers.size >= 8) return;
scene.peers.set(event.from, {
x: old?.x ?? p.x,
z: old?.z ?? p.z,
tx: p.x,
tz: p.z,
color: Number(p.color),
name: `FRIEND ${event.from.slice(-4).toUpperCase()}`,
seen: performance.now(),
seq: Number(p.seq),
});
// Two transports can hash to the same starting spot. Only the higher key
// yields, and only before movement/edit input, so friends never jump mid-play.
if (spawnFresh && playerActor > event.from &&
Math.hypot(scene.player.x-p.x,scene.player.z-p.z)<1.2) {
for (let i=0;i<20;i++) {
const x=6.5+(i%5)*1.3, z=8.5+Math.floor(i/5)*1.3;
if ([...scene.peers.values()].every(peer=>Math.hypot(x-peer.tx,z-peer.tz)>=1.2)) {
scene.player.x=x; scene.player.z=z; break;
}
}
}
} else if (event.type === 'peer') {
if (event.state === 'joined') {
connectedPeers.add(event.pubkey);
scene.peers.delete(event.pubkey);
liveState = 'Live';
void sendPresence();
editor?.sync(true);
} else if (event.state === 'left') {
connectedPeers.delete(event.pubkey);
scene.peers.delete(event.pubkey);
if (!connectedPeers.size) liveState = 'Waiting for friends';
}
} else if (event.type === 'closed') {
session = '';
liveState = 'Live disconnected';
$('worldMode').textContent = 'Shared · Live players disconnected';
scene.peers.clear();
connectedPeers.clear();
status('Live players disconnected · Rejoin from World menu', true);
}
});
} catch {
/* The conformance reference shell may expose no WebRTC methods. User actions explain errors. */
}
function shareDialog() {
if (!target) return;
showPanel(
`${closer()}<span class="stamp">SAVE THIS LITTLE INVITATION</span><h2>Room for your friends.</h2><section id="listingControl" class="listing-control"></section><label for="shareCode">Or share your persistent world code</label><input id="shareCode" class="code" readonly value="${escape(codeFor(target))}"/><button id="selectCode" class="primary wide">Select code to copy</button><p class="footnote">Copy with your device’s Copy command. Keep the code to return later. Anyone with it can edit. Worlds are public.</p><button id="lock" class="linkish">${scene.world.locked ? 'Resume' : 'Pause'} building (owner only)</button>`,
);
mountListingControl($('listingControl'),codeFor(target),scene.world.name);
$('selectCode').onclick = () => {
$<HTMLInputElement>('shareCode').focus();
$<HTMLInputElement>('shareCode').select();
};
$('lock').onclick = async () => {
if (busy || unfinished() || !target) { toast('Let pending blocks save before changing building permissions.'); return; }
setBusy(true);
pending = api.intent(target, 'setLocked', { locked: !scene.world.locked });
await saveLocal();
try {
await finishPending();
closePanel();
} catch (e) {
if (e instanceof BackendCallError && e.definitive) {
pending = undefined;
await saveLocal();
}
showError(e);
} finally {
setBusy(false);
}
};
}
$('help').onclick = () =>
showPanel(
`${closer()}<span class="stamp">MAKE YOURSELF AT HOME</span><h2>A few little tricks.</h2><div class="help-grid"><b>Wander</b><span>WASD / arrow keys, or the on-screen arrows. Friends have colored gardeners and name tags.</span><b>Desktop</b><span>Hover to aim. Click any visible block face to add on top of its column. Right-click erases the top block. Shift-click aims without building.</span><b>Touch</b><span>Tap a block to aim, then use Build or Erase. Drag to pan without building.</span><b>Keys</b><span>Space builds, X erases. Pick a material with 1–6 or the swatches.</span><b>Look around</b><span>Drag to pan. Use + / − to zoom.</span><b>Come back</b><span>Shared blocks save online. Keep your world code. Practice is temporary.</span></div><button id="helpBack" class="primary wide" style="margin-top:20px">Got it</button>`,
);
$('help').addEventListener('click', () => {
$('helpBack').onclick = () => (mode === 'lobby' ? lobby() : closePanel());
});
$('panel').addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
const focusable = [
...$('panel').querySelectorAll<HTMLElement>('button:not(:disabled),input'),
];
const first = focusable[0],
last = focusable.at(-1);
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
});
$('worldMenu').onclick = lobby;
$('share').onclick = shareDialog;
for (const [id, remove] of [['place',false],['remove',true]] as const) {
const button = $(id);
let touchAt = -Infinity;
button.onpointerdown = (event) => {
if (event.pointerType !== 'touch' && event.pointerType !== 'pen') return;
event.preventDefault(); touchAt = performance.now(); edit(remove);
};
button.onclick = (event) => {
if (event.detail && performance.now()-touchAt < 800) return;
edit(remove);
};
}
$('zoomIn').onclick = () => (scene.zoom = Math.min(2.2, scene.zoom + 0.15));
$('zoomOut').onclick = () => (scene.zoom = Math.max(0.65, scene.zoom - 0.15));
const tray = document.querySelector('.materials')!;
for (let i = 1; i <= 6; i++) {
const b = document.createElement('button');
b.className = 'material';
b.setAttribute('aria-label', MATERIALS[i].name);
b.innerHTML = `<kbd>${i}</kbd><span class="swatch" style="background:${MATERIALS[i].color}"></span><small>${MATERIALS[i].name}</small>`;
b.onclick = () => select(i);
tray.append(b);
}
function select(i: number) {
material = i;
tray
.querySelectorAll('button')
.forEach((b, k) => b.setAttribute('aria-pressed', String(k + 1 === i)));
}
select(4);
for (const b of document.querySelectorAll<HTMLButtonElement>('[data-key]')) {
const key = b.dataset.key!;
b.onpointerdown = (e) => {
e.preventDefault();
b.setPointerCapture(e.pointerId);
scene.keys.add(key);
spawnFresh = false;
};
for (const event of ['pointerup', 'pointercancel', 'lostpointercapture'])
b.addEventListener(event, () => scene.keys.delete(key));
}
document.addEventListener('keydown', (e) => {
if (!$('overlay').hidden) {
if (e.key === 'Escape') closePanel();
return;
}
if (/^[1-6]$/.test(e.key)) select(Number(e.key));
if (
[
'KeyW',
'KeyA',
'KeyS',
'KeyD',
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
].includes(e.code)
) {
scene.keys.add(e.code);
spawnFresh = false;
e.preventDefault();
}
if (e.code === 'Space') {
e.preventDefault();
if (!e.repeat) void edit(false);
}
if (e.code === 'KeyX' && !e.repeat) void edit(true);
if (e.key === 'Escape') lobby();
});
document.addEventListener('keyup', (e) => scene.keys.delete(e.code));
window.addEventListener('blur', () => scene.keys.clear());
window.addEventListener('online', () => { if (editor?.uncertain) editor.retry(); else editor?.sync(true); });
document.addEventListener('visibilitychange', () => {
scene.keys.clear();
if (!document.hidden) { if (editor?.uncertain) editor.retry(); else editor?.sync(true); }
});
let drag:
| { id: number; x: number; y: number; px: number; py: number; moved: boolean; button: number; pointerType: string }
| undefined;
scene.canvas.onpointerdown = (e) => {
if (drag || !scene.active || !$('overlay').hidden) return;
if (![0,1,2].includes(e.button)) return;
e.preventDefault();
scene.canvas.focus();
scene.canvas.setPointerCapture(e.pointerId);
drag = {
id: e.pointerId,
x: e.offsetX,
y: e.offsetY,
px: scene.pan.x,
py: scene.pan.y,
moved: false,
button: e.button,
pointerType: e.pointerType,
};
};
scene.canvas.onpointermove = (e) => {
if (!drag) {
if (e.pointerType === 'mouse' && scene.active && $('overlay').hidden)
scene.canvas.style.cursor = scene.pick(e.offsetX,e.offsetY) ? 'crosshair' : 'grab';
return;
}
if (drag.id !== e.pointerId) return;
const dx = e.offsetX - drag.x,
dy = e.offsetY - drag.y;
if (Math.hypot(dx, dy) > 8) drag.moved = true;
if (drag.moved) {
scene.canvas.style.cursor = 'grabbing';
scene.pan.x = Math.max(-600, Math.min(600, drag.px + dx));
scene.pan.y = Math.max(-400, Math.min(400, drag.py + dy));
}
};
scene.canvas.onpointerup = (e) => {
if (!drag || drag.id !== e.pointerId) return;
const gesture = drag;
drag = undefined;
if (!gesture.moved && Math.hypot(e.offsetX-gesture.x,e.offsetY-gesture.y)<=8 &&
gesture.button !== 1 && scene.pick(e.offsetX, e.offsetY) &&
gesture.pointerType === 'mouse' && !e.shiftKey) edit(gesture.button === 2);
scene.canvas.style.cursor = 'grab';
};
scene.canvas.onpointercancel = () => (drag = undefined);
scene.canvas.onlostpointercapture = () => (drag = undefined);
scene.canvas.oncontextmenu = (e) => e.preventDefault();
window.setInterval(() => {
$('presence').textContent =
mode === 'shared'
? scene.peers.size
? `${scene.peers.size + 1} building together`
: liveState
: 'CREATIVE CLUB';
if (matchMedia('(pointer: coarse)').matches)
$('hint').textContent = 'Tap a block to aim · Pick a color · Build';
else if (innerWidth < 600)
$('hint').textContent = 'Click to build · Right-click to erase';
else
$('hint').textContent =
'Click to build · Right-click to erase · WASD to wander · Drag to pan';
}, 500);
async function bootstrap() {
try {
if (runtimeHasDomain('identity')) {
identityKey = await identity.getPublicKey();
identitySub = identity.onChanged(() => {
void (async () => {
const key = await identity.getPublicKey();
if (key === identityKey) return;
identityKey = key;
await stopSession();
mode = 'lobby';
scene.active = false;
target = undefined;
$('playHud').hidden = true;
$('share').hidden = true;
await loadLocal();
lobby();
})();
});
}
} catch {}
await loadLocal();
lobby();
$('app').dataset.ready = 'true';
}
void bootstrap();
window.addEventListener('pagehide', () => {
void stopSession();
rtcSub?.close();
identitySub?.close();
scene.dispose();
});
