Skip to content

The Code Generator Journey: From Manual Hell to Declarative Heaven

How we built a YAML-to-C++ code generator in PgScript, eliminated 559+ lines of boilerplate, and made ECS components 6x faster to create

code-generationmeta-programmingecsgame-engineyamlscriptingcpp

The Problem: Writing the Same Code for the 50th Time

Picture this: You’re building a game engine with a pure Entity Component System (ECS) architecture. You need to add a new component—let’s call it HealthComponent. Simple enough, right? You need a few fields: currentHealth, maxHealth, and isDead.

But wait. In practice, here’s what you actually need to write:

  1. Component struct definition - Fields, getters, setters
  2. Event-firing setters - Notify systems when values change
  3. VM proxy schema - Expose component to scripting system
  4. Serialization code - Save/load support
  5. Attach handlers - Let scripts create and attach components

For our “simple” HealthComponent, that’s easily 100+ lines of boilerplate code. And you’ll write it all by hand. Again. For the 50th time.

The Real Cost: PositionComponent

Let me show you what “boilerplate” really means with a real example from ColumbaEngine. Our PositionComponent handles 2D position, size, rotation, and visibility. It has 8 fields. Here’s what we had to maintain:

  • position.h: 359 lines - Struct definition, enums, helper structs
  • position.cpp: 150 lines - Setter implementations, utility functions
  • ecsserialization.cpp: 98 lines - VM proxy and serialization

Total: ~607 lines of code for ONE component with 8 fields.

Every time we wanted to add a field:

  • Update the struct definition
  • Write getter/setter with event firing
  • Add to VM proxy schema
  • Update serialization code
  • Register with attach handler
  • Hope we didn’t forget anything

The Maintenance Nightmare

Copy-paste errors were common. Event firing was inconsistent. Adding viewport to TTFText? I forgot to update the serialization. Position’s rotation setter wasn’t firing events properly. The serialization for colors used a different pattern than other fields.

Every component felt like writing the same code with slightly different names.

And that’s when I asked: What if we didn’t have to?

The Vision: Declarative Components

What if defining a component looked like this instead:

name: PositionComponent
namespace: pg
base_class: Component

fields:
  - name: x
    type: float
    default: 0.0f
    setter: event

  - name: y
    type: float
    default: 0.0f
    setter: event

  - name: rotation
    type: float
    default: 0.0f
    setter: event

That’s it. Just declare what you want. The code writes itself.

Benefits:

  • Single source of truth - One .pgcomp file defines everything
  • Consistency guaranteed - Generated code follows the same patterns
  • Easy to read and modify - YAML is human-friendly
  • Regenerate anytime - Change something? Regenerate. No fear.

Why Build Our Own Generator?

Before diving in, we considered alternatives:

Option 1: External Templating (Jinja2, Mustache, etc.)

Pros: Mature, well-tested Cons: Python dependency, another language to learn, separate tooling

Option 2: Python Generator Scripts

Pros: Flexible, easy to write Cons: External dependency, not integrated with engine

Option 3: C++ Template Metaprogramming

Pros: No runtime overhead Cons: Compile-time cost, hard to read, limited flexibility

Our Choice: PgScript

We chose to write the generator in PgScript, ColumbaEngine’s own scripting language.

Why?

  1. Dogfooding - Use the engine’s own language to build engine tools
  2. Zero external dependencies - Ships with the engine
  3. Demonstrates capability - Shows PgScript is production-ready
  4. Faster iteration - No recompilation needed
  5. Educational - Others can read and learn from it

The decision to use PgScript forced us to make the language better. And that benefited everyone.

Building the Foundation: Native Modules

Before we could write a component generator in PgScript, we needed to enhance the language itself.

String Module: 349 Lines of Utilities

A YAML parser needs string manipulation. Lots of it. So we added:

// String inspection
startsWith(str, prefix)    // "PositionComponent" starts with "Position"
endsWith(str, suffix)      // "component.h" ends with ".h"
contains(str, substring)   // "type: float" contains "float"

