Skip to content

Instantly share code, notes, and snippets.

@apotema
Last active January 1, 2026 14:37
Show Gist options
  • Select an option

  • Save apotema/2674b503317640bae72cb4a50aec8b9c to your computer and use it in GitHub Desktop.

Select an option

Save apotema/2674b503317640bae72cb4a50aec8b9c to your computer and use it in GitHub Desktop.
Architecture discussion: Simplifying labelle-tasks integration with labelle-engine

Simplifying labelle-tasks Integration with labelle-engine

Current Architecture Pain Points

1. Component Wrapper Boilerplate

Currently, bakery-game has component files that just wrap task engine calls:

// bakery-game/components/storage.zig
pub const Storage = struct {
    storage_type: StorageType,
    initial_item: ?Item,
    accepts: ?Item,

    pub fn onAdd(payload: engine.ComponentPayload) void {
        // ... lots of code to register with task engine
        task_state.addStorage(entity_id, config);
    }
};

Every component needs an onAdd hook that:

  1. Gets the game/registry
  2. Extracts component data
  3. Calls task engine registration
  4. Logs debug info

2. Two-Way Communication Complexity

  • Game → Tasks: Component callbacks call task_state.addStorage(), etc.
  • Tasks → Game: Hooks like item_delivered require game to store game_ptr
// bakery-game/components/task_state.zig
var game_ptr: ?*engine.Game = null;

pub fn setGame(game: *engine.Game) void {
    game_ptr = game;
}

pub fn item_delivered(payload: anytype) void {
    const game = game_ptr orelse return;
    game.setPositionXY(item_entity, storage_pos.x, storage_pos.y);
}

3. Duplicated State

The same conceptual data exists in multiple places:

  • Storage component on entity
  • Storage state in task engine
  • Must be kept in sync manually

Proposed Architecture

Core Idea: Tasks Exports Components, Gets ECS Access

// labelle-tasks would export components directly
const tasks = @import("labelle-tasks");

// Game uses task components in prefabs
pub const Components = engine.ComponentRegistry(struct {
    pub const Storage = tasks.Storage;      // From tasks!
    pub const Worker = tasks.Worker;        // From tasks!
    pub const Workstation = tasks.Workstation;
});

Tasks Gets Direct ECS Access

// During game init
var task_engine = tasks.Engine.init(allocator, .{
    .registry = game.getRegistry(),  // Direct ECS access
    .distance_fn = myDistanceFn,     // Already exists - calculates distance between entities
});

// Tasks registers its own ECS observers
task_engine.attachToECS();  // Sets up component callbacks internally

Note: Tasks already uses a distance callback for pathfinding decisions. It doesn't need direct position access - the game provides the distance function during initialization.

Tasks Owns Its Components

// Inside labelle-tasks
pub const Storage = struct {
    storage_type: StorageType,
    initial_item: ?Item,
    accepts: ?Item,
    
    // Tasks handles its own registration
    // No game-side onAdd needed
};

// Engine auto-registers when component is added to entity
fn onStorageAdded(entity: Entity, storage: *Storage) void {
    self.registerStorage(entity, storage);
}

Challenges

1. ECS Backend Abstraction

Tasks would need to work with engine's ECS abstraction layer, not a specific backend.

// Tasks needs to accept the engine's Registry type
pub fn Engine(comptime Registry: type) type {
    return struct {
        registry: *Registry,
        // ...
    };
}

2. Circular Dependency Risk

If tasks imports engine for ECS types, and engine imports tasks for components...

Solution: Tasks defines component data only, engine provides the observer/callback mechanism:

// tasks/components.zig - pure data, no engine dependency
pub const StorageData = struct {
    storage_type: StorageType,
    initial_item: ?Item,
};

// engine integrates via its component system
const Storage = engine.ComponentWith(tasks.StorageData, .{
    .onAdd = tasks.onStorageAdded,
});

3. Game-Specific Hooks

Tasks still needs to notify the game of events (item_delivered, worker_assigned, etc.). These hooks remain necessary for game-side reactions like:

  • Moving item visuals to storage position
  • Playing animations
  • Spawning prefabs

Benefits of New Architecture

  1. Zero boilerplate components - Use tasks components directly
  2. Single source of truth - Component IS the task state
  3. Automatic sync - ECS observers handle registration
  4. Cleaner game code - No wrapper components needed
// Before: bakery-game needs 6 component files
components/
  storage.zig      // Wrapper + onAdd
  worker.zig       // Wrapper + onAdd
  workstation.zig  // Wrapper + onAdd
  dangling_item.zig
  task_state.zig   // Global state + hooks
  items.zig

