@lagless/create 0.0.46 → 0.0.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -170,6 +170,10 @@ program.name('create-lagless').description('Scaffold a new Lagless multiplayer g
170
170
  const f = path.join(docsDir, '08-physics3d.md');
171
171
  if (fs.existsSync(f)) fs.rmSync(f);
172
172
  }
173
+ if (simulationType !== 'physics2d') {
174
+ const f = path.join(docsDir, '11-2d-map-generation.md');
175
+ if (fs.existsSync(f)) fs.rmSync(f);
176
+ }
173
177
  // Clone lagless framework source for AI reference
174
178
  const sourcesDir = path.join(docsDir, 'sources');
175
179
  fs.mkdirSync(sourcesDir, {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { program } from 'commander';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as ejs from 'ejs';\nimport { execSync } from 'node:child_process';\nimport { fileURLToPath } from 'node:url';\nimport { select } from '@inquirer/prompts';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n// Read version from package.json once (single source of truth)\nconst _pkgJsonPath = path.resolve(__dirname, '..', 'package.json');\nlet _packageVersion = '0.0.0';\ntry {\n const _pkg = JSON.parse(fs.readFileSync(_pkgJsonPath, 'utf-8'));\n _packageVersion = _pkg.version || _packageVersion;\n} catch {\n // fallback\n}\n\ntype SimulationType = 'raw' | 'physics2d' | 'physics3d';\n\ninterface CreateOptions {\n preset: string;\n port: string;\n serverPort: string;\n simulationType?: SimulationType;\n}\n\nfunction toPascalCase(kebab: string): string {\n return kebab.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('');\n}\n\nfunction getTemplatesDir(): string {\n const devPath = path.resolve(__dirname, '..', 'templates');\n if (fs.existsSync(devPath)) return devPath;\n const distPath = path.resolve(__dirname, '..', '..', 'templates');\n if (fs.existsSync(distPath)) return distPath;\n throw new Error('Templates directory not found');\n}\n\nfunction walkDir(dir: string): string[] {\n const results: string[] = [];\n for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {\n const full = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n results.push(...walkDir(full));\n } else {\n results.push(full);\n }\n }\n return results;\n}\n\nfunction processPath(filePath: string, vars: Record<string, string>): string {\n let result = filePath;\n result = result.replace(/__packageName__/g, vars.packageName);\n result = result.replace(/__ProjectName__/g, vars.projectName);\n return result;\n}\n\nasync function promptSimulationType(): Promise<SimulationType> {\n return select<SimulationType>({\n message: 'Select simulation type:',\n choices: [\n { value: 'raw', name: 'Raw ECS', description: 'Manual velocity/position management, no physics engine' },\n { value: 'physics2d', name: 'Physics 2D (Rapier)', description: 'Rapier 2D rigid body physics with auto-managed transforms' },\n { value: 'physics3d', name: 'Physics 3D (Rapier)', description: 'Rapier 3D rigid body physics with top-down 2D rendering' },\n ],\n });\n}\n\nprogram\n .name('create-lagless')\n .description('Scaffold a new Lagless multiplayer game project')\n .version(_packageVersion)\n .argument('<project-name>', 'Project name in kebab-case (e.g., my-game)')\n .option('--preset <preset>', 'Project preset', 'pixi-react')\n .option('--port <port>', 'Frontend dev server port', '4203')\n .option('--server-port <port>', 'Backend server port', '3400')\n .option('--simulation-type <type>', 'Simulation type: raw, physics2d, or physics3d')\n .action(async (projectArg: string, options: CreateOptions) => {\n const targetDir = path.resolve(process.cwd(), projectArg);\n const packageName = path.basename(targetDir).toLowerCase();\n const pascalName = toPascalCase(packageName);\n\n if (fs.existsSync(targetDir)) {\n console.error(`Error: Directory \"${targetDir}\" already exists.`);\n process.exit(1);\n }\n\n const templatesDir = getTemplatesDir();\n const presetDir = path.join(templatesDir, options.preset);\n\n if (!fs.existsSync(presetDir)) {\n console.error(`Error: Preset \"${options.preset}\" not found. Available: ${fs.readdirSync(templatesDir).join(', ')}`);\n process.exit(1);\n }\n\n // Determine simulation type — from CLI flag or interactive prompt\n let simulationType: SimulationType;\n if (options.simulationType) {\n const valid: SimulationType[] = ['raw', 'physics2d', 'physics3d'];\n if (!valid.includes(options.simulationType as SimulationType)) {\n console.error(`Error: Invalid simulation type \"${options.simulationType}\". Valid: ${valid.join(', ')}`);\n process.exit(1);\n }\n simulationType = options.simulationType as SimulationType;\n } else {\n simulationType = await promptSimulationType();\n }\n\n const laglessVersion = _packageVersion;\n\n const vars: Record<string, string> = {\n projectName: pascalName,\n packageName,\n frontendPort: options.port,\n serverPort: options.serverPort,\n laglessVersion,\n simulationType,\n };\n\n console.log(`\\nCreating Lagless project \"${packageName}\"...`);\n console.log(` Preset: ${options.preset}`);\n console.log(` Simulation: ${simulationType}`);\n console.log(` Frontend port: ${options.port}`);\n console.log(` Server port: ${options.serverPort}`);\n console.log(` Target: ${targetDir}\\n`);\n\n const templateFiles = walkDir(presetDir);\n\n for (const templateFile of templateFiles) {\n const relativePath = path.relative(presetDir, templateFile);\n const outputRelative = processPath(relativePath, vars);\n const outputPath = path.join(targetDir, outputRelative);\n const outputDir = path.dirname(outputPath);\n\n const content = fs.readFileSync(templateFile, 'utf-8');\n\n // Only process text files that might contain EJS tags\n const ext = path.extname(templateFile);\n const textExts = ['.ts', '.tsx', '.json', '.yaml', '.yml', '.html', '.css', '.md', '.toml', '.gitignore'];\n\n if (textExts.includes(ext) || ext === '.ejs') {\n const rendered = ejs.render(content, vars, { filename: templateFile });\n const finalPath = ext === '.ejs' ? outputPath.replace(/\\.ejs$/, '') : outputPath;\n\n // Skip writing empty/whitespace-only files (conditional template exclusion)\n if (rendered.trim().length === 0) {\n continue;\n }\n\n if (!fs.existsSync(outputDir)) {\n fs.mkdirSync(outputDir, { recursive: true });\n }\n fs.writeFileSync(finalPath, rendered, 'utf-8');\n } else {\n // Binary or unknown — copy as-is\n if (!fs.existsSync(outputDir)) {\n fs.mkdirSync(outputDir, { recursive: true });\n }\n fs.copyFileSync(templateFile, outputPath);\n }\n }\n\n // Remove physics docs that don't match the selected simulationType\n const docsDir = path.join(targetDir, 'docs');\n if (simulationType !== 'physics2d') {\n const f = path.join(docsDir, '08-physics2d.md');\n if (fs.existsSync(f)) fs.rmSync(f);\n }\n if (simulationType !== 'physics3d') {\n const f = path.join(docsDir, '08-physics3d.md');\n if (fs.existsSync(f)) fs.rmSync(f);\n }\n\n // Clone lagless framework source for AI reference\n const sourcesDir = path.join(docsDir, 'sources');\n fs.mkdirSync(sourcesDir, { recursive: true });\n console.log('Cloning lagless framework source for AI reference...');\n try {\n execSync(`git clone --depth 1 https://github.com/GbGr/lagless.git \"${path.join(sourcesDir, 'lagless')}\"`, {\n stdio: 'inherit',\n });\n // Remove .git to save space\n fs.rmSync(path.join(sourcesDir, 'lagless', '.git'), { recursive: true, force: true });\n } catch {\n console.warn('Warning: Could not clone lagless source. AI reference will be unavailable.');\n console.warn('You can manually clone later: git clone --depth 1 https://github.com/GbGr/lagless.git docs/sources/lagless');\n }\n\n // Install dependencies\n console.log('\\nInstalling dependencies...');\n try {\n execSync('pnpm install', { cwd: targetDir, stdio: 'inherit' });\n } catch {\n console.error('Warning: pnpm install failed. Run it manually after creation.');\n }\n\n // Run ECS codegen to generate code from schema\n console.log('\\nGenerating ECS code from schema...');\n try {\n execSync('pnpm codegen', { cwd: targetDir, stdio: 'inherit' });\n } catch {\n console.error('Warning: codegen failed. Run \"pnpm codegen\" manually after creation.');\n }\n\n console.log('\\nProject created successfully!\\n');\n console.log('To start developing:');\n console.log(` cd ${packageName}`);\n console.log(' pnpm dev # Start backend + frontend + dev-player\\n');\n console.log('Or run individually:');\n console.log(' pnpm dev:backend # Game server (Bun, watches for changes)');\n console.log(' pnpm dev:frontend # Frontend (Vite HMR)');\n console.log(' pnpm dev:player # Dev-player (multiplayer testing, port 4210)\\n');\n console.log('To regenerate ECS code after schema changes:');\n console.log(' pnpm codegen\\n');\n });\n\nprogram.parse();\n"],"names":["program","fs","path","ejs","execSync","fileURLToPath","select","__filename","url","__dirname","dirname","_pkgJsonPath","resolve","_packageVersion","_pkg","JSON","parse","readFileSync","version","toPascalCase","kebab","split","map","s","charAt","toUpperCase","slice","join","getTemplatesDir","devPath","existsSync","distPath","Error","walkDir","dir","results","entry","readdirSync","withFileTypes","full","name","isDirectory","push","processPath","filePath","vars","result","replace","packageName","projectName","promptSimulationType","message","choices","value","description","argument","option","action","projectArg","options","targetDir","process","cwd","basename","toLowerCase","pascalName","console","error","exit","templatesDir","presetDir","preset","simulationType","valid","includes","laglessVersion","frontendPort","port","serverPort","log","templateFiles","templateFile","relativePath","relative","outputRelative","outputPath","outputDir","content","ext","extname","textExts","rendered","render","filename","finalPath","trim","length","mkdirSync","recursive","writeFileSync","copyFileSync","docsDir","f","rmSync","sourcesDir","stdio","force","warn"],"rangeMappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","mappings":"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"}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { program } from 'commander';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as ejs from 'ejs';\nimport { execSync } from 'node:child_process';\nimport { fileURLToPath } from 'node:url';\nimport { select } from '@inquirer/prompts';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n// Read version from package.json once (single source of truth)\nconst _pkgJsonPath = path.resolve(__dirname, '..', 'package.json');\nlet _packageVersion = '0.0.0';\ntry {\n const _pkg = JSON.parse(fs.readFileSync(_pkgJsonPath, 'utf-8'));\n _packageVersion = _pkg.version || _packageVersion;\n} catch {\n // fallback\n}\n\ntype SimulationType = 'raw' | 'physics2d' | 'physics3d';\n\ninterface CreateOptions {\n preset: string;\n port: string;\n serverPort: string;\n simulationType?: SimulationType;\n}\n\nfunction toPascalCase(kebab: string): string {\n return kebab.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('');\n}\n\nfunction getTemplatesDir(): string {\n const devPath = path.resolve(__dirname, '..', 'templates');\n if (fs.existsSync(devPath)) return devPath;\n const distPath = path.resolve(__dirname, '..', '..', 'templates');\n if (fs.existsSync(distPath)) return distPath;\n throw new Error('Templates directory not found');\n}\n\nfunction walkDir(dir: string): string[] {\n const results: string[] = [];\n for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {\n const full = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n results.push(...walkDir(full));\n } else {\n results.push(full);\n }\n }\n return results;\n}\n\nfunction processPath(filePath: string, vars: Record<string, string>): string {\n let result = filePath;\n result = result.replace(/__packageName__/g, vars.packageName);\n result = result.replace(/__ProjectName__/g, vars.projectName);\n return result;\n}\n\nasync function promptSimulationType(): Promise<SimulationType> {\n return select<SimulationType>({\n message: 'Select simulation type:',\n choices: [\n { value: 'raw', name: 'Raw ECS', description: 'Manual velocity/position management, no physics engine' },\n { value: 'physics2d', name: 'Physics 2D (Rapier)', description: 'Rapier 2D rigid body physics with auto-managed transforms' },\n { value: 'physics3d', name: 'Physics 3D (Rapier)', description: 'Rapier 3D rigid body physics with top-down 2D rendering' },\n ],\n });\n}\n\nprogram\n .name('create-lagless')\n .description('Scaffold a new Lagless multiplayer game project')\n .version(_packageVersion)\n .argument('<project-name>', 'Project name in kebab-case (e.g., my-game)')\n .option('--preset <preset>', 'Project preset', 'pixi-react')\n .option('--port <port>', 'Frontend dev server port', '4203')\n .option('--server-port <port>', 'Backend server port', '3400')\n .option('--simulation-type <type>', 'Simulation type: raw, physics2d, or physics3d')\n .action(async (projectArg: string, options: CreateOptions) => {\n const targetDir = path.resolve(process.cwd(), projectArg);\n const packageName = path.basename(targetDir).toLowerCase();\n const pascalName = toPascalCase(packageName);\n\n if (fs.existsSync(targetDir)) {\n console.error(`Error: Directory \"${targetDir}\" already exists.`);\n process.exit(1);\n }\n\n const templatesDir = getTemplatesDir();\n const presetDir = path.join(templatesDir, options.preset);\n\n if (!fs.existsSync(presetDir)) {\n console.error(`Error: Preset \"${options.preset}\" not found. Available: ${fs.readdirSync(templatesDir).join(', ')}`);\n process.exit(1);\n }\n\n // Determine simulation type — from CLI flag or interactive prompt\n let simulationType: SimulationType;\n if (options.simulationType) {\n const valid: SimulationType[] = ['raw', 'physics2d', 'physics3d'];\n if (!valid.includes(options.simulationType as SimulationType)) {\n console.error(`Error: Invalid simulation type \"${options.simulationType}\". Valid: ${valid.join(', ')}`);\n process.exit(1);\n }\n simulationType = options.simulationType as SimulationType;\n } else {\n simulationType = await promptSimulationType();\n }\n\n const laglessVersion = _packageVersion;\n\n const vars: Record<string, string> = {\n projectName: pascalName,\n packageName,\n frontendPort: options.port,\n serverPort: options.serverPort,\n laglessVersion,\n simulationType,\n };\n\n console.log(`\\nCreating Lagless project \"${packageName}\"...`);\n console.log(` Preset: ${options.preset}`);\n console.log(` Simulation: ${simulationType}`);\n console.log(` Frontend port: ${options.port}`);\n console.log(` Server port: ${options.serverPort}`);\n console.log(` Target: ${targetDir}\\n`);\n\n const templateFiles = walkDir(presetDir);\n\n for (const templateFile of templateFiles) {\n const relativePath = path.relative(presetDir, templateFile);\n const outputRelative = processPath(relativePath, vars);\n const outputPath = path.join(targetDir, outputRelative);\n const outputDir = path.dirname(outputPath);\n\n const content = fs.readFileSync(templateFile, 'utf-8');\n\n // Only process text files that might contain EJS tags\n const ext = path.extname(templateFile);\n const textExts = ['.ts', '.tsx', '.json', '.yaml', '.yml', '.html', '.css', '.md', '.toml', '.gitignore'];\n\n if (textExts.includes(ext) || ext === '.ejs') {\n const rendered = ejs.render(content, vars, { filename: templateFile });\n const finalPath = ext === '.ejs' ? outputPath.replace(/\\.ejs$/, '') : outputPath;\n\n // Skip writing empty/whitespace-only files (conditional template exclusion)\n if (rendered.trim().length === 0) {\n continue;\n }\n\n if (!fs.existsSync(outputDir)) {\n fs.mkdirSync(outputDir, { recursive: true });\n }\n fs.writeFileSync(finalPath, rendered, 'utf-8');\n } else {\n // Binary or unknown — copy as-is\n if (!fs.existsSync(outputDir)) {\n fs.mkdirSync(outputDir, { recursive: true });\n }\n fs.copyFileSync(templateFile, outputPath);\n }\n }\n\n // Remove physics docs that don't match the selected simulationType\n const docsDir = path.join(targetDir, 'docs');\n if (simulationType !== 'physics2d') {\n const f = path.join(docsDir, '08-physics2d.md');\n if (fs.existsSync(f)) fs.rmSync(f);\n }\n if (simulationType !== 'physics3d') {\n const f = path.join(docsDir, '08-physics3d.md');\n if (fs.existsSync(f)) fs.rmSync(f);\n }\n if (simulationType !== 'physics2d') {\n const f = path.join(docsDir, '11-2d-map-generation.md');\n if (fs.existsSync(f)) fs.rmSync(f);\n }\n\n // Clone lagless framework source for AI reference\n const sourcesDir = path.join(docsDir, 'sources');\n fs.mkdirSync(sourcesDir, { recursive: true });\n console.log('Cloning lagless framework source for AI reference...');\n try {\n execSync(`git clone --depth 1 https://github.com/GbGr/lagless.git \"${path.join(sourcesDir, 'lagless')}\"`, {\n stdio: 'inherit',\n });\n // Remove .git to save space\n fs.rmSync(path.join(sourcesDir, 'lagless', '.git'), { recursive: true, force: true });\n } catch {\n console.warn('Warning: Could not clone lagless source. AI reference will be unavailable.');\n console.warn('You can manually clone later: git clone --depth 1 https://github.com/GbGr/lagless.git docs/sources/lagless');\n }\n\n // Install dependencies\n console.log('\\nInstalling dependencies...');\n try {\n execSync('pnpm install', { cwd: targetDir, stdio: 'inherit' });\n } catch {\n console.error('Warning: pnpm install failed. Run it manually after creation.');\n }\n\n // Run ECS codegen to generate code from schema\n console.log('\\nGenerating ECS code from schema...');\n try {\n execSync('pnpm codegen', { cwd: targetDir, stdio: 'inherit' });\n } catch {\n console.error('Warning: codegen failed. Run \"pnpm codegen\" manually after creation.');\n }\n\n console.log('\\nProject created successfully!\\n');\n console.log('To start developing:');\n console.log(` cd ${packageName}`);\n console.log(' pnpm dev # Start backend + frontend + dev-player\\n');\n console.log('Or run individually:');\n console.log(' pnpm dev:backend # Game server (Bun, watches for changes)');\n console.log(' pnpm dev:frontend # Frontend (Vite HMR)');\n console.log(' pnpm dev:player # Dev-player (multiplayer testing, port 4210)\\n');\n console.log('To regenerate ECS code after schema changes:');\n console.log(' pnpm codegen\\n');\n });\n\nprogram.parse();\n"],"names":["program","fs","path","ejs","execSync","fileURLToPath","select","__filename","url","__dirname","dirname","_pkgJsonPath","resolve","_packageVersion","_pkg","JSON","parse","readFileSync","version","toPascalCase","kebab","split","map","s","charAt","toUpperCase","slice","join","getTemplatesDir","devPath","existsSync","distPath","Error","walkDir","dir","results","entry","readdirSync","withFileTypes","full","name","isDirectory","push","processPath","filePath","vars","result","replace","packageName","projectName","promptSimulationType","message","choices","value","description","argument","option","action","projectArg","options","targetDir","process","cwd","basename","toLowerCase","pascalName","console","error","exit","templatesDir","presetDir","preset","simulationType","valid","includes","laglessVersion","frontendPort","port","serverPort","log","templateFiles","templateFile","relativePath","relative","outputRelative","outputPath","outputDir","content","ext","extname","textExts","rendered","render","filename","finalPath","trim","length","mkdirSync","recursive","writeFileSync","copyFileSync","docsDir","f","rmSync","sourcesDir","stdio","force","warn"],"rangeMappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lagless/create",
3
- "version": "0.0.46",
3
+ "version": "0.0.50",
4
4
  "license": "CC-BY-NC-4.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -240,6 +240,9 @@ Systems run in array order every tick. Canonical ordering:
240
240
  <% } else if (simulationType === 'physics3d') { -%>
241
241
  | [docs/08-physics3d.md](docs/08-physics3d.md) | Rapier 3D integration, character controller, animation |
242
242
  <% } -%>
243
+ <% if (simulationType === 'physics2d') { -%>
244
+ | [docs/11-2d-map-generation.md](docs/11-2d-map-generation.md) | Procedural 2D map generation, terrain rendering, object placement |
245
+ <% } -%>
243
246
  | [docs/09-recipes.md](docs/09-recipes.md) | Step-by-step cookbook for common game features |
244
247
  | [docs/10-common-mistakes.md](docs/10-common-mistakes.md) | "Never do X" reference + error solutions |
245
248
  | [docs/api-quick-reference.md](docs/api-quick-reference.md) | One-page API cheat sheet |
@@ -20,7 +20,7 @@
20
20
  <% if (simulationType === 'physics2d') { -%>
21
21
  "@lagless/physics2d": "^<%= laglessVersion %>",
22
22
  "@lagless/physics-shared": "^<%= laglessVersion %>",
23
- "@dimforge/rapier2d-deterministic-compat": "^0.19.0",
23
+ "@lagless/rapier2d-deterministic-compat": "^0.19.0",
24
24
  <% } else if (simulationType === 'physics3d') { -%>
25
25
  "@lagless/physics3d": "^<%= laglessVersion %>",
26
26
  "@lagless/physics-shared": "^<%= laglessVersion %>",
@@ -1,7 +1,6 @@
1
1
  import { FC } from 'react';
2
2
  import { useRunner } from '../game-view/runner-provider';
3
3
  import { DebugPanel as SharedDebugPanel } from '@lagless/react';
4
- import { PlayerResource, DivergenceSignal } from '<%= packageName %>-simulation';
5
4
 
6
5
  export const DebugPanel: FC = () => {
7
6
  const runner = useRunner();
@@ -9,10 +8,6 @@ export const DebugPanel: FC = () => {
9
8
  return (
10
9
  <SharedDebugPanel
11
10
  runner={runner}
12
- hashVerification={{
13
- playerResourceClass: PlayerResource,
14
- divergenceSignalClass: DivergenceSignal,
15
- }}
16
11
  />
17
12
  );
18
13
  };
@@ -2,13 +2,11 @@ import {
2
2
  <%= projectName %>Runner,
3
3
  <%= projectName %>Systems,
4
4
  <%= projectName %>Signals,
5
- DivergenceSignal,
6
5
  MoveInput,
7
6
  PlayerJoined,
8
- ReportHash,
9
7
  <%= projectName %>Arena,
10
8
  } from '<%= packageName %>-simulation';
11
- import { createContext, FC, ReactNode, useContext, useEffect, useState } from 'react';
9
+ import { createContext, FC, ReactNode, useContext, useEffect, useRef, useState } from 'react';
12
10
  import { useTick } from '@pixi/react';
13
11
  import { useNavigate } from 'react-router-dom';
14
12
  import { ProviderStore } from '../hooks/use-start-match';
@@ -16,7 +14,7 @@ import { ECSConfig, LocalInputProvider, RPC, createHashReporter } from '@lagless
16
14
  import { RelayInputProvider, RelayConnection } from '@lagless/relay-client';
17
15
  import { getMatchInfo } from '../hooks/use-start-multiplayer-match';
18
16
  import { UUID } from '@lagless/misc';
19
- import { useDevBridge } from '@lagless/react';
17
+ import { useDevBridge, useDiagnosticsControl } from '@lagless/react';
20
18
  <% if (simulationType === 'physics2d') { -%>
21
19
  import { PhysicsWorldManager2d, type RapierModule2d } from '@lagless/physics2d';
22
20
  <% } else if (simulationType === 'physics3d') { -%>
@@ -41,6 +39,9 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
41
39
  const [runner, setRunner] = useState<<%= projectName %>Runner>(null!);
42
40
  const [v, setV] = useState(0);
43
41
  const navigate = useNavigate();
42
+ const diagnosticsEnabled = useDiagnosticsControl();
43
+ const hashReporterRef = useRef<ReturnType<typeof createHashReporter> | null>(null);
44
+ const connectionRef = useRef<RelayConnection | null>(null);
44
45
 
45
46
  useEffect(() => {
46
47
  return ProviderStore.onProvider(() => {
@@ -75,7 +76,7 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
75
76
  <% if (simulationType === 'physics2d') { -%>
76
77
  // Load Rapier 2D WASM
77
78
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
- const RAPIER = (await import('@dimforge/rapier2d-deterministic-compat')).default as any;
79
+ const RAPIER = (await import('@lagless/rapier2d-deterministic-compat')).default as any;
79
80
  await RAPIER.init();
80
81
  const rapier = RAPIER as unknown as RapierModule2d;
81
82
  if (disposed) { inputProvider.dispose(); return; }
@@ -105,11 +106,13 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
105
106
  onPong: (data) => inputProvider.handlePong(data),
106
107
  onStateRequest: (requestId) => inputProvider.handleStateRequest(requestId),
107
108
  onStateResponse: (data) => inputProvider.handleStateResponse(data),
109
+ onHashMismatch: (data) => hashReporterRef.current?.reportMismatch(data),
108
110
  onConnected: () => console.log('[Relay] Connected'),
109
111
  onDisconnected: () => console.log('[Relay] Disconnected'),
110
112
  },
111
113
  );
112
114
 
115
+ connectionRef.current = _connection;
113
116
  inputProvider.setConnection(_connection);
114
117
  _connection.connect();
115
118
 
@@ -137,12 +140,6 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
137
140
  <% } -%>
138
141
  }
139
142
 
140
- // Set up keyboard input drainer with hash reporting
141
- const reportHash = createHashReporter(_runner, {
142
- reportInterval: <%= projectName %>Arena.hashReportInterval,
143
- reportHashRpc: ReportHash,
144
- });
145
-
146
143
  inputProvider.drainInputs((addRPC) => {
147
144
  let dx = 0;
148
145
  let dy = 0;
@@ -158,11 +155,8 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
158
155
  }
159
156
  addRPC(MoveInput, { directionX: dx, directionY: dy });
160
157
  }
161
-
162
- reportHash(addRPC);
163
158
  });
164
159
 
165
- _runner.Simulation.enableHashTracking(<%= projectName %>Arena.hashReportInterval);
166
160
  _runner.start();
167
161
 
168
162
  if (inputProvider instanceof RelayInputProvider) {
@@ -186,11 +180,6 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
186
180
  inputProvider.addRemoteRpc(joinRpc);
187
181
  }
188
182
 
189
- const divergenceSignal = _runner.DIContainer.resolve(DivergenceSignal);
190
- divergenceSignal.Predicted.subscribe((e) => {
191
- console.warn(`[DIVERGENCE] Players ${e.data.slotA} vs ${e.data.slotB}: hash ${e.data.hashA} != ${e.data.hashB} at tick ${e.data.atTick}`);
192
- });
193
-
194
183
  setRunner(_runner);
195
184
  })();
196
185
 
@@ -198,12 +187,36 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
198
187
  disposed = true;
199
188
  window.removeEventListener('keydown', onKeyDown);
200
189
  window.removeEventListener('keyup', onKeyUp);
190
+ connectionRef.current = null;
201
191
  _connection?.disconnect();
202
192
  _runner?.dispose();
203
193
  };
