Building imgplex: part 3

game-dev imgplex tooling

This is a series of posts on building imgplex, best read in order:
Part 1 - The why, what, and how of imgplex
Part 2 - Getting things up and running
Part 3 - The node definition system
Part 4 - Executing the node graph, making it fast
Part 5 - Two graphs in one
Part 6 - Multiple inputs and outputs, processing images as sets
Part 7 - The small, measured optimizations beneath the big ones

At the end of part 2 I had an Electron window with a working Svelte Flow canvas and a single node I could drag around - the stack was wired, but nothing actually did anything yet. The obvious next question was how nodes should work in the first place. imgplex is a node-based tool on top of ImageMagick, so the node system is the heart of the whole thing, and the decision I made here shaped everything that came after.

The node definition system

One of the earliest architectural decisions was how to define nodes. The question was: should nodes be hardcoded in TypeScript, or should they be described by data that the app loads at runtime?

The hardcoded approach is simpler to start with. But it means every new node requires a code change, a recompile, and a new release. For a tool where the node library is expected to grow - and where users might eventually define their own nodes - that’s a significant constraint.

The data-driven approach means each node is described by a JSON file that the app loads at startup. Adding a new node is as simple as dropping a new file into the node-definitions/ folder. No recompile required. In development, the registry even hot-reloads when a file changes, so you can iterate on a node definition and see the result in the running app immediately.

If you’ve used Unity’s ScriptableObjects to define data-driven game content - enemy stats, item definitions, ability configs - this is the same idea. The application code defines how to interpret the data; the data files define what exists.

What a node definition looks like

Here’s a simplified example - the Brightness/Contrast node:

{
  "id": "brightness_contrast",
  "version": "1.0.0",
  "label": "Brightness / Contrast",
  "description": "Adjust image brightness and contrast",
  "aliases": ["exposure", "levels adjustment"],
  "category": "Color",
  "inputs": [
    {
      "type": "image",
      "label": "Input"
    }
  ],
  "outputs": [
    {
      "type": "image",
      "label": "Output"
    }
  ],
  "params": [
    {
      "name": "brightness",
      "label": "Brightness",
      "type": "int",
      "widget": "slider",
      "default": 0,
      "min": -100,
      "max": 100
    },
    {
      "name": "contrast",
      "label": "Contrast",
      "type": "int",
      "widget": "slider",
      "default": 0,
      "min": -100,
      "max": 100
    }
  ],
  "command_template": "-brightness-contrast {{brightness}}x{{contrast}}"
}

The command_template field is an ImageMagick command with parameter placeholders in curly braces. At execution time the pipeline substitutes the actual parameter values and passes the result to magick. That’s the entire connection between a node and the image processing backend for the majority of nodes.

The params array drives three things simultaneously: the widget rows shown in the inspector panel, the input handles on the left side of the node card for wiring values from other nodes, and the CLI export - the same parameter values get substituted into the command when exporting a shell script.

The param-wire system

Each parameter with a writable value exposes an input handle on the left side of the node. That handle can accept a wire from any compatible output port elsewhere in the graph.

This is how pure-value nodes integrate with image processing nodes. A Float node outputs a constant number. A Math node can add two floats together. The result can be wired directly into the brightness param of the Brightness/Contrast node. The pipeline resolves the full value chain before executing any ImageMagick commands.

The type system enforces compatibility at connection time - you can’t wire a color value into a float input. Each type also has a distinct wire color, making it easy to visually trace what’s flowing where. The color choices were all validated against WCAG AAA contrast requirements on the dark node background, ensuring text is easily readable at any point.

The node registry

At startup the main process scans the node-definitions/ folder and loads every JSON file into a registry. The renderer requests the full list via IPC and uses it to populate the node library sidebar and to construct node cards in the graph editor.

The registry watches the folder for changes. Edit a JSON file, save it, and the node library updates in the running app without a restart. This made iterating on node definitions very fast - the feedback loop for tweaking parameter ranges or adding a new node was just a file save away. This also has been extended to packaged/production builds, so users can add nodes without a restart in the shipped app.

The registry also validates definitions on load. Missing required fields, unknown executor keys, or malformed parameter types are caught early and logged, rather than surfacing as cryptic runtime errors later.

What this enabled

The payoff of this approach became clear as the node library grew. Adding the entire Transform, Colors, and Filters categories was a matter of writing JSON files, not TypeScript. The logic for rendering node cards, building the inspector, handling wire connections, and generating CLI output was written once and works for every node automatically.

The categories that did require custom TypeScript are the ones with non-trivial behavior: the Channel Split node forks the image stream into multiple outputs, the Properties node reads per-image metadata, and so on. Everything else is just data.

Currently there are over 60 nodes across 12 categories, and the vast majority of them are pure JSON with a command_template. That ratio is the whole point of the system.

The file export formats are defined the same way - each format is just a new JSON file in the format-definitions/ folder.

Next post in this series: Building imgplex: part 4 - Executing node graph, making it fast