# Procedural Campaign Cartography with Python

Technique for generating TTRPG world maps programmatically using Python + PIL, producing clean PNG maps with labeled locations, faction territories, sea routes, and JSON data exports.

Developed for the Cross-Timeline Convergence pirate campaign (July 2026 session).

---

## When to Use This Instead of Web Tools

| Approach | Best For | Limitation |
|----------|----------|------------|
| **Azgaar's FMG** | Quick random worlds, political states | Crowded, hard to rename 100+ locations manually |
| **Inkarnate** | Hand-painted artistic maps | Requires artistic skill, time-consuming |
| **This Python method** | Clean custom archipelagos with YOUR names already placed | Requires Python; less organic than hand-painted |

Use this when:
- You need a **clean, uncluttered** archipelago (most generators produce too many tiny islands)
- Your campaign locations are **already defined** and you want them placed precisely
- You want **faction-colored markers** and sea routes drawn automatically
- You need **both an image and structured JSON data** for a campaign website

---

## Core Technique

### 1. Terrain: Hand-Placed Blob Clusters (Not Noise)

The key insight from this session: **Perlin noise produces hundreds of tiny specks** that look cluttered. Instead, define a few large "blob" primitives and place them deliberately.

```python
import numpy as np

def add_blob(mask, cx, cy, rx, ry, rotation=0, noise_strength=0.3):
    y, x = np.ogrid[:HEIGHT, :WIDTH]
    cos_r, sin_r = math.cos(rotation), math.sin(rotation)
    dx, dy = x - cx, y - cy
    rdx = dx * cos_r + dy * sin_r
    rdy = -dx * sin_r + dy * cos_r
    dist = (rdx / max(rx, 1))**2 + (rdy / max(ry, 1))**2
    noise = np.random.randn(HEIGHT, WIDTH) * noise_strength
    blob = (dist + noise * 0.3) < 1.0
    mask |= blob
    return mask

def add_atoll(mask, cx, cy, r_outer, r_inner=None):
    if r_inner is None:
        r_inner = max(3, r_outer // 2)
    y, x = np.ogrid[:HEIGHT, :WIDTH]
    dist = np.sqrt((x - cx)**2 + (y - cy)**2)
    noise = np.random.randn(HEIGHT, WIDTH) * 1.5
    ring = (dist + noise < r_outer) & (dist - noise > r_inner)
    mask |= ring
    return mask
```

Place blobs in **clusters** that match your campaign factions/territories:
- Eastern cluster for The Reef Lords
- Central cluster for Free Ports / Port Veridian
- Western cluster for The Harrowed / The Convergence Spire
- Northern outpost for Silver Navy
- Southern cluster for The Displaced
- A few scattered tiny atolls across open ocean for flavor

### 2. Ocean: 2-3 Clean Shades (No Texture Noise)

Most generators mottle the ocean with Perlin noise, making it look busy. Use **flat color bands** based on depth:

```python
ocean_deep = np.array([6, 22, 45])       # very dark navy
ocean_mid = np.array([14, 48, 88])      # clean mid blue
ocean_shallow = np.array([25, 82, 135])  # for coastal glow only
```

Only 2 ocean shades plus a **coastal glow** (lighter shallows within ~18px of land). This creates vast, calm open water.

### 3. Coastal Glow with `scipy.ndimage.distance_transform_edt`

```python
from scipy.ndimage import distance_transform_edt

dist_to_land = distance_transform_edt(~land_mask)
glow_mask = (dist_to_land > 0) & (dist_to_land <= 18)
# Blend from ocean_shallow (near coast) to ocean_mid (far from coast)
```

This is computationally cheap and visually effective. The glow makes islands "pop" against the deep ocean without adding clutter.

### 4. Locations as Percentage Coordinates

Define all locations with `x_pct` and `y_pct` (0.0 to 1.0) relative to canvas size. At render time, convert to pixels:

```python
loc_dict = {
    loc["name"]: (int(loc["x_pct"] * WIDTH), int(loc["y_pct"] * HEIGHT))
    for loc in LOCATIONS
}
```

This makes the layout **resolution-independent**. You can render at 1920x1080 or 4K without repositioning.

### 5. Symbol Types

| Type | Shape | Size | Use |
|------|-------|------|-----|
| `capital` | Diamond (polygon) | r=12 | Port Veridian |
| `town` | Circle | r=7 | Drowned Anchor, Salt Temple |
| `landmark` | Triangle | r=10 | The Convergence Spire |
| `hazard` | Circle, dark fill + red outline | r=8 | The Sargasso Maze |
| `finale` | Large circle, black fill + red outline | r=14 | The Black Reach |
| `region` | Text only | — | Shattered Reefs, Storm-Lanes |
| `slum` | Small circle | r=6 | The Gutter |

Each symbol gets a **faction-colored fill** from the legend, plus a white outline for contrast against dark ocean.

### 6. Sea Routes as Dashed Lines

```python
dash_len = 10
gap_len = 8
steps = int(total_dist / (dash_len + gap_len))
for s in range(steps):
    t1 = s * (dash_len + gap_len) / total_dist
    t2 = min(1.0, (s * (dash_len + gap_len) + dash_len) / total_dist)
    # draw segment
```

Color: `(230, 210, 160)` — warm parchment tone that reads as "old nautical chart."

### 7. Export: PNG + JSON

Always export both:
- **PNG** for display in notes, VTT, printed handouts
- **JSON** with pixel coordinates for campaign websites, interactive maps, or Obsidian integration

```python
json.dump({
    "map_name": "...",
    "dimensions": {"width": WIDTH, "height": HEIGHT},
    "seed": SEED,
    "factions": FACTIONS,
    "locations": LOCATIONS,
    "routes": ROUTES,
}, f, indent=2)
```

---

## Design Choices That Matter

### Spacing: Deliberate Negative Space

The #1 complaint about procedural maps is crowding. Fix it by:
1. **Fewer blobs** (6-8 clusters, not 50+ noise patches)
2. **Higher land threshold** (`land_mask = heightmap > 0.60` instead of 0.52)
3. **Fewer tiny atolls** (12 scattered, not 40+)
4. **Vast distances between clusters** — let the ocean dominate

### Color: Dark Ocean + Warm Labels

| Element | Color | Why |
|---------|-------|-----|
| Deep ocean | `#06162d` | Looks bottomless |
| Mid ocean | `#0e3058` | Readable depth variation |
| Shallows | `#198287` | Coastal glow, not distracting |
| Sand | `#d7c8a0` | Warm tropical beach |
| Jungle | `#64a54b` | Bright but not neon |
| Labels | `#fff5dc` | Warm parchment, readable on dark blue |
| Routes | `#e6d2a0` | Old chart feel |

Avoid: light blue oceans (looks like a swimming pool), neon greens, pure white labels (harsh).

### Typography

Use a **serif font** (DejaVu Serif, Liberation Serif, Free Serif) at 18px for towns, 22px for capitals. Serif reads as "old map" better than sans-serif.

---

## Iteration Workflow

```
1. Place blobs roughly where you want clusters
2. Render, check spacing
3. Adjust blob sizes/positions — no need to regenerate noise
4. Tweak colors if needed
5. Add locations one by one, checking label overlap
6. Export PNG + JSON
```

Because blobs are deterministic (same seed = same shape), iteration is fast. No waiting for a web generator to reroll.

---

## Sending to Other Devices

If the user has Tailscale:
```bash
sudo tailscale file cp /path/to/map.png roamer:
```

Replace `roamer` with the target machine's Tailscale name. The file appears in the Tailscale app on the destination device.

---

## Related

- Template starter: `templates/campaign-cartography-pirate-map-generator.py`
- Azgaar setup guide (web alternative): see session notes for full settings table
