🌀 Flow Party - The dance floor for your MonoBehaviours

Hierarchical State Machines with Flow Party

Problem: State machines that are hard to read, reason about, and extend with new states.
Some examples of community posts illustrating this:

Community Quotes & Flow Party Responses (click to expand)

Quotes:

Flow Party:

With Flow Party, having dozens of states with very complex logic is not a problem.
The code is modular, easy to reason about and expand with new states.
The main example shown in this post features a Flow Machine with 11 states, and adding more is straightforward.

Quotes:

Flow Party:

It’s trivial to add OnEnter or OnExit actions at any point within a Flow Party flow. Example:

Flow LevelUp()
{
	return new Sequence()
	{
		new Enter()
		{
			DisablePlayerControl,
			PlayLevelUpMusic
		},
		PlayFireworksAnimation,
		new WaitAll
		{
			new ForSeconds(SpinDuration)
			{
				Spin()
			},
			new Sequence()
			{
				FillExperienceBar(),
				PlayStarsAnimation,
				Flow.WaitForSeconds(0.5f)
			}
		},
		new Exit()   // Called when the flow is interrupted or completes normally.
		{
			EnablePlayerControl,
			PlayGameplayMusic
		}
	}
}

Quotes:

Flow Party:

Flow Party is perfect for player movement and actions (see the main example). It makes your code “tidier and easier to manage”.

Quotes:

Flow Party:

Flow Party code is compact.

:cyclone: Main example: a State Machine built with Flow Party :cyclone:

This example shows the scripts for a 2D Player Controller with 11 states:

  • Idle
  • Running
  • Jumping
  • Falling
  • Hurting
  • Dying
  • Spawning
  • Victory
  • Landing
  • Dashing
  • BlowingUp

Each state is largely independent of the others, and adding new ones like Shooting, Defending, Singing, Dancing, and so on is straightforward.

The following scripts form the Player2D class, which has been split into multiple files. This isn’t specific to Flow Party. It’s just a programming trick to help keep the code organized:

Player2D
Note 1: This example uses a FlowMachine as the root flow, since this is a post about State Machines.
But any of the other flows (Sequence, ForSeconds, Parallel, etc.) could be used as the root flow instead, depending on the behavior you want to build. You can also include as many flows as you need within a single script.
Note 2: This is not an example of a Monobehaviour Tree. I’ll cover that in another post.

using UnityEngine;
using FlowParty;


public partial class Player2D : MonoBehaviour
{
    FlowMachine<State> flowMachine;

    Animator animator;
    new Rigidbody2D rigidbody2D;
    CapsuleCollider2D capsuleCollider2D;
    SpriteRenderer spriteRenderer;
    AudioSource audioSource;


    enum State
    {
        None,
        Idle,
        Running,
        Jumping,
        Falling,
        Hurting,
        Dying,
        Spawning,
        Victory,
        Landing,
        Dashing,
        BlowingUp
    }

    FlowMachine<State> Behavior()
    {
        return new FlowMachine<State>()
        {
            [State.Idle]      = Idle(),
            [State.Running]   = Running(),
            [State.Jumping]   = Jumping(),
            [State.Falling]   = Falling(),
            [State.Landing]   = Landing(),
            [State.Hurting]   = Hurting(),
            [State.Dying]     = Dying(),
            [State.Spawning]  = Spawning(),
            [State.Victory]   = Vitory(),
            [State.Dashing]   = Dashing(),
            [State.BlowingUp] = BlowingUp()
        };
    }

    void UpdatePlayerInputs()
    {
        if (!playerControlEnabled)
            return;

        if (Input.GetKeyDown(KeyCode.Q))
            Dash();
    }

    private void Awake()
    {
        animator = GetComponent<Animator>();
        rigidbody2D = GetComponent<Rigidbody2D>();
        capsuleCollider2D = GetComponent<CapsuleCollider2D>();
        spriteRenderer = GetComponent<SpriteRenderer>();
        audioSource = GetComponent<AudioSource>();
    }

    void Start()
    {
        InitializeDashing();
        InitializeSpawning();
        InitializeHurting();

        flowMachine = Behavior();
        flowMachine.Start();
    }

    void Update()
    {
        UpdateGrounding();
        UpdatePlayerInputs();
        UpdateMovement();


        hurtProtectionTimer.Update();
        dashCooldown.Update();
        flowMachine.Update();
    }

