Back to Black Site
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.

spec/map-format.md
# Black Site map format (v1)

This is the contract between Black Site and any map editor. A map is one JSON document.

| File | Role |
|---|---|
| `schemas/black-site-map.schema.json` | JSON Schema (draft 2020-12) for structure and types |
| `lib/validate-map.mjs` | Dependency-free `validateMap(map)`. It mirrors the schema and adds checks a schema can't express (bounds, rect corner order, doors on walls) |
| `examples/maps/outpost.json` | Small hand-built example with no procedural content |

An editor should copy the schema and validator, and treat a map as valid only when `validateMap(map)` returns `[]`.

In the game's repository, the built-in maps are `src/game/maps/*.json`, and `buildLevel(idOrMap)` in `src/game/data.js` turns a map into a tile grid. `pnpm test:spec` checks that every map validates and builds, and that the exfil can be reached from the start.

## Coordinates

- Integer tile coordinates on the authored grid `size` (default `[64, 44]`). The origin is top-left, x goes right, y goes down. A tile is 40 px in game.
- **Point**: `[x, y]`.
- **Rect**: `[x0, y0, x1, y1]`. Both corners are inclusive, with `x0 <= x1` and `y0 <= y1`. `[5, 5, 5, 5]` is one tile.
- **Area**: a point or a rect. Every terrain and cover layer accepts both.
- The outer ring of tiles (x = 0, y = 0, x = w-1, y = h-1) always becomes wall. Units (`start`, `exfil`, `enemies`, `caches`) must be inside it.

## Document

```jsonc
{
  "$schema": "../../schemas/black-site-map.schema.json", // optional, for editor tooling
  "format": "black-site-map", "version": 1,                // required
  "id": "outpost",          // required: ^[a-z0-9][a-z0-9-]{0,31}$, also the default RNG seed
  "name": "OUTPOST",        // required: 1-32 chars, shown in the HUD
  "desc": "One-line briefing.",
  "size": [40, 30],         // authored grid, 16-256 each, default [64, 44]
  "theme": { ... },         // required
  "generate": { ... },      // optional seeded procedural content (omit for an exact map)
  "spawns": { ... },        // optional per-run random extras
  "start": [3, 26], "exfil": [35, 4],   // required
  "water": [], "paved": [], "roads": [], "walls": [], "buildings": [],
  "containers": [], "tanks": [], "pipes": [], "crates": [], "wrecks": [], "sandbags": [],
  "enemies": [], "caches": []
}
```

Unknown fields are rejected, so a typo can't be silently ignored.

### `theme`

| Field | Type | Effect |
|---|---|---|
| `ground` | `[r, g, b]` | Base ground colour |
| `road` | `[r, g, b]` | Road and pavement colour |
| `style` | `grass` \| `concrete` \| `sand` \| `snow` | Ground texture, wall tint and footstep sound |
| `markings` | boolean, default `true` | `false` draws roads as dirt tracks with no lane markings, and they sound like sand |
| `clutter` | list of `b` `c` `T` `h`, default `["b", "c"]` | What `generate.cover` scatters. Repeat an entry to weight it |

### `generate` (seeded, so the same on every build)

| Field | Default | Effect |
|---|---|---|
| `expand` | `false` | Build at 1.5× `size` (64×44 becomes 96×66). One vertical and one horizontal empty band open near the middle, at lines that don't cut a building, container, tank or wreck. Roads, water, walls and pipes that cross a band stretch across it, and roads and water touching the far edge keep touching it. Everything else shifts. |
| `buildings` | `0` | Up to this many extra buildings (sheds 5–7 wide, houses 9–15 wide), each with doors, guards and usually a cache. They only go on clear plain ground, at least 6 tiles from `start`/`exfil`. |
| `cover` | `0` | Up to this many `theme.clutter` clusters (1–3 tiles) on plain ground with a clear 2-tile margin, at least 6 tiles from `start`/`exfil`. |
| `seed` | hash of `id` | RNG seed (uint32) |

