SOURCE / PINNED RELEASE
Made of little things.
Browser Side Quest
- Release
- b9d42f5768e4…
- Author-recorded commit
- c267690145fa…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/browser-side-quest
Archive hash verified: 6d4b90b8a5f328f7…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/**
* Browser Side Quest — boot. Wires the ported game to the napplet host:
* identity (character name), storage (save), common/resource (player profiles),
* config (settings) and WebRTC (shared worlds). Every domain is optional; the
* game always runs, falling back to a private solo world.
*/
import { WEBRTC_DOMAIN, type Subscription } from '@napplet/sdk';
import { App, type WorldProvider } from './client/app.js';
import { GameStorage } from './client/storage.js';
import { runtimeHasDomain } from './domain-availability.js';
import { InputLayer } from './input/input.js';
import { defaultSettings, watchSettings, type GameSettings } from './napplet-settings.js';
import { SoloWorlds } from './net/solo.js';
import { ShardNetwork } from './net/shard.js';
import { ProfileCache, onIdentityChanged, readIdentity, type NostrIdentity } from './social/profiles.js';
import './styles.css';
let settings: GameSettings = { ...defaultSettings };
/** Multiplayer when the host offers WebRTC and the player hasn't opted out. */
function createWorlds(): WorldProvider {
if (settings.multiplayer && runtimeHasDomain(WEBRTC_DOMAIN)) return new ShardNetwork();
return new SoloWorlds(runtimeHasDomain(WEBRTC_DOMAIN) ? 'Solo world (online play off)' : 'Solo world (no peer network)');
}
/** Retries (ms) for a profile name after a login that only yielded the npub fallback. */
const PROFILE_RETRY_MS = [2000, 5000, 10000];
async function boot(): Promise<void> {
const storage = new GameStorage();
const profiles = new ProfileCache();
// The identity (and its profile name) can take a few seconds to resolve; don't
// hold the intro for it. It is applied as soon as it arrives.
const identityReady = readIdentity();
await storage.load();
const app = new App({ storage, profiles, worlds: createWorlds(), identity: { pubkey: '', name: '' } });
const input = new InputLayer({
playing: () => {
const game = app.game;
return game.started && game.player && !game.player.isDead ? game : null;
},
isTyping: () => app.isTyping(),
toggleChat: () => app.toggleChat(),
hideWindows: () => app.hideWindows(),
toggleAchievements: () => app.toggleAchievements(),
toggleMute: () => app.toggleMute(),
confirm: () => app.confirm(),
});
input.bindTouchPad(document.querySelector<HTMLElement>('#touchpad')!);
const stopSettings = watchSettings((next) => {
settings = next;
app.setMuted(!next.sound);
document.querySelector('#app')?.classList.toggle('touchpad-off', !next.touchControls);
});
// Identity: the boot read, then every change (login, logout, account switch).
// Right after a login the shell may not have the profile yet; while only the
// npub fallback is known, retry a few times so the profile name replaces it.
let identityGeneration = 0;
const followIdentity = async (read: Promise<NostrIdentity>, changed: boolean): Promise<void> => {
const generation = ++identityGeneration;
let id = await read;
if (generation !== identityGeneration) return;
app.applyIdentity(id, changed);
for (const delay of PROFILE_RETRY_MS) {
if (!id.provisional) return;
await new Promise((resolve) => window.setTimeout(resolve, delay));
if (generation !== identityGeneration) return;
const next = await readIdentity();
if (generation !== identityGeneration || next.pubkey !== id.pubkey) return;
if (!next.provisional) app.applyIdentity(next, changed);
id = next;
}
};
let identitySub: Subscription | null = onIdentityChanged(() => {
void followIdentity(readIdentity(), true);
});
await app.load();
// A change that already arrived during loading is newer than the boot read.
if (identityGeneration === 0) void followIdentity(identityReady, false);
window.addEventListener(
'pagehide',
() => {
identitySub?.close();
identitySub = null;
stopSettings();
input.dispose();
app.dispose();
profiles.dispose();
},
{ once: true },
);
}
boot().catch((error: unknown) => {
const notice = document.querySelector('#identity-hint');
if (notice) notice.textContent = `Browser Side Quest failed to start: ${error instanceof Error ? error.message : String(error)}`;
});
