GroveEngine/docs/DEVELOPER_GUIDE.md
StillHammer 1b7703f07b feat(IIO)!: BREAKING CHANGE - Callback-based message dispatch
## Breaking Change

IIO API redesigned from manual pull+if-forest to callback dispatch.
All modules must update their subscribe() calls to pass handlers.

### Before (OLD API)
```cpp
io->subscribe("input:mouse");

void process(...) {
    while (io->hasMessages()) {
        auto msg = io->pullMessage();
        if (msg.topic == "input:mouse") {
            handleMouse(msg);
        } else if (msg.topic == "input:keyboard") {
            handleKeyboard(msg);
        }
    }
}
```

### After (NEW API)
```cpp
io->subscribe("input:mouse", [this](const Message& msg) {
    handleMouse(msg);
});

void process(...) {
    while (io->hasMessages()) {
        io->pullAndDispatch();  // Callbacks invoked automatically
    }
}
```

## Changes

**Core API (include/grove/IIO.h)**
- Added: `using MessageHandler = std::function<void(const Message&)>`
- Changed: `subscribe()` now requires `MessageHandler` callback parameter
- Changed: `subscribeLowFreq()` now requires `MessageHandler` callback
- Removed: `pullMessage()`
- Added: `pullAndDispatch()` - pulls and auto-dispatches to handlers

**Implementation (src/IntraIO.cpp)**
- Store callbacks in `Subscription.handler`
- `pullAndDispatch()` matches topic against ALL subscriptions (not just first)
- Fixed: Regex pattern compilation supports both wildcards (*) and regex (.*)
- Performance: ~1000 msg/s throughput (unchanged from before)

**Files Updated**
- 31 test/module files migrated to callback API (via parallel agents)
- 8 documentation files updated (DEVELOPER_GUIDE, USER_GUIDE, module READMEs)

## Bugs Fixed During Migration

1. **pullAndDispatch() early return bug**: Was only calling FIRST matching handler
   - Fix: Loop through ALL subscriptions, invoke all matching handlers

2. **Regex pattern compilation bug**: Pattern "player:.*" failed to match
   - Fix: Detect ".*" in pattern → use as regex, otherwise escape and convert wildcards

## Testing

 test_11_io_system: PASSED (IIO pub/sub, pattern matching, batching)
 test_threaded_module_system: 6/6 PASSED
 test_threaded_stress: 5/5 PASSED (50 modules, 100x reload, concurrent ops)
 test_12_datanode: PASSED
 10 TopicTree scenarios: 10/10 PASSED
 benchmark_e2e: ~1000 msg/s throughput

Total: 23+ tests passing

## Performance Impact

No performance regression from callback dispatch:
- IIO throughput: ~1000 msg/s (same as before)
- ThreadedModuleSystem: Speedup ~1.0x (barrier pattern expected)

## Migration Guide

For all modules using IIO:

1. Update subscribe() calls to include handler lambda
2. Replace pullMessage() loops with pullAndDispatch()
3. Move topic-specific logic from if-forest into callbacks

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-19 14:19:27 +07:00

31 KiB

GroveEngine - Developer Guide

Comprehensive guide for building applications with GroveEngine

⚠️ IMPORTANT: GroveEngine is currently in development stage - suitable for prototyping and experimentation, not production games. The engine is non-deterministic and optimized for rapid iteration, not stability. See Current Limitations below.

Table of Contents

  1. Current Limitations
  2. Getting Started
  3. Core System
  4. Available Modules
  5. IIO Topics Reference
  6. Complete Application Example
  7. Building Your First Game
  8. Advanced Topics

Current Limitations

⚠️ GroveEngine is EXPERIMENTAL and NOT production-ready. Understand these limitations before building with it:

Non-Deterministic Execution

  • Module execution order is NOT guaranteed - modules may run in different orders between frames
  • Not suitable for networked games - no deterministic replay or synchronization
  • Race conditions possible - only SequentialModuleSystem is currently implemented (single-threaded)

