Metaverse Development
Metaverse is an overloaded term. Before designing, choose a specific model: persistent 3D world with real-time player interaction (like Decentraland, The Sandbox), or social layer over different applications, or virtual offices for enterprise? Each model—different technology stack.
I'll describe web3-native persistent world architecture: multiplayer 3D environment with NFT ownership of land/assets, on-chain economy and decentralized governance. Most technically complex and most interesting variant.
Architectural Layers of Metaverse
1. Blockchain Layer: Ownership and Economy
Everything valuable exists on-chain:
- LAND NFT—virtual land parcels (ERC-721)
- Avatar NFT—characters with attributes
- Wearables NFT—clothes, items (ERC-1155 for fungible, ERC-721 for unique)
- Governance token—DAO management participation
- In-world currency—ERC-20 token for economy
Everything else—off-chain (land content, 3D assets, interaction history).
2. Content Layer: What's on LAND
LAND owner deploys content on parcel: 3D scenes (GLTF/GLB files), interactivity scripts, portals to other worlds. Content stored decentralized—IPFS or Arweave.
3. Real-time Layer: Multiplayer Engine
Players see each other and interact in real-time—this is real-time networking task, not blockchain. Game servers (or P2P peer relay) sync avatar positions and states.
4. Application Layer: dApps and Games on LAND
Land owner deploys applications—mini-games, NFT galleries, virtual stores, concert venues. Each application can have own on-chain logic.
LAND System: NFT Land and Coordinate Grid
Coordinate System
Classic approach: map as 2D coordinate grid. Decentraland uses (-150, -150) to (150, 150). The Sandbox—408×408.
contract LandRegistry is ERC721 {
int16 public constant MIN_X = -100;
int16 public constant MAX_X = 100;
int16 public constant MIN_Y = -100;
int16 public constant MAX_Y = 100;
// Token ID encodes coordinates: id = (x + 100) * 201 + (y + 100)
function coordinatesToId(int16 x, int16 y) public pure returns (uint256) {
require(x >= MIN_X && x <= MAX_X, "X out of range");
require(y >= MIN_Y && y <= MAX_Y, "Y out of range");
return uint256(uint16(x - MIN_X)) * 201 + uint256(uint16(y - MIN_Y));
}
function idToCoordinates(uint256 tokenId) public pure returns (int16 x, int16 y) {
y = int16(int256(tokenId % 201)) + MIN_Y;
x = int16(int256(tokenId / 201)) + MIN_X;
}
// Adjacency check: needed for Estate (merging adjacent parcels)
function isAdjacent(uint256 tokenId1, uint256 tokenId2) public pure returns (bool) {
(int16 x1, int16 y1) = idToCoordinates(tokenId1);
(int16 x2, int16 y2) = idToCoordinates(tokenId2);
int16 dx = x1 - x2;
int16 dy = y1 - y2;
return (dx == 0 && (dy == 1 || dy == -1)) || (dy == 0 && (dx == 1 || dx == -1));
}
}
Estate: Merging Adjacent LAND
Owner of several adjacent parcels can merge into Estate for larger scenes:
contract EstateRegistry is ERC721 {
struct Estate {
uint256[] landIds; // included LAND tokens
string name;
string ipfsHash; // metadata
}
mapping(uint256 => Estate) public estates;
function createEstate(
uint256[] calldata landIds,
string calldata name
) external returns (uint256 estateId) {
require(landIds.length >= 2, "Need at least 2 parcels");
// Verify ownership and adjacency
for (uint256 i = 0; i < landIds.length; i++) {
require(landRegistry.ownerOf(landIds[i]) == msg.sender, "Not land owner");
}
require(_isConnectedGraph(landIds), "Parcels not adjacent");
// Transfer LAND to estate contract (lock)
for (uint256 i = 0; i < landIds.length; i++) {
landRegistry.transferFrom(msg.sender, address(this), landIds[i]);
}
estateId = ++nextEstateId;
estates[estateId] = Estate({ landIds: landIds, name: name, ipfsHash: "" });
_mint(msg.sender, estateId);
emit EstateCreated(estateId, msg.sender, landIds);
}
// BFS check that all parcels connected in one graph
function _isConnectedGraph(uint256[] calldata ids) internal view returns (bool) {
if (ids.length == 1) return true;
bool[] memory visited = new bool[](ids.length);
uint256[] memory queue = new uint256[](ids.length);
uint256 qHead = 0;
uint256 qTail = 0;
visited[0] = true;
queue[qTail++] = ids[0];
while (qHead < qTail) {
uint256 current = queue[qHead++];
for (uint256 i = 0; i < ids.length; i++) {
if (!visited[i] && landRegistry.isAdjacent(current, ids[i])) {
visited[i] = true;
queue[qTail++] = ids[i];
}
}
}
for (uint256 i = 0; i < ids.length; i++) {
if (!visited[i]) return false;
}
return true;
}
}
Content System: What Deploys on LAND
Scene Descriptor
Each LAND has scene—set of 3D objects, scripts, portals. Stored on IPFS:
// Scene descriptor (IPFS JSON)
interface SceneDescriptor {
version: '2.0';
landId: number;
owner: string; // ethereum address
title: string;
description: string;
// 3D content
models: Array<{
src: string; // ipfs://Qm... link to GLTF/GLB
position: [number, number, number];
rotation: [number, number, number];
scale: [number, number, number];
}>;
// Interactivity scripts
scripts: Array<{
src: string; // ipfs://Qm... JavaScript module
entryPoint: string; // exported function name
}>;
// Avatar spawn points
spawnPoints: Array<{
position: [number, number, number];
cameraTarget: [number, number, number];
}>;
// Adjacent land / portal links
portals: Array<{
position: [number, number, number];
targetLandId: number;
targetUrl?: string; // or external URL
}>;
}
Publishing scene:
contract LandContent {
// IPFS hash scene for each LAND
mapping(uint256 => string) public sceneHash;
mapping(uint256 => uint256) public sceneVersion;
function publishScene(uint256 landId, string calldata ipfsHash) external {
require(landRegistry.ownerOf(landId) == msg.sender, "Not owner");
// Basic validation: non-empty hash
require(bytes(ipfsHash).length == 46, "Invalid IPFS hash"); // Qm... = 46 chars
sceneHash[landId] = ipfsHash;
sceneVersion[landId]++;
emit ScenePublished(landId, msg.sender, ipfsHash, sceneVersion[landId]);
}
}
Scene Scripting SDK
Developers write scripts for interactivity on LAND. Decentralized version of Unity/Unreal scripting:
// Metaverse Scene Script SDK (runs in sandbox iframe/WebWorker)
import { engine, Transform, MeshRenderer, OnPointerDown } from '@metaverse/sdk';
// Interactive door
const door = engine.addEntity();
engine.addComponentOrReplace(door, Transform, {
position: { x: 0, y: 0, z: 5 },
rotation: { x: 0, y: 0, z: 0, w: 1 },
scale: { x: 1, y: 1, z: 1 },
});
let isOpen = false;
engine.addComponentOrReplace(door, OnPointerDown, {
callback: async () => {
isOpen = !isOpen;
// Animate open/close
const transform = engine.getComponent(door, Transform);
transform.rotation = isOpen
? Quaternion.fromEulerDegrees(0, 90, 0)
: Quaternion.fromEulerDegrees(0, 0, 0);
},
hoverText: isOpen ? 'Close door' : 'Open door',
});
// NFT gate: only holders of specific NFT can enter
import { checkNFTOwnership } from '@metaverse/blockchain';
engine.addComponentOrReplace(nftGate, OnPointerDown, {
callback: async () => {
const userAddress = await engine.getUserAddress();
const hasNFT = await checkNFTOwnership(userAddress, NFT_CONTRACT, TOKEN_ID);
if (!hasNFT) {
engine.showNotification('You need the Golden Pass NFT to enter');
return;
}
engine.teleportPlayer({ x: INSIDE_X, y: 0, z: INSIDE_Z });
},
});
Scripting sandbox—isolated WebWorker or iframe without direct DOM access. Blockchain interaction API provided via postMessage, not direct wallet access.
Real-time Networking: Avatar Synchronization
Architecture: Area Servers
World divided into regions (each region = N×N LAND). Each region served by one game server. When player moves between regions—handoff to other server.
┌─────────────────────────────┐
│ Load Balancer / Router │
│ (by player coordinates) │
└──────────┬──────────────────┘
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Area Server │ │ Area Server │ │ Area Server │
│ Region A │ │ Region B │ │ Region C │
│ (-100,-100) │ │ (0,0) │ │ (100,100) │
│ to (0,0) │ │ to (100,100)│ │ ... │
└──────────────┘ └──────────────┘ └──────────────┘
// Area Server: manage avatars in region
import { WebSocketServer } from 'ws';
import { World } from '@dimforge/rapier3d'; // physics engine
class AreaServer {
private players = new Map<string, PlayerState>();
private physicsWorld = new World({ x: 0, y: -9.81, z: 0 });
handlePlayerJoin(playerId: string, ws: WebSocket, position: Vector3) {
const state: PlayerState = {
id: playerId,
position,
rotation: Quaternion.identity(),
animation: 'idle',
ws,
};
this.players.set(playerId, state);
// Send new player all region states
const snapshot = this.getRegionSnapshot();
ws.send(JSON.stringify({ type: 'region_snapshot', players: snapshot }));
// Notify others about new player
this.broadcast({ type: 'player_joined', player: state }, playerId);
}
handleMovement(playerId: string, movement: MovementPacket) {
const player = this.players.get(playerId);
if (!player) return;
// Server-side movement validation (anti-cheat)
if (!this.isValidMovement(player, movement)) {
// Correct position
player.ws.send(JSON.stringify({
type: 'position_correction',
position: player.position,
}));
return;
}
player.position = movement.position;
player.rotation = movement.rotation;
player.animation = movement.animation;
// Broadcast to all in region (delta compression)
this.broadcastMovement(playerId, movement);
}
// 20 updates/sec for smooth movement
private startTickLoop() {
setInterval(() => this.tick(), 50);
}
private tick() {
this.physicsWorld.step();
// Collect dirty states and broadcast as batch
const updates = this.getDirtyPlayerStates();
if (updates.length > 0) {
this.broadcast({ type: 'batch_update', updates });
}
}
}
Proximity-based Broadcasting
No need to broadcast player position to everyone in region—only those nearby. Interest Management:
const VISIBILITY_RADIUS = 100; // meters
function getVisiblePlayers(playerId: string): string[] {
const player = players.get(playerId)!;
return Array.from(players.values())
.filter(p => p.id !== playerId)
.filter(p => distance(p.position, player.position) < VISIBILITY_RADIUS)
.map(p => p.id);
}
This reduces bandwidth from O(N²) to O(N × K), where K = average visible players.
In-world Economy
Marketplace Contract
LAND owner sells virtual goods inside world:
contract InWorldMarketplace {
struct Listing {
address seller;
address nftContract;
uint256 tokenId;
uint256 price; // in in-world currency (ERC-20)
uint256 landId; // which LAND listing on
bool active;
}
// Royalty for LAND owner: 2.5% from sales on land
uint256 public constant LAND_ROYALTY = 250; // basis points
function buy(uint256 listingId) external {
Listing storage listing = listings[listingId];
require(listing.active, "Not active");
uint256 landRoyalty = listing.price * LAND_ROYALTY / 10_000;
uint256 sellerProceeds = listing.price - landRoyalty;
// Buyer pays in-world currency
worldToken.transferFrom(msg.sender, listing.seller, sellerProceeds);
worldToken.transferFrom(msg.sender, landRegistry.ownerOf(listing.landId), landRoyalty);
// Transfer NFT
IERC721(listing.nftContract).transferFrom(
address(this), msg.sender, listing.tokenId
);
listing.active = false;
emit Sale(listingId, msg.sender, listing.price);
}
}
Play-to-Earn Mechanics
In-world activities can generate in-world currency:
- Visiting events on LAND (check-in reward)
- Completing quests created by LAND owners
- Participating in mini-games
Important: emission rate must be controlled to prevent inflation. Recommendation: weekly emission cap + halving mechanism like Bitcoin.
DAO and Governance
// Governance via Compound-style voting
contract MetaverseDAO is Governor, GovernorTimelockControl {
// Holding LAND gives voting power
function _getVotes(
address account,
uint256 blockNumber,
bytes memory
) internal view override returns (uint256) {
// 1 LAND = 1 vote + bonus for governance token staking
uint256 landVotes = landRegistry.balanceOf(account); // at blockNumber
uint256 tokenVotes = govToken.getPastVotes(account, blockNumber);
return landVotes + tokenVotes;
}
}
Governance decides: world size (new LAND), economy parameters, whitelist content formats, contract upgrades.
Technology Stack
3D Rendering
Three.js + React Three Fiber—for web-native metaverse. Good GLTF support, PBR materials, performance optimizations via instancing and LOD.
Babylon.js—alternative, especially good with WebXR (VR/AR). Decentraland uses Babylon.js.
Unity WebGL—best graphics quality, but large bundle (50–200 MB), slow load. Good for desktop-oriented product.
Full Stack
| Layer | Technology |
|---|---|
| Blockchain | Polygon PoS or Arbitrum (low gas for LAND transactions) |
| LAND/NFT | Solidity ERC-721 + ERC-1155, Foundry |
| Governance | OpenZeppelin Governor + TimelockController |
| Storage | IPFS (Pinata/Web3.Storage) + Arweave for permanent content |
| Real-time | Node.js + uWebSockets.js (high performance) |
| Physics | Rapier3D (Rust/WASM, faster than Cannon.js) |
| 3D Web | Three.js + React Three Fiber + Drei |
| Avatar system | ReadyPlayerMe SDK or custom VRM avatars |
| State | Redis for region state, PostgreSQL for persistent data |
| Indexing | The Graph (LAND transfers, scene updates events) |
Development Phases
| Phase | Content | Timeline |
|---|---|---|
| Foundation | LAND contracts, coordinate system, basic marketplace | 4–6 weeks |
| Content system | Scene descriptor, IPFS storage, scene publishing | 3–4 weeks |
| 3D Client | Three.js world, scene loading, basic navigation | 6–8 weeks |
| Real-time | Area servers, avatar sync, proximity system | 6–8 weeks |
| Economy | In-world token, marketplace, play-to-earn | 4–6 weeks |
| Scripting SDK | Scene scripting sandbox, NFT gate APIs | 4–6 weeks |
| Governance | DAO contracts, voting UI | 3–4 weeks |
| Audit | LAND, marketplace, economy contracts | 5–8 weeks |
| Alpha launch | Private alpha with limited map | 2–4 weeks |
Realistic timeline from zero to public alpha: 12–18 months for team 8–12 people (2–3 blockchain, 2–3 3D/frontend, 2 backend, 1 PM, 1 designer). One of most scope-complex projects in Web3.
Main risk not technical but product: without content on LAND and active community world will be empty. Need early LAND holder and content creator program parallel to development.