204
194
  }, [v, navigate]);
205
195
 
206
- useDevBridge(runner, { hashTrackingInterval: <%= projectName %>Arena.hashReportInterval });
196
+ // Diagnostics lifecycle: enable/disable hash tracking and hash reporter based on toggle
197
+ useEffect(() => {
198
+ if (!runner || !diagnosticsEnabled) {
199
+ runner?.Simulation.disableHashTracking();
200
+ hashReporterRef.current?.dispose();
201
+ hashReporterRef.current = null;
202
+ return;
203
+ }
204
+ runner.Simulation.enableHashTracking(<%= projectName %>Arena.hashReportInterval);
205
+ const reporter = createHashReporter(runner, {
206
+ reportInterval: <%= projectName %>Arena.hashReportInterval,
207
+ send: (data) => connectionRef.current?.sendHashReport(data),
208
+ });
209
+ reporter.subscribeDivergence((data) => {
210
+ console.warn(`[DIVERGENCE] Players ${data.slotA} vs ${data.slotB}: hash ${data.hashA} != ${data.hashB} at tick ${data.atTick}`);
211
+ });
212
+ hashReporterRef.current = reporter;
213
+ return () => {
214
+ reporter.dispose();
215
+ hashReporterRef.current = null;
216
+ };
217
+ }, [runner, diagnosticsEnabled]);
218
+
219
+ useDevBridge(runner, { hashTrackingInterval: <%= projectName %>Arena.hashReportInterval, diagnosticsEnabled });
207
220
 