Development Stage

  • Optimized for rapid iteration, not stability
  • No error recovery - crashes are not handled gracefully
  • Limited performance optimizations - no profiling, memory pooling, or SIMD
  • Single-threaded only - ThreadedModuleSystem and MultithreadedModuleSystem are TODO

Module Limitations

  • InputModule: Mouse and keyboard only (gamepad Phase 2 not implemented)
  • BgfxRenderer: Basic text rendering only (8x8 bitmap font for debug)
  • UIModule: Functional but no advanced layout constraints

What GroveEngine IS Good For

Rapid prototyping - 0.4ms hot-reload for instant iteration Learning modular architecture - clean interface-based design AI-assisted development - 200-300 line modules optimized for Claude Code Experimentation - test game ideas quickly

Production Roadmap

To make GroveEngine production-ready, the following is needed:

  • Deterministic execution guarantees
  • Error recovery and graceful degradation
  • Multi-threaded module systems
  • Performance profiling and optimization
  • Network IO and distributed messaging
  • Complete gamepad support
  • Advanced text rendering

Getting Started

Prerequisites

  • C++17 compiler (GCC, Clang, or MSVC)
  • CMake 3.20+
  • Git for dependency management

Quick Start

# Clone GroveEngine
git clone <grove-engine-repo> GroveEngine
cd GroveEngine

# Build with all modules
cmake -B build -DGROVE_BUILD_BGFX_RENDERER=ON -DGROVE_BUILD_UI_MODULE=ON -DGROVE_BUILD_INPUT_MODULE=ON
cmake --build build -j4

# Run tests
cd build && ctest --output-on-failure

Documentation Structure


Core System

Architecture Overview

GroveEngine uses a module-based architecture with hot-reload support:

┌─────────────────────────────────────────────────────────────┐
│                    Your Application                          │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐   │
│  │  Input   │  │   UI     │  │ Renderer │  │  Game    │   │
│  │  Module  │  │  Module  │  │  Module  │  │  Logic   │   │
│  └────┬─────┘  └────┬─────┘  └────┬─────┘  └────┬─────┘   │
│       └─────────────┼─────────────┼─────────────┘          │
│                     │  IIO Pub/Sub System                   │
└─────────────────────┼───────────────────────────────────────┘
                      │
                IntraIOManager

Key Concepts

Component Purpose Documentation
IModule Module interface USER_GUIDE.md
IIO Pull-based pub/sub with callback dispatch USER_GUIDE.md
IDataNode Configuration & data USER_GUIDE.md
ModuleLoader Hot-reload system USER_GUIDE.md

IIO Callback Dispatch Pattern

GroveEngine uses a pull-based callback dispatch pattern for message processing:

// OLD API (deprecated):
// io->subscribe("topic:pattern");
// while (io->hasMessages()) {
//     auto msg = io->pullMessage();
//     if (msg.topic == "topic:pattern") { /* handle */ }
// }

// NEW API (callback-based):
io->subscribe("topic:pattern", [this](const Message& msg) {
    // Handle message - no if-forest needed
});

while (io->hasMessages()) {
    io->pullAndDispatch();  // Callbacks invoked automatically
}

Key advantages:

  • No if-forest dispatch: Register handlers at subscription, not in process loop
  • Module controls WHEN: Pull-based processing for deterministic ordering
  • Callbacks handle HOW: Clean separation of concerns
  • Thread-safe: Callbacks invoked in module's thread context

Available Modules

BgfxRenderer - 2D Rendering

Status: Development Ready (Phase 8 complete) | ⚠️ Non-deterministic, experimental

Multi-backend 2D renderer using bgfx (DirectX 11/12, OpenGL, Vulkan, Metal).

Features

  • Sprite rendering with batching
  • Text rendering with bitmap fonts
  • Tilemap support
  • Particle effects
  • Debug shapes (lines, rectangles)
  • Layer-based Z-ordering
  • Multi-texture batching
  • Headless mode for testing

Configuration

