Beginners Guide: Create Procedurally Generate Monsters

Beginners Guide: Create Procedurally Generate Monsters

ยท

4 min read

Original Post

Letโ€™s turn random noise into cool monster sprites! This guide breaks down each step in simple terms. The complete TypeScript implementation creates organic-looking creatures while maintaining full control over their general shape!

The Core Concept: Masks and Cellular Automata

Example Image

(Example Image) (Refresh And See It change)

Our solution combines two key techniques:

  1. Shape masks - Template patterns that guide initial generation

  2. Cellular automata - Smoothing algorithm that creates organic shapes

Let's break down the implementation piece by piece.


๐ŸŒฑ Step 1: Planting Seeds with Random Noise

We start by generating a chaotic pattern of pixels. Imagine throwing confetti at a grid and some spots get filled, others stay empty.

Key Code:

// Creates initial random pattern
_generate_random() {
  for (let y = 0; y < size; y++) {
    const row: boolean[] = [];
    for (let x = 0; x < size; x++) {
      // Check mask value at this position
      const maskValue = this.mask[y][x];

      // Apply rules:
      if (Math.random() < maskValue / 2 && Math.random() > 0.2) {
        row.push(true); // Place a pixel
      } else {
        row.push(false); // Leave empty
      }
    }
    this.map.push([...row, ...row.reverse()]); // Mirror for symmetry
  }
}

๐ŸŽญ Step 2: Controlling Shape with Masks

A mask acts like a stencil to guide where pixels can appear. Think of it as a probability map:

Mask ValueMeaning
0Never place a pixel (0% chance)
150% chance to place a pixel
280% chance to place a pixel*

\Even at 2, we add* Math.random() > 0.2 for natural variation

Example Mask (Island Shape):

[
  [0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 1, 1, 1, 1, 1],
  [0, 0, 0, 0, 1, 1, 2, 2], // Center has highest density
  // ... more rows ...
]

This 8x8 mask stretches to your desired sprite size (e.g., 32x32).


๐Ÿงช Step 3: Smoothing with Cellular Automata

Raw noise looks jagged. Cellular automata apply nature-inspired rules to create organic shapes.

The walk() Function

// Runs smoothing process
walk(steps = 4, birthLimit = 5, deathLimit = 4) {
  for (let i = 0; i < steps; i++) {
    this.map = this.getNextGeneration(birthLimit, deathLimit);
  }
}

Rules for Each Cell:

  1. Count living neighbors (8 surrounding cells) (Described Bellow)

  2. Apply survival rules:

    • Steps: How many times to repeat smoothing

    • Alive cell: Dies if neighbors โ‰ค deathLimit (default 4)

    • Dead cell: Born if neighbors == birthLimit (default 5)


How Neighbor Checking Works ๐Ÿ”

We check all 8 surrounding cells (Moore neighborhood):

const NEIGHBOR_OFFSETS = [
  [-1, -1], [-1, 0], [-1, 1], // Top row
  [ 0, -1],          [0, 1],  // Sides 
  [ 1, -1], [1, 0], [1, 1]   // Bottom row
];

Visualization:

X X X  
X โ–ˆ X  // Checks all "X" positions  
X X X

The getNextGeneration() Function

This applies the "life" rules to every pixel:

getNextGeneration(birthLimit: number, deathLimit: number) {
  const newGrid: boolean[][] = [];
  for (let y = 0; y < this.size; y++) {
    newGrid[y] = [];
    for (let x = 0; x < this.size; x++) {
      // Count living neighbors
      let liveNeighbors = 0;
      for (const [dx, dy] of NEIGHBOR_OFFSETS) {
        const ny = y + dy;
        const nx = x + dx;
        if (this.map[ny]?.[nx]) liveNeighbors++;
      }

      // Apply survival rules
      const isAlive = this.map[y][x];
      newGrid[y][x] = isAlive 
        ? liveNeighbors > deathLimit // Survive if enough neighbors
        : liveNeighbors === birthLimit; // Born if perfect neighbor count
    }
  }
  return newGrid;
}

๐ŸŽจ Step 4: Drawing Your Monster

Convert the grid into visible art!

Canvas Rendering:

plot_canvas(canvas) {
  const ctx = canvas.getContext("2d");
  ctx.fillStyle = "white";

  this.map.forEach((row, y) => {
    row.forEach((alive, x) => {
      if (alive) {
        ctx.fillRect(x * blockSize, y * blockSize, blockSize, blockSize);
      }
    });
  });
}

SVG Output (Perfect for Web)

plot_svg(bgColor: string, pixelColor: string) {
  let svg = `<svg viewBox="0 0 ${this.size} ${this.size}">`;
  svg += `<rect width="100%" height="100%" fill="${bgColor}"/>`;

  this.map.forEach((row, y) => {
    row.forEach((alive, x) => {
      if (alive) {
        svg += `<rect x="${x}" y="${y}" width="1" height="1" fill="${pixelColor}"/>`;
      }
    });
  });

  svg += "</svg>";
  return svg;
}

๐ŸŒŸ Bringing It All Together

  1. Mask Template โ†’ 2. Noisy Pattern โ†’ 3. Smoothed Shape
Mask:       Initial:      After Smoothing:
0 0 0       โ–ˆ โ–ˆ โ–ˆ         โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ  
0 1 0  โ†’   โ–ˆ   โ–ˆ   โ†’     โ–ˆ   โ–ˆ  
0 0 0         โ–ˆ           โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ

Example:

// Create a 32px island-shaped monster
const monster = new Procemon(32, undefined, maskType.island);
monster.walk(4, 5, 4); // Smooth 4 times
const svgCode = monster.plot_svg("black", "#FF00FF"); // Pink monster!

Experiment!

  • Try New Masks: Create spiky, round, or winged shapes

  • Adjust Rules: monster.walk(3, 4, 3) creates denser monsters

  • Go Colorful: Use plot_svg("navy", "gold") for royal creatures

Procedural generation lets you create infinite unique sprites. No two monsters will ever be the same! ๐ŸŽฎ๐Ÿ‘พ

Full code: github.com/sairash/procemon

Interactive Demo:

procemon.sairashgautam.com.np/?size=32&color=lime&background=black

ย