// After: Just use tasks components
pub const Components = engine.ComponentRegistry(struct {
    pub const Storage = tasks.Storage;
    pub const Worker = tasks.Worker;
    pub const Workstation = tasks.Workstation;
    pub const DanglingItem = tasks.DanglingItem;
    pub const Items = items.Items;  // Game-specific only
});

Open Questions

  1. Should tasks be tightly coupled to engine's ECS, or stay abstract?
  2. How to handle game-specific extensions to task components?
  3. Should tasks own entity creation (spawn prefabs) or just state?
  4. How to handle tasks needing game-specific types (Item enum)?

Next Steps

  1. Prototype tasks exporting a component with ECS observer
  2. Test if engine can import and use task components
  3. Measure boilerplate reduction
  4. Document the integration pattern

Simplifying labelle-tasks Integration with labelle-engine

Current Architecture Pain Points

1. Component Wrapper Boilerplate

Currently, bakery-game has component files that just wrap task engine calls:

// bakery-game/components/storage.zig
pub const Storage = struct {
    storage_type: StorageType,
    initial_item: ?Item,
    accepts: ?Item,

    pub fn onAdd(payload: engine.ComponentPayload) void {
        // ... lots of code to register with task engine
        task_state.addStorage(entity_id, config);
    }
};

Every component needs an onAdd hook that:

  1. Gets the game/registry
  2. Extracts component data
  3. Calls task engine registration
  4. Logs debug info

2. Two-Way Communication Complexity

  • Game → Tasks: Component callbacks call task_state.addStorage(), etc.
  • Tasks → Game: Hooks like item_delivered require game to store game_ptr
// bakery-game/components/task_state.zig
var game_ptr: ?*engine.Game = null;

pub fn setGame(game: *engine.Game) void {
    game_ptr = game;
}

pub fn item_delivered(payload: anytype) void {
    const game = game_ptr orelse return;
    game.setPositionXY(item_entity, storage_pos.x, storage_pos.y);
}

3. Duplicated State

The same conceptual data exists in multiple places:

  • Storage component on entity
  • Storage state in task engine
  • Must be kept in sync manually

Proposed Architecture

Core Idea: Tasks Exports Components, Gets ECS Access

// labelle-tasks would export components directly
const tasks = @import("labelle-tasks");

// Game uses task components in prefabs
pub const Components = engine.ComponentRegistry(struct {
    pub const Storage = tasks.Storage;      // From tasks!
    pub const Worker = tasks.Worker;        // From tasks!
    pub const Workstation = tasks.Workstation;
});

Tasks Gets Direct ECS Access

// During game init - Item is a comptime parameter
var task_engine = tasks.Engine(Item).init(allocator, .{
    .registry = game.getRegistry(),  // Direct ECS access
    .distance_fn = myDistanceFn,     // Calculates distance between entities
});

// Tasks registers its own ECS observers
task_engine.attachToECS();  // Sets up component callbacks internally

Tasks Owns Its Components AND Callbacks

This is the key insight: Tasks registers its own ECS observers. When the game modifies entities, the ECS automatically notifies tasks - no explicit game→tasks calls needed.

// Inside labelle-tasks
pub fn attachToECS(self: *Self, registry: *Registry) void {
    // Register observers for all task-managed components
    registry.onAdd(Storage, self, onStorageAdded);
    registry.onRemove(Storage, self, onStorageRemoved);
    
    registry.onAdd(Worker, self, onWorkerAdded);
    registry.onRemove(Worker, self, onWorkerRemoved);
    
    registry.onAdd(Workstation, self, onWorkstationAdded);
    registry.onRemove(Workstation, self, onWorkstationRemoved);
    
    registry.onAdd(DanglingItem, self, onDanglingItemAdded);
    registry.onRemove(DanglingItem, self, onDanglingItemRemoved);
}

// Internal callbacks - game never calls these explicitly
fn onStorageAdded(self: *Self, entity: Entity, storage: *Storage) void {
    self.registerStorage(entityToU64(entity), .{
        .role = storage.storage_type,
        .initial_item = storage.initial_item,
        .accepts = storage.accepts,
    });
}

fn onStorageRemoved(self: *Self, entity: Entity, storage: *Storage) void {
    self.unregisterStorage(entityToU64(entity));
}

Game Just Manipulates Entities

// Game code - no task notifications needed!
game.destroyEntity(worker_entity);  
// ^ ECS fires onWorkerRemoved internally in tasks

registry.remove(Storage, storage_entity);
// ^ ECS fires onStorageRemoved internally in tasks

Design Decisions

1. ECS Coupling

Q: Should tasks be tightly coupled to engine's ECS, or stay abstract?

