Skip to content

How to Organize Project Files

Structure your IVLS product using the base-features / impl-features pattern, keeping abstract interfaces separate from concrete implementations so that multiple products can share the same engine.

Prerequisites

  • A product that is growing beyond a few files
  • Familiarity with nodes and engine boxes

Steps

1. Create the folder structure

Organize your project into the following directory layout:

Code/
    my-engine/
        base-features/         { Abstract interfaces with required function contracts }
            engine-ranges.ksp
            engine-sound-data.ksp
            engine-releases.ksp
        features/
            boxes/              { Engine box definitions (data, meta, ui) }
                my-globals/
                    my-globals-data.ksp
                    my-globals-meta.ksp
                    my-globals-ui.ksp
                my-layers/
                    my-layers-data.ksp
                    my-layers-meta.ksp
                    my-layers-ui.ksp
            playback/           { Flow definitions, spawn, play event nodes }
                events.ksp
                flows.ksp
                spawn.ksp
            gui/                { UI logic and bindings }
                gui-binds.ksp
                gui-logic.ksp
            api-modrix.ksp      { Modrix API implementation }
            purge.ksp
            serialization.ksp
        tact-assembly.ksp       { TACT config if applicable }
        ivls-product.ksp        { Product assembly and IVLS_NODES }
    my-product/
        impl-features/          { Concrete implementations for this specific product }
            my-engine-ranges.ksp
            my-engine-sound-data.ksp
            my-engine-releases.ksp
        features/
            flows.ksp           { Product-specific flow overrides }
            product-types.ksp
        ivls-product.ksp        { Final product assembly }
    build.ksp                   { Top-level build entry point }

The key separation is between my-engine/ (the reusable engine) and my-product/ (the product-specific overrides).

2. Separate abstract interfaces into base-features

Base-features files define the interface contract: they list required functions that must be implemented by any product using this engine. Use a comment block at the top of the file documenting the required functions:

{ Code/my-engine/base-features/engine-ranges.ksp }

define MyEngine.Features += MyEngine.Impl.Ranges
{
    Required functions:

    my.get_instr_min_note() -> min
        - Return minimum MIDI note for the current instrument

    my.get_instr_max_note() -> max
        - Return maximum MIDI note for the current instrument
}

The define += pattern adds the implementation node to a bundle without hardcoding which file provides it. The base-features file declares what is needed; the impl-features file delivers it.

3. Implement concrete constants in impl-features

In the product's impl-features/ directory, create the actual implementation of each base-feature interface:

{ Code/my-product/impl-features/my-engine-ranges.ksp }

node MyEngine.Impl.Ranges:
    cb Init:
        family my
            declare instrument_ranges[6, 2] := (...
                55, 98, ...
                48, 86, ...
                36, 76, ...
                24, 67, ...
                24, 98, ...
                24, 98 ...
            )
        end family

    cb Functions:
        function my.get_instr_min_note() -> min
            min := my.instrument_ranges[INSTRUMENT_ID, 0]
        end function

        function my.get_instr_max_note() -> max
            max := my.instrument_ranges[INSTRUMENT_ID, 1]
        end function
end node

Similarly for sound data, create the base-features contract:

{ Code/my-engine/base-features/engine-sound-data.ksp }

define MyEngine.SoundData += MyEngine.Impl.SoundData, ...
                             MyEngine.Base.SoundData

{
    Required functions:

    my.get_num_sounds.const()
        - Return the total number of sounds available

    my.get_play_range_min_key.const()
        - Return the lower boundary of the playable range

    my.get_play_range_max_key.const()
        - Return the upper boundary of the playable range
}

And the implementation:

{ Code/my-product/impl-features/my-engine-sound-data.ksp }

node MyEngine.Impl.SoundData:
    cb Functions:
        function my.get_num_sounds.const() -> return
            return := 144
        end function

        function my.get_play_range_min_key.const() -> return
            return := 82
        end function

        function my.get_play_range_max_key.const() -> return
            return := 96
        end function
end node

4. Split engine boxes into the three-file pattern

Each engine box is split across three files in its own subdirectory:

Data file -- declares dimensions, members, and parameter names:

{ Code/my-engine/features/boxes/my-layers/my-layers-data.ksp }

define my.layers.NUM_DIMS := 2
define my.layers.DIMS := layer, par
define my.layers.MULTS := layer
define my.layers.SIZES := 8, 128

define my.layers.layer.MEMBERS := A, B, C, D
define my.layers.par.MEMBERS := ...
    enable, sound_id, volume, pan, mute, solo, coarse_tune, fine_tune

