@onimaxxing/worldgen
v0.16.0
Published
WASM entry point for ONI worldgen
Readme
@onimaxxing/worldgen
A WASM build of the Oxygen Not Included worldgen engine. Returns the same cluster the game would generate for a given coordinate.
What it covers
- Cluster layout and starmap (world positions, POIs, rocket destinations).
- Per-world element grid, biome polygons, and world traits.
- Geyser, building, pickupable, and other entity spawn positions.
- Per-cell mass, temperature, and disease (raw after
worldgen.generate; settled afterworldgen.advance). - Both basegame and Spaced Out clusters, including story traits, mixing codes, and DLC toggles.
Output is verified cell-by-cell against snapshots captured from the
running game, with a slight modification to force determinism during
the physics pass. The physics pass itself (temperatures, gas/liquid
displacement, critter and plant placement) is exposed as
worldgen.advance.
Install
# Browsers + ESM bundlers
npm install @onimaxxing/worldgen
# Node scripts, CLIs, SSR
npm install @onimaxxing/worldgen-nodeSame API on both. The web package ships both a serial build and a
parallel (rayon + SharedArrayBuffer) build; init() feature-detects
the host and picks one. The Node package is serial-only.
Quick start
Browsers:
import init, { worldgen } from
'@onimaxxing/worldgen';
await init();
const map = worldgen.generate('V-SNDST-C-42-0-4A-MUWF1');
// map.worlds[0].element_idx element grid, width * height
// map.worlds[0].biome_cells overworld biome polygons
// map.worlds[0].geysers geyser spawn positions
// map.element_table element names by index
// map.starmap hex grid world locationsNode:
// ESM
import { worldgen } from
'@onimaxxing/worldgen-node';
const map = worldgen.generate('V-SNDST-C-42-0-4A-MUWF1');// CommonJS
const { worldgen } =
require('@onimaxxing/worldgen-node');
const map = worldgen.generate('V-SNDST-C-42-0-4A-MUWF1');init() is a no-op on Node (WASM is loaded synchronously at import
time), so the same code runs on both targets.
Types ship in the package .d.ts:
import type { MapData, SettleSnapshot } from
'@onimaxxing/worldgen';Parallel vs serial (web only)
The web package ships two complete wasm-bindgen builds under
serial/ and parallel/. init() picks one at runtime:
- Parallel: loaded when
globalThis.crossOriginIsolated === trueandSharedArrayBufferis available. Runs the settle sim across a rayon worker pool (navigator.hardwareConcurrencyworkers by default; override withinit({ threads: N })). Requires COOP/COEP response headers on the page. - Serial: loaded otherwise. No worker pool, no shared memory, no headers required.
Loading the WASM binary
await init() with no arguments resolves the .wasm via the
bundler-rewritten new URL('oni_wasm_bg.wasm', import.meta.url)
pattern inside the selected build. For hosts that don't rewrite
that pattern, pass the URL explicitly:
// Served from your own origin
await init({ module_or_path: '/wasm/oni_wasm_bg.wasm' });
// Pre-fetched bytes or a compiled WebAssembly.Module
await init({ module_or_path: await fetch(url) });
await init({ module_or_path: myWebAssemblyModule });The URL is interpreted by the selected build; to pin a specific
.wasm, also pass threads: 'serial'.
Coordinates
Standard game coords. Examples:
SNDST-A-42-0-0-0basegame Sandstone, seed 42, no stories or mixingV-SNDST-C-42-0-4A-MUWF1Spaced Out Vanilla Sandstone, all stories and mixingCER-C-100-0-4A-MUWF1Spaced Out Ceres, seed 100
API
The public surface is a default init export and a worldgen
singleton wrapping the one-slot cluster cache. worldgen.generate(coord)
populates the cache; every other method operates on whatever's
currently in it.
Generation
worldgen.generate(coord: string): MapDataGenerate a cluster from a game coordinate and return the decoded
map (see MapData shape). Caches the cluster
internally, replacing anything previously cached.
worldgen.entities(): EntitySpawnersRe-run ambient mob spawning against the cached cluster's current
cell state and return the refreshed entity lists. Call after
worldgen.advance chunks when settled liquid/gas flow may have
invalidated earlier mob placements. Requires a prior
worldgen.generate. Template-placed entities (geysers, oil wells,
props) persist across calls.
worldgen.version(): stringThe ONI game build this WASM was baselined against, e.g. "737195".
Settle simulation
Worldgen takes hundreds of milliseconds; settling takes several seconds. The API is split so you can show a preview first and fill in settled data progressively.
worldgen.advance(targetTick: number): SettleSnapshotAdvance the cached cluster's settle sim to targetTick (in
1..=499) and return a decoded snapshot at that tick. Call
repeatedly with increasing ticks to paint intermediate frames.
The settle runs 499 ticks total; the last call with
targetTick = 499 finalises the cluster.
const preview = worldgen.generate(coord);
renderPreview(preview);
for (let tick = 50; tick < 499; tick += 50) {
const snapshot = worldgen.advance(tick);
renderFrame(snapshot);
}
const final = worldgen.advance(499);
renderFrame(final);
worldgen.clear();Throws if the cache is empty or targetTick is out of range.
Editor
The active settings live in a single slot exposed as a { path → JSON-string }
files map. The path keys are Klei's config paths (e.g.
expansion1::worldgen/clusters/CGMCluster.yaml); each value is the
JSON-encoded file body.
const files = worldgen.snapshot.files();
const cluster = JSON.parse(files['expansion1::worldgen/clusters/CGMCluster.yaml']);
//...mutate cluster...
files['expansion1::worldgen/clusters/CGMCluster.yaml'] = JSON.stringify(cluster);
worldgen.snapshot.load(files);
// next worldgen.generate(...) uses the mutated settings| Method | Description |
|---|---|
| worldgen.snapshot.loadVanilla() | Reset SETTINGS to the embedded baseline. Strict mode. |
| worldgen.snapshot.loadVanillaExtensions() | Same, Extensions mode. Accepts mod-defined enum extensions on subsequent merges. |
| worldgen.snapshot.load(files) | Replace SETTINGS with files. Strict; atomic. Throws and leaves SETTINGS untouched on invalid input. |
| worldgen.snapshot.loadExtensions(files) | Extensions-mode counterpart of load. |
| worldgen.snapshot.merge(files) | Last-wins merge into the current SETTINGS. Auto-loads vanilla if empty. The typical mod-overlay entry point. |
| worldgen.snapshot.remove(paths) | Drop paths from the current SETTINGS. |
| worldgen.snapshot.files() | Fresh deep-copy of the current SETTINGS. load(files()) is a no-op round-trip. |
| worldgen.snapshot.checksum() | Stable hex hash of the current snapshot. Same content → same hash regardless of load history. |
| worldgen.snapshot.schemaVersion() | The ONI game build (e.g. "737195"). Persist alongside snapshot bytes for migration checks. |
For ingesting mod YAML, the package also exports parseKleiYaml:
import { parseKleiYaml } from '@onimaxxing/worldgen';
import { readFileSync } from 'node:fs';
const json = parseKleiYaml(readFileSync('mod/cluster.yaml', 'utf8'));
worldgen.snapshot.merge({
'expansion1::worldgen/clusters/MyMod.yaml': JSON.stringify(json),
});Any load* / merge / remove invalidates the cluster cache.
Cache lifecycle
worldgen.clear(): void
worldgen.reset(): voidclear()drops the cached generated cluster; snapshot edits persist.reset()drops both the cluster cache and the snapshot; nextgenerate()reloads the embedded stock settings from scratch.
Cache semantics:
- One slot for the cluster cache. New
generate()replaces it. - Per-worker. Both caches are thread-local in WASM, so each Web Worker (or Node worker thread) has its own independent state.
- Snapshot edits evict the cluster cache. Mutated settings make the cached cluster stale.
Running in a web worker
The WASM module is several megabytes and worldgen can block the thread for hundreds of milliseconds; settling several seconds. Run it in a worker to keep the main thread responsive.
// worker.ts
import init, { worldgen } from
'@onimaxxing/worldgen';
await init();
self.onmessage = async ({ data: { coord } }) => {
const preview = worldgen.generate(coord);
self.postMessage({ kind: 'preview', data: preview });
for (let tick = 50; tick < 499; tick += 50) {
const snapshot = worldgen.advance(tick);
self.postMessage({ kind: 'frame', data: snapshot });
}
self.postMessage({ kind: 'frame', data: worldgen.advance(499) });
worldgen.clear();
};// main thread
const worker = new Worker(new URL('./worker.ts', import.meta.url),
{ type: 'module' });
worker.onmessage = ({ data: { kind, data } }) => {
if (kind === 'preview') renderPreview(data);
if (kind === 'frame') renderFrame(data);
};
worker.postMessage({ coord: 'V-SNDST-C-42-0-4A-MUWF1' });MapData shape
interface MapData {
coordinate: string;
seed: number;
cluster_id: string;
coordinate_prefix: string; // short tag e.g. "SNDST-C", "V-BAD-C", "VOLCA"
element_table: string[]; // element names indexed by element_idx
starmap: StarmapEntry[]; // Spaced Out hex grid world locations
starmap_pois: StarmapPoi[]; // Spaced Out non-asteroid hex POIs
vanilla_starmap: VanillaStarmapEntry[]; // basegame rocket destinations
worlds: WorldMapData[];
failure: WorldgenFailure | null; // populated on fatal worldgen error
telemetry: WorldgenEvent[]; // fail-slow warnings (empty on clean runs)
}
interface WorldgenFailure {
stage: string; // pipeline stage that reported the error
world_index: number; // -1 for cluster-level failures
message: string;
}
interface WorldgenEvent {
category: string; // e.g. "layout", "mob_spawning", "template_rules"
message: string; // per-world entries prefixed with "world[N]:"
}
interface WorldMapData {
name: string; // world config path
width: number;
height: number;
is_starting: boolean;
world_traits: string[];
element_idx: Uint16Array; // u16 per cell, row-major (width * height)
mass: Float32Array; // f32 per cell
temperature: Float32Array; // f32 per cell
disease_idx: Uint8Array; // u8 per cell, 255 = none
disease_count: Int32Array; // i32 per cell
backwall_element_idx: Uint16Array; // U59 backwall layer; Vacuum-filled outside DLC5 aquatic biomes
backwall_mass: Float32Array; // f32 per cell, kg; 0 on Vacuum cells
backwall_temperature: Float32Array; // f32 per cell, K; 0 on Vacuum cells
zone_idx: Uint16Array; // u16 per cell -> zone_table; 0xFFFF = no biome polygon
zone_table: string[]; // distinct ZoneType strings (same vocab as biome_cells[].zone_type)
subworld_idx: Uint16Array; // u16 per cell -> subworld_table; 0xFFFF = no biome polygon
subworld_table: string[]; // distinct subworld type paths (same vocab as biome_cells[].type)
biome_cells: BiomeCell[];
geysers: GeyserSpawn[];
buildings: EntitySpawn[];
pickupables: EntitySpawn[];
other_entities: EntitySpawn[];
}
interface BiomeCell {
id: number;
type: string; // subworld type path
zone_type: ZoneType | null; // see ZoneType union exported from the package
x: number;
y: number;
poly: number[]; // flat [x0,y0,x1,y1,...]
tags: string[]; // layout tags ("AtSurface", "AtDepths", distance/zone tags, subworld tags)
}
interface EntitySpawn {
tag: string; // game prefab name
cell: number; // grid cell index
x: number; // cell % width
y: number; // cell / width
connections?: number; // TemplatePrefab.connections bitmask (conduit-aware buildings only)
rotationOrientation?: string; // TemplatePrefab.rotationOrientation, e.g. "R90", "R270", "FlipH"
}
interface GeyserSpawn extends EntitySpawn {
type: string; // resolved geyser template name
// Present when geyser stats were rolled:
scaled_rate?: number;
scaled_iter_len?: number;
scaled_iter_pct?: number;
scaled_year_len?: number;
scaled_year_pct?: number;
}
interface StarmapEntry {
world_index: number;
q: number; r: number; // hex grid coords
}
interface StarmapPoi {
poi_type: string; // e.g. "HarvestableSpacePOI_*", "ArtifactSpacePOI"
q: number; r: number;
// Only on harvestable POIs:
capacity_roll?: number;
recharge_roll?: number;
total_capacity?: number;
recharge_time?: number;
}
// Basegame rocket destinations. Empty on Spaced Out clusters.
interface VanillaStarmapEntry {
type: string; // destination type id
distance: number; // distance tier
}SettleSnapshot shape
worldgen.advance returns typed-array views over cell data, not a
JSON array. One snapshot covers every world in the cluster.
interface SettleSnapshot {
tick: number; // 1..=499
worlds: SettleWorld[];
}
interface SettleWorld {
width: number;
height: number;
element_idx: Uint16Array;
mass: Float32Array;
temperature: Float32Array;
disease_idx: Uint8Array; // 255 = none
disease_count: Int32Array;
backwall_element_idx: Uint16Array; // U59 backwall layer; Vacuum-filled outside DLC5 aquatic biomes
backwall_mass: Float32Array; // f32 per cell, kg; 0 on Vacuum cells
backwall_temperature: Float32Array; // f32 per cell, K; 0 on Vacuum cells
}Performance
Worldgen runtime, same machine both rows. Coordinate:
V-SNDST-C-42-0-4A-MUWF1, the Spaced Out Vanilla Sandstone cluster
at seed 42, with every story trait (4A) and every DLC mixing
option (MUWF1) enabled. 8 worlds, ~170,000 cells total.
Time per seed:
| Runtime | Time | |---|---| | In-game World Generation | 10.4 s | | This package (Node 24) | 0.48 s |
WASM worldgen runs roughly 22x faster than the game's C# worldgen.
Memory usage (one worldgen at a time, measured on the same cluster, resident memory only):
| Runtime | Working set | |---|---| | In-game World Generation | ~3.3 GB | | This package (Node 24) | ~0.2 GB |
WASM worldgen uses roughly 15x less memory than the game's C# worldgen.
Package size is dominated by the two WASM modules; only one loads at runtime. Load it in a web worker to keep download and instantiate off the main thread.
License
MIT.
