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"}}'