JsonDataNode config("config");
config.setInt("windowWidth", 1920);
config.setInt("windowHeight", 1080);
config.setString("backend", "auto");  // auto, opengl, vulkan, dx11, dx12, metal, noop
config.setString("shaderPath", "./shaders");
config.setBool("vsync", true);
config.setInt("maxSpritesPerBatch", 10000);
config.setInt("nativeWindowHandle", (int)(intptr_t)hwnd);  // Platform window handle

renderer->setConfiguration(config, rendererIO.get(), nullptr);

Rendering a Sprite

// Publish sprite to render
auto sprite = std::make_unique<JsonDataNode>("sprite");
sprite->setDouble("x", 100.0);
sprite->setDouble("y", 200.0);
sprite->setDouble("scaleX", 1.0);
sprite->setDouble("scaleY", 1.0);
sprite->setDouble("rotation", 0.0);        // Radians
sprite->setInt("color", 0xFFFFFFFF);       // RGBA
sprite->setInt("textureId", playerTexture);
sprite->setInt("layer", 10);               // Z-order (higher = front)
io->publish("render:sprite", std::move(sprite));

Rendering Text

auto text = std::make_unique<JsonDataNode>("text");
text->setDouble("x", 50.0);
text->setDouble("y", 50.0);
text->setString("text", "Score: 100");
text->setDouble("fontSize", 24.0);
text->setInt("color", 0xFFFFFFFF);
text->setInt("layer", 100);                // Text on top
io->publish("render:text", std::move(text));

Camera Control

auto camera = std::make_unique<JsonDataNode>("camera");
camera->setDouble("x", playerX);           // Center camera on player
camera->setDouble("y", playerY);
camera->setDouble("zoom", 1.0);
camera->setInt("viewportX", 0);
camera->setInt("viewportY", 0);
camera->setInt("viewportW", 1920);
camera->setInt("viewportH", 1080);
io->publish("render:camera", std::move(camera));

Full Topic Reference: See IIO Topics - Rendering


UIModule - User Interface

Status: Development Ready (Phase 7 complete) | ⚠️ Experimental

Complete UI widget system with layout, scrolling, and tooltips.

Available Widgets

Widget Purpose Events
UIButton Clickable button ui:click, ui:action
UILabel Static text -
UIPanel Container -
UICheckbox Toggle checkbox ui:value_changed
UISlider Value slider ui:value_changed
UITextInput Text input field ui:value_changed, ui:text_submitted
UIProgressBar Progress display -
UIImage Image/sprite -
UIScrollPanel Scrollable container ui:scroll
UITooltip Hover tooltip -

Configuration

JsonDataNode uiConfig("config");
uiConfig.setInt("windowWidth", 1920);
uiConfig.setInt("windowHeight", 1080);
uiConfig.setString("layoutFile", "./ui/main_menu.json");  // JSON layout
uiConfig.setInt("baseLayer", 1000);  // UI renders above game (layer 1000+)

uiModule->setConfiguration(uiConfig, uiIO.get(), nullptr);

Creating a Button

// In your layout JSON file (ui/main_menu.json)
{
  "type": "UIButton",
  "id": "play_button",
  "x": 100,
  "y": 200,
  "width": 200,
  "height": 50,
  "text": "Play Game",
  "action": "start_game"
}
// In your game module - subscribe to button events with callbacks (in setConfiguration)
gameIO->subscribe("ui:action", [this](const grove::Message& msg) {
    std::string action = msg.data->getString("action", "");
    std::string widgetId = msg.data->getString("widgetId", "");

    if (action == "start_game" && widgetId == "play_button") {
        startGame();
    }
});

// In process() - pull and dispatch to callbacks
while (gameIO->hasMessages() > 0) {
    gameIO->pullAndDispatch();  // Callback invoked automatically
}

Handling Input Events

UIModule automatically consumes input events from InputModule:

// UIModule subscribes to:
// - input:mouse:move
// - input:mouse:button
// - input:mouse:wheel
// - input:keyboard:key
// - input:keyboard:text

// Your game module just subscribes to UI events:
gameIO->subscribe("ui:*");  // All UI events

UI Rendering

UIModule publishes render commands to BgfxRenderer via UIRenderer:

