A droplet fades. A pixelet remains.
Started on December 15, 2025.
pixelet is a location-based spatial world built over real-world geodata (OpenStreetMap + Copernicus GLO-30 elevation) — terrain rendered as voxels, with a separate voxel-object layer for building on top of it.
Requires Deno 2.x and Docker (for the local Postgres databases).
git clone git@github.com:canplane/pixelet.git
cd pixelet
cp .env.example .env
cp apps/web/.env.example apps/web/.env
docker compose --env-file .env -f infra/docker-compose.yml up -d
deno task dev| Task | Description |
|---|---|
deno task dev |
Start web + api concurrently |
deno task dev:web |
Frontend only |
deno task dev:api |
Backend only |
deno task build:web |
Build frontend |
deno task build:api |
Build backend |
deno task check |
Type-check all packages |
deno task test |
Run all tests |
deno task lint |
Lint |
deno task fmt |
Format |
The monorepo, package management, and task execution are unified under Deno. The web app uses Vite as its bundling/build entry; the api runs directly under Deno.
| Layer | Technology |
|---|---|
| Runtime / Workspace | Deno + TypeScript |
| Bundler (web) | Vite |
| Web UI | React 19 |
| API | Hono |
| 3D Rendering | Three.js + React Three Fiber |
| Database | PostgreSQL (main + geographic/PostGIS) |
| Geodata | OpenStreetMap (OSM) + Copernicus GLO-30 (elevation) |
Two more pieces of the stack are built for this project rather than adopted: a packed fixed-point coordinate format (PVF3) and a typed-array wire protocol (XTP) for terrain transport. See Repository Structure and World and Rendering Model below, or ARCHITECTURE.md for the full design.
Naming: Most identifiers here — files, functions, locals — use snake_case rather than TypeScript's usual camelCase (React hooks, stores, and components keep the ecosystem's own convention, since fighting that adds no value). The engine side of pixelet leans toward data-oriented design, closer to systems programming than typical frontend code, and a WebGPU/WASM path — including a partial Rust port — has been on the table from early on. C/C++/Rust-style abbreviations show up throughout (len, u32, new_len in @xpute/core/ds, for instance), and those read oddly bolted onto camelCase (newLen next to len); snake_case sits naturally with them instead. Unifying most of the TypeScript around it also keeps the two languages visually consistent at the boundary.
.
├── apps/
│ ├── web/ # Web client (Deno + Vite + React 19 + R3F)
│ └── api/ # API server (Deno + Hono), OSM bake pipeline
│
└── packages/
├── xpute/
│ └── core/ # Generic kit/collections/io/errors + XTP binary protocol
│ # (no pixelet domain knowledge — portable to other projects)
│
└── pxl/
└── shared/ # PVF3 coordinate system, cell/terrain model, bake wire format
# (pixelet-specific — must stay identical between web and api)
The engine is split into a pixelet-specific domain layer (@pxl/shared) and a project-agnostic utility layer (@xpute/core) that @pxl/shared's binary formats are built on.
Storage, network transfer, and rendering deliberately use different scales. See ARCHITECTURE.md for the full L0–L5 hierarchy and the layer/space/shape terminology it's built from — in short:
- L1 Block (
1uper axis): the canonical cell — a Voxel once it carries a material payload. - L2 Chunk (
64blocks per axis): the renderer's render/LOD/raycasting unit. The renderer composes it into a64³Volume per occupied Band (one signed-Z slice), grouping all occupied L2 bands at one XY address into one column component. - L3 Page (
1024blocks per axis): terrain's streaming/storage unit — terrain stores a1024²Tile there, X/Y only. - L4 Anchor (
4096blocks per axis): the floating-origin frame.get_local_world_originsnaps the camera's map center down to its L4 base;AnchorRegistrykeeps one ref-countedTHREE.Groupper live L4 index and re-bases every group's local position when that snapped origin shifts. Carries no Tile/Volume payload of its own — a pure addressing/reparenting mechanism. - L5 Shard (
65536blocks per axis): reserved for a future global sharding domain — not implemented (L5_SHAMTstays commented out inindexer.ts). Once active, its Z lane collapses (Z is only 16 bits, narrower thanL5_SHAMT), so L5 can only ever address X/Y.
Terrain (a Tile-shaped layer) stays dense 2.5D page data; placed objects (a Volume-shaped layer) stay sparse octrees in source storage. Object leaf byte 0 is UNDEF (sparse absence/fallback); every explicit object-layer value owns the high bit, including explicit AIR used for terrain removal. At render time, both implement the same WorldLayer contract and paint into one temporary signed-Z Volume of Voxels. The same composed boxes drive meshing and raycasting, so rendered and clickable geometry cannot diverge by using separate approximations.
Dense 64³ Volumes exist only while composing and meshing. Long-lived state keeps compact surface data and GPU-ready instance buffers instead, avoiding the mobile memory cost of retaining a dense Volume for every visible chunk. Most of this pipeline is renderer-independent typed-array math; Three.js / React Three Fiber owns the final scene, material, texture, frustum, and GPU submission boundary.
See ARCHITECTURE.md for how the coordinate system, spatial hierarchy, unified world-layer pipeline, and wire format work under the hood. See discussions/ for the reasoning behind those design decisions.