Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
e9139b3
docs: add CLAUDE.md and CODEBASE_DOCUMENTATION.md for the HYTOPIA engine
web3dev1337 Mar 2, 2026
601389f
docs: expand CODEBASE_DOCUMENTATION.md to cover 100% of source files
web3dev1337 Mar 2, 2026
24a295d
Merge pull request #1 from web3dev1337/docs/add-claude-and-codebase-docs
web3dev1337 Mar 2, 2026
2097cca
feat: support compressed world maps
web3dev1337 Mar 3, 2026
b4246e8
chore: add worldmap benchmark script
web3dev1337 Mar 3, 2026
f446229
fix: benchmark script skipEntities only at load
web3dev1337 Mar 3, 2026
a22368b
docs: add map compression benchmark report
web3dev1337 Mar 3, 2026
8377791
docs: add map compression feature matrix
web3dev1337 Mar 3, 2026
3555855
feat: optional .chunks.bin map cache
web3dev1337 Mar 4, 2026
3d213ac
fix: validate chunk cache header
web3dev1337 Mar 4, 2026
7cf9795
chore: add e2e size benchmarks
web3dev1337 Mar 4, 2026
036a609
feat: hash-validate .chunks.bin cache
web3dev1337 Mar 4, 2026
55645b8
chore: map compression code review
web3dev1337 Mar 4, 2026
84f6d2a
perf: speed up chunk cache collider build
web3dev1337 Mar 4, 2026
cb7c6e6
refactor: remove chunk cache magic numbers
web3dev1337 Mar 4, 2026
7a75bf3
docs: add benchmark summary tables
web3dev1337 Mar 4, 2026
ee17bd9
docs: update ship-ready code review
web3dev1337 Mar 4, 2026
b2a15c4
feat: add map artifacts generator
web3dev1337 Mar 4, 2026
1ae9f7b
feat: include node_modules assets blocks in atlas
web3dev1337 Mar 4, 2026
f7690ae
fix: tolerate missing optimized model variants
web3dev1337 Mar 4, 2026
588e1ed
fix: ensure chunk cache loads entities
web3dev1337 Mar 4, 2026
6d55f13
fix: preserve entities with chunk cache overlays
web3dev1337 Mar 4, 2026
a5d4929
docs: note entity overlays for chunk cache
web3dev1337 Mar 4, 2026
e09f40f
chore: exclude generated sdk docs from PR
web3dev1337 Mar 4, 2026
50aab92
chore: drop generated sdk docs pages
web3dev1337 Mar 4, 2026
c492dde
chore: remove MAP_COMPRESSION_CODE_REVIEW.md
web3dev1337 Mar 4, 2026
0cf692c
docs: trim map compression benchmark doc
web3dev1337 Mar 4, 2026
45303b5
feat(cli): add `hytopia map-compress` command
web3dev1337 Mar 4, 2026
397a3e2
feat(loadMap): auto-detect compressed map artifacts
web3dev1337 Mar 4, 2026
a677d2a
feat(cli): auto-recompress map on `hytopia start`
web3dev1337 Mar 5, 2026
f97a5ed
chore: remove generated docs from PR
web3dev1337 Mar 5, 2026
bc6926a
refactor(cli): DRY map-compress — use SDK codecs instead of inline du…
web3dev1337 Mar 5, 2026
ecc226d
chore: remove 58 generated docs files from PR
web3dev1337 Mar 5, 2026
a10919d
fix: tighten map artifact auto-detect + chunk cache path support
web3dev1337 Mar 5, 2026
f0cd863
chore: remove AI scaffolding files from PR
web3dev1337 Mar 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions MAP_COMPRESSION_BENCHMARK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Map Compression — Benchmark Snapshot (2026-03-04)

This PR supports loading:
- legacy `WorldMap` JSON (`map.json`)
- `CompressedWorldMap` (`map.compressed.json`)
- optional chunk cache (`map.chunks.bin`) for faster `loadMap(...)` on large maps

Bench harness: `server/scripts/worldmap-benchmark.ts`. The tables below are the high-signal snapshot used in the PR description.

## Highlights (median `loadMap(...)`)