// UIModule uses retained mode rendering (only publishes on change):
// - render:sprite:add/update/remove (for UI rectangles/images)
// - render:text:add/update/remove (for labels/buttons)

// BgfxRenderer consumes these and renders the UI
// Layer management ensures UI renders on top (layer 1000+)

Retained Mode: Widgets cache render state and only publish IIO messages when visual properties change. This reduces message traffic by 85%+ for typical UIs. See UI Rendering Documentation for details.

Full Topic Reference: See IIO Topics - UI Events


InputModule - Input Handling

Status: Development Ready (Phase 1-3 complete) | ⚠️ Gamepad Phase 2 TODO

Cross-platform input handling with SDL2 backend (mouse, keyboard).

Features

  • Mouse (move, button, wheel)
  • Keyboard (key events, text input)
  • Thread-safe event buffering
  • Multiple backend support (SDL2, extensible)
  • Hot-reload support

Configuration

JsonDataNode inputConfig("config");
inputConfig.setString("backend", "sdl");
inputConfig.setBool("enableMouse", true);
inputConfig.setBool("enableKeyboard", true);
inputConfig.setBool("enableGamepad", false);  // Phase 2

inputModule->setConfiguration(inputConfig, inputIO.get(), nullptr);

Feeding Events (SDL2)

// In your main loop
SDL_Event event;
while (SDL_PollEvent(&event)) {
    // Feed to InputModule (thread-safe)
    inputModule->feedEvent(&event);

    // Also handle window events
    if (event.type == SDL_QUIT) {
        running = false;
    }
}

// Process InputModule (converts buffered events → IIO messages)
JsonDataNode input("input");
inputModule->process(input);

Consuming Input Events with Callbacks

// Subscribe to input topics with callback handlers (in setConfiguration)
gameIO->subscribe("input:mouse:button", [this](const grove::Message& msg) {
    int button = msg.data->getInt("button", 0);  // 0=left, 1=middle, 2=right
    bool pressed = msg.data->getBool("pressed", false);
    double x = msg.data->getDouble("x", 0.0);
    double y = msg.data->getDouble("y", 0.0);

    if (button == 0 && pressed) {
        // Left mouse button pressed at (x, y)
        handleClick(x, y);
    }
});

gameIO->subscribe("input:keyboard:key", [this](const grove::Message& msg) {
    int scancode = msg.data->getInt("scancode", 0);  // SDL_SCANCODE_*
    bool pressed = msg.data->getBool("pressed", false);

    if (scancode == SDL_SCANCODE_SPACE && pressed) {
        playerJump();
    }
});

// In process() - pull and auto-dispatch to callbacks
while (gameIO->hasMessages() > 0) {
    gameIO->pullAndDispatch();  // Callbacks invoked automatically
}

Full Topic Reference: See IIO Topics - Input Events


IIO Topics Reference

Input Events

Published by InputModule, consumed by UIModule or game logic.

Mouse

Topic Payload Description
input:mouse:move {x: double, y: double} Mouse position (screen coords)
input:mouse:button {button: int, pressed: bool, x: double, y: double} Mouse click (0=left, 1=middle, 2=right)
input:mouse:wheel {delta: double} Mouse wheel (+up, -down)

Keyboard

Topic Payload Description
input:keyboard:key {scancode: int, pressed: bool, repeat: bool, shift: bool, ctrl: bool, alt: bool} Key event (scancode = SDL_SCANCODE_*)
input:keyboard:text {text: string} Text input (UTF-8, for TextInput widgets)

UI Events

Published by UIModule, consumed by game logic.

Topic Payload Description
ui:click {widgetId: string, x: double, y: double} Widget clicked
ui:action {widgetId: string, action: string} Button action triggered
ui:value_changed {widgetId: string, value: variant} Slider, checkbox, or text input changed
ui:text_submitted {widgetId: string, text: string} Text input submitted (Enter key)
ui:hover {widgetId: string, enter: bool} Mouse entered/left widget
ui:scroll {widgetId: string, scrollX: double, scrollY: double} Scroll panel scrolled

Rendering Topics

Consumed by BgfxRenderer, published by UIModule or game logic.