node MyEngine.LayerData:
    cb Init:
        Data.InitializeDimension(my.layers, layer)
        Data.InitializeDimension(my.layers, par)
        Data.CreateMultiDim(my.layers)

    cb Reload:
        Data.PCCB(my.layers)

    cb UICBS:
        Data.UICBS(my.layers)

    cb Functions:
        Data.Functions(my.layers)
end node

Meta file -- defines parameter ranges, defaults, and display properties:

{ Code/my-engine/features/boxes/my-layers/my-layers-meta.ksp }

node MyEngine.LayerMeta:
    cb Init:
        { Set engine ranges and defaults for each parameter }
        my.layers.par_meta[my.layers.par.ENABLE].engine_min := 0
        my.layers.par_meta[my.layers.par.ENABLE].engine_max := 1
        my.layers.par_meta[my.layers.par.ENABLE].default := 1

        my.layers.par_meta[my.layers.par.VOLUME].engine_min := 0
        my.layers.par_meta[my.layers.par.VOLUME].engine_max := 1000000
        my.layers.par_meta[my.layers.par.VOLUME].default := 750000
end node

UI file -- handles parameter display and UI callbacks:

{ Code/my-engine/features/boxes/my-layers/my-layers-ui.ksp }

node MyEngine.LayerUI:
    cb Functions:
        function my.layers.par_disp.response(layer, par, value, par_prefix_str, par_value_str)
            par_prefix_str := f'Layer < char_up[layer] >'

            select par
                case my.layers.par.VOLUME
                    par_value_str := group[grp].par[EP(VOLUME)].disp & " dB"
                case my.layers.par.PAN
                    par_value_str := group[grp].par[EP(PAN)].disp
            end select
        end function
end node

5. Organize node bundles with define += pattern

Use define += to accumulate nodes into bundles without monolithic lists. Each feature file adds itself to the appropriate bundle:

{ In features/playback/flows.ksp }
define MyEngine.PlaybackFeatures += MyEngine.Flows

{ In features/playback/spawn.ksp }
define MyEngine.PlaybackFeatures += MyEngine.Spawn.Layers

{ In features/playback/events.ksp }
define MyEngine.PlaybackFeatures += MyEngine.PlayEvent

Then in the engine box data files:

{ In features/boxes/my-layers/my-layers-data.ksp }
define MyEngine.Engine += MyEngine.LayerData

These bundles are referenced in IVLS_NODES:

define IVLS_NODES += ..., ...
                     MyEngine.Engine, ...
                     MyEngine.PlaybackFeatures, ...
                     MyEngine.UI.Bundle, ...
                     ...

6. Assemble the final product

The product-level ivls-product.ksp ties everything together:

{ Code/my-product/ivls-product.ksp }

define ivls.THREADS := 4

define IVLS_NODES += MyEngine.SoundData, ...
                     MyEngine.Engine, ...
                     MyEngine.PlaybackFeatures, ...
                     MyEngine.UI.Bundle, ...
                     MyProduct.Features

define PRODUCT_GUI_HEIGHT := 730

import '_IVLS/builder.ksp'

Complete folder layout

Code/
    my-engine/
        base-features/
            engine-ranges.ksp         { interface: get_instr_min_note, get_instr_max_note }
            engine-sound-data.ksp     { interface: get_num_sounds, get_play_range }
            engine-releases.ksp       { interface: release config }
        features/
            boxes/
                my-globals/
                    my-globals-data.ksp
                    my-globals-meta.ksp
                    my-globals-ui.ksp
                my-layers/
                    my-layers-data.ksp
                    my-layers-meta.ksp
                    my-layers-ui.ksp
            playback/
                events.ksp
                flows.ksp
                spawn.ksp
            gui/
                gui-binds.ksp
                gui-logic.ksp
            api-modrix.ksp
            purge.ksp
        tact-assembly.ksp
        ivls-product.ksp
    my-product/
        impl-features/
            my-engine-ranges.ksp      { implements: get_instr_min_note, get_instr_max_note }
            my-engine-sound-data.ksp  { implements: get_num_sounds, get_play_range }
        features/
            flows.ksp
        ivls-product.ksp
    build.ksp

Verify

  1. Build your product -- all base-feature required functions should be resolved by impl-features
  2. If a required function is missing, the build will fail with an undefined function error, pointing you to the missing implementation
  3. Check that adding a new product variant only requires creating a new impl-features/ directory and product-level ivls-product.ksp

Further reading

  • Guide: Nodes for how define += bundles work
  • Guide: Multi-Dim Engine Boxes for the three-file box pattern
  • Guide: run<< and read<< blocks for how Python packages integrate with the build