The built-in maps use `{ "expand": true, "buildings": 16, "cover": 80 }`. With no `generate` block the level is exactly what was authored, which is the mode an editor should preview in.

### `spawns` (random on every deployment)

| Field | Default | Effect |
|---|---|---|
| `extraEnemies` | `0` | Extra enemies as a fraction of the placed ones (built-ins use `0.2`). They spawn on tiles reachable from `start` and never within 18 tiles of it. 60% of them patrol. |
| `extraCaches` | `0` | Extra caches on indoor floor tiles reachable from `start`, more than 8 tiles from it (built-ins use `3`) |

## Placeable assets

| Layer | Entry | Tile | Movement | Bullets | Sight |
|---|---|---|---|---|---|
| `water` | area | `w` | blocked | pass | pass |
| `paved` | area | `=` | open (wood footsteps) | pass | pass |
| `roads` | area | `=` | open | pass | pass |
| `walls` | area | `#` | blocked | blocked | blocked |
| `buildings` | `{ r, doors?, walls? }` | `#` ring, `,` floor | | | |
| `containers` | `[x0, y0, x1, y1, "#rrggbb"]` | `K` | blocked | blocked | blocked |
| `tanks` | `[x, y]`, top-left of 3×3 | `o` | blocked | blocked | blocked |
| `pipes` | area | `p` | blocked | blocked | pass |
| `crates` | area | `c` | blocked | blocked | pass |
| `wrecks` | area | `v` | blocked | blocked | pass |
| `sandbags` | area | `h` | blocked | blocked | pass |
| generated only | | `b` barrel, `T` tree | blocked | blocked | pass |

The tile rules come from `SOLID_MOVE`, `SOLID_SHOT` and `SOLID_SIGHT` in `src/game/game.js`. "Low cover" (blocks bullets, not sight) lets enemies see you over it.

**Buildings**: `r` is the outer wall ring and must be at least 3×3. The inside becomes indoor floor (`,`). `walls` are interior walls (areas). `doors` are points on the outer ring that become doorways. Use two side-by-side tiles for a normal door. Building floors cycle wood, tile, concrete and carpet in list order. Interior-wall doorways are simply gaps you leave in `walls`.

**Units**

| Layer | Entry | Meaning |
|---|---|---|
| `start` | `[x, y]` | Player insertion |
| `exfil` | `[x, y]` | Helipad. The player calls extraction here and has to survive until the helicopter lands |
| `enemies` | `[x, y]` or `[x, y, "p"]` | Guard holding position, or a patroller |
| `caches` | `[x, y]`, `[x, y, "weapons"]`, `[x, y, "start"]` | Random loot (2–3 items), loot plus a guaranteed weapon, or an ammo and bandage starter cache |

A unit placed on a blocked tile moves to the nearest open tile within 5 tiles.

## Build order

`buildLevel` paints layers in this order, and later layers overwrite earlier ones:

1. `water`, then `paved`, then `roads`
2. `buildings` (wall ring, floor, interior walls, doors), then `walls`, then the outer border
3. generated buildings (`generate.buildings`)
4. *(this is the floor layer the renderer and footstep sounds use)*
5. `containers`, `tanks`, `pipes`, `crates`, `wrecks`, `sandbags`
6. generated cover (`generate.cover`)
7. units are moved off blocked tiles

So a compound wall drawn over a building door closes that door, and a crate on a road sits on top of the road.

## Authoring rules of thumb

- Keep a walkable route from `start` to `exfil`. The tests enforce this.
- Give buildings 2-wide doors and keep doorways clear of cover.
- Put a `"start"` cache next to `start`.
- Maps in a campaign get harder in `index.json` order.

## Versioning and future loading

- `format` and `version` are required. Additive, optional fields keep `version: 1`. Anything that changes the meaning of existing fields bumps the version, and the game should keep reading older versions.
- Custom maps are shared as addressable Nostr events (kind 35858) that point at the map JSON on Blossom. See [map-event.md](map-event.md). The game only loads the built-in maps so far, but `buildLevel` already accepts a map object.