Sprites

Retained Mode (UIModule current):

Topic Payload Description
render:sprite:add {renderId, x, y, scaleX, scaleY, color, textureId, layer} Register new sprite (retained)
render:sprite:update {renderId, x, y, scaleX, scaleY, color, textureId, layer} Update existing sprite
render:sprite:remove {renderId} Unregister sprite

Immediate Mode (legacy, still supported):

Topic Payload Description
render:sprite {x, y, scaleX, scaleY, rotation, u0, v0, u1, v1, color, textureId, layer} Render single sprite (ephemeral)
render:sprite:batch {sprites: [array]} Render sprite batch (optimized)

Text

Retained Mode (UIModule current):

Topic Payload Description
render:text:add {renderId, x, y, text, fontSize, color, layer} Register new text (retained)
render:text:update {renderId, x, y, text, fontSize, color, layer} Update existing text
render:text:remove {renderId} Unregister text

Immediate Mode (legacy, still supported):

Topic Payload Description
render:text {x, y, text, fontSize, color, layer} Render text (ephemeral)

Note: See UI Rendering Documentation for details on retained mode rendering.

Tilemap

Topic Payload Description
render:tilemap {chunkX, chunkY, tiles: [array], tileSize, textureId, layer} Render tilemap chunk

Particles

Topic Payload Description
render:particle {x, y, velocityX, velocityY, color, lifetime, textureId, layer} Render particle

Camera

Topic Payload Description
render:camera {x, y, zoom, viewportX, viewportY, viewportW, viewportH} Set camera transform

Clear

Topic Payload Description
render:clear {color: int} Set clear color (RGBA)

Debug

Topic Payload Description
render:debug:line {x1, y1, x2, y2, color} Draw debug line
render:debug:rect {x, y, w, h, color, filled} Draw debug rectangle

Complete Application Example

Directory Structure

MyGame/
├── CMakeLists.txt
├── src/
│   ├── main.cpp
│   └── modules/
│       ├── GameLogic.h
│       └── GameLogic.cpp
├── assets/
│   ├── ui/
│   │   └── main_menu.json
│   └── sprites/
│       └── player.png
└── external/
    └── GroveEngine/  # Git submodule

CMakeLists.txt