    void OnCollisionEnter2D(Collision2D collision)
    {
        var collider = collision.collider;
        
        if (collider.tag == "Ground")
            OnGroundCollisionEnter2D(collision);

        if (collider.tag == "Enemy")
        {
            if (flowMachine.currentState == State.Dashing)
                DashKill(collision.gameObject);
            else
                Hurt(collision.GetContact(0).point);
        }
    }

    private void OnCollisionStay2D(Collision2D collision)
    {
        if (collision.collider.tag == "Enemy")
            Hurt(collision.GetContact(0).point);
    }

    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.tag == "Poison")
        {
            if (flowMachine.currentState == State.BlowingUp)
                return;

            Destroy(collision.gameObject);
            BlowUp();
        }

        if (collision.tag == "Finish")
            Win();
    }

    void OnDrawGizmos()
    {
        OnDrawGizmosGrouding();
    }
}

Transitions

using UnityEngine;
using FlowParty;


public partial class Player2D
{
    bool CanIdle() => isGrounded && Mathf.Abs(HorizontalSpeedInput) < 0.1f;
    bool CanRun() => isGrounded && Mathf.Abs(HorizontalSpeedInput) >= 0.1f;
    bool CanJump() => Input.GetButtonDown("Jump") && playerControlEnabled && !jumped;
    bool CanFall() => !isGrounded && Velocity.y <= 0f;
    bool CanDie() => lifes <= 0;
    bool CanLand() => isGrounded && landingVelocity.y < -strongLandingSpeed;
    bool True() => true;
}

Dashing

using UnityEngine;
using FlowParty;


public partial class Player2D
{
    [Header("Dashing")]
    public float dashSpeed = 10f;
    public float dashDuration = 1f;
    public float dashCooldownTimer = 0.2f;
    public GameObject dashEffect;

    float originalGravityScale;
    Flow dashCooldown;

    float DashDuration() => dashDuration;


    Flow Dashing()
    {
        return new Sequence()
        {
            new Enter()
            {
                delegate
                {
                    EnableDamage();
                    PlayAnimation(jumpAnimation);
                    PlayDashSound();
                    DisableHorizontalSpeedControl();

                    Velocity = new Vector2(PlayerOrientation * dashSpeed, 0);
                    originalGravityScale = rigidbody2D.gravityScale;
                    rigidbody2D.gravityScale = 0;

                    DashEffect();
                }
            },
            new WaitAny()
            {
                Flow.WaitForSeconds(DashDuration),
                new Forever()
                {
                    ZeroOutVerticalVelocity,
                    new Transitions<State>()
                    {
                        [State.Jumping] = CanJump
                    }
                }
            },
            new Transitions<State>()
            {
                [State.Falling] = CanFall,
                [State.Jumping] = CanJump,
                [State.Running] = CanRun,
                [State.Idle]    = True
            },
            new Exit()
            {
                delegate
                {
                    rigidbody2D.gravityScale = originalGravityScale;
                    EnableHorizontalSpeedControl();
                    dashCooldown.Start();
                }
            }
        };
    }

    Flow DashCooldown()
    {
        return Flow.WaitForSeconds(() => dashCooldownTimer);
    }

    void DashEffect()
    {
        GameObject aux;
        if (PlayerOrientation > 0)
            aux = Instantiate(dashEffect, transform.position, Quaternion.identity);
        else
            aux = Instantiate(dashEffect, transform.position, Quaternion.Euler(0, 180, 0));

        aux.transform.parent = transform;
    }

    public void Dash()
    {
        if (!dashCooldown.running)
            flowMachine.Next(State.Dashing);
    }

    void DashKill(GameObject enemy)
    {
        var aux = enemy.GetComponent<Enemy2D>();

        Velocity = new Vector2(PlayerOrientation * dashSpeed, 0);

        if (aux != null)
        { 
            aux.Kill();
            return;
        }

        Destroy(enemy);
    }

    void InitializeDashing()
    {
        dashCooldown = DashCooldown();
    }
}

Spawning

using UnityEngine;
using FlowParty;


public partial class Player2D
{
    [Header("Spawning")]
    public Transform spawnPoint;
    public bool respawnAfterVitory = false;
    public float delayAfterVitory = 5f;

    bool RespawnAfterVitory() => respawnAfterVitory;
    float DelayAfterVitory() => delayAfterVitory;