208
221
  return !runner ? null : <RunnerContext.Provider value={runner}>{children}</RunnerContext.Provider>;
209
222
  };
@@ -24,9 +24,6 @@ playerResources:
24
24
  id: uint8[16]
25
25
  entity: uint32
26
26
  connected: uint8
27
- lastReportedHash: uint32
28
- lastReportedHashTick: uint32
29
- hashMismatchCount: uint16
30
27
 
31
28
  inputs:
32
29
  PlayerJoined:
@@ -38,9 +35,6 @@ inputs:
38
35
  MoveInput:
39
36
  directionX: float32
40
37
  directionY: float32
41
- ReportHash:
42
- hash: uint32
43
- atTick: uint32
44
38
 
45
39
  filters:
46
40
  PlayerFilter:
@@ -69,9 +63,6 @@ playerResources:
69
63
  id: uint8[16]
70
64
  entity: uint32
71
65
  connected: uint8
72
- lastReportedHash: uint32
73
- lastReportedHashTick: uint32
74
- hashMismatchCount: uint16
75
66
 
76
67
  inputs:
77
68
  PlayerJoined:
@@ -83,9 +74,6 @@ inputs:
83
74
  MoveInput:
84
75
  directionX: float32
85
76
  directionY: float32
86
- ReportHash:
87
- hash: uint32
88
- atTick: uint32
89
77
 
