Skip to main content
Home

Main navigation

  • Home
User account menu
  • Log in

Breadcrumb

  1. Home

Implementing a 1-D Binary-State Cellular Automaton with TypeScript, Svelte, and PixiJS

By Skander, 16 May, 2025
Cellular-automata-1d

In this article, we’ll walk through the implementation of a one-dimensional, binary-state cellular automaton built on top of the parametric CA framework I introduced in a previous post. The single-page app is split into two pieces: the cellular-automaton engine itself, and a graphical user interface (GUI).  The GUI includes two renderers—one for the rule and one for the grid. The engine lives in TypeScript, while the UI is built with Svelte, with each component’s behavior also written in TypeScript. Both the rule renderer and grid renderer are powered by PixiJS, again using TypeScript under the hood. Development and builds are handled with Vite, while Vitest takes care of the unit tests.

The Cellular Automaton Engine

TypeScript’s powerful type system—especially its generics—lets the CA engine stay both flexible and intuitive. By parameterizing key types, we can define polymorphic interfaces, classes, and functions without losing type safety. Every concrete automaton simply implements the CellularAutomaton interface shown below.

/* * Interface for cellular automata with arbitrary dimensionality and cell state values.
 */
export interface CellularAutomaton<
  T extends CellState,
  U extends CellLocation,
  V extends CellPopulation<T, U>
> {
  /**
   * Evolves <seedPopulation> for <generationCount> generations.
   */
  evolve(seedPopulation: V, generationCount: number): V[];
  /**
   * Calculates the next generation from <pop>.
   * @param pop
   */
  nextGeneration(pop: V): V;
}

One-dimensional automata are represented by the CellularAutomaton1D class. Because the cell state is supplied as a type parameter, it can be any type you choose.

/**
 * A generic one-dimensional automaton that allows arbitrary cell state values.
 */
export class CellularAutomaton1D<
  T extends CellState,
  U extends EvolutionRules<T, CellNeighborhood1D<T>>