    Flow Spawning()
    {
        return new Sequence()
        {
            new Enter()
            {
                delegate
                {
                    DisableDamage();
                    DisablePlayerControl();
                    transform.position = spawnPoint.position;
                    PlayAnimation(spawnAnimation);
                }
            },
            new WaitAll()
            {
                Flow.WaitUntil(AnimationEnded),
                new Sequence()
                { 
                    Flow.WaitForSeconds(SpawnSoundDelay),
                    PlaySpawnSound
                }
            },
            Flow.Next(State.Idle),
            new Exit()
            {
                EnablePlayerControl
            },
        }; 
    }

    void InitializeSpawning()
    {
        if (spawnPoint == null)
        {
            var aux = new GameObject("Spawn Point");
            spawnPoint = aux.transform;
            spawnPoint.position = transform.position;
        }
    }      
}

Victory

using UnityEngine;
using FlowParty;


public partial class Player2D
{

    Flow Vitory()
    {
        return new Sequence()
        {
            new Enter()
            {
                delegate
                {
                    DisableDamage();
                    PlayAnimation(victoryAnimation);
                    DisablePlayerControl();
                    ZeroOutHorizontalVelocity();
                }
            },
            PlayVictorySound,
            new If(RespawnAfterVitory)
            { 
                Flow.WaitForSeconds(DelayAfterVitory),
                Flow.Next(State.Spawning)
            },
            new Exit()
            {
                StopSound
            }
        };
    }


    void Win()
    { 
        flowMachine.Next(State.Victory);
    }
}

Idle

using UnityEngine;
using FlowParty;


public partial class Player2D
{
    [Header("Idle")]
    public float idleSFXTimeMin = 8;
    public float idleSFXTimeMax = 15;
    public bool dashOnExplosion = false;
    public GameObject idleExplosion;

    float TimeUntilNextSound() => Random.Range(idleSFXTimeMin, idleSFXTimeMax);
    bool DashOnExplosion() => dashOnExplosion;

    Flow Idle()
    {
        return new Sequence()
        {
            new Enter()
            {
                delegate
                {
                    EnableDamage();
                    PlayAnimation(idleAnimation);
                    jumped = false;
                }
            },
            new Parallel()
            {
                new Forever()
                {
                    new Transitions<State>()
                    {
                        [State.Falling] = CanFall,
                        [State.Jumping] = CanJump,
                        [State.Running] = CanRun
                    }
                },
                DoRandomIdleStuff()
            }
        };
    }

    Flow DoRandomIdleStuff()
    {
        return new Sequence()
        {
            new Forever()
            {
                Flow.WaitForSeconds(TimeUntilNextSound),

                new WaitAny()
                {
                    new Sequence()
                    {
                        PlayWarningSound,
                        Flow.WaitWhile(AudioIsPlaying),
                    },
                    AlternateVictoryPoses()
                },

                new WaitAll()
                {
                    SmoothTransitionToIdle(), 
                    Flow.WaitForSeconds(1f)
                },

                PlayRandomIdleSound,
                IdleExplosion,

                new If(DashOnExplosion)
                {
                    Flow.Next(State.Dashing)
                },

                PlayHurtAnimation,
                Flow.WaitUntil(AnimationEnded),
                PlayIdleAnimation
            },
            new Exit()
            {
                delegate
                {
                    AnimationSpeed = 1;
                    StopSound();
                }                    
            }        
        };
    }

    Flow AlternateVictoryPoses()
    {
        return new Forever()
        {
            Flow.WaitForSeconds(0.1f),
            PlayVitoryAnimation,
            Flow.WaitUntil(AnimationEnded),
            Flow.WaitForSeconds(0.1f),

            delegate
            {
                AnimationSpeed = 2;
                PlayVitoryReversedAnimation();
            },

            Flow.WaitUntil(AnimationEnded),

            delegate
            {
                AnimationSpeed = 1;
                PlayIdleAnimation();
            }
        };
    }

    Flow SmoothTransitionToIdle()
    {
        return new Sequence()
        {
            new If(IsPlayingIdleAnimation)
            { 
                Flow.Break<Sequence>()
            },
            new If(IsPlayingVictoryAnimation)
            {
                delegate
                {
                    AnimationSpeed = 2;
                    PlayAnimation(victoryReversedAnimation, 1 - AnimationNormalizedTime);
                }
            },
            Flow.WaitUntil(AnimationEnded),
            delegate
            {
                AnimationSpeed = 1;
                PlayIdleAnimation();
            }
        };
    }