// String manipulation
substring(str, start, end) // Extract portions
trim(str)                  // Remove whitespace
replace(str, from, to)     // Replace all occurrences

// String transformation
capitalize(str)            // "position" -> "Position"
indexOf(str, substring)    // Find position of substring

Commit: 191babed - Added 349 lines to stringmodule.h

File Module: Reading and Writing

import "file"

var content = readFile("PositionComponent.pgcomp")
var success = writeFile("PositionComponent.generated.h", generatedCode)

Simple but essential. The generator reads .pgcomp files and writes generated .h and .cpp files.

Algorithm Module: Vector Operations

YAML has arrays. Arrays everywhere. Field lists, include lists, method lists. We needed robust array manipulation to build the generator.

What we added to the algorithm module:

import "algorithm"

// Array operations
var fields = []
push(fields, fieldDef)           // Add to end
insert(fields, 0, fieldDef)      // Insert at index
var field = pop(fields)          // Remove and return last
removeAt(fields, 2)              // Remove at specific index

// Length and access
var count = len(fields)          // Get array length
var field = fields[0]            // Access by index

// Type checking and introspection
var type = typeOf(fields)        // Returns "array"
if (typeOf(value) == "string")   // Dynamic type checking

// Table/object operations
if (contain(currentItem, "name")) // Check if key exists
{
    var name = currentItem["name"]
}

// Type conversion
var number = toInt("42")         // String to integer

Why these functions matter:

  1. push() and pop() - Essential for building arrays during parsing
  2. len() - Needed for loops and bounds checking
  3. contain() - Critical for checking if YAML fields exist before accessing
  4. typeOf() - Dynamic type checking for flexible parsing
  5. toInt() - Converting string numbers in YAML to integers

Without these utilities, we’d have no way to work with the structured data coming from YAML. Arrays are first-class citizens in the component generator.

Commit: 4db26392 - Added 158 lines of vector manipulation

Commit: ebc08c06 - Added typeOf() utility

With these modules in place, PgScript had everything needed for the generator: string manipulation, file I/O, and data structure operations.

The YAML Parser Challenge

Why YAML?

We needed a format for .pgcomp files that was:

  • Human-readable - Developers edit these by hand
  • Structured - Support nested objects and arrays
  • Standard - Don’t invent a new format
  • Comment-friendly - Documentation inline

YAML checked all the boxes. But… we had to write a YAML parser. In PgScript.

Parser Architecture

Here’s the core structure from component_generator.pg:

class YamlParser
{
    init()
    {
        this.indentStack = []
    }

    parse(content)
    {
        var lines = splitLines(content)
        var result = {}
        var currentSection = ""
        var currentArray = []
        var currentItem = {}

        for (var i = 0; i < len(lines); i++)
        {
            var line = lines[i]
            var trimmed = trim(line)

            // Skip empty lines and comments
            if (len(trimmed) > 0 and not startsWith(trimmed, "#"))
            {
                var indent = this.getIndent(line)

                if (startsWith(trimmed, "-"))
                {
                    // Array item
                    this.parseArrayItem(trimmed, currentItem, currentArray)
                }
                else if (contains(trimmed, ":"))
                {
                    // Key-value pair
                    this.parseKeyValue(trimmed, indent, result, currentSection)
                }
            }
        }

        return result
    }
}

The Tricky Parts

Writing a YAML parser from scratch revealed complexity we didn’t anticipate. Here are the challenges we had to solve:


1. Indentation Tracking

YAML uses indentation for nesting. We needed to count spaces to determine structure:

getIndent(line)
{
    var indent = 0
    for (var i = 0; i < len(line); i++)
    {
        var char = substring(line, i, i + 1)
        if (char == " ")
            indent = indent + 1
        else
            break
    }
    return indent
}

The challenge: Different indent levels mean different things:

fields:              # indent = 0 (top-level)
  - name: x          # indent = 2 (array item)
    type: float      # indent = 4 (nested property)
    default: 0.0f    # indent = 4 (same level)