cmake_minimum_required(VERSION 3.20)
project(MyGame VERSION 1.0.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# GroveEngine + Modules
add_subdirectory(external/GroveEngine)
set(GROVE_BUILD_BGFX_RENDERER ON CACHE BOOL "" FORCE)
set(GROVE_BUILD_UI_MODULE ON CACHE BOOL "" FORCE)
set(GROVE_BUILD_INPUT_MODULE ON CACHE BOOL "" FORCE)

# Main executable
add_executable(mygame src/main.cpp)
target_link_libraries(mygame PRIVATE
    GroveEngine::impl
    SDL2::SDL2
    spdlog::spdlog
)

# Game logic module
add_library(GameLogic SHARED
    src/modules/GameLogic.cpp
)
target_link_libraries(GameLogic PRIVATE
    GroveEngine::impl
    spdlog::spdlog
)
set_target_properties(GameLogic PROPERTIES
    LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/modules
)

main.cpp

#include <grove/ModuleLoader.h>
#include <grove/IntraIOManager.h>
#include <grove/JsonDataNode.h>
#include <SDL2/SDL.h>
#include <iostream>

int main(int argc, char* argv[]) {
    // Initialize SDL
    SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS);
    SDL_Window* window = SDL_CreateWindow("MyGame", SDL_WINDOWPOS_CENTERED,
        SDL_WINDOWPOS_CENTERED, 1920, 1080, SDL_WINDOW_SHOWN);

    // Get native window handle
    SDL_SysWMinfo wmInfo;
    SDL_VERSION(&wmInfo.version);
    SDL_GetWindowWMInfo(window, &wmInfo);
    void* nativeHandle = nullptr;
#ifdef _WIN32
    nativeHandle = wmInfo.info.win.window;  // HWND
#elif __linux__
    nativeHandle = (void*)(uintptr_t)wmInfo.info.x11.window;
#endif

    // Create IIO instances
    auto& ioManager = grove::IntraIOManager::getInstance();
    auto rendererIO = ioManager.createInstance("renderer");
    auto uiIO = ioManager.createInstance("ui");
    auto inputIO = ioManager.createInstance("input");
    auto gameIO = ioManager.createInstance("game");

    // Load modules
    grove::ModuleLoader rendererLoader, uiLoader, inputLoader, gameLoader;

    auto renderer = rendererLoader.load("./modules/BgfxRenderer.dll", "renderer");
    auto uiModule = uiLoader.load("./modules/UIModule.dll", "ui");
    auto inputModule = inputLoader.load("./modules/InputModule.dll", "input");
    auto gameModule = gameLoader.load("./modules/GameLogic.dll", "game");

    // Configure BgfxRenderer
    grove::JsonDataNode rendererConfig("config");
    rendererConfig.setInt("windowWidth", 1920);
    rendererConfig.setInt("windowHeight", 1080);
    rendererConfig.setString("backend", "auto");
    rendererConfig.setInt("nativeWindowHandle", (int)(intptr_t)nativeHandle);
    renderer->setConfiguration(rendererConfig, rendererIO.get(), nullptr);

    // Configure UIModule
    grove::JsonDataNode uiConfig("config");
    uiConfig.setInt("windowWidth", 1920);
    uiConfig.setInt("windowHeight", 1080);
    uiConfig.setString("layoutFile", "./assets/ui/main_menu.json");
    uiConfig.setInt("baseLayer", 1000);
    uiModule->setConfiguration(uiConfig, uiIO.get(), nullptr);

    // Configure InputModule
    grove::JsonDataNode inputConfig("config");
    inputConfig.setString("backend", "sdl");
    inputModule->setConfiguration(inputConfig, inputIO.get(), nullptr);

    // Configure GameLogic
    grove::JsonDataNode gameConfig("config");
    gameModule->setConfiguration(gameConfig, gameIO.get(), nullptr);

    // Main loop
    bool running = true;
    Uint64 lastTime = SDL_GetPerformanceCounter();

    while (running) {
        // 1. Handle SDL events
        SDL_Event event;
        while (SDL_PollEvent(&event)) {
            if (event.type == SDL_QUIT) {
                running = false;
            }
            inputModule->feedEvent(&event);  // Feed to InputModule
        }

        // 2. Calculate deltaTime
        Uint64 now = SDL_GetPerformanceCounter();
        double deltaTime = (now - lastTime) / (double)SDL_GetPerformanceFrequency();
        lastTime = now;

        // 3. Process all modules
        grove::JsonDataNode input("input");
        input.setDouble("deltaTime", deltaTime);

        inputModule->process(input);   // Input → IIO messages
        uiModule->process(input);      // UI → IIO messages
        gameModule->process(input);    // Game logic
        renderer->process(input);      // Render frame

        // 4. Optional: Hot-reload check
        // (file watcher code here)
    }

    // Cleanup
    renderer->shutdown();
    uiModule->shutdown();
    inputModule->shutdown();
    gameModule->shutdown();

    SDL_DestroyWindow(window);
    SDL_Quit();

    return 0;
}

GameLogic.cpp

#include <grove/IModule.h>
#include <grove/JsonDataNode.h>
#include <grove/IIO.h>
#include <spdlog/spdlog.h>

class GameLogic : public grove::IModule {
public:
    GameLogic() {
        m_logger = spdlog::stdout_color_mt("GameLogic");
    }

    void setConfiguration(const grove::IDataNode& config,
                          grove::IIO* io,
                          grove::ITaskScheduler* scheduler) override {
        m_io = io;

        // Subscribe to UI events with callback handlers
        m_io->subscribe("ui:action", [this](const grove::Message& msg) {
            std::string action = msg.data->getString("action", "");
            if (action == "start_game") {
                startGame();
            }
        });

        m_io->subscribe("ui:click", [this](const grove::Message& msg) {
            std::string widgetId = msg.data->getString("widgetId", "");
            double x = msg.data->getDouble("x", 0.0);
            double y = msg.data->getDouble("y", 0.0);
            handleClick(widgetId, x, y);
        });
    }