A: Couple to engine's ECS abstraction.

  • labelle-engine already abstracts ECS (zig_ecs, zflecs backends)
  • Tasks couples to the abstraction, not a specific backend
  • Enables automatic callback pattern via ECS observers

2. Game-Specific Component Extensions

Q: How to handle game-specific extensions to task components?

A: Composition - game adds separate components.

// Entity has both task component and game extension
.components = .{
    .Storage = .{ .storage_type = .eis, .accepts = .Flour },  // Tasks
    .StorageStyle = .{ .style = .wooden, .glow = true },       // Game
}
  • Follows ECS philosophy: combine components as needed
  • Tasks components stay simple
  • Game has full control over its extensions

3. Entity Creation (Spawning Prefabs)

Q: Should tasks own entity creation (spawn prefabs) or just state?

A: Just state. Tasks notifies, game spawns.

// Tasks emits hook when something should be created
process_completedGame's hook handler spawns output prefab
item_produced     → Game decides which prefab to instantiate

// Tasks never calls game.spawnPrefab() directly
// Tasks doesn't know about prefabs at all

This keeps tasks as a pure state machine - it says "bread was produced" and the game decides how to visualize that (spawn bread.zon prefab, play sound, show particles, etc.)

4. Game-Specific Types (Item Enum)

Q: How to handle tasks needing game-specific types like Item enum?

A: Comptime parameter on task initialization.

// Game defines its item types
const Item = enum { Flour, Water, Yeast, Bread, Cake };

// Tasks is generic over Item type
const TaskEngine = tasks.Engine(Item);

// Components use the same type
const Storage = tasks.Storage(Item);  // storage.accepts: ?Item

This is already how tasks works today - Item is a comptime type parameter.


Communication Flow

Input: Game → Tasks (Automatic via ECS)

Game adds Storage component
  → ECS fires tasks.onStorageAdded (automatic)
  → Tasks registers storage internally

Game removes entity
  → ECS fires tasks.onStorageRemoved (automatic)
  → Tasks unregisters storage internally

No manual game→tasks calls needed.

Output: Tasks → Game (Hooks)

// Tasks emits hooks for game to react
item_deliveredGame moves item visual to storage position
worker_assignedGame plays assignment animation  
process_completedGame spawns output prefab
pickup_startedGame starts worker movement

Game subscribes to hooks it cares about.


Final Architecture

// Game initialization
const Item = @import("items.zig").Item;
const TaskEngine = tasks.Engine(Item);

var task_engine = TaskEngine.init(allocator, .{
    .distance_fn = myDistanceFn,
});
task_engine.attachToECS(game.getRegistry());

// Component registration - use tasks components directly
pub const Components = engine.ComponentRegistry(struct {
    // From tasks (no wrappers needed)
    pub const Storage = tasks.Storage(Item);
    pub const Worker = tasks.Worker;
    pub const Workstation = tasks.Workstation(Item);
    pub const DanglingItem = tasks.DanglingItem(Item);
    
    // Game-specific extensions (optional)
    pub const StorageStyle = game.StorageStyle;
    pub const WorkerVisuals = game.WorkerVisuals;
});

// Hook handlers for tasks→game communication
const TaskHooks = struct {
    pub fn process_completed(payload: anytype) void {
        // Game spawns the output prefab
        game.spawnPrefab("bread", payload.output_position);
    }
    
    pub fn item_delivered(payload: anytype) void {
        // Game moves item visual
        game.setPositionXY(payload.item_id, payload.storage_pos);
    }
};

Benefits

  1. Zero boilerplate components - Use tasks components directly
  2. Single source of truth - Component IS the task state
  3. Automatic sync - ECS observers handle registration
  4. Clean separation - Tasks = state, Game = visuals/audio
  5. Type-safe - Item type is comptime, no runtime type confusion
// Before: 6 component files with manual sync
components/
  storage.zig, worker.zig, workstation.zig,
  dangling_item.zig, task_state.zig, items.zig

// After: Just items.zig (game-specific) + optional style extensions
components/
  items.zig           // Item enum definition
  storage_style.zig   // Optional visual extensions

Next Steps

  1. Add ECS observer registration API to labelle-engine if not present
  2. Refactor tasks to export components with comptime Item type
  3. Implement attachToECS() in tasks
  4. Update bakery-game to use new pattern
  5. Measure boilerplate reduction
