Skip to main content

Craft Logic with Sutra

ยท 8 min read
Marak Squires

Introductionโ€‹

๐Ÿ‘‹๐Ÿฝ Hello! Let's get started with crafting Game custom logic using the Sutra library.

Sutraโ€‹

Sutra is a versatile library for creating and managing behavior trees in JavaScript. It allows for easy definition of complex behavior patterns using a simple and intuitive syntax. Sutras can be crafted into complex chains of behaviors, allowing for intricate game logic with minimal complexity.

Sutras can be exported to a human-readable format. If you don't prefer using code to define your Sutra we have a Visual Editor currently in development.

Crafting Sutrasโ€‹

Here we have the human read-able exported Sutra definition that we will get at the end:

if isBoss
if isHealthLow
entity::updateEntity
color: 0xff0000
speed: 5

It's clear to read that this Sutra will be responsible for changing the color and speed of isBoss when isHealthLow.

Howโ€‹

Sutras can be deeply nested, use composite conditions, evaluate dynamic conditions with scoped parameters, emit events, and each node can be dynamically updated. Sutra is full-featured. In order to navigate the feature matrix, it's best we start with a most basic example of detecting if a Boss Entity is low on health.

Full Example Code on Github

Now let's look at the actual JavaScript code that has generated the above output:

import Sutra from '@yantra-core/sutra';

// creates a new sutra instance
const sutra = new Sutra();

// adds a new condition as function which returns value
sutra.addCondition('isBoss', (entity) => entity.type === 'BOSS');

// adds a new condition using DSL conditional object
sutra.addCondition('isHealthLow', {
op: 'lessThan',
property: 'health',
value: 50
});

sutra.addAction({
if: ['isBoss', 'isHealthLow'],
then: [{
action: 'entity::updateEntity',
data: { color: 0xff0000, speed: 5 }
}]
});

// exports the sutra as json
const json = sutra.toJSON();
console.log(json);

// exports the sutra as plain english
const english = sutra.toEnglish();
console.log(english);

This simple rule set will create a Sutra that changes the color of the Boss entity when it's health is low.

Running a Sutra with Dataโ€‹

Now that we have crafted a suitable Sutra for detecting if the boss's health is low, we will need to send some data to the Sutra in order to run the behavioral tree logic.

For this example, we will create a simple array of entities:

// create a simple array of entities
let allEntities = [
{ id: 1, type: 'BOSS', health: 100 },
{ id: 2, type: 'PLAYER', health: 100 }
];

Then we'll create a simple gameTick() function to iterate through our Entities array:

// create a gameTick function for processing entities with sutra.tick()
function gameTick () {
allEntities.forEach(entity => {
sutra.tick(entity);
});
}

Now that we have a way to send data into our Sutra, we'll need to listen for events on the Sutra in order to know if any of our conditional actions have triggered.

// listen for all events that the sutra instance emits
sutra.onAny(function(ev, data, node){
// console.log('onAny', ev, data);
})

// listen for specific events that the sutra instance emits
sutra.on('entity::updateEntity', function(entity, node){
// here we can write arbitrary code to handle the event
console.log('entity::updateEntity =>', JSON.stringify(entity, true, 2));
// In `mantra`, we simply call game.emit('entity::updateEntity', data);
});

Now that we have defined our Sutra, defined data to send our Sutra, and have added event listeners to the Sutra. We can run our gameTick() function once with the Boss at full-health, then again with the Boss at low health.

// run the game tick with Boss at full health
// nothing should happen
gameTick();

// run the game tick with Boss at low health
// `entity::updateEntity` event should be emitted
allEntities[0].health = 40;
gameTick();

Results in:

entity::updateEntity => {
id: 1,
type: 'BOSS',
health: 40,
color: 0xff0000,
speed: 5
}

It's that simple. This demonstrates a single-level conditional action using basic logic.

Composition and Nested Sutrasโ€‹

In the previous example, we created a simple compositional if statement which used two conditions, isBoss and isHealthLow. Sutra supports conditional composition as well as deeply nested behavior trees.

For example, our previous Sutra Action could be rewritten as:

sutra.addAction({
if: 'isBoss',
then: [{
if: 'isHealthLow',
then: [{
action: 'entity::updateEntity',
data: { color: 0xff0000, speed: 5 } // Example with multiple properties
}]
}]
});

It's also possible to create a compositional condition using a logic operator. The default logical operator always defaults to and.

// Composite AND condition
sutra.addCondition('isBossAndHealthLow', {
op: 'and', // and, or, not
conditions: ['isBoss', 'isHealthLow']
});

Global Game State Scopeโ€‹