> implements CellularAutomaton<T, CellLocation1D, CellPopulation1D<T>>
{
  constructor(private readonly rules: U) {}
  ...

However, each automaton expects its rule set to be injected as an immutable  EvolutionRules property. The evolve() method takes a seed population and repeatedly applies the automaton’s rules for the requested number of iterations, returning every intermediate generation along the way. Under the hood, it simply loops over nextGeneration(), collecting each result as shown below.

/**
   *
   * @inheritdoc
   */
  evolve(
    seedPopulation: CellPopulation1D<T>,
    generationCount: number
  ): CellPopulation1D<T>[] {
    assert(
      Number.isInteger(generationCount) && generationCount > 0,
      `Invalid generation count ${generationCount}`
    );
    const generations = [seedPopulation];
    let currentGen = seedPopulation;
    for (let i = 0; i < generationCount; i++) {
      currentGen = this.nextGeneration(currentGen);
      generations.push(currentGen);
    }
    return generations;
  }

Thanks to the CellNeighborhood1D and EvolutionRules abstractions—plus a simple location iterator—nextGeneration() stays concise and readable. It walks through every cell location in the current population, extracts that cell’s neighborhood, feeds the neighborhood into the rule, and writes the resulting state into the next-generation.

nextGeneration(currentGen: CellPopulation1D<T>): CellPopulation1D<T> {
    const nextGen = currentGen.clone();
    for (let cellLoc of currentGen.locations()) {
      const cellNeighborhood = currentGen.getCellNeighborhood(cellLoc);
      const nextCell = this.rules.apply(cellNeighborhood);
      nextGen.setCellAtLocation(cellLoc, nextCell);
    }
    return nextGen;
  }

We’ll wrap up this section by examining the EvolutionRules interface and its concrete implementation, RulesBin1D, which encodes the classic 8-bit rule set for one-dimensional, binary-state automata.

import { CellNeighborhood, CellNeighborhood1D } from "./cell-neighborhood";
import { BinaryState, CellState } from "./cell-state";
import { Cell } from "./cell";
import { assert } from "../utils/assert";
/**
 * Set of automata evolution rules.
 */
export interface EvolutionRules<
  T extends CellState,
  U extends CellNeighborhood<T>
> {
  apply(neighborhood: U): Cell<T>;
}
export type EightBinStateTuple = [
  BinaryState,
  BinaryState,
  BinaryState,
  BinaryState,
  BinaryState,
  BinaryState,
  BinaryState,
  BinaryState
];
/**
 * Evolution rules for a one-dimensional automaton where cells take binary states: DEAD | ALIVE.
 */
export class RulesBin1D
  implements EvolutionRules<BinaryState, CellNeighborhood1D<BinaryState>>
{
  /**
   * Encoded automata rules: e.g rules[3] = ALIVE means if neighbohood A D A, then the next state of middle cell is A.
   */
  private readonly rules: EightBinStateTuple;
  /**
   *
   * @param rulesCode Code of this rules set between 0 and 255. This code defines the one-dimensional
   * cellular automata following Wolfram's notation: R0, R30, R121...
   */
  constructor(rulesCode: number) {
    assert(
      Number.isInteger(rulesCode) && rulesCode >= 0 && rulesCode < 256,
      `Invalid ${rulesCode}, value must be an integer in [0, 255]`
    );
    const binaryCode = rulesCode.toString(2).padStart(8, "0");
    // Wolfram convention: binaryCode[0] is MSB ("111")…binaryCode[7] is LSB ("000").
    // reverse() makes rules[0] ↔ "000", rules[7] ↔ "111".
    this.rules = binaryCode
      .split("")
      .map((bit: string) => {
        return bit == "0" ? BinaryState.DEAD : BinaryState.ALIVE;
      }).reverse() as EightBinStateTuple;
  }
  apply(neighborhood: CellNeighborhood1D<BinaryState>): Cell<BinaryState> {
    const ruleIdx = this.neighborhoodToRuleIdx(neighborhood);
    return new Cell(this.rules[ruleIdx]);
  }
  private neighborhoodToRuleIdx(
    neighborhood: CellNeighborhood1D<BinaryState>
  ): number {
    const [left, center, right] = neighborhood.cells.map((cell) =>
      cell.state == BinaryState.DEAD ? 0 : 1
    );
    const ruleIdx = right + center * 2 + left * 4;
    return ruleIdx;
  }
}

Each rule is stored as an 8-bit tuple. The RulesBin1D constructor takes a rule code—an integer from 0 to 255—and decodes it into that eight-bit tuple for easy lookup during evolution.

The Graphic User Interface

The graphical user interface is built from four Svelte components: the root App component and three reactive children—ControlPanel, AutomatonRule, and AutomatonGrid. The rest of this section offers a concise walkthrough of how each component is built and wired together.

One-dimensional binary-state cellular automaton GUI

App Component

This root component defines the application-wide state, including both the configuration settings and the current automaton grid.

let ruleCode = $state(165);
let iterationCount = $state(90);
let colorDead = $state("#ede9e8");
let colorAlive = $state("#142661");
let seeder = $state("One-alive");
let grid = $state<Array<Array<boolean>>>([]);

Because simulating a CA can be computationally expensive, the user must start it manually rather than having it launch automatically whenever a configuration variable changes. The run_simulation function handles this: it creates a fresh set of evolution rules, seeds the initial population, instantiates the cellular automaton, and then evolves it for the specified number of iterations.

function runSimulation() {
  grid = [];
  const rules = new RulesBin1D(ruleCode);
  const seedPopulation = createSeedPopulation();
  const automaton = new CellularAutomaton1D(rules);
  const generations = automaton.evolve(seedPopulation, iterationCount);
  grid = generations.map((gn) => toBooleanArray(gn));
 }

The App markup declares three child components—the control panel, the CA-rule renderer, and the CA grid—and includes a button to start the simulation.

<ControlPanel
    bind:ruleCode
    bind:iterationCount
    bind:colorDead
    bind:colorAlive
    bind:seeder
  />
  <button class="run-btn" onclick={runSimulation}>▶︎ Run simulation</button>
  <AutomatonRule {ruleCode} {colorAlive} {colorDead} />
  <AutomatonGrid {colorDead} {colorAlive} {grid} />

The ControlPanel properties are bound bidirectionally, allowing configuration settings to flow both into and out of the component. The runSimulation function is wired as the click handler for the Run Simulation button, while the renderer components use one-way bindings.

ControlPanel Component

The panel deconstructs its properties into bindable fields, and its markup uses standard HTML.

let {
    ruleCode = $bindable<number>(),
    iterationCount = $bindable<number>(),
    seeder = $bindable<
      "One-alive" | "All-alive" | "All-dead" | "Alternating" | "Random"
    >("One-alive"),
    colorDead = $bindable<string>(),
    colorAlive = $bindable<string>(),
  } = $props();
  const clamp = (n: number) => Math.max(0, Math.min(255, n));

AutomatonRule Component

This component renders a cellular-automaton rule from its numeric code. Under the hood, it relies on PixiJS. A PixiJS application is created and initialized when Svelte mounts the AutomatonRule component. Because that setup is asynchronous, a pixiReady flag signals when the PixiJS app is ready, preventing race conditions between PixiJS and Svelte.

onMount(async () => {
    pixi = new Application();
    await pixi.init({ background: "#fff", resizeTo: host });
    host.appendChild(pixi.canvas);
    gridContainer = new Container();
    pixi.stage.addChild(gridContainer);
    pixiReady = true;
  }); 

We use Svelte’s $effect rune to make the component reactive: it redraws the rule whenever its numeric code changes or whenever the alive/dead cell colors are modified. The dummy assignments in the implementation force Svelte to read these properties and register them as dependencies for the $effect rune, ensuring reactivity.

$effect(() => {
    if (!pixiReady) return;
    const code = ruleCode;
    const alive = colorAlive;
    const dead = colorDead;
    drawRule(code, dead, alive);
  });

The numeric rule code is first converted to its binary representation. We then draw the eight possible neighborhoods—and the resulting state dictated by the rule—using PixiJS rectangles.

function drawRule(code: number, dead: string, alive: string) {
    function toColor(bit: number): number {
      return toHex(bit ? alive : dead);
    }
    const cellSize = 15;
    const cellPadding = 2;
    const neighborhoodWidth = 3 * cellSize + 2 * cellPadding;
    const neighborhoodPadding = 13;
    const ruleWidth = 8 * neighborhoodWidth + 7 * neighborhoodPadding;
    const xOffset = (pixi.screen.width - ruleWidth) / 2;
    const maxX = xOffset + 7 * (neighborhoodWidth + neighborhoodPadding);
    gfx.clear();
    for (let i = 0; i < 8; i++) {
      const cellState = (code >> i) & 1;
      // Convert i to a neighborhood
      const rightNeighbor = i & 1;
      const center = (i >> 1) & 1;
      const leftNeighbor = (i >> 2) & 1;
      // Draw neighborhood
      const x = maxX - i * (neighborhoodWidth + neighborhoodPadding);
      const y = 0;
      gfx
        .rect(x, y, cellSize, cellSize)
        .fill(toColor(leftNeighbor));
      gfx
        .rect(x + cellPadding + cellSize, y, cellSize, cellSize)
        .fill(toColor(center));
      gfx
        .rect(x + 2 * (cellPadding + cellSize), y, cellSize, cellSize)
        .fill(toColor(rightNeighbor));
      gfx
        .rect(
          x + cellSize + cellPadding,
          y + cellSize + cellPadding,
          cellSize,
          cellSize
        )
        .fill(toColor(cellState));
    }
  } 

AutomatonGrid Component

This component renders every generation of the cellular automaton as a stacked grid. Like AutomatonRule, it relies on PixiJS. The PixiJS application is set up in the onMount lifecycle hook, and a $effect rune keeps the grid reactive—redrawing it whenever the alive or dead cell colors change.

$effect(() => {
    if (!pixiReady) return;
    const updatedGrid = grid;
    const aliveColor = colorAlive;
    const deadColor = colorDead;
    renderGrid(updatedGrid);
  });

Class Grid is used to describe a CA grid.

 class Grid {
    public readonly rowsCount: number;
    public readonly columnsCount: number;
    public readonly cellSize: number;
    public readonly padding: number = 2;
    public readonly width: number;
    public readonly height: number;
    public readonly leftMargin: number;
    public readonly topMargin: number;
    constructor(
      rowsCount: number,
      columnsCount: number,
      containerWidth: number,
      containerHeight: number
    ) {
      this.rowsCount = rowsCount;
      this.columnsCount = columnsCount;
      const cellWidth = Math.floor(
        (containerWidth - (columnsCount - 1) * this.padding) / columnsCount
      );
      const cellHeight = Math.floor(
        (containerHeight - (rowsCount - 1) * this.padding) / rowsCount
      );
      this.cellSize = Math.min(cellHeight, cellWidth);
      this.width =
        this.columnsCount * this.cellSize +
        (this.columnsCount - 1) * this.padding;
      this.height =
        this.rowsCount * this.cellSize + (this.rowsCount - 1) * this.padding;
      this.leftMargin = Math.max(0, (containerWidth - this.width) / 2);
      this.topMargin = Math.max(0, (containerHeight - this.height) / 2);
    }
  }

  We use lightweight PixiJS sprites—rather than rectangles—to draw the grid. The code snippet below illustrates this process.

function createGridCell(rowIdx: number, colIdx: number, grid: Grid): Sprite {
    const cell = new Sprite(Texture.WHITE);
    cell.anchor.set(0, 0);
    const cellOffset = grid.cellSize + grid.padding;
    cell.position.set(colIdx * cellOffset, rowIdx * cellOffset);
    cell.scale.set(grid.cellSize, grid.cellSize);
    cell.tint = 0x444444;
    return cell;
  }
  function renderGridCells(cells: Sprite[], states: Boolean[]) {
    for (let i = 0; i < cells.length; i++) {
      cells[i].tint = getTint(states[i]);
    }
  }
  /**
   * Returns the proper tint for a given cell state.
   */
  function getTint(isAlive: Boolean): string {
    return isAlive ? colorAlive : colorDead;
  }

Summary

In this article, we explored how to implement a one-dimensional, binary-state cellular automaton. Our parametric design yielded reusable components that simplified development, and the combination of Svelte with PixiJS enabled a reactive graphical user interface.

The next step is to apply this same parametric design to build a two-dimensional, binary-state automaton.

  • Add new comment

My Apps

  • One-dimensional Cellular Automata Simulator
  • Collatz (Syracuse) Sequence Calculator / Visualizer
  • Erdős–Rényi Random Graph Generator / Analyzer
  • KMeans Animator
  • Language Family Explorer

New Articles

Escape YouTube Filter Bubble - An LLM-based Video Recommender
Implementing a 1-D Binary-State Cellular Automaton with TypeScript, Svelte, and PixiJS
A Parametric Approach to Cellular Automata Framework Design
Divine Connections: Building Promptheon, a GenAI Semantic Graph Generator of Ancient Gods
Machine Learning Mind Maps

Skander Kort