90
78
  filters:
91
79
  PlayerFilter:
@@ -112,9 +100,6 @@ playerResources:
112
100
  id: uint8[16]
113
101
  entity: uint32
114
102
  connected: uint8
115
- lastReportedHash: uint32
116
- lastReportedHashTick: uint32
117
- hashMismatchCount: uint16
118
103
 
119
104
  inputs:
120
105
  PlayerJoined:
@@ -126,9 +111,6 @@ inputs:
126
111
  MoveInput:
127
112
  directionX: float32
128
113
  directionY: float32
129
- ReportHash:
130
- hash: uint32
131
- atTick: uint32
132
114
 
133
115
  filters:
134
116
  PlayerFilter:
@@ -1,5 +1,3 @@
1
- import { ISignalConstructor, DivergenceSignal } from '@lagless/core';
1
+ import { ISignalConstructor } from '@lagless/core';
2
2
 
3
- export { DivergenceSignal, type DivergenceData } from '@lagless/core';
4
-
5
- export const <%= projectName %>Signals: ISignalConstructor[] = [DivergenceSignal];
3
+ export const <%= projectName %>Signals: ISignalConstructor[] = [];
@@ -10,7 +10,6 @@ import { BoundarySystem } from './boundary.system.js';
10
10
  <% } else { -%>
11
11
  import { PhysicsStepSystem } from './physics-step.system.js';
12
12
  <% } -%>
13
- import { HashVerificationSystem } from './hash-verification.system.js';
14
13
 
15
14
  export const <%= projectName %>Systems: IECSSystemConstructor[] = [
16
15
  SavePrevTransformSystem,
@@ -24,5 +23,4 @@ export const <%= projectName %>Systems: IECSSystemConstructor[] = [
24
23
  <% } else { -%>
25
24
  PhysicsStepSystem,
26
25
  <% } -%>
27
- HashVerificationSystem,
28
26
  ];
@@ -360,3 +360,63 @@ export class ProjectileLifetimeSystem implements IECSSystem {
360
360
  this._arenaConfig.radius -= this._arenaConfig.shrinkRate;
361
361
  }
362
362
  ```
363
+ <% if (simulationType === 'physics2d') { -%>
364
+
365
+ ## Add Procedural 2D Map
366
+
367
+ 1. **Install packages:**
368
+ ```bash
369
+ pnpm add @lagless/2d-map-generator @lagless/2d-map-renderer
370
+ ```
371
+
372
+ 2. **Define object types** in `simulation/src/lib/map-config/objects.ts`:
373
+ ```typescript
374
+ import type { MapObjectDef, MapObjectRegistry } from '@lagless/2d-map-generator';
375
+ import { RenderLayer, ShapeType, CANOPY_SENSOR_TAG } from '@lagless/2d-map-generator';
376
+
377
+ export enum ObjectType { Tree = 0 }
378
+
379
+ const TREE: MapObjectDef = {
380
+ typeId: ObjectType.Tree,
381
+ colliders: [
382
+ { shape: { type: ShapeType.Circle, radius: 30 } },
383
+ { shape: { type: ShapeType.Circle, radius: 128 }, isSensor: true, tag: CANOPY_SENSOR_TAG },
384
+ ],
385
+ visuals: [
386
+ { texture: 'tree-trunk', layer: RenderLayer.Ground },
387
+ { texture: 'tree-foliage', layer: RenderLayer.Canopy },
388
+ ],
389
+ scaleRange: [0.1, 0.2],
390
+ includeSensorsInBounds: true,
391
+ };
392
+
393
+ export const OBJECT_REGISTRY: MapObjectRegistry = new Map([[0, TREE]]);
394
+ ```
395
+
396
+ 3. **Create generator factory** in `simulation/src/lib/map-config/create-map-generator.ts`:
397
+ ```typescript
398
+ import { MapGenerator, BiomeFeature, ShoreFeature, GrassFeature,
399
+ ObjectPlacementFeature, PlacementKind, TerrainZone, STANDARD_BIOME,
400
+ } from '@lagless/2d-map-generator';
401
+ import { OBJECT_REGISTRY, ObjectType } from './objects.js';
402
+
403
+ export function createMapGenerator(): MapGenerator {
404
+ return new MapGenerator({ baseWidth: 720, baseHeight: 720, scale: 1, extension: 80, gridSize: 16 })
405
+ .addFeature(new BiomeFeature(), STANDARD_BIOME)
406
+ .addFeature(new ShoreFeature(), { inset: 48, divisions: 12, variation: 4 })
407
+ .addFeature(new GrassFeature(), { inset: 18, variation: 3 })
408
+ .addFeature(new ObjectPlacementFeature(), {
409
+ registry: OBJECT_REGISTRY,
410
+ stages: [{ kind: PlacementKind.Density, typeId: ObjectType.Tree, density: 100, terrainZone: TerrainZone.Grass }],
411
+ });
412
+ }
413
+ ```
414
+
415
+ 4. **Extend runner** — generate map + create physics colliders in constructor, call `capturePreStartState()` after.
416
+
417
+ 5. **Render** — use `MapTerrainRenderer` and `MapObjectRenderer` from `@lagless/2d-map-renderer`.
418
+
419
+ 6. **Canopy transparency** — use `extractCanopyZones()` + `isInsideCanopyZone()` per frame.
420
+
421
+ > Full details: [docs/11-2d-map-generation.md](11-2d-map-generation.md)
422
+ <% } -%>
@@ -0,0 +1,707 @@
1
+ # 2D Map Generation
2
+
3
+ ## Overview
4
+
5
+ `@lagless/2d-map-generator` produces deterministic 2D maps from a seed using a feature pipeline. `@lagless/2d-map-renderer` renders those maps using Pixi.js. Together they provide procedural terrain, rivers, lakes, object placement, and rendering for top-down 2D games.
6
+
7
+ **Key properties:**
8
+ - **Deterministic** — same seed + same config = identical map on every client (uses `MathOps` trig, `ISeededRandom`)
9
+ - **Feature-based** — compose terrain, water, objects via independent features with automatic dependency resolution
10
+ - **Physics-integrated** — `createMapColliders()` converts placed objects into Rapier 2D rigid bodies
11
+ - **Render-ready** — `MapTerrainRenderer` and `MapObjectRenderer` output Pixi.js containers
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pnpm add @lagless/2d-map-generator @lagless/2d-map-renderer
17
+ ```
18
+
19
+ Both packages are peer dependencies — they are NOT included by default. Add them when your game needs procedural map generation.
20
+
21
+ ## Architecture
22
+
23
+ ```
24
+ MapGenerator
25
+ ├── addFeature(feature, config) // register features
26
+ └── generate(random, collision) // run all features in dependency order
27
+
28
+ ├── BiomeFeature → BiomeOutput (color palette)
29
+ ├── ShoreFeature → ShoreOutput (island shore polygon)
30
+ ├── GrassFeature → GrassOutput (grass area polygon)
31
+ ├── RiverFeature → RiverOutput (river polygons)
32
+ ├── LakeFeature → LakeOutput (lake polygons)
33
+ ├── BridgeFeature → BridgeOutput (bridge placements)
34
+ ├── ObjectPlacementFeature → ObjectPlacementOutput (placed objects)
35
+ ├── GroundPatchFeature → GroundPatchOutput (ground patches)
36
+ └── PlacesFeature → PlacesOutput (named positions)
37
+ ```
38
+
39
+ Features declare dependencies via `requires`. The generator resolves them with topological sort — no manual ordering needed. You only add the features your game needs.
40
+
41
+ ## Integration Flow
42
+
43
+ The full integration spans three layers:
44
+
45
+ ```
46
+ 1. Simulation (runner constructor)
47
+ └── MapGenerator.generate() → IGeneratedMap
48
+ └── createMapColliders() → Rapier 2D physics bodies
49
+ └── capturePreStartState() → snapshot includes map colliders
50
+
51
+ 2. DI Bridge
52
+ └── MapData class registered via extraRegistrations
53
+ └── Systems access map data via DI constructor injection
54
+
55
+ 3. Client (React/Pixi.js)
56
+ └── MapTerrainRenderer.buildTerrain() → terrain container
57
+ └── MapObjectRenderer.build() → ground + canopy ParticleContainers
58
+ └── extractCanopyZones() + isInsideCanopyZone() → per-frame transparency
59
+ ```
60
+
61
+ ## Setting Up the Generator
62
+
63
+ ### Step 1: Define Object Types and Registry
64
+
65
+ Create a file in your simulation package (e.g., `map-config/objects.ts`):
66
+
67
+ ```typescript
68
+ import type { MapObjectDef, MapObjectRegistry } from '@lagless/2d-map-generator';
69
+ import { RenderLayer, ShapeType, CANOPY_SENSOR_TAG } from '@lagless/2d-map-generator';
70
+
71
+ export enum ObjectType { Tree = 0, Building = 1 }
72
+
73
+ const TREE: MapObjectDef = {
74
+ typeId: ObjectType.Tree,
75
+ colliders: [
76
+ { shape: { type: ShapeType.Circle, radius: 30 } },
77
+ // Sensor for canopy transparency zone (view-only, skipped by createMapColliders)
78
+ { shape: { type: ShapeType.Circle, radius: 128 }, isSensor: true, tag: CANOPY_SENSOR_TAG },
79
+ ],
80
+ visuals: [
81
+ { texture: 'tree-trunk', layer: RenderLayer.Ground },
82
+ { texture: 'tree-foliage', layer: RenderLayer.Canopy },
83
+ ],
84
+ scaleRange: [0.1, 0.2],
85
+ // Include sensor radius in placement bounds (prevents canopy overlap)
86
+ includeSensorsInBounds: true,
87
+ // Optional: minimap display
88
+ mapDisplay: {
89
+ shapes: [
90
+ { collider: { type: ShapeType.Circle, radius: 30 }, color: 0x2d5a1e, scale: 1 },
91
+ ],
92
+ },
93
+ };
94
+
95
+ const BUILDING: MapObjectDef = {
96
+ typeId: ObjectType.Building,
97
+ colliders: [
98
+ { shape: { type: ShapeType.Cuboid, halfWidth: 30, halfHeight: 20 } },
99
+ { shape: { type: ShapeType.Cuboid, halfWidth: 30, halfHeight: 20 }, isSensor: true, tag: CANOPY_SENSOR_TAG },
100
+ ],
101
+ visuals: [
102
+ { texture: 'building-floor', layer: RenderLayer.Ground },
103
+ { texture: 'building-roof', layer: RenderLayer.Canopy },
104
+ ],
105
+ scaleRange: [1, 1],
106
+ groundPatches: [
107
+ {
108
+ offset: { x: 0, y: 0 },
109
+ halfExtents: { x: 32, y: 22 },
110
+ color: 0x8b4513,
111
+ roughness: 0.5,
112
+ offsetDist: 2,
113
+ order: 0,
114
+ useAsMapShape: false,
115
+ },
116
+ ],
117
+ };
118
+
119
+ export const OBJECT_REGISTRY: MapObjectRegistry = new Map<number, MapObjectDef>([
120
+ [ObjectType.Tree, TREE],
121
+ [ObjectType.Building, BUILDING],
122
+ ]);
123
+ ```
124
+
125
+ ### Step 2: Create Map Generator Factory
126
+
127
+ Create `map-config/create-map-generator.ts`:
128
+
129
+ ```typescript
130
+ import {
131
+ MapGenerator, BiomeFeature, ShoreFeature, GrassFeature,
132
+ RiverFeature, LakeFeature, ObjectPlacementFeature,
133
+ PlacementKind, TerrainZone, STANDARD_BIOME,
134
+ } from '@lagless/2d-map-generator';
135
+ import { OBJECT_REGISTRY, ObjectType } from './objects.js';
136
+
137
+ export function createMapGenerator(): MapGenerator {
138
+ const generator = new MapGenerator({
139
+ baseWidth: 720,
140
+ baseHeight: 720,
141
+ scale: 1.0,
142
+ extension: 80,
143
+ gridSize: 16,
144
+ });
145
+
146
+ generator
147
+ .addFeature(new BiomeFeature(), STANDARD_BIOME)
148
+ .addFeature(new ShoreFeature(), { inset: 48, divisions: 12, variation: 4 })
149
+ .addFeature(new GrassFeature(), { inset: 18, variation: 3 })
150
+ .addFeature(new RiverFeature(), {
151
+ weights: [
152
+ { weight: 0.25, widths: [8, 4] },
153
+ { weight: 0.75, widths: [4] },
154
+ ],
155
+ subdivisionPasses: 5,
156
+ masks: [],
157
+ })
158
+ .addFeature(new LakeFeature(), {
159
+ lakes: [{ odds: 1.0, innerRad: 30, outerRad: 200, spawnBound: { pos: { x: 0.5, y: 0.5 }, rad: 300 } }],
160
+ })
161
+ .addFeature(new ObjectPlacementFeature(), {
162
+ registry: OBJECT_REGISTRY,
163
+ stages: [
164
+ { kind: PlacementKind.Density, typeId: ObjectType.Tree, density: 100, terrainZone: TerrainZone.Grass },
165
+ { kind: PlacementKind.Fixed, typeId: ObjectType.Building, count: 3, terrainZone: TerrainZone.Grass },
166
+ ],
167
+ });
168
+
169
+ return generator;
170
+ }
171
+ ```
172
+
173
+ ### Step 3: Create MapData DI Token
174
+
175
+ Create `map-data.ts` in your simulation:
176
+
177
+ ```typescript
178
+ import type { IGeneratedMap, MapObjectRegistry } from '@lagless/2d-map-generator';
179
+
180
+ export class MapData {
181
+ map!: IGeneratedMap;
182
+ registry!: MapObjectRegistry;
183
+ }
184
+ ```
185
+
186
+ ### Step 4: Extend Runner with Map Generation
187
+
188
+ Create a runner subclass that generates the map and creates physics colliders:
189
+
190
+ ```typescript
191
+ import { AbstractInputProvider, ECSConfig, PRNG } from '@lagless/core';
192
+ import { PhysicsConfig2d, PhysicsWorldManager2d, type RapierModule2d, RapierRigidBody2d } from '@lagless/physics2d';
193
+ import {
194
+ SpatialGridCollisionProvider, ObjectPlacementFeature,
195
+ createMapColliders, CANOPY_SENSOR_TAG,
196
+ } from '@lagless/2d-map-generator';
197
+ import type { ObjectPlacementOutput, MapPhysicsProvider } from '@lagless/2d-map-generator';
198
+ import { createMapGenerator } from './map-config/create-map-generator.js';
199
+ import { OBJECT_REGISTRY } from './map-config/objects.js';
200
+ import { MyGameRunner } from './schema/code-gen/MyGame.runner.js';
201
+ import { MyGameSystems } from './systems/index.js';
202
+ import { MyGameSignals } from './signals/index.js';
203
+ import { MapData } from './map-data.js';
204
+
205
+ function createPhysicsAdapter(wm: PhysicsWorldManager2d, rapier: RapierModule2d): MapPhysicsProvider {
206
+ return {
207
+ createFixedBody(x, y, rotation) {
208
+ const desc = rapier.RigidBodyDesc.fixed().setTranslation(x, y).setRotation(rotation);
209
+ return wm.createBodyFromDesc(desc);
210
+ },
211
+ createCircleCollider(body, radius, ox, oy, isSensor, _tag, collisionGroup) {
212
+ let desc = rapier.ColliderDesc.ball(radius).setTranslation(ox, oy).setSensor(isSensor);
213
+ if (collisionGroup != null) desc = desc.setCollisionGroups(collisionGroup);
214
+ wm.createColliderFromDesc(desc, body as RapierRigidBody2d);
215
+ },
216
+ createCuboidCollider(body, hw, hh, ox, oy, isSensor, _tag, collisionGroup) {
217
+ let desc = rapier.ColliderDesc.cuboid(hw, hh).setTranslation(ox, oy).setSensor(isSensor);
218
+ if (collisionGroup != null) desc = desc.setCollisionGroups(collisionGroup);
219
+ wm.createColliderFromDesc(desc, body as RapierRigidBody2d);
220
+ },
221
+ };
222
+ }
223
+
224
+ export class MyGameRunnerWithMap extends MyGameRunner {
225
+ constructor(
226
+ config: ECSConfig,
227
+ inputProvider: AbstractInputProvider,
228
+ rapier: RapierModule2d,
229
+ physicsConfig?: PhysicsConfig2d,
230
+ ) {
231
+ const mapData = new MapData();
232
+
233
+ super(
234
+ config, inputProvider,
235
+ MyGameSystems, MyGameSignals,
236
+ rapier, physicsConfig, undefined,
237
+ [[MapData, mapData]], // Register MapData for DI
238
+ );
239
+
240
+ // Generate map using ECS PRNG (available after super)
241
+ const prng = this.DIContainer.resolve(PRNG);
242
+ const generator = createMapGenerator();
243
+ const collision = new SpatialGridCollisionProvider(1024, 1024, 64);
244
+ const map = generator.generate(prng, collision);
245
+ mapData.map = map;
246
+ mapData.registry = OBJECT_REGISTRY;
247
+
248
+ // Create physics colliders for placed objects
249
+ const placement = map.get<ObjectPlacementOutput>(ObjectPlacementFeature);
250
+ if (placement) {
251
+ const physics = createPhysicsAdapter(this.PhysicsWorldManager, rapier);
252
+ createMapColliders(physics, placement.objects, mapData.registry, {
253
+ skipTags: [CANOPY_SENSOR_TAG],
254
+ });
255
+ }
256
+
257
+ // CRITICAL: re-capture initial state AFTER creating static bodies
258
+ // Without this, rollback to tick 0 restores a world without map colliders
259
+ this.Simulation.capturePreStartState();
260
+ }
261
+ }
262
+ ```
263
+
264
+ **Key points:**
265
+ - Map is generated in the runner constructor, BEFORE `start()` is called
266
+ - Uses ECS `PRNG` for determinism — same seed (from `serverHello.seed` in multiplayer) = same map
267
+ - `capturePreStartState()` MUST be called after creating static bodies — otherwise rollback loses them
268
+ - `MapData` is registered via `extraRegistrations` so systems can access map data through DI
269
+ - `skipTags: [CANOPY_SENSOR_TAG]` prevents creating physics bodies for view-only sensor colliders
270
+
271
+ ## MapGenerator Configuration
272
+
273
+ ```typescript
274
+ const generator = new MapGenerator({
275
+ baseWidth: 720, // map width before scaling (pixels)
276
+ baseHeight: 720, // map height before scaling (pixels)
277
+ scale: 1.0, // multiplier applied to base dimensions
278
+ extension: 80, // extra border around the map (water area)
279
+ gridSize: 16, // terrain grid cell size for rendering
280
+ });
281
+ ```
282
+
283
+ The actual map dimensions are `(baseWidth + 2 * extension) * scale` by `(baseHeight + 2 * extension) * scale`.
284
+
285
+ ## Object Definitions
286
+
287
+ ### MapObjectDef
288
+
289
+ | Field | Type | Description |
290
+ |-------|------|-------------|
291
+ | `typeId` | `number` | Unique identifier for this object type |
292
+ | `colliders` | `MapColliderDef[]` | Physics collision shapes |
293
+ | `visuals` | `MapVisualDef[]` | Texture references with render layer |
294
+ | `scaleRange` | `[min, max]` | Random scale range applied during placement |
295
+ | `orientations` | `number[]?` | Allowed rotation angles (default: `[0]`) |
296
+ | `groundPatches` | `GroundPatchDef[]?` | Ground patches drawn under the object |
297
+ | `mapDisplay` | `MapDisplayDef?` | Minimap display shapes |
298
+ | `children` | `ChildObjectDef[]?` | Child objects spawned relative to parent |
299
+ | `includeSensorsInBounds` | `boolean?` | Include sensor colliders in placement AABB (default: false) |
300
+
301
+ ### MapColliderDef
302
+
303
+ | Field | Type | Description |
304
+ |-------|------|-------------|
305
+ | `shape` | `MapCollisionShape` | `{ type: ShapeType.Circle, radius }` or `{ type: ShapeType.Cuboid, halfWidth, halfHeight }` |
306
+ | `offsetX` | `number?` | Offset from object center |
307
+ | `offsetY` | `number?` | Offset from object center |
308
+ | `isSensor` | `boolean?` | Sensor collider (no physics response) |
309
+ | `tag` | `number?` | Tag for filtering (e.g., `CANOPY_SENSOR_TAG`) |
310
+ | `collisionGroup` | `number?` | Rapier collision group bitmask |
311
+
312
+ ### MapVisualDef
313
+
314
+ | Field | Type | Description |
315
+ |-------|------|-------------|
316
+ | `texture` | `string` | Texture key (resolved by `getTexture` callback in renderer) |
317
+ | `layer` | `RenderLayer` | `RenderLayer.Ground` (under entities) or `RenderLayer.Canopy` (over entities) |
318
+ | `offsetX/Y` | `number?` | Visual offset from object center |
319
+ | `anchorX/Y` | `number?` | Sprite anchor (default: 0.5, 0.5) |
320
+
321
+ ## Placement Stages
322
+
323
+ Stages define how and where objects are placed. All stages run during `ObjectPlacementFeature.generate()`.
324
+
325
+ | Kind | Description | Key Fields |
326
+ |------|-------------|------------|
327
+ | `PlacementKind.Location` | Place at a specific position | `typeId`, `pos: {x, y}`, `rad`, `optional` |
328
+ | `PlacementKind.Fixed` | Place exact count randomly | `typeId`, `count`, `important?`, `terrainZone?` |
329
+ | `PlacementKind.Random` | Choose N types from a list | `spawns: number[]`, `choose`, `terrainZone?` |
330
+ | `PlacementKind.Density` | Count proportional to map area | `typeId`, `density`, `terrainZone?` |
331
+
332
+ ### Examples
333
+
334
+ ```typescript
335
+ stages: [
336
+ // Place 1 tree per 100 sq. units of grass area
337
+ { kind: PlacementKind.Density, typeId: 0, density: 100, terrainZone: TerrainZone.Grass },
338
+
339
+ // Place exactly 3 buildings on grass
340
+ { kind: PlacementKind.Fixed, typeId: 1, count: 3, terrainZone: TerrainZone.Grass },
341
+
342
+ // Place a spawn point at (100, 100) within 20px radius; skip if placement fails
343
+ { kind: PlacementKind.Location, typeId: 2, pos: { x: 100, y: 100 }, rad: 20, optional: true },
344
+
345
+ // Randomly pick 5 objects from types [0, 1, 2]
346
+ { kind: PlacementKind.Random, spawns: [0, 1, 2], choose: 5 },
347
+ ]
348
+ ```
349
+
350
+ ### Terrain Zones
351
+
352
+ Restrict placement to specific terrain types:
353
+
354
+ | Zone | Value | Description |
355
+ |------|-------|-------------|
356
+ | `TerrainZone.Grass` | 0 | Main land area |
357
+ | `TerrainZone.Beach` | 1 | Shore/beach area |
358
+ | `TerrainZone.RiverShore` | 2 | Riverbank |
359
+ | `TerrainZone.River` | 3 | Inside river |
360
+ | `TerrainZone.Lake` | 4 | Inside lake |
361
+ | `TerrainZone.Bridge` | 5 | On a bridge |
362
+ | `TerrainZone.WaterEdge` | 6 | Water edge |
363
+
364
+ ## Collision Providers
365
+
366
+ Collision providers prevent object overlap during placement. Two options:
367
+
368
+ ```typescript
369
+ import { SpatialGridCollisionProvider, RapierCollisionProvider } from '@lagless/2d-map-generator';
370
+
371
+ // Fast grid-based provider (recommended for most cases)
372
+ const collision = new SpatialGridCollisionProvider(mapWidth, mapHeight, cellSize);
373
+
374
+ // Rapier-based provider (more accurate, slower — use when shapes need exact overlap testing)
375
+ const collision = new RapierCollisionProvider(rapier);
376
+ ```
377
+
378
+ ## Terrain Query
379
+
380
+ Classify world positions into terrain zones at runtime:
381
+
382
+ ```typescript
383
+ import { TerrainQuery, TerrainZone } from '@lagless/2d-map-generator';
384
+ import type { ShoreOutput, GrassOutput, RiverOutput, LakeOutput } from '@lagless/2d-map-generator';
385
+ import { ShoreFeature, GrassFeature, RiverFeature, LakeFeature } from '@lagless/2d-map-generator';
386
+
387
+ const terrain = new TerrainQuery({
388
+ shore: map.get<ShoreOutput>(ShoreFeature),
389
+ grass: map.get<GrassOutput>(GrassFeature),
390
+ river: map.get<RiverOutput>(RiverFeature),
391
+ lake: map.get<LakeOutput>(LakeFeature),
392
+ });
393
+
394
+ const zone = terrain.classify(playerX, playerY); // TerrainZone.Grass, .Beach, etc.
395
+ ```
396
+
397
+ Useful for terrain-dependent game logic (speed modifiers, footstep sounds, spawn restrictions).
398
+
399
+ ## Physics Integration
400
+
401
+ ### MapPhysicsProvider Adapter
402
+
403
+ `createMapColliders()` uses a `MapPhysicsProvider` adapter to create physics bodies. This decouples the generator from Rapier's API:
404
+
405
+ ```typescript
406
+ import { createMapColliders, CANOPY_SENSOR_TAG } from '@lagless/2d-map-generator';
407
+ import type { MapPhysicsProvider } from '@lagless/2d-map-generator';
408
+
409
+ const physics: MapPhysicsProvider = {
410
+ createFixedBody(x, y, rotation) {
411
+ const desc = rapier.RigidBodyDesc.fixed().setTranslation(x, y).setRotation(rotation);
412
+ return worldManager.createBodyFromDesc(desc);
413
+ },
414
+ createCircleCollider(body, radius, ox, oy, isSensor, tag, collisionGroup) {
415
+ let desc = rapier.ColliderDesc.ball(radius).setTranslation(ox, oy).setSensor(isSensor);
416
+ if (collisionGroup != null) desc = desc.setCollisionGroups(collisionGroup);
417
+ worldManager.createColliderFromDesc(desc, body);
418
+ },
419
+ createCuboidCollider(body, hw, hh, ox, oy, isSensor, tag, collisionGroup) {
420
+ let desc = rapier.ColliderDesc.cuboid(hw, hh).setTranslation(ox, oy).setSensor(isSensor);
421
+ if (collisionGroup != null) desc = desc.setCollisionGroups(collisionGroup);
422
+ worldManager.createColliderFromDesc(desc, body);
423
+ },
424
+ };
425
+
426
+ // Create colliders, skipping canopy sensors (view-only)
427
+ createMapColliders(physics, placement.objects, registry, {
428
+ skipTags: [CANOPY_SENSOR_TAG],
429
+ });
430
+ ```
431
+
432
+ ### skipTags Option
433
+
434
+ | Option | Type | Description |
435
+ |--------|------|-------------|
436
+ | `skipTags` | `readonly number[]` | Skip colliders whose `tag` is in this list |
437
+
438
+ Use `skipTags: [CANOPY_SENSOR_TAG]` to prevent creating physics bodies for canopy transparency sensors — they are view-only and don't need physics responses.
439
+
440
+ ### capturePreStartState (CRITICAL)
441
+
442
+ Static map bodies must be created BEFORE calling `capturePreStartState()`:
443
+
444
+ ```typescript
445
+ // 1. Generate map + create colliders (in runner constructor)
446
+ // 2. Re-capture initial snapshot:
447
+ this.Simulation.capturePreStartState();
448
+ // 3. Start simulation:
449
+ runner.start();
450
+ ```
451
+
452
+ Without this, rollback to tick 0/1 restores a physics world without map colliders.
453
+
454
+ ## Rendering
455
+
456
+ ### MapTerrainRenderer
457
+
458
+ Renders terrain layers (background, beach, grass, rivers, lakes, grid, ground patches):
459
+
460
+ ```typescript
461
+ import { MapTerrainRenderer } from '@lagless/2d-map-renderer';
462
+
463
+ const terrain = new MapTerrainRenderer();
464
+ const terrainContainer = terrain.buildTerrain(map);
465
+ viewport.addChildAt(terrainContainer, 0); // add at bottom of display list
466
+
467
+ // Cleanup:
468
+ terrain.destroy();
469
+ ```
470
+
471
+ ### MapObjectRenderer
472
+
473
+ Renders placed objects as two `ParticleContainer` layers — ground (under entities) and canopy (over entities):
474
+
475
+ ```typescript
476
+ import { MapObjectRenderer } from '@lagless/2d-map-renderer';
477
+ import { ObjectPlacementFeature } from '@lagless/2d-map-generator';
478
+ import type { ObjectPlacementOutput } from '@lagless/2d-map-generator';
479
+ import { Assets, Texture } from 'pixi.js';
480
+
481
+ const objectRenderer = new MapObjectRenderer({ dynamicCanopyAlpha: true });
482
+
483
+ const placement = map.get<ObjectPlacementOutput>(ObjectPlacementFeature);
484
+ if (placement) {
485
+ objectRenderer.build(
486
+ placement.objects,
487
+ registry,
488
+ (textureKey) => Assets.get<Texture>(textureKey) ?? Texture.EMPTY,
489
+ );
490
+
491
+ viewport.addChild(objectRenderer.ground); // under entities
492
+ // ... add entity views here ...
493
+ viewport.addChild(objectRenderer.canopy); // over entities
494
+ }
495
+
496
+ // Cleanup:
497
+ objectRenderer.destroy();
498
+ ```
499
+
500
+ **Display order:** terrain → `objectRenderer.ground` → entity sprites → `objectRenderer.canopy`
501
+
502
+ ### Canopy Transparency
503
+
504
+ Canopy transparency is a **view-only** concern — it must NOT live in ECS or affect determinism. When a player is under a tree/building canopy, the canopy becomes transparent so the player remains visible.
505
+
506
+ ```typescript
507
+ import { extractCanopyZones, isInsideCanopyZone } from '@lagless/2d-map-generator';
508
+ import type { ObjectPlacementOutput } from '@lagless/2d-map-generator';
509
+
510
+ // Pre-compute once (e.g., in useMemo):
511
+ const placement = map.get<ObjectPlacementOutput>(ObjectPlacementFeature);
512
+ const canopyZones = placement ? extractCanopyZones(placement.objects, registry) : [];
513
+
514
+ // Per frame (e.g., in useTick):
515
+ const px = playerX, py = playerY;
516
+ for (const zone of canopyZones) {
517
+ const inside = isInsideCanopyZone(zone, px, py);
518
+ objectRenderer.setCanopyAlpha(zone.objectIndex, inside ? 0.3 : 1.0);
519
+ }
520
+ ```
521
+
522
+ **How it works:**
523
+ 1. `extractCanopyZones()` finds all sensor colliders tagged with `CANOPY_SENSOR_TAG` (default tag)
524
+ 2. Returns `CanopyZone[]` with pre-computed `radiusSq` (for circles) or `halfWidth/halfHeight` (for cuboids)
525
+ 3. `isInsideCanopyZone()` performs the appropriate distance check based on zone type
526
+ 4. `objectRenderer.setCanopyAlpha()` sets the alpha of the canopy particle at that index
527
+
528
+ **Performance:** O(N) per frame with N objects — just a distance comparison per object, negligible cost.
529
+
530
+ ### MinimapRenderer
531
+
532
+ Renders a simplified minimap:
533
+
534
+ ```typescript
535
+ import { MinimapRenderer } from '@lagless/2d-map-renderer';
536
+ import { ObjectPlacementFeature } from '@lagless/2d-map-generator';
537
+ import type { ObjectPlacementOutput } from '@lagless/2d-map-generator';
538
+
539
+ const minimap = new MinimapRenderer();
540
+ const minimapContainer = minimap.buildMinimap(map, 200); // 200px size
541
+
542
+ // Add object dots to minimap (uses mapDisplay shapes from object definitions)
543
+ const placement = map.get<ObjectPlacementOutput>(ObjectPlacementFeature);
544
+ if (placement) {
545
+ minimap.addObjectShapes(placement.objects, registry);
546
+ }
547
+
548
+ stage.addChild(minimapContainer);
549
+
550
+ // Cleanup:
551
+ minimap.destroy();
552
+ ```
553
+
554
+ Objects only show on the minimap if their `MapObjectDef` has a `mapDisplay` property with shapes.
555
+
556
+ ## Accessing Feature Outputs
557
+
558
+ ```typescript
559
+ import type { BiomeOutput, ShoreOutput, GrassOutput, RiverOutput, ObjectPlacementOutput } from '@lagless/2d-map-generator';
560
+ import { BiomeFeature, ShoreFeature, GrassFeature, RiverFeature, ObjectPlacementFeature } from '@lagless/2d-map-generator';
561
+
562
+ const map = generator.generate(random, collision);
563
+
564
+ // Type-safe access via feature class:
565
+ const biome = map.get<BiomeOutput>(BiomeFeature); // color palette
566
+ const shore = map.get<ShoreOutput>(ShoreFeature); // island polygon
567
+ const grass = map.get<GrassOutput>(GrassFeature); // grass polygon + area
568
+ const river = map.get<RiverOutput>(RiverFeature); // river polygons
569
+ const placement = map.get<ObjectPlacementOutput>(ObjectPlacementFeature); // placed objects
570
+ ```
571
+
572
+ ## Biome Colors
573
+
574
+ Use `STANDARD_BIOME` for default colors, or define custom:
575
+
576
+ ```typescript
577
+ import { STANDARD_BIOME } from '@lagless/2d-map-generator';
578
+
579
+ // Standard biome (green grass, blue water, sandy beach):
580
+ generator.addFeature(new BiomeFeature(), STANDARD_BIOME);
581
+
582
+ // Custom biome:
583
+ generator.addFeature(new BiomeFeature(), {
584
+ background: 0x80af49,
585
+ water: 0x3d85c6,
586
+ waterRipple: 0x3478b2,
587
+ beach: 0xcdb35b,
588
+ riverbank: 0x905e24,
589
+ grass: 0x80af49,
590
+ underground: 0x1b0d00,
591
+ });
592
+ ```
593
+
594
+ ## Ground Patches
595
+
596
+ Objects can define `groundPatches` — colored rectangles drawn under the object (e.g., building foundations, dirt patches):
597
+
598
+ ```typescript
599
+ groundPatches: [
600
+ {
601
+ offset: { x: 0, y: 0 }, // offset from object center
602
+ halfExtents: { x: 12, y: 10 }, // half-size of the rectangle
603
+ color: 0x8b4513, // fill color
604
+ roughness: 0.5, // edge roughness (0 = smooth)
605
+ offsetDist: 2, // random edge offset distance
606
+ order: 0, // 0 = under grid, 1 = over grid
607
+ useAsMapShape: false, // whether to use as map boundary shape
608
+ },
609
+ ]
610
+ ```
611
+
612
+ Add `GroundPatchFeature` to your generator to enable ground patches:
613
+
614
+ ```typescript
615
+ import { GroundPatchFeature } from '@lagless/2d-map-generator';
616
+
617
+ generator.addFeature(new GroundPatchFeature(), { registry: OBJECT_REGISTRY });
618
+ ```
619
+
620
+ ## Utilities
621
+
622
+ ### sortPlacedObjects
623
+
624
+ Sorts placed objects by position (Y then X). Used internally by both `MapObjectRenderer.build()` and `extractCanopyZones()` to guarantee consistent object indices.
625
+
626
+ ```typescript
627
+ import { sortPlacedObjects } from '@lagless/2d-map-generator';
628
+
629
+ const sorted = sortPlacedObjects(placement.objects);
630
+ // sorted[i] index matches MapObjectRenderer particle index and CanopyZone.objectIndex
631
+ ```
632
+
633
+ ### CANOPY_SENSOR_TAG
634
+
635
+ Constant (`= 1`) used as a tag on sensor colliders to mark canopy transparency zones. Used by:
636
+ - `extractCanopyZones()` — default tag parameter
637
+ - `createMapColliders()` with `skipTags` — prevents creating physics bodies for canopy sensors
638
+
639
+ ## Determinism Notes
640
+
641
+ All map generation is deterministic:
642
+ - Uses `ISeededRandom` interface — the ECS `PRNG` satisfies this structurally
643
+ - Trigonometry uses `MathOps` (WASM-backed, cross-platform identical)
644
+ - Same seed + same config = identical map on every client
645
+ - Map generation happens ONCE before simulation starts, not during ticks
646
+ - In multiplayer, the seed comes from `serverHello.seed` — guaranteed identical for all clients
647
+
648
+ ## Enums Reference
649
+
650
+ | Enum | Values |
651
+ |------|--------|
652
+ | `ShapeType` | `Circle = 0`, `Cuboid = 1` |
653
+ | `PlacementKind` | `Location = 0`, `Fixed = 1`, `Random = 2`, `Density = 3` |
654
+ | `RenderLayer` | `Ground = 0`, `Canopy = 1` |
655
+ | `TerrainZone` | `Grass = 0`, `Beach = 1`, `RiverShore = 2`, `River = 3`, `Lake = 4`, `Bridge = 5`, `WaterEdge = 6` |
656
+
657
+ ## Full Client Example
658
+
659
+ ```typescript
660
+ // In your game view component:
661
+ import { FC, useEffect, useMemo, useRef } from 'react';
662
+ import { useTick } from '@pixi/react';
663
+ import { Assets, Texture } from 'pixi.js';
664
+ import { MapTerrainRenderer, MapObjectRenderer } from '@lagless/2d-map-renderer';
665
+ import { ObjectPlacementFeature, extractCanopyZones, isInsideCanopyZone } from '@lagless/2d-map-generator';
666
+ import type { ObjectPlacementOutput } from '@lagless/2d-map-generator';
667
+
668
+ export const MapView: FC<{ runner: MyGameRunner; viewport: Viewport }> = ({ runner, viewport }) => {
669
+ const mapData = useMemo(() => runner.DIContainer.resolve(MapData), [runner]);
670
+ const objectRendererRef = useRef<MapObjectRenderer | null>(null);
671
+
672
+ // Build terrain + objects once
673
+ useEffect(() => {
674
+ const terrain = new MapTerrainRenderer();
675
+ viewport.addChildAt(terrain.buildTerrain(mapData.map), 0);
676
+
677
+ const objRenderer = new MapObjectRenderer({ dynamicCanopyAlpha: true });
678
+ objectRendererRef.current = objRenderer;
679
+ const placement = mapData.map.get<ObjectPlacementOutput>(ObjectPlacementFeature);
680
+ if (placement) {
681
+ objRenderer.build(placement.objects, mapData.registry, (key) => Assets.get<Texture>(key) ?? Texture.EMPTY);
682
+ viewport.addChild(objRenderer.ground);
683
+ viewport.addChild(objRenderer.canopy);
684
+ }
685
+
686
+ return () => { terrain.destroy(); objRenderer.destroy(); };
687
+ }, [viewport, mapData]);
688
+
689
+ // Pre-compute canopy zones
690
+ const canopyZones = useMemo(() => {
691
+ const placement = mapData.map.get<ObjectPlacementOutput>(ObjectPlacementFeature);
692
+ return placement ? extractCanopyZones(placement.objects, mapData.registry) : [];
693
+ }, [mapData]);
694
+
695
+ // Per-frame canopy transparency
696
+ useTick(() => {
697
+ const objRenderer = objectRendererRef.current;
698
+ if (!objRenderer) return;
699
+ const px = playerX, py = playerY; // get from transform
700
+ for (const zone of canopyZones) {
701
+ objRenderer.setCanopyAlpha(zone.objectIndex, isInsideCanopyZone(zone, px, py) ? 0.3 : 1.0);
702
+ }
703
+ });
704
+
705
+ return null;
706
+ };
707
+ ```
@@ -1,17 +0,0 @@
1
- import { ECSConfig, ECSSystem, InputProvider, PlayerResources, AbstractHashVerificationSystem, DivergenceSignal } from '@lagless/core';
2
- import { PlayerResource, ReportHash } from '../schema/code-gen/index.js';
3
-
4
- @ECSSystem()
5
- export class HashVerificationSystem extends AbstractHashVerificationSystem {
6
- protected readonly _reportHashRpc = ReportHash;
7
- protected readonly _playerResourceClass = PlayerResource;
8
-
9
- constructor(
10
- ecsConfig: ECSConfig,
11
- inputProvider: InputProvider,
12
- playerResources: PlayerResources,
13
- divergenceSignal: DivergenceSignal,
14
- ) {
15
- super(ecsConfig, inputProvider, playerResources, divergenceSignal);
16
- }
17
- }