| Map | WorldMap | Compressed | Chunk cache |
|---|---:|---:|---:|
| big-world | 2.99 s | 2.35 s | 1.08 s |
| boilerplate | 4.97 s | 4.42 s | 2.40 s |
| HyFire2 | 81.20 ms | 67.13 ms | 50.61 ms |

## Highlights (disk)

| Map | map.json | map.compressed.json | compressed + chunks |
|---|---:|---:|---:|
| big-world | 29.99 MB | 1.01 MB | ~1.64 MB |
| boilerplate | 28.28 MB | 439.94 KB | ~679.31 KB |
| HyFire2 | 649.96 KB | 102.04 KB | ~114.46 KB |

## Highlights (end-to-end: read + parse + load)

| Map | WorldMap(file) | Compressed(file) | Chunk cache(file) |
|---|---:|---:|---:|
| big-world | 2.74 s | 2.20 s | 1.03 s |
| boilerplate | 5.16 s | 4.17 s | 2.44 s |

## How to reproduce

```bash
cd server
bun scripts/worldmap-benchmark.ts \
--map ../sdk-examples/big-world/assets/map.json \
--skip-entities --validate --bench-e2e --bench-chunk-cache --iterations 3
```

Notes:
- `--skip-entities` only affects spawning during load; entities are still preserved in the compressed formats.
- **Rotations preserved:** the native codec supports rotated block values (`{ i, r }`). The plugin’s compressors/tools assume `blocks: { [key]: string]: number }` and don’t encode rotations.
- **Streaming decode:** avoids materializing a massive coordinate-keyed blocks object when loading compressed maps.

### Plugin-only features (not implemented natively)

The plugin includes a cache pipeline and “precomputed chunks” formats (`.chunks`, `.chunks.bin`) aimed at **much faster** loading by bypassing normal per-block placement.

On `sdk-examples/big-world/assets/map.json`, generating caches with the plugin’s tooling produced:
- `.chunks.bin`: `~16.71MB` in `~1050ms`
- `.chunks` (brotli JSON): `~1.75MB` in `~4703ms` (compressed from `~172.91MB` JSON payload)

Important caveat for the current SDK:
- The plugin’s “direct chunk injection” loader is tightly coupled to specific internal shapes (e.g., a string-keyed chunk map and custom chunk objects) and doesn’t rebuild the current engine’s collider/mask structures.
- Because the native SDK `loadMap(...)` includes collider creation, “50x faster load” isn’t apples-to-apples unless an equivalent **official** precomputed-chunk + collider pipeline exists.

## Takeaway

Native compressed maps give:
- **Huge disk/transfer win** (e.g., 30MB → ~1MB),
- **Lower parse cost** (hundreds of ms → ~0.1ms),
- **Moderate `loadMap(...)` speedup** (~1.2–1.3× on multi‑million‑block maps) while keeping full physics/collider correctness and backward compatibility.

**Update (Wednesday, March 4, 2026):** adding an optional `.chunks.bin` chunk cache yields a larger `loadMap(...)` speedup on very large maps (about **~2×** in the benches below) because it bypasses per-block `"x,y,z"` key parsing and per-block placement bookkeeping.

### Cache invalidation (optional)

`WorldMapFileLoader` prefers a sibling `*.chunks.bin` only if it looks valid. If the cache contains `metadata.source.sha256` and a sibling `*.compressed.json` exists, it validates that hash and **falls back automatically** when it doesn’t match (stale cache).

### Chunk cache benches (2026-03-04)

All runs: `bun server/scripts/worldmap-benchmark.ts --bench-chunk-cache --bench-e2e --validate --iterations 3`
177 changes: 175 additions & 2 deletions sdk/bin/scripts.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
#!/usr/bin/env node

import { execSync, spawn } from 'child_process';
import crypto from 'crypto';
import archiver from 'archiver';
import fs from 'fs';
import path from 'path';
import nodemon from 'nodemon';
import readline from 'readline';
import { fileURLToPath } from 'url';

// Lazy-loaded SDK module (loaded once on first use from ../server.mjs)
let _sdk = null;

// Store command-line flags
const flags = {};

