Region map format

GET /roads/region/<id>/map returns a single roads_cache.RegionMapSnapshot protobuf message. Terrain, overlay, and claim_table are captured under one read lock so claim indices in the overlay always resolve against the bundled table. Schema: roads_cache.proto.

HTTP semantics

StatusWhenBody
200 OK Region loaded and ready Full RegionMapSnapshot protobuf (~280 MiB). Response includes ETag header (same value as message field etag).
202 Accepted Region exists but grid not ready yet Empty. Poll again after checking /roads/health for state=READY.
304 Not Modified Client sent matching If-None-Match Empty. Snapshot unchanged since last fetch.
404 Not Found Unknown region id Plain text: unknown region N
503 Service Unavailable Roads cache disabled on host Plain text: roads cache not enabled

Always send Accept: application/x-protobuf. Cache-Control: private is set on responses.

Protobuf message fields

FieldTypeMeaning
region uint32 BitCraft region id (path segment).
generation uint64 Monotonic snapshot counter; bumps on each applied update batch.
last_update_unix_ms int64 Wall-clock ms of the last grid mutation.
origin_x, origin_z int32 World small-hex origin of this region’s overlay grid (south-west corner). Same as RegionsResponse.regions[].origin_x/z.
claim_table repeated uint64 (packed) Index → claim entity id lookup. See below.
neutral_claim_ids repeated uint64 (packed) Entity ids of claims marked neutral in upstream claim_state.
terrain bytes Super-hex terrain grid, little-endian u64 array. See below.
overlay bytes Small-hex overlay grid, little-endian u32 array. See below.
etag string SHA-256 hex digest of terrain || overlay || claim_table_le_bytes (used for conditional GET).

World and region coordinates

BitCraft regions sit on a 5×5 grid. Constants (from the relay implementation):

ConstantValue
REGION_COUNT_SQRT5
CHUNKS_PER_SIDE80 chunks per region edge
DEFAULT_CHUNK_SIZE96 small hexes per chunk
REGION_SIDE7680 (= 80 × 96) small hexes per region edge
SUPER_SIDE2560 (= 80 × 32) super hexes per region edge

Region grid position from id (id is 1-based):

idx = region_id - 1
rx  = idx mod 5
rz  = idx div 5
origin_x = rx * 7680
origin_z = rz * 7680

Example: region 14 → rx=3, rz=2, origin (23040, 15360).

Convert world small-hex coordinates to region-local overlay indices:

lx = world_x - origin_x    // 0 … 7679
lz = world_z - origin_z    // 0 … 7679
overlay_index = lz * 7680 + lx   // row-major, u32 cell

Terrain grid (terrain bytes)

Layout: 2560 × 2560 super hexes, row-major (super_z * SUPER_SIDE + super_x), each cell a little-endian u64. Total size: 52 428 800 bytes (~52.4 MiB).

Cell packing (bits in the u64, all little-endian on wire):

BitsFieldType
0–15elevationi16 stored as u16
16–31original_elevationi16 stored as u16
32–47water_leveli16 stored as u16
48–55water_body_typeu8
56–63(unused)zero
// Pack (matches relay pack_terrain):
packed = (elev & 0xFFFF)
       | ((orig & 0xFFFF) << 16)
       | ((water & 0xFFFF) << 32)
       | ((wbt & 0xFF) << 48)

// Unpack:
elev  = int16(packed & 0xFFFF)
orig  = int16((packed >> 16) & 0xFFFF)
water = int16((packed >> 32) & 0xFFFF)
wbt   = uint8((packed >> 48) & 0xFF)

Terrain is built from overworld-dimension (dimension = 1) terrain_chunk_state rows. Each chunk covers a 32×32 super-hex block inside the region grid.

Overlay grid (overlay bytes)

Layout: 7680 × 7680 small hexes, row-major (lz * REGION_SIDE + lx), each cell a little-endian u32. Total size: 235 929 600 bytes (~225.9 MiB).

Cell packing (facet-masked — paving and claim index update independently):

BitsFieldMeaning
0–15paving_type_id0 = unpaved; otherwise id from /roads/paving-types
16–31claim_index0 = no claim tile; otherwise index into claim_table
paving_type_id = cell & 0xFFFF
claim_index    = (cell >> 16) & 0xFFFF

