Skip to main content

Spheres

System Architecture

Anonymous real-time 3D social space — pnpm monorepo, React 19 + Three.js + R3F client, dedicated Socket.io world server on Railway, Firebase only for auth and sparse Firestore writes. Positions, presence, and chat never touch the database.

Monorepo layout

pnpm workspaces · shared types between client and server

spheres/
├── apps/
│   ├── web/                  React 19 + Vite + TypeScript
│   │   └── src/
│   │       ├── pages/        LoginPage · AccountPage · WorldPage
│   │       ├── components/   world/ (R3F) · AuraPicker · SpherePreview
│   │       ├── stores/       authStore · userStore · worldStore
│   │       ├── lib/          firebase.ts · firestore.ts · socket.ts
│   │       └── i18n/         en / he / ru
│   └── world-server/         Node.js 22 + Express + Socket.io
│       └── src/
│           ├── index.ts      Socket event router · AI tick loop
│           ├── world.ts      WorldManager (in-memory Map, max 50 players)
│           ├── auth.ts       Firebase Admin token verification
│           ├── ai.ts         Generic AI sphere behaviour
│           └── jul/          JUL — persistent AI character (9 files)
└── packages/
    └── shared/               PlayerState · Vec3 · AuraType · protocol

Runtime stack

Browser · React 19 + Vite + Three.js + R3F + React Router 7

WorldPage renders 3D scene — flight controls, sphere meshes, ChatOverlay, ContactOverlay, RatingOverlay. Zustand drives all UI state.

Firebase · Auth + Firestore (sparse writes)

User profiles · aura · coreValue · rating cooldowns · reports — NOT positions, presence, or chat

World server · Node.js 22 + Express + Socket.io 4

Token verify (Admin SDK) · WorldManager in memory · chat relay · JUL AI tick · 10 Hz broadcast loop

Deployment

Web · Vercel

Static Vite build, CDN-distributed

world-server · Railway

Auto-deploy from GitHub main

OpenRouter

LLM API powering JUL conversations

Client state · Zustand stores

useAuthStore

Firebase User, loading, error, initialized. Methods: init · signIn · signUp · signInWithGoogle · signOut · sendVerification · reloadUser

useUserStore

Firestore UserProfile: aura · coreValue · language · displayName. Written on registration + debounced aura / language changes.

useWorldStore

Socket, remotePlayers map (prevPos / targetPos), contactState machine, chatMessages, cooldowns, ratingFeedback, kickedMessage.

Real-time loop

From login token to smooth remote spheres

1

Firebase Auth

Login → ID token issued client-side

2

connect()

Socket.io client, token passed in auth{}

3

join_world

Server verifies token via Admin SDK

4

update_state

Client emits pos + rotation at 10 Hz

5

Broadcast

Server → player_update to world room

6

Interpolate

prevPos→targetPos smoothed in R3F loop

Player state machine

worldStore.contactState on client · PlayerStatus in WorldManager on server

idlerequestingchattingratingidle
  • idle → requesting — proximity detected; client emits request_contact. Server validates both players idle, then emits incoming_request to target. Times out after 30 s.
  • requesting → chatting — target emits respond_contact (accept: true). Server emits contact_started to both players.
  • chatting → rating — either player emits end_chat. Server checks 24-hour cooldown, emits chat_ended with optional ratingCooldownUntil.
  • rating → idle rate_core applies a ±0.05 step to target's coreValue in memory + Firestore, then broadcasts core_updated to all world players.

Socket.io protocol · all events

Types defined in packages/shared/src/protocol.ts

C→Sjoin_worldworldId + Firebase ID token. Server kicks duplicate sessions (DUPLICATE_SESSION error).
S→Cworld_snapshotFull PlayerState map on join — positions, auras, statuses of all players.
C→Supdate_stateposition (Vec3) + rotation (Quaternion) + aura. Throttled to 10 Hz server-side.
S→Cplayer_joined / leftAdd or remove remote sphere from worldStore.remotePlayers.
S→Cplayer_updateposition, aura, status — stored as targetPos for interpolation.
C→Srequest_contacttargetUid. Server validates both players are idle before forwarding.
S→Cincoming_requestfromUid. Notifies target player of incoming contact request.
C→Srespond_contactfromUid + accept boolean. Times out after 30 s (request_timeout).
S→Ccontact_startedwithUid. Both clients transition to chatting state.
C→Schat_messagetoUid + text (≤500 chars). Server relays and sanitizes; never persisted.
C→Styping_start / stopTyping indicator relayed to chat partner only.
C→Send_chatwithUid. Server checks rating cooldown, emits chat_ended to both sides.
C→Srate_corewithUid + value (−2…+2). Server applies ratingStep (0.05), updates Firestore.
S→Ccore_updateduid + new coreValue — refreshes sphere colour for everyone in the world.
C→Sreport_usertargetUid + worldId. Written to reports/{id} (admin-only read).
S→CerrorAUTH_FAILED · DUPLICATE_SESSION · WORLD_FULL (max 50 players per world).

Firestore · sparse writes only

users/{uid}

uid, email, displayName, language, aura, coreValue, createdAt, updatedAt

Written on registration + debounced aura/language changes. coreValue updated after each rating.

ratingCooldowns/{key}

raterUid, targetUid, timestamp

key = 'raterUid:targetUid'. 24-hour cooldown. Checked server-side before applying rate_core.

jul-memory/{uid}

Conversation history and relationship data between JUL and each user

One document per user who has chatted with JUL. Enables persistent memory across sessions.

reports/{id}

reporterUid, targetUid, timestamp, worldId

Admin-only read. Created by report_user event handler.

Never in Firestore: player positions · presence · chat messages · world state. All live in the server's in-memory WorldManager (a plain TypeScript Map) and are discarded on disconnect. Expected Firestore usage: fewer than 100 reads/writes per session.

Identity · 16 aura types + coreValue

Each player's sphere is fully defined by two values — no username, no avatar, no profile photo.

AuraType · 16 moods

enlightened · inspiration · joy · gratitude · confidence · calm · neutral · doubt · anxiety · sadness · apathy · irritation · anger · despair · hopelessness · sos

coreValue · −1 to +1

Starts at 0. Drifts ±0.05 per rating received. Controls sphere colour spectrum — warm hues for positive, cool/dark for negative. A visible emergent reputation with no explicit profile or score display.

JUL · the AI sphere

JUL (uid: "jul") is a persistent AI character spawned in the default world (global-1). Powered by OpenRouter, running on the world server alongside human players.

personality.ts

Fixed character traits, tone, and communication style baked into the system prompt.

memory.ts

Reads/writes jul-memory/{uid} in Firestore. Conversation history persists across sessions.

behavior.ts

High-level state machine: wandering → approaching → initiating contact.

targeting.ts

Selects which idle player to approach based on proximity. Ignores players on cooldown.

movement.ts

Smooth navigation toward target. Writes directly to WorldManager — JUL is a real player entry.

conversation.ts

Active chat tracking: message locks, daily conversation limits per user.

prompt.ts

Assembles LLM prompt from personality + memory + conversation context.

providers/openrouter.ts

OpenRouter API integration. JUL's mood maps to AuraType, broadcast via core_updated.

The server's 10 Hz AI tick calls tickJul() which may produce an initiate_contact action. JUL then goes through the full request_contact → contact_started → chat → rate_core flow — exactly like a human player. After chat ends, JUL rates the player and updates its Firestore memory.

Design choices

WORLD_CONFIG: defaultWorldId global-1 · maxPlayersPerWorld 50 · contactRequestTimeoutMs 30 000 · ratingCooldown 24 h