    void process(const grove::IDataNode& input) override {
        double deltaTime = input.getDouble("deltaTime", 0.016);

        // Process UI events - pull and auto-dispatch to callbacks
        while (m_io->hasMessages() > 0) {
            m_io->pullAndDispatch();  // Callbacks invoked automatically
        }

        // Update game logic
        if (m_gameStarted) {
            updatePlayer(deltaTime);
            renderPlayer();
        }
    }

    // ... other IModule methods ...

private:
    void startGame() {
        m_gameStarted = true;
        m_playerX = 960.0;
        m_playerY = 540.0;
        m_logger->info("Game started!");
    }

    void updatePlayer(double deltaTime) {
        // Update player position, etc.
    }

    void renderPlayer() {
        // Publish sprite to renderer
        auto sprite = std::make_unique<grove::JsonDataNode>("sprite");
        sprite->setDouble("x", m_playerX);
        sprite->setDouble("y", m_playerY);
        sprite->setInt("textureId", 0);  // Player texture
        sprite->setInt("layer", 10);
        m_io->publish("render:sprite", std::move(sprite));
    }

    std::shared_ptr<spdlog::logger> m_logger;
    grove::IIO* m_io = nullptr;
    bool m_gameStarted = false;
    double m_playerX = 0.0;
    double m_playerY = 0.0;
};

extern "C" {
    grove::IModule* createModule() { return new GameLogic(); }
    void destroyModule(grove::IModule* m) { delete m; }
}

Interactive Demo - Try It First!

Before reading further, try the full stack interactive demo to see everything in action:

# Windows
run_full_stack_demo.bat

# Linux
./build/tests/test_full_stack_interactive

What it demonstrates:

  • BgfxRenderer rendering sprites and text
  • UIModule with buttons, sliders, panels
  • InputModule capturing mouse and keyboard
  • Complete IIO message flow (input → UI → game → render)
  • Hit testing and click detection (raycasting 2D)
  • Game logic responding to UI events

Interactive controls:

  • Click buttons to spawn/clear sprites
  • Drag slider to change speed
  • Press SPACE to spawn from keyboard
  • Press ESC to exit

See: tests/visual/README_FULL_STACK.md for details.


Building Your First Game

Step-by-Step Tutorial

1. Create Project Structure

mkdir MyGame && cd MyGame
git init
git submodule add <grove-engine-repo> external/GroveEngine
mkdir -p src/modules assets/ui

2. Create CMakeLists.txt

(See Complete Application Example)

3. Create UI Layout

assets/ui/main_menu.json:

{
  "widgets": [
    {
      "type": "UIPanel",
      "id": "main_panel",
      "x": 0,
      "y": 0,
      "width": 1920,
      "height": 1080,
      "color": 2155905279
    },
    {
      "type": "UIButton",
      "id": "play_button",
      "x": 860,
      "y": 500,
      "width": 200,
      "height": 60,
      "text": "Play",
      "action": "start_game"
    },
    {
      "type": "UIButton",
      "id": "quit_button",
      "x": 860,
      "y": 580,
      "width": 200,
      "height": 60,
      "text": "Quit",
      "action": "quit_game"
    }
  ]
}

4. Build and Run

cmake -B build
cmake --build build -j4
./build/mygame

Advanced Topics

Hot-Reload Workflow

# Terminal 1: Run game
./build/mygame

# Terminal 2: Edit and rebuild module
vim src/modules/GameLogic.cpp
cmake --build build --target GameLogic

# Game automatically reloads GameLogic with state preserved!

Performance Optimization

Sprite Batching

// Instead of publishing 100 individual sprites:
for (auto& enemy : enemies) {
    auto sprite = std::make_unique<JsonDataNode>("sprite");
    sprite->setDouble("x", enemy.x);
    // ...
    io->publish("render:sprite", std::move(sprite));  // 100 IIO messages
}

