Virtual Land System Development
Virtual land—NFT representing coordinate space in virtual world. Decentraland, The Sandbox, Otherside implemented this on Ethereum. Architecture not trivial: coordinate system, parcel adjacency, adjacency effects, rights system and building, rendering.
Development of virtual land system from scratch—intersection of several technical tasks: smart contracts (NFT, ownership, permissions), spatial database (coordinate-indexed storage), game engine or 3D renderer, and economic model (scarcity, adjacency bonuses, district mechanics).
Coordinate System and Storage
Land usually represented as 2D Grid—integer coordinates (x, y). Each parcel—unique NFT with coordinates as key attribute.
contract VirtualLand is ERC721 {
// Packed coordinates as tokenId: x (int16) + y (int16) → uint32
// Limits world: x from -32768 to 32767, y similarly
struct LandInfo {
int16 x;
int16 y;
address owner;
bool developed; // has buildings
uint32 districtId; // which district belongs to
}
mapping(uint256 => LandInfo) public lands;
mapping(bytes32 => uint256) public coordsToTokenId; // hash(x,y) → tokenId
function _coordsToId(int16 x, int16 y) internal pure returns (uint256) {
return uint256(uint32(uint16(int16(x))) | (uint32(uint16(int16(y))) << 16));
}
function _hashCoords(int16 x, int16 y) internal pure returns (bytes32) {
return keccak256(abi.encodePacked(x, y));
}
function mint(int16 x, int16 y, address to) external onlyMinter {
bytes32 coordHash = _hashCoords(x, y);
require(coordsToTokenId[coordHash] == 0, "Land already exists");
uint256 tokenId = _coordsToId(x, y);
_safeMint(to, tokenId);
lands[tokenId] = LandInfo({
x: x,
y: y,
owner: to,
developed: false,
districtId: _getDistrictId(x, y)
});
coordsToTokenId[coordHash] = tokenId;
}
function getLandAtCoords(int16 x, int16 y) external view returns (LandInfo memory) {
uint256 tokenId = coordsToTokenId[_hashCoords(x, y)];
require(tokenId != 0, "No land at these coords");
return lands[tokenId];
}
}
Estate: Merging Parcels
Estate (manor)—several adjacent parcels merged into single NFT. Simplifies management of large contiguous space. Implemented as separate contract:
contract Estate is ERC721 {
mapping(uint256 => uint256[]) public estateLands; // estateId → list of parcel tokenIds
mapping(uint256 => uint256) public landToEstate; // landTokenId → estateId
function createEstate(uint256[] calldata landTokenIds, string calldata name) external {
// Check all parcels belong to caller
// Check parcels adjacent (adjacency check)
for (uint i = 0; i < landTokenIds.length; i++) {
require(landContract.ownerOf(landTokenIds[i]) == msg.sender, "Not owner");
require(!landToEstate[landTokenIds[i]], "Land in estate");
}
require(_areAdjacent(landTokenIds), "Lands not adjacent");
uint256 estateId = _nextId++;
_safeMint(msg.sender, estateId);
estateLands[estateId] = landTokenIds;
for (uint i = 0; i < landTokenIds.length; i++) {
landToEstate[landTokenIds[i]] = estateId;
landContract.transferFrom(msg.sender, address(this), landTokenIds[i]);
}
}
}
Adjacency Check On-chain
Checking that set of parcels forms connected graph—expensive on-chain operation with large parcel count. Optimization: check each parcel has at least one neighbor in set (not full connectivity, enough for estate):
function _areAdjacent(uint256[] calldata tokenIds) internal view returns (bool) {
for (uint i = 1; i < tokenIds.length; i++) {
LandInfo memory land = landContract.lands[tokenIds[i]];
bool hasNeighbor = false;
for (uint j = 0; j < i; j++) {
LandInfo memory other = landContract.lands[tokenIds[j]];
int16 dx = land.x - other.x;
int16 dy = land.y - other.y;
if ((dx == 0 && (dy == 1 || dy == -1)) ||
(dy == 0 && (dx == 1 || dx == -1))) {
hasNeighbor = true;
break;
}
}
if (!hasNeighbor) return false;
}
return true;
}
O(n²)—for estates up to 20-30 parcels acceptable. For larger estates—off-chain check + Merkle proof or ZK proof.
Rights and Operators System
Parcel owner controls who can build on land. Rights system:
// Bitmap rights: bit 0 = BUILD, bit 1 = SCRIPT, bit 2 = VOICE, bit 3 = ADMIN
uint8 public constant RIGHT_BUILD = 1 << 0;
uint8 public constant RIGHT_SCRIPT = 1 << 1;
uint8 public constant RIGHT_ADMIN = 1 << 3;
mapping(uint256 => mapping(address => uint8)) public landOperators;
// landId → operator → rights bitmap
function grantRights(uint256 landId, address operator, uint8 rights) external {
require(ownerOf(landId) == msg.sender, "Not owner");
landOperators[landId][operator] |= rights;
emit OperatorRightsGranted(landId, operator, rights);
}
function revokeRights(uint256 landId, address operator, uint8 rights) external {
require(ownerOf(landId) == msg.sender, "Not owner");
landOperators[landId][operator] &= ~rights;
}
function hasRight(uint256 landId, address operator, uint8 right) public view returns (bool) {
return ownerOf(landId) == operator ||
(landOperators[landId][operator] & right) != 0;
}
District System and Adjacency Economy
District—administrative unit grouping parcels. Can have own governance, shared revenue from trading within district, common parameters:
struct District {
string name;
uint32 id;
int16 minX; int16 maxX;
int16 minY; int16 maxY;
address council; // multisig managing district
uint256 floorPrice; // minimum price for listing in district
}
function _getDistrictId(int16 x, int16 y) internal view returns (uint32) {
// Iterate districts and find containing coordinates
// For efficiency: districts in R-tree off-chain,
// on-chain only config hash
return districtMap[_quantizeToSector(x, y)];
}
Adjacency bonus—popular mechanic: parcels near landmarks (city center, plaza) more expensive. Implemented via landmark coordinate mapping and distance calculation:
mapping(bytes32 => uint8) public landmarkTier; // hash(x,y) → tier (0 = none, 1-3 = importance)
function getAdjacencyBonus(int16 x, int16 y) external view returns (uint8 maxBonus) {
int16[4] memory dx = [int16(-1), 1, 0, 0];
int16[4] memory dy = [int16(0), 0, -1, 1];
for (uint8 i = 0; i < 4; i++) {
uint8 tier = landmarkTier[_hashCoords(x + dx[i], y + dy[i])];
if (tier > maxBonus) maxBonus = tier;
}
}
Content Layer: What's Built on Parcel
Buildings on parcel—off-chain data (3D models, scripts), references written on-chain:
struct LandContent {
string contentHash; // IPFS CID or content hash
string contentType; // "scene", "model", "script"
uint256 version; // for versioning
address contentOwner; // who uploaded (may differ from owner)
}
mapping(uint256 => LandContent) public landContent;
function setContent(
uint256 landId,
string calldata contentHash,
string calldata contentType
) external {
require(
hasRight(landId, msg.sender, RIGHT_BUILD),
"No build rights"
);
landContent[landId] = LandContent({
contentHash: contentHash,
contentType: contentType,
version: landContent[landId].version + 1,
contentOwner: msg.sender
});
emit ContentUpdated(landId, contentHash, msg.sender);
}
IPFS—standard for scene content storage. For large 3D scenes (tens MB)—additionally Arweave for permanent storage, or custom CDN with content hash verification.
Marketplace and Royalties
Built-in marketplace for land trading with EIP-2981 royalties:
struct Listing {
uint256 tokenId;
uint256 price;
address seller;
uint256 expiresAt;
}
mapping(uint256 => Listing) public listings;
function list(uint256 tokenId, uint256 price, uint256 duration) external {
require(ownerOf(tokenId) == msg.sender);
require(getApproved(tokenId) == address(this) || isApprovedForAll(msg.sender, address(this)));
listings[tokenId] = Listing({
tokenId: tokenId,
price: price,
seller: msg.sender,
expiresAt: block.timestamp + duration
});
}
function buy(uint256 tokenId) external payable {
Listing memory listing = listings[tokenId];
require(block.timestamp <= listing.expiresAt, "Listing expired");
require(msg.value >= listing.price, "Insufficient payment");
delete listings[tokenId];
// EIP-2981 royalty
(address royaltyReceiver, uint256 royaltyAmount) = royaltyInfo(tokenId, listing.price);
uint256 sellerProceeds = listing.price - royaltyAmount - (listing.price * PLATFORM_FEE / 10000);
payable(royaltyReceiver).transfer(royaltyAmount);
payable(PLATFORM_TREASURY).transfer(listing.price * PLATFORM_FEE / 10000);
payable(listing.seller).transfer(sellerProceeds);
if (msg.value > listing.price) {
payable(msg.sender).transfer(msg.value - listing.price);
}
_safeTransfer(listing.seller, msg.sender, tokenId, "");
}
3D Rendering and World Map
Interactive map—core UI for virtual land. Two approaches:
2D Map (top-down view)—SVG or Canvas rendering coordinate grid. Each parcel—square colored by status (free/owned/for_sale/developed). Clickable, zoom, pan. Implemented via react-konva or pixi.js. Performance on 10,000+ parcels—requires viewport culling (render only visible).
3D World View—Three.js or Babylon.js for rendering 3D scenes from parcels. Loading scene content from IPFS on parcel approach. Much more complex—streaming load, LOD, collision detection.
// Viewport culling example for 2D map
function getVisibleLands(
viewport: { x: number; y: number; width: number; height: number },
tileSize: number
): [number, number][] {
const startX = Math.floor(viewport.x / tileSize)
const startY = Math.floor(viewport.y / tileSize)
const endX = Math.ceil((viewport.x + viewport.width) / tileSize)
const endY = Math.ceil((viewport.y + viewport.height) / tileSize)
const visible: [number, number][] = []
for (let x = startX; x <= endX; x++) {
for (let y = startY; y <= endY; y++) {
visible.push([x, y])
}
}
return visible
}
Indexing: The Graph or Custom Indexer
On-chain data (who owns what parcel, transaction history) inefficient to read directly. Need indexer:
The Graph—standard choice. Subgraph for virtual land:
type Land @entity {
id: ID!
x: Int!
y: Int!
owner: Bytes!
districtId: Int
content: LandContent
listings: [Listing!]! @derivedFrom(field: "land")
transactions: [Transfer!]! @derivedFrom(field: "land")
}
type Transfer @entity {
id: ID!
land: Land!
from: Bytes!
to: Bytes!
price: BigInt
timestamp: BigInt!
}
Custom indexer (Node.js + PostgreSQL + PostGIS)—if need geospatial queries. PostGIS supports spatial indexes—search all parcels in region in O(log n).
Stack and Infrastructure
| Component | Technology |
|---|---|
| Land NFT contract | ERC-721 + Solidity |
| Estate contract | ERC-721 + adjacency logic |
| Marketplace | Solidity (custom or Seaport) |
| Indexer | The Graph / custom + PostGIS |
| 2D map | React + Pixi.js / Konva.js |
| 3D rendering | Three.js / Babylon.js |
| Content storage | IPFS + Pinata / Arweave |
| Network | Polygon / Immutable zkEVM |
Economy and Scarcity
World size critically important. Too large—land cheap, no scarcity. Too small—barrier to entry for new players.
Decentraland: 90,601 parcels (301×301 grid). The Sandbox: 166,464 parcels. Both showed: primary sale creates hype, secondary market exists, but activity drops without compelling use case.
Key question not smart contracts but retention: why enter world? Without content—land useless. Strategy: first-party content from team (game experiences, concerts, brand activations) as anchor for initial traffic.
Timelines
MVP (Land NFT, basic marketplace, 2D map, content upload): 2-3 months.
Full system with Estate, Districts, 3D rendering, rights system, The Graph indexer, adjacency mechanics: 5-7 months.
3D world engine with user content support, scene streaming, physics—separate project, 6-12 months. This is Decentraland SDK level—big engineering work.
Contract audit mandatory—land NFT may be worth significant sums, errors in transfer logic or marketplace—direct financial risk.







