Building MyZubster World: The Architecture We Have Implemented and What Is Still Missing
MyZubster started as a marketplace, but our long-term vision is broader: we want to build a circular ecosystem connecting digital identity, virtual communities, knowledge sharing, real-world projects, and privacy-conscious transactions.
That vision is becoming MyZubster World, an experimental metaverse connected directly to the MyZubster marketplace.
This is not a post announcing a finished metaverse. It is a technical development log explaining what is already implemented, how the main components work, and what still needs to be built before the platform can offer a complete immersive experience.
The architecture
The current system is divided into several layers:
React frontend
↓
Express API
↓
Authentication and authorization
↓
Metaverse services
↓
MongoDB persistence
↓
Vercel deployment
The frontend renders the world, room controls, character state, marketplace destinations, and user feedback.
The backend remains authoritative for identity, access, room lifecycle, invitations, capacity, and session state. The browser can request an action, but it cannot declare that the action succeeded.
This distinction is important. Client-side controls can improve usability, but they cannot provide real security.
Verified character identity
One of the first problems we addressed was identity.
A character name stored only in localStorage is not a verified identity. Anyone can open the browser’s developer tools and change it.
For authenticated users, the backend now retrieves the character linked to the account:
const character = await MetaverseCharacter.findOne({
accountUserId: userId,
worldId: WORLD.id,
identityStatus: 'account-linked'
});
The server returns the canonical character profile:
{
"characterName": "H4x0r",
"identityStatus": "account-linked",
"archetype": "explorer",
"worldId": "neon-plaza"
}
The frontend replaces any stale local profile with this server-provided identity.
Guest identities remain possible, but they are explicitly treated as unverified:
const verified =
player.identityStatus === 'account-linked';
This gives us two distinct modes:
Guest → temporary and unverified
Account-linked character → persistent and verified
We do not treat a client-supplied name, GitHub username, or MYZ-ID as proof of identity.
Authentication recovery
We also implemented expired-session handling.
A common failure occurred when the frontend still contained an old JWT. The interface appeared authenticated, but protected API calls returned 401 Unauthorized.
The frontend now recognizes this state:
if (profileError.status === 401) {
localStorage.removeItem('myzubster-token');
setAuthenticated(false);
setMessage('Session expired. Please sign in again.');
}
This prevents the application from remaining trapped between authenticated and unauthenticated states.
The GitHub OAuth callback and production environment configuration were also reviewed so authenticated users can return to the metaverse after login.
Persistent mission progress
The metaverse includes landmarks that users can discover.
For authenticated characters, visited landmarks are recorded server-side:
await MetaverseCharacter.findOneAndUpdate(
{
accountUserId: userId,
worldId: WORLD.id,
identityStatus: 'account-linked'
},
{
$addToSet: {
'missionProgress.visitedLandmarks': landmarkId
}
}
);
Using $addToSet prevents duplicate landmark entries.
This allows progress to persist across:
- Browser refreshes
- New sessions
- Different devices
- Local-storage deletion
Local state is still used for responsiveness, but MongoDB is the durable source of truth for verified accounts.
Shared presence
MyZubster World currently uses database-backed synchronization rather than a permanent WebSocket connection.
The frontend periodically sends the active session identifier:
const result = await syncMetaverse(
sessionId,
cursor
);
The server returns:
{
"players": [],
"messages": [],
"online": 0,
"cursor": "2026-09-15T00:00:00.000Z",
"transport": "shared-polling"
}
The current shared state supports:
- Player presence
- Position updates
- Join and leave events
- Chat messages
- Emotes
- Online-player counts
- Reconnection states
The synchronization cycle is intentionally predictable:
const SYNC_INTERVAL_MS = 1800;
If a request temporarily fails, the frontend enters a reconnecting state and tries again instead of immediately removing the player.
Why we currently use polling
MyZubster is deployed in a serverless environment.
Long-lived connections can be unreliable when requests are distributed between stateless instances. An in-memory event stream on one server instance may not be visible to another instance.
For this reason, the current architecture favors shared persistence:
Client A ─┐ ├── API instances ── MongoDB
Client B ─┘
This is less immediate than a dedicated realtime server, but it provides a coherent shared state during the experimental phase.
The older event-stream endpoint remains available for compatibility, but the current web client uses shared polling.
Presence expiration
Player presence must not remain online forever after a browser closes unexpectedly.
Presence records therefore include expiration timestamps:
{
lastSeenAt: Date,
expiresAt: Date
}
Each synchronization refreshes the expiration time. MongoDB’s expiration strategy removes abandoned presence records after their retention window.
This handles cases such as:
- Closing the browser without pressing “Leave”
- Losing the network connection
- Browser crashes
- Mobile operating systems suspending the tab
Chat and emotes
The current world includes temporary chat and emote actions.
Chat input is cleaned and limited before persistence:
const text = cleanText(req.body?.text, 280);
if (!text) {
return res.status(400).json({
success: false,
error: 'Message is empty'
});
}
Actions also use rate limits to reduce spam:
if (!allowAction(sessionId, 'chat', 700)) {
return res.status(429).json({
success: false,
error: 'Chat rate exceeded'
});
}
Chat history is intentionally temporary. It is not designed as permanent public content.
Production logging excludes message bodies, authorization headers, tokens, query strings, and session identifiers.
Server-authoritative virtual rooms
We have implemented persistent virtual rooms with an explicit lifecycle:
Draft
↓
Published
↓
Scheduled
↓
Live
↓
Ended
↓
Archived
The permitted transitions are represented on the backend:
const ROOM_TRANSITIONS = {
draft: new Set(['published']),
published: new Set(['scheduled', 'live']),
scheduled: new Set(['live']),
live: new Set(['ended']),
ended: new Set(['archive']),
archive: new Set()
};
A request attempting an invalid transition is rejected:
if (!ROOM_TRANSITIONS[room.state]?.has(nextState)) {
return {
valid: false,
status: 409,
error: `Invalid room transition`
};
}
The frontend cannot move a room from ended back to live, even if someone manually modifies the request.
Host permissions
Room-management actions verify the account on the server:
function canManage(
actorUserId,
actorRole,
hostUserId
) {
return Boolean(
actorUserId &&
(
actorRole === 'admin' ||
String(actorUserId) === String(hostUserId)
)
);
}
Only the room host or an administrator can:
- Update room settings
- Publish a room
- Create a session
- Start a session
- End a session
- Generate an invitation
- Inspect invitation status
- Revoke an invitation
Showing or hiding a button in React is not the authorization mechanism. Every operation is validated again by the backend.
Room access policies
Rooms currently support three policies:
accessPolicy: {
type: String,
enum: [
'public',
'authenticated',
'private'
]
}
Public rooms
Public rooms can be discovered without authentication.
Authenticated rooms
These rooms require a valid MyZubster account.
Private rooms
Private rooms require explicit authorization.
Private rooms do not appear in the public discovery response:
function roomDiscoveryQuery(authenticated) {
return {
state: {$in: ['published', 'scheduled', 'live']
},
accessPolicy: authenticated
? { $in: ['public', 'authenticated'] }
: 'public'
};
}
Even authenticated users cannot discover private rooms unless they have received access through the invitation system.
Unauthorized requests return a generic 404 response. This reduces information leakage about the existence of private spaces.
Room capacity
Hosts can configure room capacity between 1 and 500 participants.
The value is validated server-side:
const capacity = Number(input.capacity);
if (
!Number.isInteger(capacity) ||
capacity < 1 ||
capacity > 500
) {
return {
valid: false,
status: 400,
error: 'Capacity must be between 1 and 500'
};
}
When a session is full, new participants cannot join. An existing participant, however, can reconnect without being rejected as an additional person.
Room access and capacity become locked after session scheduling. This avoids changing fundamental rules while a session is already underway.
Session participation
The platform now supports:
Create session
↓
Start session
↓
Participant joins
↓
Participant leaves
↓
Host ends session
When joining, the backend checks:
- Authentication
- Session state
- Room blocklist
- Private-room authorization
- Capacity
- Existing participation
A simplified version of the logic looks like this:
if (session.state !== 'live') {
return deny('Session is not live');
}
if (room.blockedUserIds.includes(userId)) {
return deny('Access blocked');
}
if (
room.accessPolicy === 'private' &&
!room.allowedUserIds.includes(userId) &&
userId !== room.hostUserId
) {
return deny('Private room access denied');
}
if (
!alreadyJoined &&
participantCount >= session.capacity
) {
return deny('Session is at capacity');
}
Short-lived realtime tokens
After authorization, the backend can generate a short-lived token for future room-scoped realtime connections:
jwt.sign(
{
sub: userId,
purpose: 'metaverse-realtime',
sessionId: session.sessionId,
roomId: session.roomId,
sceneManifestVersion:
session.sceneManifestVersion
},
secret,
{
expiresIn: '5m'
}
);
These tokens expire after five minutes.
The current room page does not pretend this token launches a complete immersive realtime client. That integration is still pending.
Privacy-safe session history
Room sessions produce technical events:
- session_created
- session_started
- participant_joined
- participant_left
- session_ended
The frontend polls for new events using an incremental cursor.
Public event representations contain aggregate information, such as participant count, but do not expose participant account IDs.
{
"type": "participant_joined",
"participantCount": 4,
"sequence": 12
}
Events are retained temporarily and are presented as operational history, not permanent user tracking.
Private invitation links
The latest implementation makes private rooms practically accessible.
A host can generate an invitation code:
const code = crypto
.randomBytes(24)
.toString('base64url');
The raw code is returned once to the host. It is not stored in the database.
Instead, the backend stores a SHA-256 hash:
const hash = crypto
.createHash('sha256')
.update(code)
.digest('hex');
During redemption, the submitted code is hashed and compared using timingSafeEqual:
const valid =
expected.length === supplied.length &&
crypto.timingSafeEqual(expected, supplied);
The invitation:
- Expires after 24 hours
- Can be redeemed once
- Can be revoked immediately
- Is replaced when a new invitation is generated
- Cannot override a blocklist
- Survives the login redirect
- Never exposes its stored hash through the status API
After successful redemption, the authenticated account is added to the room allowlist and the invitation is cleared.
Invitation management
An host can inspect only safe invitation metadata:
{
"active": true,
"expiresAt": "2026-09-16T00:00:00.000Z"
}
The response never includes:
- The invitation code
- Its hash
- The invited user ID
- The room blocklist
The host can revoke the invitation through an authenticated DELETE request:
DELETE /api/metaverse/rooms/:room/invitations
After revocation, the old link can no longer authorize a new account.
The connection with the marketplace
MyZubster World is not intended to remain separate from the marketplace.
The marketplace explores areas such as:
- Underground and alternative subcultures
- Kefir communities
- Plant cultivation and documentation
- University assistance
- Creative and technical collaboration
- Privacy-conscious services
Our proposed cycle is:
Discover
↓
Learn
↓
Collaborate
↓
Exchange
↓
Document
↓
Share
↓
Discover again
A user may discover a cultivation project inside the metaverse, join its community room, contribute knowledge, request assistance through the marketplace, and document the result for other members.
This is what we mean by a circular ecosystem: the virtual environment should lead to useful activity rather than functioning only as decoration.
What is still missing
The foundations are growing, but several important systems are not complete.
- Immersive room connection
The authorization and session layers exist, but entering a room does not yet launch a dedicated immersive scene.
We still need to connect:
Authorized room session ↓
Short-lived realtime token
↓
Room-specific transport
↓
Immersive scene
The scene must preserve the same access, capacity, and moderation rules enforced by the API.
- Dedicated realtime transport
Database polling is appropriate for the current prototype, but larger rooms will require a dedicated realtime service.
We need to evaluate:
- WebSockets
- Managed realtime infrastructure
- Horizontal scaling
- Reconnection strategies
- Presence consistency
- Regional latency
- Abuse protection
The migration should be driven by measurements, not by the assumption that WebSockets are automatically better.
- Participant moderation
Hosts need privacy-conscious moderation tools.
The roadmap includes:
- Removing a participant
- Blocking future access
- Restoring access
- Revoking private-room membership
- Recording moderation events
- Preventing immediate unauthorized re-entry
We must provide these controls without publishing internal account identifiers.
- Stage requests
Rooms already contain a stage policy:
stagePolicy: {
type: String,
enum: [
'host-only',
'host-approved'
]
}
However, the complete interaction is not yet implemented.
The intended flow is:
Participant requests to speak ↓
Host reviews the request
↓
Host approves or rejects
↓
Server assigns stage capability
↓
Capability expires or is revoked
- Room-specific chat
The global prototype supports chat, but room-scoped communication still needs stronger boundaries.
Messages should be associated with:
- A room
- A session
- An authorized participant
- A defined retention policy
- Moderation state
- Marketplace destinations
Marketplace categories currently need a deeper connection with the virtual world.
Our plan is to turn categories into interactive destinations:
Neon Plaza
├── Underground Culture
├── Kefir and Cultivation
├── University Collaboration
├── Creators
└── Privacy Technology
Each destination should load real marketplace information rather than static decorative content.
- Collaborative activities
The long-term experience should support useful interaction:
- Workshops
- Study sessions
- Community presentations
- Cultivation journals
- Project showcases
- Mentoring
- Marketplace demonstrations
These activities still require data models, permissions, interfaces, and moderation rules.
- Automated testing
We have policy and interface-wiring tests, but the project still needs broader coverage:
- Integration tests with MongoDB
- Authentication tests
- Invitation race-condition tests
- Capacity concurrency tests
- OAuth callback tests
- Browser end-to-end tests
- Accessibility tests
- Load testing
One particularly important case is simultaneous redemption of a monouso invitation. The final production implementation should use an atomic database update so two requests cannot consume the same code at nearly the same time.
- Monitoring
The application uses Vercel Analytics and deployment observability, but product-specific monitoring needs to improve.
We want to measure:
- Metaverse entry funnel
- Successful authenticated joins
- Room creation
- Session starts
- Invitation redemption
- Marketplace transitions
- Reconnection frequency
- API errors and latency
Analytics events must remain privacy-conscious and avoid collecting unnecessary identity or message data.
- Accessibility and mobile interaction
The metaverse must remain usable outside a desktop keyboard environment.
We still need:
- Touch controls
- Keyboard navigation
- Reduced-motion support
- Screen-reader labels
- High-contrast states
- Responsive room controls
- Accessible chat
- Performance testing on low-end devices
- Monero research
We are evaluating Monero as a possible payment option for appropriate marketplace transactions.
A production integration still needs research and implementation for:
- Wallet architecture
- Payment verification
- Unique payment identification
- Confirmation policies
- Refunds
- Disputes
- Exchange-rate handling
- Accounting
- Regulatory responsibilities
- Secret management
- Operational security
Monero payment support should therefore be considered a roadmap item, not a finished production feature.
How we are building
We are developing MyZubster through small, reviewable increments.
Recent pull requests introduced:
- Persistent mission progress
- Canonical character identity
- Expired-JWT recovery
- Filtered room discovery
- Authoritative room details
- Room lifecycle management
- Join and leave controls
- Private-room authorization
- Privacy-safe session events
- Host room settings
- Secure invitation links
- Invitation status and revocation
This approach lets us review each security and privacy boundary before adding more visual complexity.
The infrastructure underneath the world may be less visible than a 3D animation, but it determines whether the final experience can be trusted.
Explore MyZubster
- Enter MyZubster World
- Explore the Marketplace
- Learn how MyZubster works
- View the source code
MyZubster World is still experimental, but its identity, access, lifecycle, invitation, persistence, and privacy foundations are now taking shape.
We are building it publicly, one verifiable layer at a time.
What would you implement next: immersive rooms, realtime transport, marketplace destinations, or host moderation?