In many cases, your Sutra will need to reference a context outside of the Entity it's evaluating. For example, a global gameData object may have information about the boundary size of the map, or a global constant value such as maxUnits which is required as reference for a spawner.

For Global Game State, sutra.tick() supports an additional context property as it's second argument.

const gameState = { isGameRunning: false };
const allEntities = [
{ id: 1, type: 'BOSS', health: 40 },
{ id: 2, type: 'PLAYER', health: 100 }
];

sutra.addCondition('isGameRunning', (entity, gameState) => gameState.isGameRunning);
sutra.addCondition('isBoss', (entity) => entity.type === 'BOSS');
sutra.addCondition('isHealthLow', {
op: 'lessThan',
property: 'health',
value: 50
});

sutra.addAction({
if: ['isGameRunning', 'isBoss', 'isHealthLow'],
then: [{
action: 'entity::updateEntity',
data: { speed: 5 }
}]
});

allEntities.forEach(entity => {
sutra.tick(entity, gameState);
});
// nothing happens, `isGameRunning` condition returns false

// update the global game state
gameState.isGameRunning = true;

allEntities.forEach(entity => {
sutra.tick(entity, gameState);
});
// `entity::updateEntity` will be emitted

Dynamic Action Valuesโ€‹

Some Sutras may require a dynamic value by function reference when evaluating triggered actions. For example, if you wanted to change the Boss's color to a random color instead of providing a static color.

In this example, you will pass a function reference as a value, which will be dynamically executed upon each condition evaluation using the appropriate tree scope.

// Function to generate a random color integer
function generateRandomColorInt(entity, gameState, node) {
// entity is the entity scoped which is being evaluated
// gameState is the optional second argument to sutra.tick(entity, gameState)
// node is the reference to current Sutra Tree node element
return Math.floor(Math.random() * 255);
}

sutra.addAction({
if: ['isBoss', 'isHealthLow'],
then: [{
action: 'entity::updateEntity',
data: { color: generateRandomColorInt, speed: 5 }
}]
});

Using Nested Sutras with Subtreesโ€‹

Nested Sutras with subtrees provide a powerful way to organize complex behavior trees into modular, manageable sections. This feature allows you to create distinct Sutras for different aspects of your game logic and then integrate them into a main Sutra. Each subtree can have its own conditions and actions, which are executed within the context of the main Sutra.

Implementing Nested Sutrasโ€‹

Consider a tower defense game where we need separate logic for round management and NPC actions. We can create two Sutras: roundSutra for round logic and npcLogic for NPC behavior.

see: ./examples/nested-sutra.js

import Sutra from '@yantra-core/sutra';

let roundSutra = new Sutra();
roundSutra.addCondition('roundStarted', (entity, gameState) => gameState.roundStarted === true);
roundSutra.addCondition('roundEnded', (entity, gameState) => gameState.roundEnded === true);
roundSutra.addCondition('roundRunning', {
op: 'not',
conditions: ['roundEnded']
});

let npcLogic = new Sutra();
npcLogic.addCondition('isSpawner', (entity) => entity.type === 'UnitSpawner');
npcLogic.addAction({
if: 'isSpawner',
then: [{
action: 'spawnEnemy',
data: {
type: 'ENEMY',
position: { x: 100, y: 50 },
health: 100
}
}]
});

let levelSutra = new Sutra();
levelSutra.use(roundSutra);
levelSutra.use(npcLogic, 'npcLogic'); // optionally, identify the subtree with name
levelSutra.addAction({
if: 'roundRunning',
subtree: 'npcLogic'
});

In this setup, roundSutra and npcLogic are defined separately with their specific conditions and actions. Then, they are integrated into the main levelSutra``. The roundRunning condition in levelSutra`` governs whether the npcLogic subtree should be executed.

Running Nested Sutrasโ€‹

To run a nested Sutra, you call the tick method on the main Sutra with relevant data and `gameState``. The main Sutra evaluates its conditions and decides whether to invoke the actions or subtrees.

levelSutra.tick({ type: 'UnitSpawner' }, { roundStarted: true, roundEnded: false });

Moreโ€‹

Sutra's API is versatile and still in development. We have several examples and a comprehensive test suite which serves as the definitive source of the API specification.

If you are interested in Sutra or have any additional question you can Open an Issue or Join our Discord

Try Sutra with Mantra on Yantra

mantra games can be developed offline using Sutra Rules. These games can be deployed to yantra where the Sutra Rules are enforced as authorative server logic.

The best way to incorporate Sutra with Matra is using the Sutra Plugin. This will enable you to extend any mantra game to use Sutra Behavioral Trees for controlling game state and entities.

Here is a live demo of Sutra + Mantra with CodePen