A spatial explorer for the Cyberspace Protocol v2.
Move through the 85-bit axis space one step at a time and watch what each step actually costs. The protocol's central and least intuitive property is that movement cost is set by which power-of-two boundary you cross, not by how far you travel. This makes that visible: bright gridlines are expensive crossings, and the terrain underneath shows where the temporal work is concentrated.
Status: MVP. Rendering verified manually in a real browser; the protocol maths and view geometry are covered by unit tests.
The client speaks NIP-42: it answers a relay's AUTH challenge with the session
key before reading or writing, so an auth-required relay works. cyberspace.nostr1.com
requires auth; publishing (movement kind:3333, hidden content kind:33330,
deletions kind:5) works for any pubkey, and reading requires the relay to
authorize the pubkey for REQs.
npm install
npm run dev # http://localhost:5173cyberspace-core is a file: dependency pointing at ../cyberspace-cli-js.
Clone both repos as siblings, and build the core once:
cd ../cyberspace-cli-js && npm install && npm run build| Key | Action |
|---|---|
W A S D |
move the cursor one step in a screen direction |
Space |
commit the cursor's hop and compute its proof |
X |
cancel an in-flight proof, or recall the cursor |
Shift + W A S D |
rotate the view 90 degrees |
Tab |
return to the previous view |
Esc |
reset to top-down |
Q / E |
scale the step up / down, logarithmically |
R / F |
cursor along the axis into / out of the screen |
C |
canonical view ("facing the black sun") |
P |
toggle dataspace and ideaspace |
[ / ] |
chain explorer: one action back / forward (hold to repeat) |
Home / End |
chain explorer: the spawn / the live head |
H |
hyperspace line scrubber: browse the block line, set a destination |
Movement is expressed in screen directions and resolved to world axes through
the current view, so W is always "away from you" in any of the 24 reachable
axis-aligned orientations.
Movement is two-phase. WASD noodles a free cursor; nothing is computed
until Space commits the hop. While the cursor is away from the avatar the
proof panel live-previews what committing would cost (the estimate is
closed-form, so it never blocks), the dashed tether shows the hop being lined
up, and both turn red when the hop is beyond the compute ceiling. Position
only advances when a committed proof completes, so the movement chain shown in
the HUD is contiguous by construction.
You spawn at your pubkey. The first visit generates a keypair and keeps it in localStorage, and per spec section 8.3 the spawn coordinate IS the pubkey: the 256-bit key decodes directly to x / y / z / plane.
The chain is real. Spawning signs a kind:3333 spawn event, and every
committed hop or sidestep is signed into its own event the moment the proof
lands, naming the spawn as genesis and the previous event as previous
(spec section 8). The next proof's temporal work is bound to that event's id,
exactly as a verifier recomputes it. The chain persists as those events, so a
reload reads position, plane and history back out of them.
The chain explorer. Under the XOR readout, the CHAIN instrument walks the chain: back, forward, spawn, head, or scrub the rail. The scene re-anchors on the action shown (avatar, trail, terrain, rooms, and the readout, which then shows what that hop cost); what came after is drawn faint. Off the head the movement controls stand down and RETURN TO LIVE brings them back. The same instrument walks any other avatar's chain while spectating.
Shards. The Shards panel opens the workshop: a full-screen bench where a
shard is built on an integer grid. STAMP places a whole shape where you tap
(BLOCK, WEDGE, PYRAMID, COLUMN, RING, STAR, ARROW), sized in whole units and
turned a quarter at a time; a ghost shows where it will land before it does.
ADD places one vertex at the current level; SELECT picks a point for the nudge
pad (a unit along X, Y or Z), the color input and DELETE; FACE picks corners
in order and FILL joins them into a face, any number of corners, notches and
all (tap the first corner again to close), and a tap on a drawn face selects
it for DELETE FACE. The color picker paints live and puts each color it
settles on at the front of the palette, which persists; hold a swatch to be
asked whether to delete it. A shard renders SOLID (triangles,
colors blended across faces), POINTS (every vertex a light) or LINES (a
polyline through the vertices, colors blending along it), chosen per shard
and previewed live; a new shard starts in LINES and switches itself to SOLID
the first time it gets faces. Stamps keep their own vertices even where they
touch, so a red block against a blue one keeps a crisp seam (the wall between
them is dropped rather than paid for), and a point that several vertices share
moves, colors and deletes as one. unit says what one grid unit is in
gibsons (2^unit), shown beside the control as a real size. Every edit undoes.
While the bench is up the world canvas behind it stops drawing (its frameloop
goes to never, with the clock kept across the pause), which on a phone was
most of every frame. Shards persist in localStorage; COPY and
PASTE in the shard list carry one through the clipboard in its wire form.
Keys on the bench: 1 2 3 4 pick tools, Q turns a stamp, WASD / RF or arrows
nudge, Del deletes, Enter fills, [ ] change the level, Ctrl+Z / Ctrl+Shift+Z
undo and redo, Esc deselects then closes.
Comments. A found shard or message, and each of your own deployments,
carries a comment section: NIP-22 kind:1111 events whose root scope is the
bag by address (A, K, P) and whose parent is the inner item (e, k,
p), replies parenting the comment they answer, every one tagged
["client", "ONOSENDAI"]. The author is p-tagged, so any nostr client tells
them. The words are sealed to the place like the item they answer: per FF-1
(a derived-key event) the public .content is the placeholder "This comment
is hidden at an undisclosed location in cyberspace. Happy hunting:
https://onosendai.tech" and the text sits in
["encrypted", "aes-256-gcm", <ciphertext>, "cyberspace:region"] under the
bag's region key, with no d tag. Other clients show the invitation; this
one derives the key from where the bag is, as it did to open the bag, and
shows the words. A comment the key does not open shows the placeholder,
dimmed.
Hidden content is a bag of signed events. A hidden thing is a full signed
nostr event — a shard (kind:3330, geometry in the content) or a message
(kind:1, text). All the things one author hides in one region-and-height live
together in a single kind:33330 envelope (spec section 8.6), keyed by
d = lookup_id: the envelope's encrypted content is a bag of those inner
events. Deploying reads the region's bag, appends the new item, and rewrites it
— kind:33330 is addressable, so the newer bag replaces the old, and one place
accumulates content with no per-item tags. Discovery decrypts a bag, verifies
each inner signature (and that its author is the one who wrapped it), and
renders every item by its kind. The location encryption is the access control:
there is no NIP-70 protected tag, because the ciphertext is public anyway and
anyone who can decrypt can re-sign identical content as themselves regardless.
Leave a hidden message. The Shards panel has a WRITE A MESSAGE box: type text, then aim and place at a height, exactly like a shard. Discovered messages render as billboarded notes at their coordinate with a marker of who left them.
Test discovery. A deployment's detail overlay has TEST DISCOVERY: it derives the region key fresh from the coordinate, as a stranger would, asks the relay, and opens what comes back — proving the whole round-trip independent of local state. A SCANNING / N NEAR indicator on the Shards panel shows the background scan working.
Models vs instances. A model is a named design that lives on this device (the Shards panel's Models section). A deployed instance is one copy of it placed at a coordinate and published (the Deployed section). One model can have many instances. Deleting a model (behind a modal) removes it from this device and leaves its instances alone; deleting an instance sends a NIP-09 deletion to the relays it is on and leaves the model alone. Tapping a deployment flies the scene to it and opens its wire record: event id, relays, lookup id, coordinate, and the height it is hidden at.
Deploying a shard. DEPLOY on the bench, or from the Shards panel, enters
deploy mode: aim the movement cursor at where it should go (it ghosts there at
its true size), pick a height to hide it at, and place (DEPLOY, Space, or the
PLACE button). Height is the discovery radius (spec section 7.3): 0 is a single
gibson, each step up doubles the aligned cube it hides in. What is published is
a location-encrypted kind:33330 event (spec section 8.6): the shard, its
coordinate and plane are AES-256-GCM encrypted to the region key
sha256(region_bytes), and only the lookup_id = sha256(key) is public, so
the coordinate never leaves in the clear. The derivation is spec section 7.2
exactly, so a shard hidden here is one the reference CLI can find and open. The
events go to wss://cyberspace.nostr1.com alongside movement.
Discovery. A background scan derives the region keys for heights 0 to 12 at wherever the scene is anchored (in a worker, since that is the O(2^h) work the protocol says looking must cost), asks the relay for content under those lookup ids, and renders whatever decrypts. Your own deployments always render; others' appear when you are near enough to compute their region. The scan only re-runs when you cross a region boundary (spec section 7.5).
Targets. The Targets panel points at any number of pubkeys the way the HUD points at Earth: a reticle in frame, a chevron on the nearest edge out of frame, the distance either way, and the avatar itself (a wireframe icosahedron in the target's own color, named) once you are near. Add a key, toggle one from the Avatars list, or load a kind 3 contact list for any npub and toggle follows. Positions are chain heads on the relay, kept live by a per-target subscription; a key with no chain is pointed at its spawn coordinate. Targets persist across reloads.
Spectating. The Avatars panel lists every pubkey with a v2 action on the relay, newest first, with a field for any npub. SPECTATE anchors the scene on that avatar's chain head (their terrain, their rooms, their trail, their spawn marker), keeps their hops arriving live, hands the chain explorer their history, and points a YOU marker back at your own avatar. The panels lock, the movement controls stand down, the compass and view controls stay, and END SPECTATION brings you home.
The spawn marker. v1's spawn model (public/spawn.glb: three hexagonal
rings, a hollow cube, six radiating bars) stands at the pubkey coordinate,
sized in cells like the avatar so it marks the spawn cell at any scale.
Derezz. The last panel in the HUD abandons the chain: a new spawn event is signed (and published, when Live), which per spec section 3.2 retires every action before it, and the avatar is back at its pubkey with nothing behind it. It arms a warning first, in v1's words, because it cannot be undone.
Local / Live. The switch under RECALL and COMMIT decides whether events
leave the device. Live (the default) publishes each one to
wss://cyberspace.nostr1.com as it is signed, in chain order, and the proof
chain panel shows how many the relay has acknowledged. Local keeps them here;
switching to Live later publishes the whole backlog, oldest first, so every
prefix the relay holds is itself a valid chain.
Cell fill is terrain K. K in [0, 16] is the terrain-derived temporal
height for a destination, setting the non-cacheable temporal work every hop into
that cell costs. It is Binomial(16, 0.5), so it clusters around 8; the palette
is tuned to spread the common 5..11 band rather than the unused extremes.
Line brightness is LCA height. For each gridline the app computes the real
findLcaHeight of crossing it at the current scale, and lights it in proportion
to how far above the cheapest possible crossing that is. Stepping 7 -> 8 costs
height 4 while 8 -> 9 costs height 1, for the same single gibson. That
asymmetry is the whole point, and it is drawn.
Scale changes what a boundary means. Q and E move the step size by
powers of two. At 2^0 you see individual gibsons; at 2^30 each cell is a
sector. Terrain correlates at cell sizes 2^3 to 2^11, so the field looks
smooth when you zoom below that band and uncorrelated above it.
Cyberspace is a left-handed coordinate system. Section 9.4 defines it as
ECEF with two axes swapped (X_cs = X_ecef, Y_cs = Z_ecef, Z_cs = Y_ecef),
and swapping two axes of a right-handed frame inverts handedness. That is why
section 11.1's convention (+X screen-right, +Y up, +Z forward into the
screen) cannot be reproduced in three.js, which is right-handed, by camera
placement alone.
Section 11.4 requires resolving this with a render-space transform rather than
by mirroring or re-labelling axes. This app does it in exactly one place:
flipHandedness in lib/space.ts, applied at the boundary where viewAxes
converts camera directions back into cyberspace axis names. Everything upstream
is render space; everything downstream is cyberspace.
Getting this wrong does not produce a visibly broken picture. It produces a mirrored one, which looks perfectly fine on its own and silently disagrees with every other viewer about which way is left. It is asserted directly in the test suite across all 24 reachable views.
Two consequences worth knowing:
- The canonical view comes out as the identity quaternion, i.e. three.js's default camera. That it lands exactly there is a good sign the transform sits in the right place.
- The top-down map view puts
+Z(forward, the black sun direction) up the screen, which is the conventional map orientation. Before the handedness fix it pointed down.
src/
lib/space.ts coordinate <-> render-space maths, view orientation
lib/palette.ts the two visual encodings (terrain fill, boundary lines)
lib/events.ts kind:3333 builders, parser, chain reassembly (spec 8, 10)
lib/relay.ts the one relay, publish / query / subscribe
lib/publisher.ts drains unpublished events in chain order while Live
lib/workers.ts worker singletons
store/ zustand store: position, scale, view, proof telemetry
workers/ proof and terrain sampling, off the main thread
scene/ R3F: camera rig, terrain field, lattice, avatar, cursor
hud/ overlay panels
Two rules hold the design together:
No absolute coordinate is ever converted to a float. Positions are bigint
throughout and are rendered as integer offsets from the avatar's aligned cell,
so precision is exact at any depth into the axis. subCellFraction uses
fixed-point bigint division for the same reason: past 2^53 a float ratio
collapses to zero.
Compute never blocks the frame. A hop is O(2^h) Cantor pairings, so at
higher scales a single keypress is real work. Proofs and terrain sampling both
run in workers, and the proof worker streams progress so the HUD shows genuine
elapsed cost rather than a spinner.
When a hop's LCA height exceeds the Cantor compute ceiling, Space commits a Merkle sidestep instead (spec section 6): a Merkle hash tree over the same LCA subtree, which has no storage wall and costs purely time (2^(h+1) SHA-256 evaluations per crossing axis). A sidestep lands exactly 1 gibson past the blocking boundary, not at the cursor: crossing the mountain drops you at the pass, and the rest of the journey is the next commit. The tether shows this as two legs: purple to the landing, then amber (or red, if another wall stands) for the remainder. The proof panel previews the hash count and a rough wall-clock estimate before you pay; X cancels mid-hash.
This branch starts from zero. ONOSENDAI v1 lives on master and targets
Cyberspace v1, whose movement model (proof-of-work drift and velocity over
DecimalVector3) is precisely what v2 replaced. The two share constants but not
semantics, so v1 code is cherry-picked deliberately rather than carried over.
npm test # view geometry and coordinate mathsThe protocol maths itself is tested in cyberspace-cli-js against golden
vectors shared with the Python implementation.