- Overview
- Runtime Topology
- Main App Structure
- Messaging and Adapters
- World Ownership Model
- World Generation
- Content Authoring and Generation
- Rendering Pipeline
- Input, Player, and Gameplay Loop
- Inventory
- Chat and Commands
- Persistence
- UI
- Tests
- Future Extension Points
Craftvale is a Bun/TypeScript voxel sandbox with a macOS-first native bridge for GLFW and OpenGL. The repo is a Bun workspaces monorepo: apps/client (desktop app), apps/dedicated-server, apps/cli (developer tooling), and packages/core (shared gameplay runtime).
The runtime splits into a client side and an authoritative server side:
- Client — app shell, rendering, input, menus, and a local replicated world cache.
- Server — world generation, persistence, and all authoritative world mutations on a shared fixed-step tick loop. Runs either inside a local Worker for singleplayer or as a dedicated WebSocket server for multiplayer.
Package structure at a glance:
| Package | Role |
|---|---|
apps/client |
Desktop app bootstrap, GameApp, rendering, UI, input, singleplayer worker startup |
apps/dedicated-server |
Dedicated WebSocket server process |
apps/cli |
Native build, dev flow, asset generation scripts |
packages/core |
Shared server runtime, messaging, world gen, content, math |
native |
Minimal GLFW/OpenGL C bridge |
The intended shared import surfaces are @craftvale/core/shared and @craftvale/core/server. Deep relative cross-workspace imports should be avoided.
The main thread hosts the playable client app:
- Creates the window through
NativeBridge. - Owns the render loop and input polling.
- Runs the
GameAppinstance fromapps/client/src/app/game-app.ts. - Keeps a replicated
VoxelWorldfor rendering, raycast, and collision. - Sends typed requests/events to either a worker-backed local server or a WebSocket-backed dedicated server.
The local worker hosts the authoritative gameplay server for one selected world:
- Boots through
apps/client/src/worker-entry.ts. - Is attached to a client-owned
WorkerServerHostfromapps/client/src/worker/host.ts. - Constructs a
ServerRuntimefrom@craftvale/core/server. - Generates chunks, applies block mutations, and owns the authoritative state for that one world.
- Saves the world through
BinaryWorldStoragefrom@craftvale/core/server.
This separation means the client never directly mutates authoritative world state — it asks the server to do so and applies the resulting authoritative updates.
The dedicated multiplayer path is hosted separately from the desktop app:
- Boots through
apps/dedicated-server/src/index.ts. - Starts a
DedicatedServerand exposes a WebSocket endpoint at/ws. - Creates or loads exactly one world on startup; keeps it authoritative for all connected sessions.
There is no remote world browser. A multiplayer client connects to one saved server entry and joins the server's single authoritative world.
GameApp in apps/client/src/app/game-app.ts is the top-level state owner for the client runtime.
It owns:
- App mode:
menu,loading, orplaying. - Menu state, loading-screen state, current world/session metadata.
- Local player identity, transient HUD/status text, chat state.
- Timing state for the fixed-step loop and input edge tracking.
- Lifecycle-managed event-bus subscriptions.
Its dependencies are injected explicitly: NativeBridge, PlayerController, VoxelRenderer, menu seed, client settings storage, and saved-server storage.
The active transport connection is lifecycle-managed by GameApp:
- Local singleplayer connects a
WorkerClientAdapter. - Remote multiplayer connects a
WebSocketClientAdapter. - Each connection owns its own
ClientWorldRuntime.
The app loop per frame:
- Poll native input.
- Advance timing state.
- If in menu mode — evaluate UI and issue world-management requests.
- If in loading mode — render the loading screen and wait for startup readiness.
- If in play mode — run fixed-step gameplay updates.
- Build HUD/UI data.
- Render the frame.
- Yield back to the event loop.
Shutdown is instance-owned: GameApp saves the current world, closes the client adapter, and shuts down the native bridge.
The client/server boundary is strongly typed through packages/core/src/shared/messages.ts, re-exported at @craftvale/core/shared.
Three message categories:
- Client requests — request/response operations:
joinWorld,requestChunks,saveWorld. - Client events — one-way gameplay intents:
mutateBlock,selectInventorySlot, chat submission, player-state updates. - Server events — one-way authoritative updates:
chunkDelivered,chunkChanged,inventoryUpdated,playerUpdated, chat/system messages,saveStatus.
Gameplay events that affect authoritative state are enqueued and applied on the next authoritative server tick. Request/response flows (joining, chunk delivery, saving) run immediately.
packages/core/src/shared/event-bus.ts wraps raw transport messages with typed handlers and request correlation.
packages/core/src/shared/message-codec.ts serializes typed transport messages for the WebSocket path, including chunk payload byte buffers.
Transport layers:
| Layer | Location |
|---|---|
WorkerClientAdapter |
Client side (singleplayer) |
WorkerServerAdapter |
Client-owned worker side |
WorkerServerHost |
apps/client/src/worker/host.ts |
WebSocketClientAdapter |
Multiplayer client side |
DedicatedServerTransport |
Dedicated server |
Because the transport abstraction is explicit, local and remote play share the same gameplay/message semantics even though their process boundaries differ.
ClientWorldRuntime owns the replicated client view:
- Loaded chunk cache and pending chunk requests.
clientPlayerName,clientPlayerEntityId, and replicated player snapshots.- Replicated dropped-item and local-player inventory snapshots.
- Recent replicated chat/system messages.
Inventory and dropped-item snapshots are item-based: slots and floor loot carry ItemId stack contents, and item metadata drives held-item display and placement affordances on the client.
This local world is used for terrain rendering, remote player rendering, first-person arm/held-item rendering, player collision, voxel raycast/highlight, and HUD display.
AuthoritativeWorld owns the real gameplay state for one active world session:
- Authoritative chunks, world-level entity state, dirty/save tracking.
- Spawn computation, block mutation rules, and chat-driven command parsing.
PlayerSystem operates within that entity state: allocates and restores player entities, owns component mutation and snapshot assembly, and persists per-player position/rotation, gamemode, and inventory.
DroppedItemSystem also operates within the shared entity state: allocates dropped-item actors from the same registry, stores transform/stack/pickup-cooldown components, indexes drops by chunk for pickup queries, and persists active floor loot with the world save.
World-level entity state:
- One
EntityRegistryfor actor ids in the active world. - Component stores for player identity, transform, mode, movement, inventory, session presence, and persistence.
- Component stores for dropped-item transform, stack contents, and pickup cooldown.
- Component stores for block-entity type and block position, with a dedicated
BlockEntitySystemowning entity-backed blocks such as crafting tables.
Chunks are not entities — chunk data stays coordinate-addressed. World generation, chunk persistence, and chunk resend decisions stay in AuthoritativeWorld.
Interactive blocks still live in chunk voxel data for placement/breaking, but their
server-owned state, use handling, and future per-tick simulation live in the block-entity layer.
- The authoritative world preloads a bounded startup chunk radius near the joining player's initial position.
- Local worker sessions emit monotonic loading-progress events while that startup area is prepared.
- The client only leaves the loading screen after the joined payload is applied and the required startup chunks are present in the replicated cache.
- Chunk generation on demand.
- Draining queued gameplay intents on the authoritative tick boundary.
- Simulating dropped items and other world systems once per authoritative tick.
- Simulating block entities once per authoritative tick, including future furnace/chest/door behavior.
- Batching replication after each tick so clients observe coherent world-state updates.
- Validating and applying block mutations.
- Handling chat-driven commands such as
/gamemodeand/timeset. - Loading, saving, and replicating per-player position, rotation, gamemode, and inventory.
- Resolving broken blocks into dropped item ids and spawning them as dropped item actors.
- Simulating dropped item gravity, cooldown, and pickup checks.
- Deciding which chunks must be resent after a mutation.
World generation is deterministic and seed-driven. The world is 256 blocks tall with sea level at Y 64. Chunks are full-height horizontal columns — 16×256×16 — so runtime streaming works in horizontal areas only.
The worldgen pipeline in packages/core/src/world/:
| File | Role |
|---|---|
noise.ts |
Shared deterministic noise helpers |
biomes.ts |
Biome sampling and biome definitions |
terrain.ts |
Terrain heights, cave carving, ore placement, water, tree decoration |
ore-config.ts |
Authored ore-distribution settings (Y range, attempts per chunk, vein size) |
Generation flow per chunk:
- Sample biome-influenced terrain parameters per world column.
- Compute terrain height for each
(x, z)column. - Fill top/filler/deep blocks according to the local biome.
- Fill low terrain up to sea level with static generated water.
- Deterministic cave-carving pass — may leave enclosed caves or open cave mouths.
- Deterministic ore-vein pass using ore config, replacing only eligible host stone.
- Deterministic tree decoration pass.
Generation is chunk-order safe. Trees, caves, and ore all sample world-space coordinates but write only within the current chunk column, so generation is deterministic regardless of load order.
Block and item ids are not hand-authored. Content starts from one source and flows through deterministic generation.
| File | Role |
|---|---|
packages/core/src/world/content-spec.ts |
Authored block/item definitions |
packages/core/src/world/ore-config.ts |
Ore distribution defaults |
packages/core/src/world/content-id-lock.json |
Checked-in id stability snapshot |
packages/core/src/world/generated/ |
Generated outputs — do not hand-edit |
apps/client/assets/textures/tiles-src/ |
Authored per-tile PNG source textures |
apps/client/assets/textures/voxel-atlas.png |
Generated runtime atlas — do not hand-edit |
- Author or edit block/item definitions in
content-spec.ts. - Run
bun run generate:contentto regenerate stable ids and registries. - Add or edit referenced tile PNGs in
apps/client/assets/textures/tiles-src/. - Run
bun run generate:atlasto rebuild the runtime voxel atlas. - Run
bun run typecheckandbun test.
- Add a block entry to
AUTHORED_BLOCK_SPECS— new stablekey, collision/render/light metadata,dropItemKeypointing at the item key. - Add an item entry to
AUTHORED_ITEM_SPECS—placesBlockKeyandrenderBlockKeypointing at the block key. - If the block uses textures, set
tiles.top,tiles.bottom, andtiles.sideto names already inAtlasTiles(add them if needed). - Optionally add an absolute-slot entry to
DEFAULT_STARTER_INVENTORY_STACK_SPECS. - Run
bun run generate:content. - Add or edit matching tile PNGs in
apps/client/assets/textures/tiles-src/. - Run
bun run generate:atlas. - Run
bun run typecheckandbun test.
Add a block entry in AUTHORED_BLOCK_SPECS. Set dropItemKey to null if breaking it should not create an item drop. Omit any matching item entry unless players need to hold, place, or pick it up. bedrock is the current example.
Add an item entry in AUTHORED_ITEM_SPECS. Set placesBlockKey and renderBlockKey to null. This is the intended path for future tools, consumables, and ingredients.
| Field | Description |
|---|---|
key |
Stable authoring identity — keep short, lowercase, and durable |
name |
Player-facing display name |
color |
Fallback/debug item display color |
dropItemKey |
Item dropped when the block is broken |
placesBlockKey |
Block the item places, if any |
renderBlockKey |
Block mesh used to render the held item, dropped item, and HUD inventory item |
renderPass |
"opaque" for solid terrain, "cutout" for alpha-discard blocks (e.g. leaves), null for non-rendered blocks (e.g. air) |
occlusion |
"full" for solid cubes, "self" for leaf-style self-culling, "none" for non-occluding blocks |
emittedLightLevel |
Block light emitted, 0–15 |
- One
16×16RGBA PNG per tile id inapps/client/assets/textures/tiles-src/(e.g.dirt.png,grass-top.png). - Tile ids must align with
AtlasTilesand the tile names referenced fromcontent-spec.ts. - After changing any source tile PNG, run
bun run generate:atlas. bun run generate:tile-sourcesregenerates the default tile PNG set from code — normal art iteration should edit PNGs intiles-srcdirectly.
- Do not hand-pick numeric ids — the generator assigns them.
- Do not reorder generated files by hand — the generator and lockfile own the final ids.
- Adding a new key appends a new id while preserving existing ids.
- Renaming or removing a key is a compatibility-sensitive change — existing saves may reference the old ids. Update
content-spec.tsandcontent-id-lock.jsontogether and treat it as a migration or save-reset decision.
- Add a block spec
blue_lanternwith tiles,emittedLightLevel, anddropItemKey: "blue_lantern". - Add a matching item spec with
placesBlockKey: "blue_lantern"andrenderBlockKey: "blue_lantern". - Optionally add
"blue_lantern"toDEFAULT_STARTER_INVENTORY_STACK_SPECS. - Run
bun run generate:contentand review the diff incontent-id-lock.jsonandgenerated/*.
VoxelRenderer in apps/client/src/render/renderer.ts owns rendering.
Pipeline per frame:
- Build or refresh chunk meshes from the replicated client world.
- Render opaque voxel faces.
- Render opaque dropped-item cubes.
- Render opaque remote-player body parts.
- Render cutout voxel faces.
- Render cutout dropped-item cubes.
- Render the focused-block highlight.
- Render the local first-person arm and held block.
- Render text overlay.
- Render UI overlay (play HUD and crosshair).
Key details:
- Voxel textures come from a shared atlas; directional face shading is applied in the shader.
- Remote players are rendered as dynamic cube-based actors, not chunk-meshed terrain.
- The local first-person arm uses the same cube-based visual language, drawn as a late viewmodel-style pass.
- Leaves use alpha-cutout rendering, not full sorted translucency.
- Terrain meshing is split into opaque and cutout passes.
- Dropped items render as lightweight atlas-textured cubes outside terrain meshing.
- The play HUD is composed from lightweight rectangle/text overlays rather than a separate retained UI layer.
The native bridge in apps/client/src/platform/native.ts and native/bridge.c exposes the minimal GLFW/OpenGL surface needed by the renderers.
The native bridge polls input every frame and returns a plain InputState.
PlayerController owns FPS movement, creative flight toggling, gravity/jump behavior, collision checks against the replicated world, and camera/view-projection state.
Gameplay updates per tick:
- Requesting nearby chunks.
- Movement and collision.
- Raycasting from the eye position.
- Sending local player-state updates for the server's authoritative snapshot.
- Receiving replicated dropped-item spawn/update/remove events.
- Picking up nearby dropped items after server validation (proximity, cooldown, inventory space).
- Opening chat, submitting chat lines, routing slash commands through the server.
- Receiving authoritative world-time updates after server-side
/timesetchanges. - Breaking blocks and placing the selected hotbar block through server events.
- Selecting inventory slots with number keys
1–9.
The client never assumes a local placement or removal succeeded until the authoritative server sends updated chunks and/or inventory.
Player identity is separate from world identity — each client has a persisted player name stored in local client metadata, optionally overridden at launch with --player-name.
Inventory is modeled as one canonical snapshot: an ordered slot list, a selected hotbar index, and an optional carried cursor stack. The default setup is a nine-slot hotbar with starter stacks for new players.
The client uses the replicated inventory for hotbar display, inventory overlay layout, placement selection, and local feedback (e.g. out-of-stock messages).
The server owns the real counts and persists them per player name inside each world:
- Breaking collectible blocks increments counts.
- Successful placement decrements counts; invalid placement does not consume inventory.
- Joining the same world with the same player name restores that player's inventory and position/rotation.
Inventory is player-owned state (not a standalone entity), mutated through PlayerSystem, and replicated separately from chunk replication.
Chat is a client-visible session feature backed by server events:
- The client opens chat from the gameplay loop and submits plain text.
- Slash-prefixed lines are parsed on the server as commands; non-slash lines are replicated as normal player chat.
- Command feedback is emitted as system chat messages.
Supported commands: /gamemode, /timeset, /seed, /teleport, /save.
/gamemode 0— normal grounded movement./gamemode 1— creative-mode flight; double-tapSpaceto toggle flying,Shiftto descend.
Implemented in packages/core/src/server/world-storage.ts.
Persisted data:
- World registry metadata.
- Per-world chunk override files.
- Per-world per-player files containing player snapshot and inventory data.
The save model is baseline-aware: generated chunks are deterministic from the world seed, so only changed chunks need to be persisted. If a chunk matches its regenerated baseline again, its override file is removed. This keeps storage focused on player-caused differences rather than caching the full generated world.
UI is intentionally lightweight and code-driven:
| File | Role |
|---|---|
apps/client/src/ui/menu.ts |
Menu layout generation |
apps/client/src/ui/hud.ts |
Play HUD and crosshair composition |
apps/client/src/ui/components.ts |
Panel/label/button model and hit evaluation |
apps/client/src/ui/renderer.ts |
Draws UI rectangles and text |
The menu is evaluated each frame from state plus pointer input rather than maintained through a retained widget tree. In play mode, the hotbar, selected-item label, and crosshair follow the same code-driven overlay model.
The test suite covers the main architectural seams:
- Client/server request-response behavior and authoritative chunk/inventory replication.
- Storage round-trips and shared world entity ownership for player allocation.
- Terrain and biome determinism, meshing and atlas behavior.
- Hotbar normalization and HUD composition.
- Player collision and movement.
- Worker-host lifecycle behavior.
This matters because the architecture depends heavily on deterministic generation and explicit ownership boundaries.
The current architecture should support future work such as:
- Alternate transports beyond worker-backed local play.
- Richer biome/decorator systems.
- More inventory/content systems.
- Improved renderer resource cleanup and lifecycle.
- Larger app-shell testing surface through injected fakes.