    void IdleExplosion()
    {
        Quaternion rotation;

        if (PlayerOrientation > 0)
            rotation = Quaternion.identity;
        else
            rotation = Quaternion.Euler(0, 180f, 0);

        Instantiate(idleExplosion, transform.position, rotation);
    }
}

BlowingUp

using UnityEngine;
using FlowParty;


public partial class Player2D
{
    [Header("Blowing Up")]
    public GameObject explosion;
    public float blowUpDuration = 1;
    public Color blowUpColor = Color.red;
    public float blowUpSize = 1;
    public float blowUpSpeedStart = 2;
    public float blowUpSpeedEnd = 5;

    public float soundPitchStart = 0.5f;
    public float soundPitchEnd = 3f;

    Color originalColor;

    float BlowUpDuration() => blowUpDuration;

    Flow BlowingUp()
    {
        return new Sequence()
        {
            new Enter()
            {
                delegate
                {
                    DisableDamage();
                    DisablePlayerControl();
                    PlayInflatingSound();
                    PlayAnimation(idleAnimation);
                    originalColor = spriteRenderer.color;
                    Velocity = Vector2.zero;
                }
            },
            new WaitAny()
            {
                Inflate(),
                InflatingSoundFlow()
            },
            delegate
            {
                Instantiate(explosion, transform.position, Quaternion.identity);
            },
            Dying(),
            new Exit()
            {
                delegate
                {
                    transform.localScale = Vector3.one;
                    spriteRenderer.color = originalColor;

                    EnablePlayerControl();
                    AnimationSpeed = 1;
                }
            }
        };
    }

    Flow Inflate()
    {
        return new ForSeconds(out var fs, BlowUpDuration)
        {
            delegate
            {
                transform.localScale = Vector3.one * Mathf.Lerp(1, blowUpSize, fs.normalizedTime);
                spriteRenderer.color = Color.Lerp(originalColor, blowUpColor, fs.normalizedTime);
                AnimationSpeed = Mathf.Lerp(blowUpSpeedStart, blowUpSpeedEnd, fs.normalizedTime);
            }
        };
    }

    Flow InflatingSoundFlow()
    {
        return new Sequence()
        {
            delegate
            {
                audioSource.clip = inflatingSound;
                audioSource.loop = true;
                audioSource.Play();
            },
            new ForSeconds(out var fs, BlowUpDuration)
            {
                delegate
                {
                    audioSource.pitch = Mathf.Lerp(soundPitchStart, soundPitchEnd, fs.normalizedTime);
                }
            },
            Flow.Hold(),
            new Exit()
            {
                delegate
                {
                    audioSource.Stop();
                    audioSource.loop = false;
                    audioSource.pitch = 1;
                }
            }
        };
    }

    public void BlowUp()
    {
        flowMachine.Next(State.BlowingUp);
    }
}

Dying

using UnityEngine;
using FlowParty;


public partial class Player2D
{
    Flow Dying()
    {
        return new Sequence()
        {
            new Enter()
            {
                delegate
                {
                    DisableDamage();
                    PlayAnimation(deathAnimation);
                    PlayDeathSound();
                    DisablePlayerControl();
                }
            },
            Flow.WaitUntil(AnimationEnded),
            Flow.Next(State.Spawning),

            new Exit()
            {
                EnablePlayerControl
            },
        };
    }
}

Falling

using UnityEngine;
using FlowParty;


public partial class Player2D
{
    Flow Falling()
    {
        return new Sequence()
        {
            new Enter()
            {
                delegate
                {
                    EnableDamage();
                    PlayAnimation(jumpAnimation);
                    groundingChekEnabled = false;
                }
            },
            new Forever()
            {
                new Transitions<State>()
                {
                    [State.Jumping] = CanJump,
                    [State.Landing] = CanLand,
                    [State.Running] = CanRun,
                    [State.Idle]    = CanIdle
                }
            },
            new Exit()
            {
                delegate
                {
                    groundingChekEnabled = true;
                }
            }
        };
    }
}

Hurting

using UnityEngine;
using FlowParty;


