Skip to content

Developer Guide — Add a Module

Step-by-step guide to creating a new Module in projectMM. For the architecture behind modules, see architecture.md. For the full Module definition-of-done checklist, see standards.md — Definition of Done.


Minimal interface

A module must implement exactly two methods: name() and loop(). Everything else is opt-in.

#pragma once
#include "core/StatefulModule.h"

class MyModule : public StatefulModule {
public:
    const char* name()     const override { return "MyModule"; }
    const char* category() const override { return "effect"; }
    void loop() override {
        // hot path — no allocations, no blocking
    }
};

Register it, build, and it is usable at runtime. setup() and teardown() default to no-ops; override them only when the module allocates resources.

category() should be one of "effect", "modifier", "layout", "driver", "layer", "system". It controls the UI group and the folder where the header lives (see Step 1).


Full pattern — controls and inputs

Most modules manage controls and data-flow inputs:

#pragma once
#include "core/StatefulModule.h"

class MyModule : public StatefulModule {
public:
    const char* name()     const override { return "MyModule"; }
    const char* category() const override { return "effect"; }

    // Called to wire data-flow inputs (e.g. an EffectsLayer).
    // Only setInput() needs to be overridden — props and state are handled automatically.
    void setInput(const char* key, Module* src) override {
        if (strcmp(key, "layer") == 0)
            layer_ = static_cast<EffectsLayer*>(src);
    }

    void setup() override {
        // Register controls before using their fields.
        // Any pending prop or saved state value is applied here by addControl().
        addControl(speed_, "speed", "slider", 0, 255);
    }

    void loop() override {
        // hot path — no allocations, no blocking
    }

    void teardown() override {
        layer_ = nullptr;
    }

    size_t classSize() const override { return sizeof(*this); }

private:
    EffectsLayer* layer_ = nullptr;
    uint8_t speed_ = 128;
};

Rules: - Default constructor only — no arguments. Config arrives via setInput() and controls. - All memory allocated in setup(), freed in teardown(). - No allocations or blocking calls in loop(). - Register a control before you use its field in setup(). addControl() applies any pending prop or saved state value at registration time — so the field is correct when the next line of setup() runs. - Do not override setProps, loadState, or saveState — the base class handles all three automatically.


Optional metadata

Override these to enable UI filtering and future dimension validation:

const char* tags() const override { return "🌊🆕"; }  // emoji shown in the add-module picker
uint8_t     dim()  const override { return DIM_3D; }   // DIM_ANY, DIM_1D, DIM_2D, DIM_3D

Step 1 — Create the header

Place the header in the subfolder matching its category:

category folder
"effect" src/modules/effects/
"modifier" src/modules/modifiers/
"layout" src/modules/layers/
"driver" src/modules/drivers/
"system" src/modules/system/

Create src/modules/effects/MyModule.h with the pattern above.


Step 2 — Register the module

Add one line to src/modules/ModuleRegistrations.cpp:

#include "modules/effects/MyModule.h"
// ...
REGISTER_MODULE(MyModule)

The macro maps the string "MyModule" to a factory. It must be in ModuleRegistrations.cpp (compiled into the executable, not the static library) to prevent the linker from dead-stripping it.


Step 3 — Write a test

#include "doctest.h"
#include "modules/effects/MyModule.h"

TEST_CASE("MyModule — loop runs without crash") {
    MyModule m;
    m.runSetup();
    m.runLoop();
    m.runTeardown();
}

Run with deploy/build/pc/tests/tests. See deploy.md for build instructions.


Step 4 — Add a doc page

Create docs/modules/my-module.md with at minimum:

  • What the module does
  • Its controls (name, type, range, default)
  • Any platform constraints or known limitations

Register it in mkdocs.yml under Modules.


Step 5 — Add at runtime

After building and running:

curl -X POST http://localhost:80/api/modules \
  -H 'Content-Type: application/json' \
  -d '{"type":"MyModule","id":"my1","props":{}}'

Or use the + button in the frontend UI. The module appears immediately and its state is persisted to state/my1.json.


Props and state — automatic via addControl()

Props (from modulemanager.json) and saved state (from state/<id>.json) are applied automatically. You do not need to override setProps, loadState, or saveState.

The framework calls setProps and loadState before setup() runs. Both stash their values into a pending store. When you call addControl(speed_, "speed", ...) in setup(), the framework immediately applies any stashed value for "speed" to speed_. After setup() completes, the pending store is cleared.

saveState is handled the same way: the base class iterates all registered controls and writes their current field values automatically on teardown.

// No overrides needed — this is handled by the base class:
//   setProps  → stashes into pendingProps_
//   loadState → merges into pendingProps_ (overrides props)
//   saveState → iterates ControlDescriptors, writes each field value

// Just register controls in setup() — values are already correct:
void setup() override {
    addControl(speed_, "speed", "slider", 0, 255);  // applies stashed value here
}

The only exception is array-valued props (e.g. "start": [x, y, z]) or props that trigger side effects. In those cases, override setProps and call StatefulModule::setProps(props) for the generic path, then handle the special case.


setInput for data-flow wiring

If your module reads from another module (e.g. a EffectsLayer), override setInput:

void setInput(const char* key, Module* src) override {
    if (strcmp(key, "layer") == 0)
        layer_ = static_cast<EffectsLayer*>(src);
}

Wire it at add-time:

curl -X POST http://localhost:80/api/modules \
  -d '{"type":"MyModule","id":"my1","inputs":{"layer":"producer1"}}'