The first version of 5land was called visa.rsvp. It was far from rsvp and took 7 seconds to load. It was a simple multiplayer clicker game- a globe with 1000 tiles where you can upload memes. Reddit liked our game but we were unable to scale. It didn't take long before a bot took over the whole world with his frog and QR.
The tech was simple. We used globe-gl in tile mode to render memes on a sphere. We used Nextjs for full stack development.
Quick to get things off the ground but we soon hit cracks.
Over span of a month, we completely re-imagined and rebuilt our game's architecture.
Source code- https://github.com/5land/5land-client
function (equirectangular projection, latitude of centre in radians, longitude of centre in radians) = orthographic projection
!pip install numpy
!pip install matplotlib
!pip install pillow
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
import math
def create_orthographic_projection(image_path, centre_lat, centre_lon):
"""Create orthographic projection from equirectangular image."""
# Read the image
img = Image.open(image_path)
width, height = img.size
img_array = np.array(img)
# Create output image (use square dimensions based on smaller side)
output_size = min(width, height)
output = np.zeros((output_size, output_size) + img_array.shape[2:], dtype=img_array.dtype)
# Convert center coordinates to radians
centre_lat_rad = np.radians(centre_lat)
centre_lon_rad = np.radians(centre_lon)
# Create coordinate meshgrid for output image
y_coords, x_coords = np.mgrid[0:output_size, 0:output_size]
# Normalize coordinates to [-1, 1] range for orthographic projection
x_norm = 2 * (x_coords - output_size/2) / output_size
y_norm = -2 * (y_coords - output_size/2) / output_size
# Calculate radius for each point
r = np.sqrt(x_norm**2 + y_norm**2)
# Create mask for points inside the globe
mask = r <= 1
# Pre-compute some constants
sin_centre_lat = np.sin(centre_lat_rad)
cos_centre_lat = np.cos(centre_lat_rad)
# For each point inside the globe, find corresponding input pixel
for y in range(output_size):
for x in range(output_size):
if mask[y, x]:
# Convert orthographic coordinates back to lat/lon
if r[y, x] == 0:
lat = centre_lat_rad
lon = centre_lon_rad
else:
rho = np.arcsin(r[y, x]) # Angular distance from center
sin_rho = np.sin(rho)
cos_rho = np.cos(rho)
# Calculate latitude
lat = np.arcsin(cos_rho * sin_centre_lat +
(y_norm[y, x] * sin_rho * cos_centre_lat) / r[y, x])
# Calculate longitude
lon = centre_lon_rad + np.arctan2(
x_norm[y, x] * sin_rho,
r[y, x] * cos_centre_lat * cos_rho -
y_norm[y, x] * sin_centre_lat * sin_rho
)
# Convert lat/lon to input image coordinates
# For equirectangular projection, this is a direct linear mapping
input_x = int(((np.degrees(lon) + 180) % 360) * width / 360)
input_y = int((90 - np.degrees(lat)) * height / 180)
if 0 <= input_x < width and 0 <= input_y < height:
output[y, x] = img_array[input_y, input_x]
return output
def display_projection(image_path, centre_lat, centre_lon):
"""Display the original and orthographic projection side by side."""
# Create orthographic projection
orthographic = create_orthographic_projection(image_path, centre_lat, centre_lon)
# Display results
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 7))
# Show original image
ax1.imshow(Image.open(image_path))
ax1.set_title('Original Equirectangular Projection')
ax1.axis('off')
# Show orthographic projection
ax2.imshow(orthographic)
ax2.set_title(f'Orthographic Projection\nCenter: ({centre_lat}°, {centre_lon}°)')
ax2.axis('off')
plt.tight_layout()
plt.show()
# Usage example:
display_projection('equirectangular.jpg', 76, -100)
.wgsl
shader for WebGPU that was spinning my globe.So what next?
Broadly we are using two WebGL programs
Additionally we use an off-screen canvas in 2D mode to overlay tiles at specific coordinates on an equirectangular image. Since area shrinks near poles, the polar areas have lesser columns. We obtain the output and project it on the planet shader.
$state()
, $effect()
and the .svelte
format is not unlike JSX.tile.svelte.ts
let tile = $state<number | undefined>()
export function setTile(newTile: number | undefined) {
tile = newTile
}
export function getTile(): number | undefined {
return tile
}
// Magic!
Here is my Orbiter dictum. Frameworks and libraries were good at abtracting away code but came at the price of bloat. The time has come where AI can write bespoke components and bring santity to the space so accustomed to dependency hells. The future frameworks would not be libraries at all! Istead they will be a bunch of examples that AI can easily pick up for spitting out.
Challenge- write this in an ORM
INSERT INTO tiles (id, image, user_id, updated_at)
SELECT ?, ?, ?, ?
WHERE (SELECT free_coins + bonus_coins FROM users WHERE id = ?) >= ?
ON CONFLICT(id) DO UPDATE SET
image = excluded.image,
user_id = excluded.user_id,
updated_at = excluded.updated_at;
UPDATE users
SET
free_coins = MAX(0, free_coins - ?),
bonus_coins = MAX(0, bonus_coins - MAX(0, ? - free_coins))
WHERE id = ? AND free_coins + bonus_coins >= ?
RETURNING id, free_coins, bonus_coins;
sqlx
is really good to work it and gives type safety! All thanks to rust magic.{ config, pkgs, ... }:
let unstable = import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/nixos-unstable.tar.gz") {
config = config.nixpkgs.config;
};
in {
# systemd service
systemd.services.visa-rsvp = {
enable = true;
after = ["network.target"];
wantedBy = ["multi-user.target"];
serviceConfig = {
Type = "simple";
WorkingDirectory = "/root/visa-rsvp-web2";
ExecStart = ''
${pkgs.bash}/bin/bash -c '${unstable.nodejs_22}/bin/node node_modules/next/dist/bin/next start'
'';
Restart = "always";
RestartSec = "5";
};
};
security.acme = {
acceptTerms = true;
defaults.email = "[email protected]";
};
services.nginx = {
enable = true;
clientMaxBodySize = "10m";
proxyTimeout = "3600s";
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
# Add rate limiting configuration
commonHttpConfig = ''
limit_req_zone $binary_remote_addr zone=image_upload:10m rate=12r/m;
'';
virtualHosts."visa.rsvp" = {
enableACME = true;
forceSSL = true;
# Handle SSE endpoint first
# Fix for images not live loading
locations."/api/events" = {
proxyPass = "http://127.0.0.1:3000";
extraConfig = ''
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection "keep-alive";
proxy_http_version 1.1;
proxy_read_timeout 24h;
proxy_set_header Connection "";
'';
};
# Add rate limited location for image uploads
locations."/api/images" = {
proxyPass = "http://127.0.0.1:3000";
extraConfig = ''
limit_req zone=image_upload burst=1 nodelay;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 3600s;
# Only apply rate limiting to POST requests
if ($request_method != POST) {
set $limit_req_enable 0;
}
'';
};
# Then handle everything else
locations."/" = {
proxyPass = "http://127.0.0.1:3000";
proxyWebsockets = true;
extraConfig = ''
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 3600s;
'';
};
};
};
networking.firewall = {
enable = true;
allowedTCPPorts = [ 80 443 ];
};
}
We're pleased to finally launch 5land! Thanks to Murphy's law, we could miraculously ship few hours before the Svelte hackathon deadline! Go post some memes on https://5land.org, or play around with our frontend code!