// Publish as batch:
auto batch = std::make_unique<JsonDataNode>("batch");
auto sprites = std::make_unique<JsonDataNode>("sprites");
for (auto& enemy : enemies) {
    auto sprite = std::make_unique<JsonDataNode>("sprite");
    sprite->setDouble("x", enemy.x);
    // ...
    sprites->setChild(enemy.id, std::move(sprite));
}
batch->setChild("sprites", std::move(sprites));
io->publish("render:sprite:batch", std::move(batch));  // 1 IIO message

Low-Frequency Subscriptions

// For non-critical analytics/logging
grove::SubscriptionConfig config;
config.batchInterval = 1000;  // Batch messages for 1 second
io->subscribeLowFreq("analytics:*", config);

Multi-Module Communication Patterns

Request-Response Pattern

// Module A: Subscribe to response first (in setConfiguration)
moduleA_io->subscribe("pathfinding:response", [this](const grove::Message& msg) {
    std::string requestId = msg.data->getString("requestId", "");
    // ... apply path result ...
});

// Module A: Request pathfinding (in process)
auto request = std::make_unique<JsonDataNode>("request");
request->setString("requestId", "path_123");
request->setDouble("startX", 10.0);
request->setDouble("startY", 20.0);
moduleA_io->publish("pathfinding:request", std::move(request));

// Module B: Subscribe to request (in setConfiguration)
moduleB_io->subscribe("pathfinding:request", [this](const grove::Message& msg) {
    std::string requestId = msg.data->getString("requestId", "");
    // ... compute path ...

    auto response = std::make_unique<JsonDataNode>("response");
    response->setString("requestId", requestId);
    // ... add path data ...
    m_io->publish("pathfinding:response", std::move(response));
});

// Module A/B: In process() - pull and dispatch
while (io->hasMessages() > 0) {
    io->pullAndDispatch();  // Callbacks invoked automatically
}

Event Aggregation

// Multiple modules publish events
io->publish("combat:damage", damageData);
io->publish("combat:kill", killData);
io->publish("combat:levelup", levelupData);

// Analytics module aggregates all combat events (in setConfiguration)
analyticsIO->subscribe("combat:*", [this](const grove::Message& msg) {
    aggregateCombatEvent(msg);
});

// In process()
while (analyticsIO->hasMessages() > 0) {
    analyticsIO->pullAndDispatch();  // Callback invoked for each event
}

Testing Strategies

Headless Testing

// Configure renderer in headless mode
JsonDataNode config("config");
config.setString("backend", "noop");  // No actual rendering
config.setBool("vsync", false);
renderer->setConfiguration(config, io, nullptr);

// Run tests without window
for (int i = 0; i < 1000; i++) {
    // Simulate game logic
    renderer->process(input);
}

Integration Tests

See tests/integration/IT_014_ui_module_integration.cpp for complete example.


Troubleshooting

Common Issues

Module not loading

# Check module exports
nm -D build/modules/GameLogic.so | grep createModule
# Should show: createModule and destroyModule

# Check dependencies
ldd build/modules/GameLogic.so

IIO messages not received

// Verify subscription with callback BEFORE publishing (in setConfiguration)
io->subscribe("render:sprite", [this](const grove::Message& msg) {
    handleSprite(msg);
});

// Check topic patterns
io->subscribe("render:*", [this](const grove::Message& msg) {
    // Matches render:sprite, render:text, etc.
});

io->subscribe("render:sprite:*", [this](const grove::Message& msg) {
    // Only matches render:sprite:batch, render:sprite:add, etc.
});

// Remember to pullAndDispatch in process()
while (io->hasMessages() > 0) {
    io->pullAndDispatch();
}

Hot-reload state loss

// Ensure ALL state is serialized in getState()
std::unique_ptr<IDataNode> MyModule::getState() {
    auto state = std::make_unique<JsonDataNode>("state");

    // DON'T FORGET any member variables!
    state->setInt("score", m_score);
    state->setDouble("playerX", m_playerX);
    state->setDouble("playerY", m_playerY);
    // ...

    return state;
}

Additional Resources


GroveEngine - Build modular, hot-reloadable games with ease 🌳