Expand Down Expand Up @@ -39,6 +43,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
'help': displayHelp,
'init': init,
'init-mcp': initMcp,
'map-compress': mapCompress,
'package': packageProject,
'run': run,
'start': start,
Expand Down Expand Up @@ -81,14 +86,20 @@ async function start() {
const buildCmd = `hytopia build-dev ${inputFile}`;
const runCmd = `"${process.execPath}" --enable-source-maps "${entryFile}"`;

// Auto-recompress map if stale before first build
await autoRecompressMap();

// Start nodemon to watch for changes, rebuild, then run the server
nodemon({
watch: ['.'],
ext: 'js,ts,html',
ignore: ['node_modules/**', '.git/**', '*.zip', outputFile, 'assets/**'],
ext: 'js,ts,html,json,bin',
ignore: ['node_modules/**', '.git/**', '*.zip', outputFile, 'assets/map.compressed.json', 'assets/map.chunks.bin'],
exec: `${buildCmd} && ${runCmd}`,
delay: 100,
})
.on('restart', () => {
autoRecompressMap().catch(() => {});
})
.on('quit', () => {
console.log('👋 Shutting down...');
process.exit();
Expand Down Expand Up @@ -494,6 +505,165 @@ async function packageProject() {
archive.finalize();
}

async function getSDK() {
if (!_sdk) {
const sdkPath = path.resolve(__dirname, '..', 'server.mjs');
_sdk = await import(sdkPath);
}
return _sdk;
}

function getSDKSync() {
if (!_sdk) throw new Error('SDK not loaded — call await getSDK() first');
return _sdk;
}

function generateArtifacts(worldMap, options = {}) {
const sdk = getSDKSync();
return sdk.WorldMapArtifactsGenerator.create(worldMap, {
compressed: { algorithm: options.algorithm || 'brotli', level: options.level ?? 9 },
chunkCache: { algorithm: options.algorithm || 'brotli', level: options.cacheLevel ?? 6 },
});
}

function isCompressedMap(parsed) {
return parsed && typeof parsed === 'object' &&
typeof parsed.data === 'string' && parsed.bounds &&
typeof parsed.bounds.minX === 'number';
}

function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
return `${(kb / 1024).toFixed(2)} MB`;
}

/**
* Auto-recompress map if compressed artifacts are stale.
* Called by `hytopia start` before each build cycle.
* Only acts if compressed artifacts already exist (i.e. user has run map-compress before).
*/
async function autoRecompressMap() {
const mapPath = path.resolve(process.cwd(), 'assets/map.json');
const compressedPath = path.resolve(process.cwd(), 'assets/map.compressed.json');
const chunkCachePath = path.resolve(process.cwd(), 'assets/map.chunks.bin');

if (!fs.existsSync(mapPath)) return;
if (!fs.existsSync(compressedPath) && !fs.existsSync(chunkCachePath)) return;

const mapMtime = fs.statSync(mapPath).mtimeMs;
const compressedMtime = fs.existsSync(compressedPath) ? fs.statSync(compressedPath).mtimeMs : 0;
if (compressedMtime >= mapMtime) return;

console.log('📦 map.json changed — recompressing...');

try {
await getSDK();
const rawText = fs.readFileSync(mapPath, 'utf-8');
const parsed = JSON.parse(rawText);
if (isCompressedMap(parsed)) return;

const inputSize = Buffer.byteLength(rawText);
const artifacts = generateArtifacts(parsed);

fs.writeFileSync(compressedPath, artifacts.compressedMapJson);
fs.writeFileSync(chunkCachePath, artifacts.chunkCacheBuffer);

const compressedSize = Buffer.byteLength(artifacts.compressedMapJson);
const ratio = ((1 - (compressedSize / inputSize)) * 100).toFixed(1);
console.log(` ✅ Recompressed: ${formatSize(compressedSize)} (${ratio}% smaller) + ${formatSize(artifacts.chunkCacheBuffer.byteLength)} chunk cache`);
} catch (err) {
console.error(` ⚠️ Auto-recompress failed: ${err.message}`);
}
}

/**
* Map compress command
*
* Compresses a WorldMap JSON into optimized formats for faster loading
* and smaller file sizes.
*
* @example
* `hytopia map-compress`
* `hytopia map-compress assets/map.json`
* `hytopia map-compress assets/map.json --algorithm brotli --level 9`
* `hytopia map-compress assets/map.json --no-chunk-cache`
*/
async function mapCompress() {
const mapPath = process.argv[3] || 'assets/map.json';
const absoluteMapPath = path.resolve(process.cwd(), mapPath);

if (!fs.existsSync(absoluteMapPath)) {
console.error(`❌ Map file not found: ${absoluteMapPath}`);
process.exit(1);
}

const algorithm = flags['algorithm'] || 'brotli';
const level = flags['level'] !== undefined ? Number(flags['level']) : 9;
const cacheLevel = flags['cache-level'] !== undefined ? Number(flags['cache-level']) : 6;
const noChunkCache = process.argv.includes('--no-chunk-cache');

if (!['brotli', 'gzip', 'none'].includes(algorithm)) {
console.error(`❌ Invalid algorithm: ${algorithm}. Must be brotli, gzip, or none.`);
process.exit(1);
}

const basePath = absoluteMapPath.endsWith('.json')
? absoluteMapPath.slice(0, -'.json'.length)
: absoluteMapPath;
const compressedOutPath = basePath + '.compressed.json';
const chunkCacheOutPath = basePath + '.chunks.bin';

console.log(`📦 Compressing map: ${mapPath}`);

const rawText = fs.readFileSync(absoluteMapPath, 'utf-8');
const inputSize = Buffer.byteLength(rawText);
const parsed = JSON.parse(rawText);

if (isCompressedMap(parsed)) {
console.error('❌ Input file is already a compressed map. Provide the original map.json.');
process.exit(1);
}

const blocks = parsed.blocks || {};
const blockCount = Object.keys(blocks).length;
console.log(` Input: ${formatSize(inputSize)} (${blockCount.toLocaleString()} blocks)`);

await getSDK();
const sdk = getSDKSync();

const compressStart = performance.now();
const compressedMap = sdk.WorldMapCodec.compress(parsed, { algorithm, level });
const compressMs = performance.now() - compressStart;
const compressedJson = JSON.stringify(compressedMap);
const compressedSize = Buffer.byteLength(compressedJson);

fs.writeFileSync(compressedOutPath, compressedJson);
const ratio = ((1 - (compressedSize / inputSize)) * 100).toFixed(1);
console.log(` Compressed: ${formatSize(compressedSize)} (${ratio}% smaller) [${compressMs.toFixed(0)}ms]`);
console.log(` ✅ ${path.relative(process.cwd(), compressedOutPath)}`);

if (!noChunkCache) {
const sha256 = crypto.createHash('sha256').update(compressedJson).digest('hex');

const cacheStart = performance.now();
const chunkCache = sdk.WorldMapChunkCacheCodec.create(compressedMap, {
algorithm, level: cacheLevel, sourceSha256: sha256,
});
const cacheMs = performance.now() - cacheStart;
const chunkCacheBuffer = Buffer.from(chunkCache.data, 'base64');

fs.writeFileSync(chunkCacheOutPath, chunkCacheBuffer);
console.log(` Chunk cache: ${formatSize(chunkCacheBuffer.byteLength)} [${cacheMs.toFixed(0)}ms]`);
console.log(` ✅ ${path.relative(process.cwd(), chunkCacheOutPath)}`);
}

logDivider();
console.log('Done! Your game will automatically use these files when the');
console.log('SDK detects them alongside your map.json.');
}

// ================================================================================
// UTILITY FUNCTIONS
// ================================================================================
Expand Down Expand Up @@ -649,6 +819,7 @@ function displayHelp() {
console.log(' run [FILE] Run the project once without watching (default: index.ts)');
console.log(' init [--template NAME] Initialize a new project');
console.log(' init-mcp Setup MCP integrations');
console.log(' map-compress [FILE] Compress a map for faster loading (default: assets/map.json)');
console.log(' package Create a zip of the project for uploading to the HYTOPIA create portal.');
console.log(' upgrade-assets-library [VERSION] Upgrade the @hytopia.com/assets package (default: latest)');
console.log(' upgrade-cli [VERSION] Upgrade the HYTOPIA CLI (default: latest)');
Expand All @@ -657,5 +828,7 @@ function displayHelp() {
console.log('Examples:');
console.log(' hytopia init --template zombies-fps');
console.log(' hytopia start playground.ts');
console.log(' hytopia map-compress');
console.log(' hytopia map-compress assets/map.json --algorithm gzip');
console.log(' hytopia upgrade-project 0.8.12');
}
Loading