public partial class Player2D
{
    [Header("Hurting")]
    public int lifes = 1;
    public float unhurtableDuration = 0.2f;
    public float knockbackSpeed = 4;

    [SerializeField]
    bool hurtable;
    Flow hurtProtectionTimer;


    Flow Hurting()
    {
        return new Sequence()
        {
            new Enter()
            {
                delegate
                {
                    DisableDamage();
                    PlayAnimation(hurtAnimation);
                    PlayHurtSound();
                    DisablePlayerControl();
                    lifes--;
                }
            },
            Flow.WaitUntil(AnimationEnded),
            new Transitions<State>()
            {
                [State.Dying] = CanDie,
                [State.Idle]  = True
            },
            new Exit()
            {
                delegate
                {
                    EnablePlayerControl();
                    hurtProtectionTimer.Restart();
                }
            },
        };
    }

    public void Hurt()
    {
        if (!hurtable || hurtProtectionTimer.running)
            return;

        flowMachine.Next(State.Hurting);
    }

    void Hurt(Vector2 contact)
    {
        if (!hurtable || hurtProtectionTimer.running)
            return;

        float dx = transform.position.x - contact.x;
        Vector2 direction;

        if (Mathf.Abs(dx) < 0.1f)
        {
            direction = Vector2.up;
        }
        else
        {
            direction = new Vector2(Mathf.Sign(dx), 1);
            direction.Normalize();
        }

        Knockback(direction * knockbackSpeed);

        flowMachine.Next(State.Hurting);
    }

    void EnableDamage()
    {
        hurtable = true;
    }

    void DisableDamage()
    {
        hurtable = false;
    }

    void Knockback(Vector2 impulse)
    {
        rigidbody2D.AddForce(impulse, ForceMode2D.Impulse);
    }

    void InitializeHurting()
    {
        hurtProtectionTimer = Flow.WaitForSeconds(() => unhurtableDuration);
    }
}

Jumping

using UnityEngine;
using FlowParty;


public partial class Player2D
{
    [Header("Jumping")]
    public float jumpSpeed = 4;

    bool jumped;

    Flow Jumping()
    {
        return new Sequence()
        {
            new Enter()
            {
                delegate
                {
                    EnableDamage();
                    PlayAnimation(jumpAnimation);
                    PlayJumpSound();
                    ZeroOutVerticalVelocity();
                    rigidbody2D.AddForce(Vector2.up * jumpSpeed, ForceMode2D.Impulse);
                    jumped = true;
                }
            },
            Flow.WaitForSeconds(0.1f),
            new Forever()
            {
                new Transitions<State>()
                {
                    [State.Falling] = CanFall,
                    [State.Idle]    = CanIdle,
                    [State.Running] = CanRun
                }
            }
        };
    }
}

Landing

using UnityEngine;
using FlowParty;


public partial class Player2D
{
    [Header("Landing")]
    [Min(0f)]
    public float strongLandingSpeed = 3f;

    Flow Landing()
    {
        return new Sequence()
        {
            new Enter()
            {
                delegate
                {
                    EnableDamage();
                    PlayAnimation(landAnimation);
                    jumped = false;
                }
            },
            new WaitAny()
            {
                Flow.WaitUntil(AnimationEnded),
                new Forever()
                {
                    new Transitions<State>()
                    {
                        [State.Jumping] = CanJump,
                        [State.Falling] = CanFall
                    }
                }
            },
            new Transitions<State>()
            {
                [State.Running] = CanRun,
                [State.Idle]  = True
            }
        };
    }
}

Running

using UnityEngine;
using FlowParty;


public partial class Player2D
{
    Flow Running()
    {
        return new Sequence()
        {
            new Enter()
            {
                delegate
                {
                    EnableDamage();
                    PlayAnimation(runAnimation);
                    jumped = false;
                }
            },
            new Forever()
            {
                new Transitions<State>()
                {
                    [State.Falling] = CanFall,
                    [State.Jumping] = CanJump,
                    [State.Idle]    = CanIdle
                }
            }
        };
    }
}

Flow Party code is highly structured, with almost no variables to keep track of time or other states.
Nothing like traditional MonoBehaviour scripts.

Bonus!

Using flows with Animator.Play or Animator.CrossFade removes the need to set Animator parameters or conditions.

Zero transitions. This is the maximum joy you can ever get from an Animator Controller :smiling_face:

2 Likes