SOURCE / PINNED RELEASE
Made of little things.
Black Site
- Release
- 666bc7c74ebd…
- Author-recorded commit
- 05e95af79676…
- License
- LICENSE
- Author’s source reference
- nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-d2225ddc391
Archive hash verified: 1544e072e245c4a8…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
'use strict';
// BLACK SITE - static data: weapons, items, loot tables and the level layout.
const TILE = 40;
// Size of the current level in tiles. Set from the built level when a deployment starts.
let MAP_W = 96, MAP_H = 66;
// dmg is per bullet (per pellet for shotguns). speed in px/s, range in px.
const WEAPONS = {
m4a1: { name: 'M4A1', slot: 'primary', cls: 'Assault Rifle', dmg: 24, rpm: 780, auto: true, mag: 30, reserve: 120, reload: 2.1, speed: 2000, range: 1200, mobility: 0.93, kick: 2, snd: 'rifle' },
ak47: { name: 'AK-47', slot: 'primary', cls: 'Assault Rifle', dmg: 30, rpm: 600, auto: true, mag: 30, reserve: 120, reload: 2.4, speed: 2000, range: 1200, mobility: 0.90, kick: 3, snd: 'rifle2' },
mp5: { name: 'MP5', slot: 'primary', cls: 'Submachine Gun', dmg: 18, rpm: 860, auto: true, mag: 30, reserve: 150, reload: 1.8, speed: 1700, range: 750, mobility: 1.00, kick: 1.5, snd: 'smg' },
spas12: { name: 'SPAS-12', slot: 'primary', cls: 'Shotgun', dmg: 14, rpm: 75, auto: false, mag: 8, reserve: 32, reload: 2.8, speed: 1500, range: 480, mobility: 0.95, kick: 6, snd: 'shotgun', pellets: 8, spread: 0.12 },
barrett: { name: 'Barrett .50 Cal', slot: 'primary', cls: 'Sniper Rifle', dmg: 160, rpm: 50, auto: false, mag: 5, reserve: 20, reload: 3.3, speed: 3400, range: 2400, mobility: 0.82, kick: 9, snd: 'sniper', pierce: true },
m249: { name: 'M249 SAW', slot: 'primary', cls: 'Light Machine Gun', dmg: 22, rpm: 820, auto: true, mag: 100, reserve: 200, reload: 4.6, speed: 2000, range: 1300, mobility: 0.80, kick: 2, snd: 'rifle' },
m1911: { name: 'M1911', slot: 'secondary', cls: 'Pistol', dmg: 26, rpm: 420, auto: false, mag: 8, reserve: 48, reload: 1.4, speed: 1600, range: 750, mobility: 1.06, kick: 2, snd: 'pistol' },
glock18: { name: 'Glock 18', slot: 'secondary', cls: 'Machine Pistol', dmg: 14, rpm: 1100, auto: true, mag: 20, reserve: 100, reload: 1.5, speed: 1600, range: 600, mobility: 1.06, kick: 1, snd: 'smg' },
deagle: { name: 'Desert Eagle', slot: 'secondary', cls: 'Hand Cannon', dmg: 48, rpm: 220, auto: false, mag: 7, reserve: 35, reload: 1.9, speed: 1900, range: 900, mobility: 1.02, kick: 5, snd: 'magnum' },
magnum: { name: '.357 Magnum', slot: 'secondary', cls: 'Revolver', dmg: 55, rpm: 160, auto: false, mag: 6, reserve: 36, reload: 2.6, speed: 1900, range: 950, mobility: 1.02, kick: 5, snd: 'magnum' },
};
for (const id in WEAPONS) {
const w = WEAPONS[id];
w.id = id;
w.pellets = w.pellets || 1;
w.spread = w.spread || 0;
}
const PRIMARY_IDS = Object.keys(WEAPONS).filter(id => WEAPONS[id].slot === 'primary');
const SECONDARY_IDS = Object.keys(WEAPONS).filter(id => WEAPONS[id].slot === 'secondary');
const ITEMS = {
frag: { name: 'Frag Grenade', kind: 'throwable', cls: 'Lethal', unlock: 2000, desc: 'Bounces off walls and detonates after 1.9s. Lethal close to the blast.' },
molotov: { name: 'Molotov Cocktail', kind: 'throwable', cls: 'Lethal', unlock: 2500, desc: 'Shatters on impact. The fire burns for 5s and deals 15 damage per second to anyone standing in it. Anyone it catches keeps burning for 3s afterwards (10 damage per second).' },
smoke: { name: 'Smoke Grenade', kind: 'throwable', cls: 'Tactical', unlock: 1500, desc: 'Pops a thick smoke cloud after 1.6s. Nobody - you or the enemy - can see through it.' },
flash: { name: 'Flashbang', kind: 'throwable', cls: 'Tactical', unlock: 1500, desc: 'Blinds and stuns every enemy with a line of sight to the blast. Don\'t look at it.' },
medkit: { name: 'Medkit', kind: 'medical', heal: 100, use: 2.6, desc: 'Restores full health. Takes 2.6s to apply.' },
bandage: { name: 'Bandage', kind: 'medical', heal: 35, use: 1.2, desc: 'Restores 35 health. Takes 1.2s to apply.' },
ammo: { name: 'Ammo Box', kind: 'ammo', desc: 'Opened automatically when you reload with no reserve ammo left. +3 magazines.' },
intel: { name: 'Intel Files', kind: 'valuable', value: 250, desc: 'Classified documents. Worth $250 if you extract with them.' },
drive: { name: 'Encrypted Drive', kind: 'valuable', value: 750, desc: 'Black site server data. Worth $750 if you extract with it.' },
};
const THROWABLE_IDS = ['frag', 'molotov', 'flash', 'smoke'];
// Body armour is bought in the armory (never found on the map). It takes a flat
// amount off every enemy bullet that hits the player.
const ARMOURS = {
soft: { name: 'Soft Armour', tier: 'SOFT', reduce: 5, hp: 50, price: 1000, col: '#6b6a55' },
l1: { name: 'Level I Armour', tier: 'I', reduce: 7, hp: 75, price: 2500, col: '#5a6444' },
l2: { name: 'Level II Armour', tier: 'II', reduce: 9, hp: 125, price: 4000, col: '#46553a' },
l3: { name: 'Level III Armour', tier: 'III', reduce: 11, hp: 175, price: 6000, col: '#3a4148' },
l4: { name: 'Level IV Armour', tier: 'IV', reduce: 13, hp: 225, price: 8500, col: '#25292e' },
};
const ARMOUR_IDS = Object.keys(ARMOURS);
// The damage armour absorbs comes off its own health; at 0 it is broken and stops protecting.
// Owned armour can be upgraded 10 times for +5 health each.
const MAX_ARMOUR_LEVEL = 10;
const ARMOUR_UPGRADE_COST = 500;
const ARMOUR_UPGRADE_HP = 5;
// Weapon upgrades: every level adds a small damage boost; max level unlocks a laser sight.
// Difficulty. Slider order in the menu, left to right: hard, normal, easy.
// enemyDmg multiplies enemy bullet damage, extraDmg is added to every enemy bullet that hits,
// fireDelay stretches the time between enemy shots, money multiplies every money reward,
// extractTime is how long the helicopter takes to arrive (seconds) after it is called.
const DIFFICULTIES = {
hard: { name: 'HARD', extractTime: 60, enemyDmg: 1, extraDmg: 8, fireDelay: 1, money: 3, desc: 'Enemy bullets do +8 damage. All money rewards x3.' },
normal: { name: 'NORMAL', extractTime: 60, enemyDmg: 1, extraDmg: 0, fireDelay: 1, money: 1, desc: 'The standard experience.' },
easy: { name: 'EASY', extractTime: 60, enemyDmg: 0.5, extraDmg: 0, fireDelay: 1.6, money: 1 / 3, desc: 'Enemies do half damage and shoot slower. All money rewards are 1/3 of normal.' },
};
const DIFFICULTY_ORDER = ['hard', 'normal', 'easy'];
// Playable characters. Each has its own look and perks (applied in game.js).
const CHARACTERS = {
assaulter: {
name: 'ASSAULTER', role: 'Infiltration',
perks: ['Footsteps are much quieter - enemies hear you far less', 'Extra grenade slot: a grenade in it can be thrown 3 times'],
pal: { body: '#3a3d40', vest: '#1c1e20', helmet: '#2a2c2f', sleeve: '#33363a', skin: '#c79b74', boot: '#151617', gun: '#141516', accent: '#a5d63f', pack: '#26282a', camo: '#202224' },
},
gunner: {
name: 'MACHINE GUNNER', role: 'Suppression',
perks: ['M249: +50 rounds in every belt', 'M249: reloads 40% faster'],
pal: { body: '#56603e', vest: '#3a4128', helmet: '#4d5636', sleeve: '#4f5838', skin: '#b88a64', boot: '#1f211c', gun: '#18191b', accent: '#e8c547', pack: '#454d30', camo: '#3a4229' },
},
sniper: {
name: 'SNIPER', role: 'Marksman',
perks: ['Barrett: +35% rate of fire', 'Barrett: 8-round magazine and 40 reserve rounds'],
pal: { body: '#4a5a34', vest: '#3a4a28', helmet: '#56683a', sleeve: '#506038', skin: '#c79b74', boot: '#1f211c', gun: '#18191b', accent: '#7fe36a', pack: '#3e4c2c', camo: '#6b7a44' },
},
cutthroat: {
name: 'CUT THROAT', role: 'Close quarters',
perks: ['[Q] throw a knife - kills in one hit', '[Q] up close: knife slash - kills in one hit', 'Knife kills are silent, and the victim is left crawling'],
pal: { body: '#2e2a2a', vest: '#1a1717', helmet: '#262222', sleeve: '#2a2626', skin: '#c79b74', boot: '#121010', gun: '#121314', accent: '#c3352a', pack: '#1e1b1b', camo: '#3a1e1c' },
},
hauler: {
name: 'HAULER', role: 'Logistics',
perks: ['6 item slots instead of 4', 'Heavy pack: moves at 25% speed'],
pal: { body: '#5e4a33', vest: '#3f3122', helmet: '#4f3f2b', sleeve: '#574530', skin: '#b88a64', boot: '#1f1a14', gun: '#18191b', accent: '#e0913a', pack: '#6b5434', camo: '#46372a' },
},
};
const CHARACTER_IDS = Object.keys(CHARACTERS);
const MAX_WEAPON_LEVEL = 20;
const LEVEL_DMG_BONUS = 0.03;
// Weapons whose upgrades raise fire rate (per level) instead of damage.
const RPM_UPGRADES = { barrett: 0.02 };
function upgradeCost(level) { return level === 0 ? 1500 : 2000; }
const LOOT_TABLE = [['bandage', 24], ['medkit', 9], ['frag', 12], ['molotov', 8], ['flash', 8], ['smoke', 8], ['ammo', 20], ['intel', 12], ['drive', 5]];
function pick(a) { return a[(Math.random() * a.length) | 0]; }
function makeItem(id) { return { type: 'item', id }; }
function makeWeapon(id, partial) {
const w = WEAPONS[id];
if (!partial) return { type: 'weapon', id, mag: w.mag, reserve: w.reserve };
return { type: 'weapon', id, mag: Math.ceil(w.mag * (0.3 + Math.random() * 0.7)), reserve: w.mag * (1 + ((Math.random() * 2) | 0)) };
}
function rollItem() {
let total = 0;
for (const [, wt] of LOOT_TABLE) total += wt;
let r = Math.random() * total;
for (const [id, wt] of LOOT_TABLE) if ((r -= wt) < 0) return makeItem(id);
return makeItem('bandage');
}
function rollWeapon() { return makeWeapon(pick(Object.keys(WEAPONS)), true); }
function itemDef(it) { return it.type === 'weapon' ? WEAPONS[it.id] : ITEMS[it.id]; }
// The maps themselves are JSON files in src/game/maps (format: spec/map-format.md, schema:
// spec/schemas/black-site-map.schema.json). vite.config.ts injects them, in campaign order, as
// `const MAPS = [...]` ahead of this file.
//
// Tile chars in a built level: '#' wall, '.' ground, '=' road/pavement, ',' indoor floor,
// 'w' water, 'c' crate, 'h' sandbags, 'v' vehicle wreck, 'p' pipe, 'K' shipping container,
// 'o' fuel tank, 'b' barrel, 'T' tree. Crates, sandbags, wrecks, pipes, barrels and trees are
// low cover: they stop movement and bullets but not line of sight. Walls, containers and tanks
// block everything. Water only blocks movement.
//
// A map is authored on a `size` grid (default 64x44). With `generate.expand` the level is 1.5x
// that: the authored layout keeps its size, and two empty bands (one vertical, one horizontal)
// are opened up through it. Anything crossing a band (roads, water, compound walls) is extended
// across it; everything else is shifted. Generated buildings and cover then fill open ground.
const DEFAULT_MAP_SIZE = [64, 44];
// Small seeded RNG (mulberry32) so a map's generated content is the same every time it is built.
function seededRng(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6D2B79F5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hashStr(s) { let h = 2166136261; for (const ch of s) { h ^= ch.charCodeAt(0); h = Math.imul(h, 16777619); } return h >>> 0; }
// Tiles that block walking (used when placing things).
const BLOCKING_TILES = '#chvpKowbT';
// Pick the band position: the line closest to the middle that doesn't cut through a building,
// container, tank or wreck (those must keep their size).
function findSplit(M, axis, len) {
const lo = axis === 0 ? 0 : 1;
const solidRects = [
...(M.buildings || []).map(b => b.r), ...(M.containers || []),
...(M.tanks || []).map(([x, y]) => [x, y, x + 2, y + 2]),
...(M.wrecks || []).map(r => (r.length >= 4 ? r : [r[0], r[1], r[0], r[1]])),
];
const ok = s => solidRects.every(r => !(r[lo] < s && s <= r[lo + 2]));
for (let d = 0; d < len / 2; d++) for (const s of [Math.round(len / 2) + d, Math.round(len / 2) - d]) if (ok(s)) return s;
return Math.round(len / 2);
}
// Builds a playable level from a built-in map id or a map object (see spec/map-format.md).
function buildLevel(idOrMap) {
const M = typeof idOrMap === 'string' ? MAPS.find(m => m.id === idOrMap) || MAPS[0] : idOrMap;
const [AW, AH] = M.size || DEFAULT_MAP_SIZE;
const gen = M.generate || {};
const W = gen.expand ? Math.round(AW * 1.5) : AW, H = gen.expand ? Math.round(AH * 1.5) : AH;
const DX = W - AW, DY = H - AH;
const SX = gen.expand ? findSplit(M, 0, AW) : Infinity, SY = gen.expand ? findSplit(M, 1, AH) : Infinity;
const mx = v => (v >= SX ? v + DX : v), my = v => (v >= SY ? v + DY : v);
const mapRect = r => [mx(r[0]), my(r[1]), mx(r.length >= 4 ? r[2] : r[0]), my(r.length >= 4 ? r[3] : r[1])];
const mapPt = ([x, y, ...rest]) => [mx(x), my(y), ...rest];
// runs that keep their size when shifted (piers and cover never stretch across a band)
const shiftRun = r => { const [x, y] = mapPt(r); return r.length >= 4 ? [x, y, x + r[2] - r[0], y + r[3] - r[1]] : [x, y, x, y]; };
// authored runs touching the old map edge reach the new map edge
const edge = r => { const q = mapRect(r); if (q[2] >= W - 2 - 1 && (r.length >= 4 ? r[2] : r[0]) >= AW - 2) q[2] = W - 2; if (q[3] >= H - 2 - 1 && (r.length >= 4 ? r[3] : r[1]) >= AH - 2) q[3] = H - 2; return q; };
const g = Array.from({ length: H }, () => new Array(W).fill('.'));
const inb = (x, y) => x >= 0 && y >= 0 && x < W && y < H;
const set = (x, y, c) => { if (inb(x, y)) g[y][x] = c; };
const fill = (x0, y0, x1, y1, c) => { for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) set(x, y, c); };
const place = (list, c, fn = mapRect) => { const out = []; for (const r of list || []) { const q = fn(r); fill(q[0], q[1], q[2], q[3], c); out.push(q); } return out; };
place(M.water, 'w', edge);
const paved = place(M.paved, '=', shiftRun);
const roads = place(M.roads, '=', edge);
const buildings = [];
const addBuilding = (R, walls, doors) => {
fill(R[0], R[1], R[2], R[3], '#');
fill(R[0] + 1, R[1] + 1, R[2] - 1, R[3] - 1, ',');
for (const w of walls) fill(w[0], w[1], w[2], w[3], '#');
for (const [x, y] of doors) set(x, y, ',');
buildings.push({ r: R });
};
for (const b of M.buildings || []) addBuilding(mapRect(b.r), (b.walls || []).map(mapRect), (b.doors || []).map(mapPt));
place(M.walls, '#');
fill(0, 0, W - 1, 0, '#'); fill(0, H - 1, W - 1, H - 1, '#');
fill(0, 0, 0, H - 1, '#'); fill(W - 1, 0, W - 1, H - 1, '#');
const start = mapPt(M.start), exfil = mapPt(M.exfil);
const enemies = (M.enemies || []).map(mapPt), caches = (M.caches || []).map(mapPt);
const R = seededRng((gen.seed !== undefined ? gen.seed : hashStr(M.id)) ^ 0x51ed270b);
// new buildings (with guards and supplies) in the opened-up space; they keep clear of the
// authored cover too, which is placed afterwards
const probe = g.map(row => row.slice());
for (const [list, fn] of [[M.crates, shiftRun], [M.wrecks, shiftRun], [M.sandbags, shiftRun], [M.pipes, mapRect], [M.containers, mapRect]]) {
for (const r of list || []) { const q = fn(r); for (let y = q[1]; y <= q[3]; y++) for (let x = q[0]; x <= q[2]; x++) if (inb(x, y)) probe[y][x] = 'c'; }
}
for (const p of M.tanks || []) { const [tx, ty] = mapPt(p); for (let y = ty; y <= ty + 2; y++) for (let x = tx; x <= tx + 2; x++) probe[y][x] = 'o'; }
generateBuildings(g, probe, R, [start, exfil], addBuilding, enemies, caches, gen.buildings || 0);
// floor layer (before cover objects) for the renderer
const floor = g.map(row => row.slice());
const containers = (M.containers || []).map(r => { const q = mapRect(r); fill(q[0], q[1], q[2], q[3], 'K'); return [...q, r[4]]; });
const tanks = (M.tanks || []).map(p => { const [tx, ty] = mapPt(p); fill(tx, ty, tx + 2, ty + 2, 'o'); return [tx, ty, 3]; });
place(M.pipes, 'p');
place(M.crates, 'c', shiftRun);
place(M.wrecks, 'v', shiftRun);
place(M.sandbags, 'h', shiftRun);
generateCover(g, R, M, [start, exfil], gen.cover || 0);
const open = (x, y) => inb(x, y) && !BLOCKING_TILES.includes(g[y][x]);
const nudge = ([x, y, ...rest]) => {
if (open(x, y)) return [x, y, ...rest];
for (let r = 1; r < 6; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) if (open(x + dx, y + dy)) return [x + dx, y + dy, ...rest];
return [x, y, ...rest];
};
return {
id: M.id, name: M.name, theme: M.theme, w: W, h: H, grid: g, floor, spawns: M.spawns || {},
buildings, roads, paved, containers, tanks,
start: nudge(start), exfil: nudge(exfil),
enemies: enemies.map(nudge), caches: caches.map(nudge),
};
}
// Extra buildings: rooms of normal size with 2-wide doors on several sides, sometimes split by
// an interior wall with a doorway. Only placed on open ground with a clear margin all round,
// so they never block a road or another building's door.
function generateBuildings(g, probe, R, keep, addBuilding, enemies, caches, count) {
const H = g.length, W = g[0].length;
const clear = (x0, y0, x1, y1) => {
if (x0 < 2 || y0 < 2 || x1 > W - 3 || y1 > H - 3) return false;
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) if (g[y][x] !== '.' || probe[y][x] !== '.') return false;
return true;
};
let made = 0;
for (let i = 0; i < 4000 && made < count; i++) {
const shed = R() < 0.3;
const w = shed ? 5 + Math.floor(R() * 3) : 9 + Math.floor(R() * 7), h = shed ? 5 + Math.floor(R() * 2) : 7 + Math.floor(R() * 5);
const x0 = 2 + Math.floor(R() * (W - w - 4)), y0 = 2 + Math.floor(R() * (H - h - 4));
const x1 = x0 + w - 1, y1 = y0 + h - 1;
if (!clear(x0 - 2, y0 - 2, x1 + 2, y1 + 2)) continue;
if (keep.some(([kx, ky]) => kx >= x0 - 6 && kx <= x1 + 6 && ky >= y0 - 6 && ky <= y1 + 6)) continue;
const walls = [], doors = [];
let split = null;
if (!shed && w >= 11 && R() < 0.7) { // interior wall with a 2-wide doorway
split = x0 + Math.floor(w / 2);
const gy = y0 + 2 + Math.floor(R() * (h - 5));
if (gy - 1 >= y0 + 1) walls.push([split, y0 + 1, split, gy - 1]);
if (gy + 2 <= y1 - 1) walls.push([split, gy + 2, split, y1 - 1]);
}
// doors: one per chosen side, away from corners and the interior wall
const sides = ['top', 'bottom', 'left', 'right'].sort(() => R() - 0.5).slice(0, shed ? 1 : 2 + Math.floor(R() * 2));
if (split !== null && !sides.includes('left') && !sides.includes('right')) sides.push(R() < 0.5 ? 'left' : 'right');
for (const side of sides) {
if (side === 'top' || side === 'bottom') {
let dx;
for (let k = 0; k < 10; k++) { dx = x0 + 2 + Math.floor(R() * (w - 5)); if (split === null || (dx + 1 < split - 0 && dx + 1 !== split) || dx > split) break; }
if (split !== null && (dx === split || dx + 1 === split)) dx = split + 1 <= x1 - 2 ? split + 1 : split - 2;
const y = side === 'top' ? y0 : y1;
doors.push([dx, y], [dx + 1, y]);
} else {
const dy = y0 + 2 + Math.floor(R() * (h - 5));
const x = side === 'left' ? x0 : x1;
doors.push([x, dy], [x, dy + 1]);
}
}
addBuilding([x0, y0, x1, y1], walls, doors);
// guards and supplies inside
const inside = () => [x0 + 1 + Math.floor(R() * (w - 2)), y0 + 1 + Math.floor(R() * (h - 2))];
const guards = shed ? (R() < 0.5 ? 1 : 0) : 1 + Math.floor(R() * 2);
for (let k = 0; k < guards; k++) { const p = inside(); if (g[p[1]][p[0]] === ',') enemies.push(p); }
if (R() < (shed ? 0.6 : 0.8)) { const p = inside(); if (g[p[1]][p[0]] === ',') caches.push(R() < 0.25 ? [...p, 'weapons'] : p); }
// a patrol outside the bigger buildings
if (!shed && R() < 0.6) enemies.push([x0 + Math.floor(w / 2), y1 + 2, 'p']);
made++;
}
}
// Scatter cover (barrels, crates, trees, sandbag nests...) across open ground. Only where there
// is open space all around, so it never closes off a passage.
function generateCover(g, R, M, keep, count) {
const H = g.length, W = g[0].length;
const kinds = M.theme.clutter || ['b', 'c'];
const clearAround = (x, y, r) => {
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
const yy = y + dy, xx = x + dx;
if (yy < 1 || xx < 1 || yy >= H - 1 || xx >= W - 1) return false;
if (g[yy][xx] !== '.') return false;
}
return true;
};
let placed = 0;
for (let i = 0; i < 3000 && placed < count; i++) {
const x = 2 + Math.floor(R() * (W - 4)), y = 2 + Math.floor(R() * (H - 4));
if (!clearAround(x, y, 2)) continue;
if (keep.some(([kx, ky]) => Math.hypot(kx - x, ky - y) < 6)) continue;
const kind = kinds[Math.floor(R() * kinds.length)];
g[y][x] = kind;
if (kind !== 'T' && R() < 0.55) {
const [dx, dy] = R() < 0.5 ? [1, 0] : [0, 1];
g[y + dy][x + dx] = kind;
if (kind === 'h' && R() < 0.5) g[y + dy * 2][x + dx * 2] = kind;
}
placed++;
}
}