We maintain a state machine tracking current section, current array, and current object. When indent decreases, we know we’re back to a parent level.


2. Array Items with Key-Value Pairs

This YAML pattern was particularly tricky:

fields:
  - name: x          # Array item with nested key-value
    type: float
    default: 0.0f
  - name: y          # Next array item
    type: float

The problem: The - starts a new array item, but the following indented lines are properties of that item.

Our solution:

var currentItem = {}

if (startsWith(trimmed, "-"))
{
    // Save previous item if it exists
    if (contain(currentItem, "name") or contain(currentItem, "params"))
    {
        push(currentArray, currentItem)
    }

    // Start new item
    currentItem = {}

    // Parse the line after the dash
    var afterDash = trim(substring(trimmed, 1, len(trimmed)))

    if (contains(afterDash, ":"))
    {
        // "- name: x" format
        var parts = this.splitFirst(afterDash, ":")
        var key = trim(parts[0])
        var value = trim(parts[1])
        currentItem[key] = this.parseValue(value)
    }
}
else if (indent > 0 and contains(trimmed, ":"))
{
    // Nested property of current item
    var parts = this.splitFirst(trimmed, ":")
    var key = trim(parts[0])
    var value = trim(parts[1])
    currentItem[key] = this.parseValue(value)
}

We accumulate properties into currentItem, then push it to the array when we see the next - or reach a different section.


3. Type Inference for Values

YAML values can be strings, numbers, booleans, arrays, or objects. We needed to parse them correctly:

parseValue(value)
{
    // Parse inline arrays: [item1, item2, item3]
    if (startsWith(value, "[") and endsWith(value, "]"))
    {
        var innerContent = substring(value, 1, len(value) - 1)
        var items = split(innerContent, ",")
        var result = []

        for (var i = 0; i < len(items); i = i + 1)
        {
            push(result, trim(items[i]))
        }

        return result
    }

    // Parse inline objects: {key1: value1, key2: value2}
    if (startsWith(value, "{") and endsWith(value, "}"))
    {
        // ... parse object
        return obj
    }

    // Parse booleans
    if (value == "true")
        return true
    if (value == "false")
        return false

    // Parse numbers (kept as strings for simplicity)
    // Everything else is a string
    return value
}

For simplicity, we keep most values as strings and let the code generator handle type conversion. This avoided complex number parsing.


4. Comment Handling