# Simplifying labelle-tasks Integration with labelle-engine
## Current Architecture Pain Points
### 1. Component Wrapper Boilerplate
Currently, bakery-game has component files that just wrap task engine calls:
```zig
// bakery-game/components/storage.zig
pub const Storage = struct {
storage_type: StorageType,
initial_item: ?Item,
accepts: ?Item,
pub fn onAdd(payload: engine.ComponentPayload) void {
// ... lots of code to register with task engine
task_state.addStorage(entity_id, config);
}
};
```
Every component needs an `onAdd` hook that:
1. Gets the game/registry
2. Extracts component data
3. Calls task engine registration
4. Logs debug info
### 2. Two-Way Communication Complexity
- **Game → Tasks**: Component callbacks call `task_state.addStorage()`, etc.
- **Tasks → Game**: Hooks like `item_delivered` require game to store `game_ptr`
```zig
// bakery-game/components/task_state.zig
var game_ptr: ?*engine.Game = null;
pub fn setGame(game: *engine.Game) void {
game_ptr = game;
}
pub fn item_delivered(payload: anytype) void {
const game = game_ptr orelse return;
game.setPositionXY(item_entity, storage_pos.x, storage_pos.y);
}
```
### 3. Duplicated State
The same conceptual data exists in multiple places:
- Storage component on entity
- Storage state in task engine
- Must be kept in sync manually
---
## Proposed Architecture
### Core Idea: Tasks Exports Components, Gets ECS Access
```zig
// labelle-tasks would export components directly
const tasks = @import("labelle-tasks");
// Game uses task components in prefabs
pub const Components = engine.ComponentRegistry(struct {
pub const Storage = tasks.Storage; // From tasks!
pub const Worker = tasks.Worker; // From tasks!
pub const Workstation = tasks.Workstation;
});
```
### Tasks Gets Direct ECS Access
```zig
// During game init
var task_engine = tasks.Engine.init(allocator, .{
.registry = game.getRegistry(), // Direct ECS access
.game = &game, // For position queries
});
// Tasks registers its own ECS observers
task_engine.attachToECS(); // Sets up component callbacks internally
```
### Tasks Owns Its Components
```zig
// Inside labelle-tasks
pub const Storage = struct {
storage_type: StorageType,
initial_item: ?Item,
accepts: ?Item,
// Tasks handles its own registration
// No game-side onAdd needed
};
// Engine auto-registers when component is added to entity
fn onStorageAdded(entity: Entity, storage: *Storage) void {
self.registerStorage(entity, storage);
}
```
---
## Challenges
### 1. ECS Backend Abstraction
Tasks would need to work with engine's ECS abstraction layer, not a specific backend.
```zig
// Tasks needs to accept the engine's Registry type
pub fn Engine(comptime Registry: type) type {
return struct {
registry: *Registry,
// ...
};
}
```
### 2. Circular Dependency Risk
If tasks imports engine for ECS types, and engine imports tasks for components...
**Solution**: Tasks defines component *data* only, engine provides the observer/callback mechanism:
```zig
// tasks/components.zig - pure data, no engine dependency
pub const StorageData = struct {
storage_type: StorageType,
initial_item: ?Item,
};
// engine integrates via its component system
const Storage = engine.ComponentWith(tasks.StorageData, .{
.onAdd = tasks.onStorageAdded,
});
```
### 3. Query Position from Tasks
Tasks needs positions for distance calculations. Options:
A) **Pass position getter function**:
```zig
task_engine.setPositionFn(fn(entity: u64) ?Position {
return game.getPosition(entityFromU64(entity));
});
```
B) **Tasks queries ECS directly**:
```zig
// Tasks has registry access
const pos = self.registry.tryGet(Position, entity);
```
---
## Benefits of New Architecture
1. **Zero boilerplate components** - Use tasks components directly
2. **Single source of truth** - Component IS the task state
3. **Automatic sync** - ECS observers handle registration
4. **Cleaner game code** - No wrapper components needed
```zig
// Before: bakery-game needs 6 component files
components/
storage.zig // Wrapper + onAdd
worker.zig // Wrapper + onAdd
workstation.zig // Wrapper + onAdd
dangling_item.zig
task_state.zig // Global state + hooks
items.zig
// After: Just use tasks components
pub const Components = engine.ComponentRegistry(struct {
pub const Storage = tasks.Storage;
pub const Worker = tasks.Worker;
pub const Workstation = tasks.Workstation;
pub const DanglingItem = tasks.DanglingItem;
pub const Items = items.Items; // Game-specific only
});
```
---
## Open Questions
1. Should tasks be tightly coupled to engine's ECS, or stay abstract?
2. How to handle game-specific extensions to task components?
3. Should tasks own entity creation (spawn prefabs) or just state?
4. How to handle tasks needing game-specific types (Item enum)?
---
## Next Steps
1. Prototype tasks exporting a component with ECS observer
2. Test if engine can import and use task components
3. Measure boilerplate reduction
4. Document the integration pattern
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment