Skip to main content
Glama
river.ts16.3 kB
/** * River Generation Module * * Generates rivers using flow accumulation algorithm. * Inspired by Azgaar's river system (reference/AZGAAR_SNAPSHOT.md Section 4). * * Algorithm: * 1. Calculate flow accumulation from precipitation * 2. Identify high-flux cells as river sources * 3. Trace river paths downhill to ocean/lake * 4. Track confluences where rivers merge * * Key properties: * - Deterministic (seedable PRNG) * - Rivers always flow downhill * - Acyclic network (DAG structure) * - Flux increases downstream * - Memory optimized using TypedArrays */ import seedrandom from 'seedrandom'; /** * Point in 2D grid */ export interface Point { x: number; y: number; } /** * Single river with path and flow data */ export interface River { id: string; /** River path from source to mouth */ path: Point[]; /** Water flux at each point in path */ flux: number[]; /** Points where tributaries join this river */ confluences: Point[]; } /** * Complete river system for a world */ export interface RiverSystem { rivers: River[]; /** Flow accumulation map */ flowMap: number[][]; } /** * River generation options */ export interface RiverGenerationOptions { /** Deterministic seed */ seed: string; width: number; height: number; /** Elevation map (Uint8Array) */ elevation: Uint8Array; /** Precipitation map (optional, defaults to uniform) */ precipitation?: Float32Array; // Changed to Float32Array to match internal usage /** Sea level (default 20) */ seaLevel?: number; /** Minimum flux to form a river (default 100) */ minFlux?: number; } // Helper to convert 2D coords to 1D index export const toIndex = (x: number, y: number, width: number) => y * width + x; export const fromIndex = (index: number, width: number) => ({ x: index % width, y: Math.floor(index / width) }); /** * Generate river system */ export function generateRivers(options: RiverGenerationOptions): RiverSystem { const { seed, width, height, elevation, seaLevel = 20, precipitation, minFlux = 300, // Increased threshold - fewer river branches, reduces lake seed points } = options; const size = width * height; const rng = seedrandom(seed); // Clone elevation to avoid mutating input (Uint8Array copy is fast) const workingElevation = new Uint8Array(elevation); ensureSeaOutlets(workingElevation, seaLevel, width, height); // Use TypedArrays for internal calculations const oceanDistance = calculateOceanDistance(workingElevation, seaLevel, width, height); // Carve spillways to guarantee downhill routes without raising terrain carveSpillways(workingElevation, oceanDistance, seaLevel, width, height); // Default uniform precipitation if not provided let precipFlat: Float32Array; if (precipitation) { precipFlat = precipitation; } else { precipFlat = new Float32Array(size); precipFlat.fill(100); } // Step 1: Calculate flow directions (steepest descent, distance-aware) // Stores index of target cell, or -1 if none const flowDirection = calculateFlowDirection(workingElevation, oceanDistance, seaLevel, width, height); // Step 2: Calculate flow accumulation following flow directions const flowMapFlat = calculateFlowAccumulation(workingElevation, oceanDistance, precipFlat, seaLevel, flowDirection, width, height); // Step 3: Identify sources const sources = findRiverSources(flowMapFlat, workingElevation, seaLevel, minFlux, rng, width, height); // Step 4: Trace rivers const incomingCounts = calculateIncomingCounts(flowDirection, size); const rivers: River[] = []; const globalVisited = new Uint8Array(size); // 0 = unvisited, 1 = visited // Reuse buffer for cycle detection // Stores traceId for the current river trace const pathSetBuffer = new Int32Array(size); let traceIdCounter = 1; for (const source of sources) { // Skip if already part of a river const sourceIdx = toIndex(source.x, source.y, width); if (globalVisited[sourceIdx] === 1) continue; const river = traceRiverPath( source, workingElevation, flowMapFlat, // Use flat map internally flowDirection, incomingCounts, seaLevel, globalVisited, pathSetBuffer, traceIdCounter++, width, height ); if (river && river.path.length > 10) { // Increased from 5 - skip short river fragments river.id = `river_${rivers.length + 1}`; rivers.push(river); } } // Convert flow map back to 2D for return (API compatibility) const flowMap2D: number[][] = Array.from({ length: height }, (_, y) => Array.from({ length: width }, (_, x) => flowMapFlat[toIndex(x, y, width)]) ); return { rivers, flowMap: flowMap2D, }; } /** * Calculate flow direction for each cell (steepest descent) */ function calculateFlowDirection( elevation: Uint8Array, oceanDistance: Int32Array, seaLevel: number, width: number, height: number ): Int32Array { const size = width * height; const dir = new Int32Array(size).fill(-1); const neighborDeltas = [ { x: 0, y: -1 }, // N { x: 1, y: -1 }, // NE { x: 1, y: 0 }, // E { x: 1, y: 1 }, // SE { x: 0, y: 1 }, // S { x: -1, y: 1 }, // SW { x: -1, y: 0 }, // W { x: -1, y: -1 } // NW ]; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = toIndex(x, y, width); // Ocean cells don't flow if (elevation[idx] < seaLevel) continue; const currentElev = elevation[idx]; const currentDist = oceanDistance[idx]; let bestIdx = -1; let maxDrop = -Infinity; let bestDist = Infinity; for (const { x: dx, y: dy } of neighborDeltas) { const nx = x + dx; const ny = y + dy; if (nx >= 0 && nx < width && ny >= 0 && ny < height) { const nIdx = toIndex(nx, ny, width); const nElev = elevation[nIdx]; const nDist = oceanDistance[nIdx]; // Priority 1: Must be lower or equal elevation // Priority 2: Must be closer to ocean (if equal elevation) const drop = currentElev - nElev; // Valid flow target: // 1. Strictly lower elevation // 2. OR Equal elevation but strictly closer to ocean (plateau flow) const isValid = drop > 0 || (drop === 0 && nDist < currentDist); if (isValid) { // Pick steepest drop // Tie-break with distance to ocean if (drop > maxDrop) { maxDrop = drop; bestDist = nDist; bestIdx = nIdx; } else if (drop === maxDrop) { if (nDist < bestDist) { bestDist = nDist; bestIdx = nIdx; } } } } } if (bestIdx !== -1) { dir[idx] = bestIdx; } } } return dir; } /** * Calculate flow accumulation given fixed flow directions */ function calculateFlowAccumulation( elevation: Uint8Array, oceanDistance: Int32Array, precipitation: Float32Array, seaLevel: number, flowDirection: Int32Array, width: number, height: number ): Float32Array { const size = width * height; const flow = new Float32Array(size); // Initialize flow with precipitation for land cells for (let i = 0; i < size; i++) { if (elevation[i] >= seaLevel) { flow[i] = precipitation[i]; } } // Order cells from high to low elevation (filled), ensuring upstream processed first // We can use a flat array of indices and sort them const cells = new Int32Array(size); let cellCount = 0; for (let i = 0; i < size; i++) { if (elevation[i] >= seaLevel) { cells[cellCount++] = i; } } // Sort only the valid cells const validCells = cells.subarray(0, cellCount); validCells.sort((a, b) => { const elevDiff = elevation[b] - elevation[a]; if (elevDiff !== 0) return elevDiff; // Secondary sort: distance to ocean (descending) // Further from ocean = upstream return oceanDistance[b] - oceanDistance[a]; }); for (let i = 0; i < cellCount; i++) { const idx = validCells[i]; const targetIdx = flowDirection[idx]; if (targetIdx !== -1) { flow[targetIdx] += flow[idx]; } } return flow; } /** * Identify potential river sources */ function findRiverSources( flowMap: Float32Array, elevation: Uint8Array, seaLevel: number, minFlux: number, _rng: seedrandom.PRNG, width: number, height: number ): Point[] { const sources: Point[] = []; const size = width * height; // Candidate selection: High flux but not too high (avoid main rivers starting mid-stream?) // Actually, standard algorithm is: any cell with flux > threshold is part of a river. // Sources are the "start" of these segments. // But usually we just pick high flux points that don't have high flux inputs? // Simplification: Pick random points with high flux, or iterate all. // Let's iterate all cells with flux > threshold and sort by elevation (highest first) const candidates: number[] = []; for (let i = 0; i < size; i++) { if (elevation[i] >= seaLevel && flowMap[i] >= minFlux) { candidates.push(i); } } // Sort by elevation descending candidates.sort((a, b) => elevation[b] - elevation[a]); for (const idx of candidates) { sources.push(fromIndex(idx, width)); } return sources; } /** * Trace a single river from source to sea/confluence */ function traceRiverPath( start: Point, elevation: Uint8Array, flowMap: Float32Array, flowDirection: Int32Array, incomingCounts: Int32Array, seaLevel: number, globalVisited: Uint8Array, // Now Uint8Array pathSetBuffer: Int32Array, // Reused buffer traceId: number, // Unique ID for this trace width: number, height: number ): River { const path: Point[] = [start]; const startIdx = toIndex(start.x, start.y, width); const flux: number[] = [clampFlux(flowMap[startIdx])]; const confluences: Point[] = []; let currentIdx = startIdx; // Mark start pathSetBuffer[currentIdx] = traceId; globalVisited[currentIdx] = 1; const maxSteps = width * height; while (true) { const nextIdx = flowDirection[currentIdx]; if (nextIdx === -1) { // Check for invalid direction index break; } const next = fromIndex(nextIdx, width); if (pathSetBuffer[nextIdx] === traceId) { // Check cycle using traceId console.warn('Cycle detected in river path, terminating'); break; } pathSetBuffer[nextIdx] = traceId; path.push(next); flux.push(clampFlux(flowMap[nextIdx])); globalVisited[nextIdx] = 1; if (incomingCounts[nextIdx] > 1) { confluences.push(next); } // Stop if reached ocean if (elevation[nextIdx] < seaLevel) { break; } if (path.length > maxSteps) { console.warn('River path exceeded grid cells, terminating'); break; } currentIdx = nextIdx; // Update current index } return { id: '', path, flux, confluences, }; } function clampFlux(val: number): number { return Math.max(0, Math.min(10000, val)); // Reasonable cap } /** * Calculate incoming flow count for each cell */ function calculateIncomingCounts(flowDirection: Int32Array, size: number): Int32Array { const counts = new Int32Array(size); for (let i = 0; i < size; i++) { const target = flowDirection[i]; if (target !== -1) { counts[target]++; } } return counts; } /** * Ensure map edges are sea level or lower to allow drainage */ function ensureSeaOutlets(elevation: Uint8Array, seaLevel: number, width: number, height: number): void { let hasOcean = false; const size = width * height; // Check if any cell is already ocean for (let i = 0; i < size; i++) { if (elevation[i] < seaLevel) { hasOcean = true; break; } } // If no ocean, force an outlet at the lowest edge point if (!hasOcean) { let minEdgeElev = 255; let minEdgeIdx = -1; // Check top/bottom edges for (let x = 0; x < width; x++) { const topIdx = x; const bottomIdx = (height - 1) * width + x; if (elevation[topIdx] < minEdgeElev) { minEdgeElev = elevation[topIdx]; minEdgeIdx = topIdx; } if (elevation[bottomIdx] < minEdgeElev) { minEdgeElev = elevation[bottomIdx]; minEdgeIdx = bottomIdx; } } // Check left/right edges for (let y = 0; y < height; y++) { const leftIdx = y * width; const rightIdx = y * width + (width - 1); if (elevation[leftIdx] < minEdgeElev) { minEdgeElev = elevation[leftIdx]; minEdgeIdx = leftIdx; } if (elevation[rightIdx] < minEdgeElev) { minEdgeElev = elevation[rightIdx]; minEdgeIdx = rightIdx; } } // Lower the lowest edge to seaLevel - 1 if (minEdgeIdx !== -1) { elevation[minEdgeIdx] = Math.max(0, seaLevel - 1); } } } /** * Calculate distance to nearest ocean for each cell */ function calculateOceanDistance( elevation: Uint8Array, seaLevel: number, width: number, height: number ): Int32Array { const size = width * height; const distance = new Int32Array(size).fill(2147483647); // Max int32 roughly const queue = new Int32Array(size); // Circular buffer or just large enough array let head = 0; let tail = 0; // Initialize queue with ocean/edge cells let initialQueueSize = 0; for (let i = 0; i < size; i++) { if (elevation[i] < seaLevel) { distance[i] = 0; queue[tail++] = i; initialQueueSize++; } } console.error(`BFS Initializing Queue Size: ${initialQueueSize} for size ${size} (seaLevel: ${seaLevel})`); const neighborDeltas = [ { x: 0, y: -1 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: -1, y: 0 } ]; while (head < tail) { // BFS loop const idx = queue[head++]; const { x, y } = fromIndex(idx, width); const dist = distance[idx]; for (const { x: dx, y: dy } of neighborDeltas) { const nx = x + dx; const ny = y + dy; if (nx >= 0 && nx < width && ny >= 0 && ny < height) { const nIdx = toIndex(nx, ny, width); if (distance[nIdx] > dist + 1) { distance[nIdx] = dist + 1; queue[tail++] = nIdx; } } } } return distance; } /** * Carve spillways to guarantee downhill exits without raising terrain */ function carveSpillways( elevation: Uint8Array, oceanDistance: Int32Array, seaLevel: number, width: number, height: number ): void { const size = width * height; // Process cells furthest from ocean first to propagate low elevations downstream const cells = new Int32Array(size); for (let i = 0; i < size; i++) cells[i] = i; cells.sort((a, b) => oceanDistance[b] - oceanDistance[a]); const neighbors = [ { x: 1, y: 0 }, { x: -1, y: 0 }, { x: 0, y: 1 }, { x: 0, y: -1 }, ]; for (let i = 0; i < size; i++) { const idx = cells[i]; const { x, y } = fromIndex(idx, width); if (elevation[idx] < seaLevel) continue; const currentElev = elevation[idx]; const currentDist = oceanDistance[idx]; // Find best downstream neighbor (closer to ocean) let bestNIdx = -1; let bestNElev = Number.MAX_VALUE; for (const { x: dx, y: dy } of neighbors) { const nx = x + dx; const ny = y + dy; if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue; const nIdx = toIndex(nx, ny, width); const dist = oceanDistance[nIdx]; // Must be strictly closer to ocean if (dist >= currentDist) continue; const elev = elevation[nIdx]; // Pick the neighbor with lowest elevation to minimize carving if (elev < bestNElev) { bestNElev = elev; bestNIdx = nIdx; } } // If we found a downstream neighbor that is higher than current, carve it // This ensures that 'current' can flow into 'bestN' if (bestNIdx !== -1 && bestNElev > currentElev) { elevation[bestNIdx] = currentElev; } else if (bestNIdx === -1) { console.error(`CarveSpillways failed for cell ${x},${y} dist=${currentDist} elev=${currentElev}`); } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Mnehmos/rpg-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server