Comments (#) should be ignored:

fields:
  - name: x           # X coordinate
    type: float       # Using float for precision

Simple solution:

// Skip empty lines and comments
if (len(trimmed) > 0 and not startsWith(trimmed, "#"))
{
    // Process line
}

We check this early and skip commented lines entirely.


Parser Statistics

The complete YAML parser is:

  • ~400 lines of PgScript
  • Supports:
    • Top-level key-value pairs
    • Nested sections
    • Arrays with - notation
    • Objects within arrays
    • Inline arrays [a, b, c]
    • Inline objects {key: value}
    • Comments
  • Does NOT support:
    • Multiline strings
    • Anchors and aliases (&, *)
    • Complex multiline syntax (|, >)
    • Explicit typing (!!str, !!int)
    • Merge keys (<<:)

Why this is enough: We control the .pgcomp format. We don’t need full YAML spec compliance—just enough to make component definitions readable and writable by humans.

The parser works perfectly for our needs, and we can extend it when new features require new YAML constructs.

Code Generation: The Heart of the System

Now for the interesting part: generating C++ code from YAML definitions.

The .pgcomp Format

Here’s a complete example - our Health component:

name: HealthComponent
namespace: pg

includes:
  - <cstdint>

fields:
  - name: currentHealth
    type: int32_t
    default: 100
    setter: event

  - name: maxHealth
    type: int32_t
    default: 100
    setter: basic

  - name: isDead
    type: bool
    default: false
    setter: none

Simple, readable, and complete. Let’s see what gets generated.

Header Generation

From the YAML above, we generate:

// PositionComponent.generated.h
// AUTO-GENERATED - DO NOT EDIT

#pragma once

#include <cstdint>
#include "ECS/system.h"

namespace pg
{
    struct HealthComponent : public Component
    {
        // Fields
        int32_t currentHealth = 100;
        int32_t maxHealth = 100;
        bool isDead = false;

        // Setters
        void setCurrentHealth(int32_t value);  // event setter
        void setMaxHealth(int32_t value);      // basic setter
        // No setter for isDead (setter: none)

        // ECS integration
        _unique_id id = 0;
        EntitySystem* ecsRef = nullptr;
    };
}

Clean, consistent, and complete.

Setter Levels

This is where the system shines. We support 4 levels of setters, each for different needs:

Level 1: No Setter (Direct Access)

- name: isDead
  type: bool
  default: false
  setter: none

Generated: Just the field. Scripts access it directly.

Use case: Derived state, internal flags


Level 2: Basic Setter

- name: maxHealth
  type: int32_t
  default: 100
  setter: basic

Generated:

void setMaxHealth(int32_t value) {
    maxHealth = value;
}

Use case: Simple assignment with validation opportunity


Level 3: Event Setter (The Key Innovation)

- name: currentHealth
  type: int32_t
  default: 100
  setter: event

Generated:

void setCurrentHealth(int32_t value)
{
    if (this->currentHealth != value) // Only fire if changed!
    {
        this->currentHealth = value;

        if (ecsRef)
        {
            ecsRef->sendEvent(HealthComponentChangedEvent{id});
        }
    }
}

The optimization: if (this->currentHealth != value)

This one line prevents cascading updates. Before code generation, we wrote this check manually (sometimes). Now it’s guaranteed for every event setter.

Before the generator, we had code like this in PositionComponent:

void PositionComponent::setX(float x)
{
    if (areNotAlmostEqual(this->x, x))  // Sometimes we remembered the check
    {
        LOG_THIS(DOM);
        this->x = x;

        if (ecsRef)
            ecsRef->sendEvent(PositionComponentChangedEvent{id});
    }
}

But other setters forgot the check. Inconsistent. Now it’s automatic.

Use case: Components that trigger system updates, fields that affect rendering or game state


Future: Level 4 - Custom Setters

We’re planning a 4th level for complex custom logic:

- name: wrap
  type: bool
  default: false
  setter: custom
  custom_code: |
    this->wrap = value;
    this->changed = true;  // Custom logic
    recalculateLayout();   // More custom logic
    fireEvent();

This will allow embedding arbitrary C++ code for setters that need more than just event firing. Not yet implemented, but on the roadmap!


Constructor Generation

Components often need constructors with default parameters:

constructors:
  - params: [text, fontPath, scale, colors]
    defaults: {colors: "{255.0f, 255.0f, 255.0f, 255.0f}"}

Generated:

TTFText(const std::string& text,
        const std::string& fontPath,
        float scale,
        constant::Vector4D colors = {255.0f, 255.0f, 255.0f, 255.0f})
    : text(text)
    , fontPath(fontPath)
    , scale(scale)
    , colors(colors)
{}

Member initialization, default parameters, proper formatting—all automatic.

The Bootstrap Problem

Now we hit an interesting challenge: How do you build an engine that needs components when the components are generated by the engine?

This is a classic chicken-and-egg problem:

  • We need the engine to run PgScript
  • PgScript runs the component generator
  • The component generator creates components
  • The engine needs those components to build

Solution: The Bootstrap Compiler

Commit: b7508293 - “Can now build a minimal pgcompiler without any graphical deps”

We created a minimal PgCompiler that:

  1. Has zero graphical dependencies (no SDL, no OpenGL)
  2. Can run PgScript programs
  3. Compiles separately from the main engine
  4. Runs during CMake configuration phase

CMake Integration

# Build minimal bootstrap compiler first
add_executable(pgcompiler_bootstrap
    src/Engine/Compiler/*.cpp
    # Exclude graphical systems
)

# Generate components before main build
file(GLOB PGCOMP_FILES "src/Engine/Components/*.pgcomp")

foreach(PGCOMP_FILE ${PGCOMP_FILES})
    get_filename_component(COMP_NAME ${PGCOMP_FILE} NAME_WE)

    add_custom_command(
        OUTPUT "${CMAKE_BINARY_DIR}/Generated/${COMP_NAME}.generated.h"
        COMMAND pgcompiler_bootstrap
                tools/component_generator.pg
                ${PGCOMP_FILE}
        DEPENDS ${PGCOMP_FILE} tools/component_generator.pg
        COMMENT "Generating ${COMP_NAME} component..."
    )
endforeach()

Build flow:

  1. CMake configures
  2. Bootstrap compiler builds (fast, no graphics deps)
  3. Component generator runs for each .pgcomp file
  4. Generated files created in build/Generated/
  5. Main engine build includes generated files

Beautiful.

Preventing Rebuild Loops

Problem: The generator ran every time, triggering full rebuilds even when nothing changed.

Commit: 3f771c7d - “Only regenerate .pgcomp file on change”

Solution:

add_custom_command(
    OUTPUT "${GENERATED_FILE}"
    COMMAND pgcompiler_bootstrap ... > "${GENERATED_FILE}.tmp"
    COMMAND ${CMAKE_COMMAND} -E copy_if_different
            "${GENERATED_FILE}.tmp"
            "${GENERATED_FILE}"
    DEPENDS ${PGCOMP_FILE}
)

copy_if_different only updates the file if content actually changed. This preserves timestamps and prevents unnecessary recompilation.

Migration: Real Impact with Real Numbers

Time to migrate our existing components and measure the impact.

PositionComponent - The Big One

PositionComponent is complex. It handles 2D transforms, anchoring, visibility, events, and more.

Before: The Manual Implementation

position.h (359 lines):

#pragma once

#include "ECS/system.h"
#include "pgconstant.h"

namespace pg
{
    enum class AnchorType : uint8_t { ... };
    enum class ResizeHandle : uint8_t { ... };

    struct PositionComponentChangedEvent
    {
        _unique_id id = 0;
    };

    struct PositionComponent : public Component
    {
        float x = 0.0f;
        float y = 0.0f;
        float z = 0.0f;
        float width = 0.0f;
        float height = 0.0f;
        float rotation = 0.0f;
        bool visible = true;
        bool observable = true;

        // Getters
        float getX() const { return x; }
        float getY() const { return y; }
        // ... 6 more getters

        // Setters with event firing
        void setX(float x);
        void setY(float y);
        // ... 6 more setters

        // Utility methods
        bool isVisible() const { return visible; }
        bool isObservable() const { return observable; }
        bool isRenderable() const { return visible && observable; }

        // ... more methods

        _unique_id id = 0;
        EntitySystem* ecsRef = nullptr;
    };
}

position.cpp (150 lines):

void PositionComponent::setX(float x)
{
    if (areNotAlmostEqual(this->x, x))
    {
        LOG_THIS(DOM);
        this->x = x;

        if (ecsRef)
            ecsRef->sendEvent(PositionComponentChangedEvent{id});
    }
}

// Repeat for setY, setZ, setWidth, setHeight, setRotation...

ecsserialization.cpp (98 lines for this component):

// VM proxy schema
auto positionProxySchema = [](VM* vm, EntityRef entity) -> Value {
    auto component = entity.getComponent<PositionComponent>();
    // ... 100 lines of proxy setup
};

// Serialization
archive("x", component->x);
archive("y", component->y);
// ... serialization for each field

Total: ~607 lines

After: The Declarative Implementation

PositionComponent.pgcomp (62 lines):

name: PositionComponent
namespace: pg
base_class: Component

includes:
  - "ECS/system.h"
  - "pgconstant.h"

forwards:
  - "struct UiAnchor;"

fields:
  - name: x
    type: float
    default: 0.0f
    setter: event

  - name: y
    type: float
    default: 0.0f
    setter: event

  - name: z
    type: float
    default: 0.0f
    setter: event

  - name: width
    type: float
    default: 0.0f
    setter: event

  - name: height
    type: float
    default: 0.0f
    setter: event

  - name: rotation
    type: float
    default: 0.0f
    setter: event

  - name: visible
    type: bool
    default: true
    setter: event

  - name: observable
    type: bool
    default: true
    setter: event

methods:
  - "bool isVisible() const { return visible; }"
  - "bool isObservable() const { return observable; }"
  - "bool isRenderable() const { return visible and observable; }"
  - "bool updatefromAnchor(const UiAnchor& anchor);"
  - "void setVisibility(const bool& value);"

events:
  - PositionComponentChangedEvent

Total: 62 lines

The Numbers

Reduction: 607 lines → 62 lines = 90% smaller

The Impact: Beyond Line Counts

Development Velocity

Adding a new component:

  • Before:

    • Write struct definition
    • Implement setters with event firing
    • Add VM proxy schema
    • Write serialization code
    • Register attach handler
    • Debug inconsistencies
  • After:

    • Write .pgcomp definition
    • Run generator
    • Done

Adding a field to existing component:

  • Before:

    • Update struct
    • Add getter/setter
    • Update proxy
    • Update serialization
    • Pray you didn’t forget anything
  • After:

    • Add 4 lines to .pgcomp
    • Regenerate

Faster iteration, and less bug prone


Bug Reduction: The “Only Fire When Changed” Story

During code generation implementation, we discovered an important optimization:

void setX(float value)
{
    if (this->x != value) // This check!
    {  
        this->x = value;

        if (ecsRef)
        {
            ecsRef->sendEvent(PositionComponentChangedEvent{id});
        }
    }
}

The problem: Without the if (this->x != value) check, setting a field to its current value fires an event. Systems respond to that event. Those systems might set more fields. More events fire. Cascading updates.

Before the generator: This check was manual. Sometimes we remembered. Sometimes we forgot. Inconsistent.

After the generator: This check is in every event setter. Guaranteed. Consistent.

Commit: 25754476 - “Fixed event generation in component to only fire when the value changes”

One fix in the generator → all components benefit forever.


Documentation Benefits

The .pgcomp files double as documentation:

Before: “What fields does PositionComponent have?” → Open position.h, scroll through 359 lines of code

After: “What fields does PositionComponent have?” → Open PositionComponent.pgcomp, see 8 clear field definitions

fields:
  - name: x
    type: float
    default: 0.0f
    setter: event

Type, default value, behavior—all visible at a glance.

New team members understand components in minutes, not hours.

Lessons Learned: What Worked and What Didn’t

What Went Exceptionally Well

Dogfooding Accelerated Language Development

Using PgScript to build engine tools revealed gaps:

  • String manipulation was basic → Added 46+ utility functions
  • File I/O needed write support → Added writeFile()
  • Array operations needed helpers → Added vector module
  • Type introspection was missing → Added typeof()

Every gap we filled made PgScript more capable. Not just for the generator, but for all PgScript users.

Lesson: Use your own tools. The pain you feel is real user pain.


Incremental Approach Reduced Risk

We didn’t migrate everything at once:

  1. Phase 1: Write the generator (simple Health component example)
  2. Phase 2: Migrate PositionComponent (medium complexity)
  3. Phase 3: Migrate TTFText (complex, with custom constructors)
  4. Phase 4: Generate serialization and VM proxies

Each phase validated the approach. Each success built confidence.

Lesson: Small iterations makes it easier for refactoring old or complex systems.


What Was Challenging

YAML Parsing Complexity

Writing a YAML parser is harder than it looks:

  • Indentation tracking - Spaces vs tabs, mixed indentation
  • Nested structures - Objects within arrays within objects
  • Edge cases - Empty values, comments, multiline strings
  • Error handling - Where exactly did parsing fail?

The parser is now 400+ lines and still doesn’t handle all YAML edge cases. We support the subset we need, but adding new .pgcomp features sometimes means parser changes.

Lesson: Consider an existing parser library if your language supports it. We couldn’t, but you might be able to.


Build System Integration Took Iteration

The bootstrap compiler solution works, but we went through multiple iterations:

  1. Attempt 1: Generate during build -> rebuild loops
  2. Attempt 2: Generate in separate step -> dependency issues
  3. Attempt 3: Bootstrap compiler -> chicken-and-egg problem
  4. Final solution: Minimal bootstrap + copy_if_different

CMake’s dependency tracking is subtle. Getting it right required patience and testing.

Lesson: Build system integration is never as simple as you think. Plan extra time.


Balancing Flexibility vs Simplicity

We have 4 setter levels. Is that too many? Not enough?

  • Some components need custom logic
  • Most components need simple patterns
  • Adding flexibility adds complexity

We’re still finding the right balance. Too many options overwhelm users. Too few options make the system inflexible.

Lesson: Start simple. Add flexibility only when needed. Resist feature creep.


Future Improvements

Ideas for next iteration:

  • Better error messages - Parser errors should show line numbers
  • Validation - Catch typos in setter levels, invalid types
  • Component relationships - Parent/child components, dependencies
  • IDE integration - Syntax highlighting for .pgcomp files
  • Hot-reload - Change .pgcomp, see changes in editor without rebuild
  • Type checking - Validate field types against C++ types
  • Automatic Docs Creation - Reuse the YAML parser to automatically create the docs of the components in our official docs

Conclusion: The Power of Meta-Programming

Let’s return to where we started: the 50th component.

Before the generator: Sigh. Another component. Open position.h, copy-paste struct definition, update field names, write 8 setters, add proxy schema, write serialization, register attach handler, test, debug, commit. 45 minutes later…

After the generator: Create WeaponComponent.pgcomp, define 5 fields, run generator. Done. 5 minutes.

That’s the power of code generation. But the benefits run deeper:


Key Takeaways

1. Boilerplate is a Signal

Repetitive code isn’t just annoying—it’s a signal. If you’re writing the same pattern over and over, automate it.

2. Dogfooding Accelerates Development

Using PgScript to build PgScript tools made the language better. Use your own creations. Feel the pain. Fix it.

3. Declarative > Imperative

Compare these:

  • 607 lines of imperative C++
  • 62 lines of declarative YAML

Which would you rather maintain?

4. Code Generation Compounds

The first component saved 545 lines. The second saved 200 more. The third will save even more. Benefits multiply with scale.

5. Meta-Programming is Accessible

You don’t need complex C++ template magic. A simple script can generate code. YAML + string concatenation is often enough.


What’s Your Boilerplate?

Think about your codebase:

  • What do you copy-paste frequently?
  • What patterns do you repeat?
  • Where do inconsistencies creep in?

That’s your opportunity for code generation.

Maybe it’s:

  • Database models
  • API endpoints
  • Serialization code
  • Unit test boilerplate
  • UI component definitions

Whatever it is, consider: Could a generator help?


Final Thought

“The best code is the code you don’t have to write.”

The component generator doesn’t just save time—it eliminates entire classes of bugs, enforces consistency, and makes our ECS architecture more accessible to everyone on the team.

559 lines eliminated. Countless bugs prevented. Infinite time saved.

That’s the journey from manual hell to declarative heaven.

Resources

Key Commits Referenced

  • 9f061bb6 - Start working on generator tool
  • 191babed - Added native functions for string/file manipulation
  • 3883d054 - Working basic component generator
  • 4c6f4b5a - Added bootstrap compiler
  • b7508293 - Minimal pgcompiler without graphical deps
  • 3f2ecc26 - Position component migration (-208 lines)
  • a9f154a4 - TTFText migration with constructor generation
  • 25754476 - Fixed event generation optimization
  • 3f771c7d - Only regenerate on change

Want to discuss code generation, ECS architecture, or game engine development? Find me on Discord or check out columbaengine.org.