This document describes how to synchronize map files, tile data, zones, and related assets across the codebase.
Mandatory recurring workflow for map edits:
docs/reference/map-change-routine.md
The map system in openClawWorld has a single source of truth in the world pack, which gets synchronized to server and client packages.
world/packs/base/maps/grid_town_outdoor.json (SOURCE)
|
+-- sync-maps.mjs -->
|
+-- packages/server/assets/maps/village.json (SERVER COPY)
+-- packages/client/public/assets/maps/village.json (CLIENT COPY)
| Purpose | Path | Description |
|---|---|---|
| Source Map | world/packs/base/maps/grid_town_outdoor.json |
Master Tiled JSON map |
| Server Map | packages/server/assets/maps/village.json |
Server's collision/spawn data |
| Client Map | packages/client/public/assets/maps/village.json |
Client's rendering data |
| Sync Script | scripts/sync-maps.mjs |
Copies source to server/client |
| Zone Bounds | packages/shared/src/world.ts |
Zone pixel coordinates |
| Tile Interpreter | packages/client/src/world/TileInterpreter.ts |
Client tile rendering |
| World Pack Manifest | world/packs/base/manifest.json |
World pack metadata |
# Run from project root
node scripts/sync-maps.mjs
# Output:
# Syncing maps from world pack...
# Maps synced successfully:
# Source: /path/to/world/packs/base/maps/grid_town_outdoor.json
# -> Server: /path/to/packages/server/assets/maps/village.json
# -> Client: /path/to/packages/client/public/assets/maps/village.json
#
# MD5 checksums:
# abc123... (all three should match)Verify sync worked: All three MD5 checksums should be identical.
The Tiled JSON map has three critical layers:
Contains tile IDs for visual rendering:
| Tile ID | Meaning | Visual |
|---|---|---|
| 1 | Grass | Green terrain |
| 2 | Road | Light gray path |
| 3 | Water (Lake) | Blue water |
| 4 | Stone Wall | Dark gray brick (collision) |
| 5 | Wood Floor | Brown wood (Lobby, Meeting, Lounge) |
| 6 | Forest | Dark green trees |
| 7 | Sand | Beige sand (Plaza) |
| 9 | Light Wall | Light gray floor (Office) |
| 13 | Carpet | Purple rug (Arcade) |
Binary collision data:
| Value | Meaning |
|---|---|
| 0 | Passable (walkable) |
| 1 | Blocked (collision) |
Contains spawn points, building entrances, and facility markers for navigation and gameplay.
The map is 64x64 tiles. Each layer's data array has 4096 elements (64 * 64).
index = y * width + x
index = y * 64 + x
Examples:
| Tile (x, y) | Index | Calculation |
|---|---|---|
| (0, 0) | 0 | 0 * 64 + 0 |
| (10, 0) | 10 | 0 * 64 + 10 |
| (0, 5) | 320 | 5 * 64 + 0 |
| (10, 5) | 330 | 5 * 64 + 10 |
| (63, 63) | 4095 | 63 * 64 + 63 |
// Get tile at position (x, y)
function getTile(data, x, y) {
return data[y * 64 + x];
}
// Set tile at position (x, y)
function setTile(data, x, y, value) {
data[y * 64 + x] = value;
}Buildings require BOTH layers to be set correctly:
| Ground Tile | Collision | Result |
|---|---|---|
| 4 (building) | 1 | Wall - blocked, dark gray |
| 5 (lounge) | 1 | Interior - blocked, brown |
| 6 (plaza) | 0 | Floor - walkable, gray stone |
| 1 (grass) | 0 | Grass - walkable, green |
| 2 (road) | 0 | Road - walkable, light gray |
| 3 (water) | 1 | Lake - blocked, blue |
Critical Rule: Ground tile ID alone does NOT block movement. Collision layer determines walkability.
To create a building (e.g., 6x4 tiles at position 10,5):
// 1. Define building area
const buildingX = 10, buildingY = 5;
const buildingW = 6, buildingH = 4;
// 2. Fill ground layer with building tile (4)
for (let y = buildingY; y < buildingY + buildingH; y++) {
for (let x = buildingX; x < buildingX + buildingW; x++) {
groundData[y * 64 + x] = 4; // Building tile
}
}
// 3. Fill collision layer with blocked (1)
for (let y = buildingY; y < buildingY + buildingH; y++) {
for (let x = buildingX; x < buildingX + buildingW; x++) {
collisionData[y * 64 + x] = 1; // Blocked
}
}
// 4. Create door (2 tiles on south side, at y = buildingY + buildingH - 1)
const doorY = buildingY + buildingH - 1;
const doorX1 = buildingX + 2;
const doorX2 = buildingX + 3;
collisionData[doorY * 64 + doorX1] = 0; // Door tile 1 - passable
collisionData[doorY * 64 + doorX2] = 0; // Door tile 2 - passableSolid Wall (no entry):
Ground: [4, 4, 4, 4] Collision: [1, 1, 1, 1]
Wall with Door:
Ground: [4, 4, 4, 4] Collision: [1, 0, 0, 1]
^door^
Building Interior (blocked):
Ground: [4, 4, 4, 4] Collision: [1, 1, 1, 1]
[4, 5, 5, 4] [1, 1, 1, 1]
[4, 5, 5, 4] [1, 1, 1, 1]
[4, 4, 4, 4] [1, 0, 0, 1] <- door
| Zone | Top-Left (x,y) | Size (w×h) | Door Location |
|---|---|---|---|
| lobby | (6, 2) | (12×12) | South: (11,13), (12,13) |
| office | (42, 2) | (20×14) | West: (42,8), (42,9) |
| arcade | (44, 16) | (18×16) | West: (44,23), (44,24) |
| meeting | (2, 28) | (16×18) | East: (17,36), (17,37) |
| lounge-cafe | (18, 38) | (20×14) | North: (27,38), (28,38) |
| plaza | (38, 38) | (16×16) | West: (38,45), (38,46) |
| lake | (2, 2) | (4×14) | None (water, always blocked) |
Zone bounds are defined in packages/shared/src/world.ts:
export const ZONE_BOUNDS: Record<ZoneId, ZoneBounds> = {
lobby: { x: 192, y: 64, width: 384, height: 384 },
office: { x: 1344, y: 64, width: 640, height: 448 },
'central-park': { x: 640, y: 512, width: 768, height: 640 },
arcade: { x: 1408, y: 512, width: 576, height: 512 },
meeting: { x: 64, y: 896, width: 512, height: 576 },
'lounge-cafe': { x: 576, y: 1216, width: 640, height: 448 },
plaza: { x: 1216, y: 1216, width: 512, height: 512 },
lake: { x: 64, y: 64, width: 128, height: 448 },
};Coordinate system: All values are in pixels (not tiles). Tile size is 16x16 px.
To convert:
- Pixel to Tile:
tileX = Math.floor(pixelX / 16) - Tile to Pixel:
pixelX = tileX * 16
Building entrances are defined in the map's objects layer as type: "building_entrance".
{
"id": 1,
"name": "lobby.entrance",
"type": "building_entrance",
"x": 352,
"y": 416,
"width": 64,
"height": 32,
"properties": [
{ "name": "zone", "type": "string", "value": "lobby" },
{ "name": "direction", "type": "string", "value": "south" },
{ "name": "connectsTo", "type": "string", "value": "central-park" }
]
}| Zone | Direction | Pixel Position | Connects To |
|---|---|---|---|
| lobby | south | (352, 416) | central-park |
| office | west | (1344, 256) | lobby/road |
| arcade | west | (1408, 736) | central-park |
| meeting | east | (544, 1152) | central-park |
| meeting | south | (320, 1440) | lounge-cafe |
| lounge-cafe | north | (864, 1216) | central-park |
| lounge-cafe | west | (576, 1408) | meeting |
| plaza | north | (1440, 1216) | central-park |
Physical layout and door connections:
┌──────────┐ ┌────────────────────┐
│ LAKE │ │ │
│ (blocked) │ LOBBY │
└──────────┘ │ ↓ south │
└────────────────────┘
│
┌─────────────────────────┐ │ ┌────────────────────┐
│ │ ↓ │ │
│ │ ═══ ROAD ═══ │ OFFICE │
│ │ │ │ ← west │
│ MEETING │ │ └────────────────────┘
│ ↓ east ─────────┼────→ CENTRAL ←────┼──── ARCADE ← west
│ ↓ │ PARK │
│ ↓ south │ ↓ └────────────────────┘
└─────────┼───────────────┘ │
│ │ ┌────────────────────┐
↓ ↓ │ │
┌─────────┼───────────────────────────────────┤ PLAZA │
│ ↓ west ↑ north │ ↑ north │
│ LOUNGE-CAFE │ │
│ └────────────────────┘
└─────────────────────────────────────────────┘
Simplified path diagram:
LOBBY ──→ road ←── OFFICE
↓
↓
MEETING ←─→ CENTRAL-PARK ←─→ ARCADE
↓ ↓
↓ ↓
└──→ LOUNGE-CAFE ←─→ road ←── PLAZA
Building doors must face roads or open areas for NPC/player access.
| Zone | Door Side | Door Tiles (x, y) | Collision Value |
|---|---|---|---|
| lobby | South | (11, 13), (12, 13) | 0 (passable) |
| office | West | (42, 8), (42, 9) | 0 (passable) |
| arcade | West | (44, 23), (44, 24) | 0 (passable) |
| meeting | East | (17, 36), (17, 37) | 0 (passable) |
| meeting | South | (10, 45), (11, 45) | 0 (passable) |
| lounge-cafe | North | (27, 38), (28, 38) | 0 (passable) |
| lounge-cafe | West | (18, 46), (18, 47) | 0 (passable) |
| plaza | North | (45, 38), (46, 38) | 0 (passable) |
- Identify building boundary tiles (collision=1)
- Determine which side faces a road or open area
- Clear 2 tiles on that side (set collision=0)
- Add
building_entranceobject to objects layer - Set properties:
zone,direction,connectsTo
-
Edit the source map:
world/packs/base/maps/grid_town_outdoor.json -
Run sync script:
node scripts/sync-maps.mjs
-
Rebuild packages:
pnpm build
-
Verify changes:
# Start server and client pnpm dev:server & pnpm dev:client # Open http://localhost:5173, press F3 for debug overlay
When zone boundaries change, update these files:
-
Zone bounds -
packages/shared/src/world.tsZONE_BOUNDS- pixel coordinatesZONE_IDS- zone ID array
-
Tile interpreter -
packages/client/src/world/TileInterpreter.tsZONE_FLOOR_TYPES- tile ID per zone
-
Zone banner -
packages/client/src/ui/ZoneBanner.tsZONE_DISPLAY_NAMES- human-readable names
-
World pack manifest -
world/packs/base/manifest.jsonzonesarrayentryZoneif entry point changes
-
NPC positions -
world/packs/base/npcs/*.json- Update NPC zone assignments and positions
-
Server world loader -
packages/server/src/world/WorldPackLoader.ts- Zone-based NPC/facility mappings
- Edit the
collisionlayer in the source map - Each tile is either
0(passable) or1(blocked) - Run sync script
- Server collision check will use the new data
After any map change:
- MD5 checksums match (source, server, client)
-
pnpm buildsucceeds -
pnpm testpasses - Server starts without errors
- Client renders map correctly
- F3 debug shows correct collision overlay
- NPCs spawn in correct zones
- Player can walk through doors
- Player cannot walk through walls
# Test blocked tile (lake at 3,3)
curl -X POST http://localhost:2567/aic/v0.1/moveTo \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"agentId": "agt_test", "roomId": "channel-1", "dest": {"tx": 3, "ty": 3}, "txId": "tx_blocked_test"}'
# Expected: {"result": "rejected", "reason": "blocked"}
# Test passable tile (central park at 25,25)
curl -X POST http://localhost:2567/aic/v0.1/moveTo \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"agentId": "agt_test", "roomId": "channel-1", "dest": {"tx": 25, "ty": 25}, "txId": "tx_passable_test"}'
# Expected: {"result": "accepted"}- Open game at http://localhost:5173
- Press F3 to toggle debug overlay
- Red tiles = blocked, no overlay = passable
- Try clicking on red tiles - movement should be rejected
Symptom: Server and client behave differently
Fix: Run node scripts/sync-maps.mjs and verify MD5 checksums match
Symptom: Player walks through walls
Fix:
- Check collision layer has
1for blocked tiles - Run sync script
- Rebuild and restart
Symptom: Wrong zone name in UI
Fix:
- Check
ZONE_BOUNDSinpackages/shared/src/world.ts - Verify pixel coordinates are correct
- Remember: values are pixels, not tiles
Symptom: NPCs appear outside their zone
Fix:
- Check NPC JSON in
world/packs/base/npcs/ - Update
position.xandposition.y(pixels) - Verify position is within zone bounds
Symptom: Visual shows building but player walks through, OR player blocked on grass
Cause: Ground layer and collision layer are out of sync
Fix:
- Verify ground tile 4 (building) always has collision=1
- Verify ground tile 1,2,6 (walkable) have collision=0
- Exception: doors have ground=4 but collision=0
Diagnostic script:
// Find mismatches between ground and collision
for (let i = 0; i < 4096; i++) {
const ground = groundData[i];
const collision = collisionData[i];
const x = i % 64, y = Math.floor(i / 64);
// Building should be blocked (except doors)
if (ground === 4 && collision === 0) {
console.log(`Possible door at (${x}, ${y})`);
}
// Grass/road should be passable
if ((ground === 1 || ground === 2) && collision === 1) {
console.log(`ERROR: Walkable tile blocked at (${x}, ${y})`);
}
}Symptom: Cannot enter building through door
Fix:
- Verify door tiles have collision=0
- Check door is on building edge (adjacent to road/grass)
- Verify ground layer shows building (tile 4) at door position
The verification script (scripts/verify-map-stack-consistency.mjs) performs automated validation of map integrity, including zone-specific wall and collision checks.
# Run full verification
node scripts/verify-map-stack-consistency.mjs
# Or via pnpm
pnpm verify:map-changeThe script performs the following validations:
| Check | Description | On Failure |
|---|---|---|
| File Existence | Source, server, and client map files exist | ❌ Exit with error |
| Hash Consistency | All three map copies have identical MD5 checksums | ❌ Exit with error |
| Map Dimensions | Width, height, and tile size match expected values | ❌ Exit with error |
| Tileset Contract | Correct tileset name and dimensions | ❌ Exit with error |
| Kenney Curation | Asset curation manifest is valid | ❌ Exit with error |
| Tile ID Contract | Used tile IDs are within defined contract | ❌ Exit with error |
| Facility Zone Contracts | Facilities have valid zone assignments | ❌ Exit with error |
| Collision Layer | Contains only binary values (0/1) | ❌ Exit with error |
| Spawn Point | Spawn tile is passable (collision=0) | ❌ Exit with error |
| Entrance Properties | All entrances have valid zone/direction/connectsTo | ❌ Exit with error |
| Zone Entrance Collision | Building entrances have at least one passable tile | ❌ Exit with error |
| Spawn Reachability | BFS verifies zones are reachable from spawn | ❌ Exit with error |
| Zone Block Statistics | Reports blocked/passable tile percentages |
Each building_entrance object must have at least one passable tile (collision=0):
✅ Zone entrance collision: 8 zone entrances have passable collision tiles
Error example:
❌ ZONE ENTRANCE COLLISION VALIDATION FAILED
entrance "lobby.entrance" at tiles (22,26)-(26,28) has no passable tiles (all blocked)
Verifies all non-blocked zones can be reached from the spawn point:
✅ Spawn reachability: 3696 tiles reachable, 7/8 zones accessible (1 fully blocked: lake)
Zones that are 100% blocked (like decorative water) are excluded from the reachability requirement.
Reports collision statistics per zone:
✅ Zone block statistics:
Zone | Total | Blocked | Passable | Block%
------------------|-------|---------|----------|--------
lobby | 144 | 42 | 102 | 29%
office | 280 | 62 | 218 | 22%
central-park | 480 | 0 | 480 | 0%
arcade | 288 | 62 | 226 | 22%
meeting | 288 | 60 | 228 | 21%
lounge-cafe | 280 | 60 | 220 | 21%
plaza | 256 | 58 | 198 | 23%
lake | 56 | 56 | 0 | 100%
⚠️ Zone block warnings:
zone "lake" has 100% blocked tiles (56/56) - possible misconfiguration
Warning threshold: Zones with >80% blocked tiles generate a warning (may indicate misconfiguration).
The verification script classifies violations into two severity levels:
These violations cause the CI to fail and must be fixed before merging:
| Error Code | Description | Example |
|---|---|---|
ZONE_MISMATCH |
NPC/facility zone mismatch | NPC defined in zone A but placed in zone B |
UNKNOWN_NPC_REF |
NPC ID not found in zone mapping | Zone references NPC that doesn't exist |
UNKNOWN_FACILITY_REF |
Facility ID not found | Zone references facility with no object mapping |
INVALID_ZONE_ID |
Invalid zone identifier | Zone property references non-existent zone |
INVALID_ENTRANCE_CONTRACT |
Invalid entrance contract | Entrance connectsTo references invalid zone |
FACILITY_ZONE_CONFLICT |
Facility has conflicting zones | Same facility ID mapped to different zones |
These violations are logged but do not fail CI:
| Warning Code | Description | Example |
|---|---|---|
HIGH_BLOCK_PCT |
Zone has >80% blocked tiles | Lake zone with 100% blocked (intentional) |
MIXED_ENTRANCE_TILES |
Entrance has mixed passable/blocked tiles | Entrance area partially blocked |
MISSING_OPTIONAL |
Optional field missing | Direction property not set |
NPC_ZONE_NOT_MAPPED |
NPC zone mapping not found | NPC exists but zone assignment missing |
┌─────────────────────────────────────────────────────────────┐
│ Validation Severity │
├─────────────────────────────────────────────────────────────┤
│ ERROR (Blocks CI) │
│ ├── Zone mismatch │
│ ├── Unknown NPC/facility reference │
│ ├── Invalid zone ID │
│ └── Invalid entrance contract │
├─────────────────────────────────────────────────────────────┤
│ WARN (Reported only) │
│ ├── High block percentage (>80%) │
│ ├── Mixed entrance tiles │
│ ├── Missing optional fields │
│ └── NPC zone not mapped │
└─────────────────────────────────────────────────────────────┘
The verification script is designed to run in CI/CD pipelines:
# Example GitHub Actions step
- name: Verify Map Consistency
run: node scripts/verify-map-stack-consistency.mjsExit Codes:
| Exit Code | Meaning |
|---|---|
| 0 | All validations passed (no ERROR-level violations) |
| 1 | One or more ERROR-level validations failed |
Note: WARN-level violations do not cause CI failures but are logged for review.
When ERROR-level violations are detected, the script outputs:
❌ FACILITY CONTRACT VALIDATION FAILED
[ZONE_MISMATCH] facility "reception_desk" has zone prefix "office" but zone property "lobby"
[INVALID_ZONE_ID] facility object "meeting.desk" has invalid zone "invalid-zone"
When WARN-level violations are detected:
⚠️ Zone block warnings:
[HIGH_BLOCK_PCT] zone "lake" has 100% blocked tiles (56/56) - possible misconfiguration
- Grid-Town Map Spec - Zone layout, facilities, and current map specification
- Demo Runbook - Testing procedures
- Map Change Routine - Mandatory workflow for map edits