claim_entity_id = (claim_index == 0) ? 0 : claim_table[claim_index]

Overlay cells are populated from paved_tile_state and claim_tile_state, joined through location_state (world x/z) and claim_state (neutral flag). Only tiles whose location falls inside the region bounds appear in the grid. Harvestable trees, ore, rocks, clay, and sand are not stored in this overlay — look those up with POST /roads/region/<id>/resources using the same world small-hex (x, z).

claim_table and neutral_claim_ids

The overlay stores compact claim_index values (16 bits) instead of full 64-bit entity ids. The parallel claim_table array resolves them:

neutral_claim_ids lists claim entity ids where upstream claim_state.neutral is true. Use this to style or filter neutral territory separately from player claims.

overlay cell (u32) bits 0-15: paving_type_id ──→ /roads/paving-types bits 16-31: claim_index ─────→ claim_table[claim_index] → claim entity_id │ └─ in neutral_claim_ids? → neutral claim

Reading a world tile (Python sketch)

import struct
from roads_cache_pb2 import RegionMapSnapshot  # generated from roads_cache.proto

snap = RegionMapSnapshot()
snap.ParseFromString(open("region14.pb", "rb").read())

REGION_SIDE = 7680
SUPER_SIDE = 2560

def overlay_at_world(x: int, z: int) -> tuple[int, int, int]:
    """Return (paving_type_id, claim_index, claim_entity_id) at world small-hex (x, z)."""
    lx = x - snap.origin_x
    lz = z - snap.origin_z
    if not (0 <= lx < REGION_SIDE and 0 <= lz < REGION_SIDE):
        raise ValueError("out of region bounds")
    idx = (lz * REGION_SIDE + lx) * 4
    cell, = struct.unpack_from("<I", snap.overlay, idx)
    paving = cell & 0xFFFF
    claim_idx = (cell >> 16) & 0xFFFF
    claim_id = 0 if claim_idx == 0 else snap.claim_table[claim_idx]
    return paving, claim_idx, claim_id

def terrain_at_super(super_x: int, super_z: int) -> tuple[int, int, int, int]:
    """Return (elev, orig, water, wbt) at region-local super-hex."""
    if not (0 <= super_x < SUPER_SIDE and 0 <= super_z < SUPER_SIDE):
        raise ValueError("out of terrain bounds")
    idx = (super_z * SUPER_SIDE + super_x) * 8
    packed, = struct.unpack_from("<Q", snap.terrain, idx)
    elev = struct.unpack("<h", struct.pack("<H", packed & 0xFFFF))[0]
    orig = struct.unpack("<h", struct.pack("<H", (packed >> 16) & 0xFFFF))[0]
    water = struct.unpack("<h", struct.pack("<H", (packed >> 32) & 0xFFFF))[0]
    wbt = (packed >> 48) & 0xFF
    return elev, orig, water, wbt

Conditional refresh with ETag

The server computes etag as SHA-256 hex over the raw terrain bytes, then overlay bytes, then each claim_table entry as little-endian u64. The same value is returned in the HTTP ETag response header.

# First fetch — save body + ETag
curl -sH 'Accept: application/x-protobuf' -D headers.txt \
  https://relay.bitcraftsync.app/roads/region/14/map -o region14.pb
ETAG=$(grep -i '^etag:' headers.txt | awk '{print $2}' | tr -d '\r')

# Later — skip download if unchanged
curl -sH 'Accept: application/x-protobuf' -H "If-None-Match: $ETAG" \
  -D headers2.txt -o /dev/null -w '%{http_code}\n' \
  https://relay.bitcraftsync.app/roads/region/14/map
# 304 → use cached region14.pb
# 200 → new body written, update local copy + ETag

Size and memory expectations

ComponentDimensionsRaw size
terrain2560×2560 × u64 LE~52.4 MiB
overlay7680×7680 × u32 LE~225.9 MiB
claim_tablevariable (packed u64)typically < 1 MiB
Total snapshotprotobuf-encoded~280 MiB per region

On the relay host each live region holds one in-memory copy (~289 MiB steady state). Plan client storage and bandwidth accordingly; prefer ETag polling over full re-downloads.