Gameplay Design & AI Scripting

THE GAME

Anchored best described as a real-time strategy survival game, about managing a settlement and surviving on floating islands.

The player controls a group of settlers in the classic RTS fashion. They have to manage the settlement on the floating island as well as lead hunting parties down into the jungle to gather resources in order to expand their settlement and survive.

Anchored is released and available at itch.io and has been downloaded over 117 000 times.

Genre

Platform

Engine

Language

Development Time

Team Size

Survival RTS

PC

Unity

C#

Eight weeks

Six people

Genre: Survival + Real Time Strategy

Platform: PC

Engine: Unity

Scripting Language: C#

Development Time: Eight weeks

Team Size: Six people

Downloads from

RESPONSIBILITIES

  • Enemy AI
  • Spawn systems
  • Day and Night Cycle
  •  VFX / UI / Animation implementation

As a Scripter and Gameplay Designer, I was very involved in the core gameplay– and combat design of the game. My main responsibility was to script the enemy AI, spawn systems, the dynamic day & night cycle and set up the character animations in Unity. I also did some miscellaneous task like implementing various visual effects and UI elements.

At the Gotland Game Conference 2015 Anchored won the award for the Best second-year project, the Cha-Ching award for the project with the best market potential and the Pwnage award for the best project during the entire conference.

GENERAL DESIGN

The dynamic between the jungle and the island was a key component in the design of Anchored. The jungle is where the player will spend the majority of their time, gathering supplies and combating enemies, while the island is the player’s home base, where they can refine resources, build buildings and craft new gear.

Initially, we wanted a dynamic where the player manages settler on both layers (island and jungle) simultaneously. In the end, this design did not work out and we shifted approach, making the player focus on one layer at a time, but the challenge became how we keep the island gameplay relevant, relative to the jungle.

Our solution was to make the island gameplay, play sort of like a tower defense game. Each night the player needs to defend their food supplies from waves of enemies attacking the island. This results in two different game loops which feeding nicely into each other and the entire game is designed around the player gathering supplies during the day in order to defend their settlement during the night.

ENEMIES

BEETLES

The beetles are the most common enemy in the game. They appear both in the jungle and on the island and come in three variations, Worker, Warrior and Spitter.

Beetle Worker

A smaller melee enemy designed to swarm the player and act as the cannon fodder of a Beetle encounter. Due to their fast speed, they usually reach their target first, preventing the player from dealing with other high priority threats.

Beetle Warrior

A bigger and stronger version of the Worker. It is the slowest beetle, has medium-high health and deals medium damage.

Beetle Spitter 

The only ranged enemy in the game. It shoots poisonous projectiles at settlers, dealing low damage.

BEAR

A rare and tough enemy with a lot of health. It has a special Ground Slam attack which deals high amounts of damage and knocks back settlers. When the bear is killed the player can harvest it for food.

AI

The enemy AI behavior was created using finite state machines. Each state is separated into its own class and implements their own definition of an Enter-, Update- and Exit method.

The enemy class is a generic template for any type of enemy. Depending on the properties an enemy is given the behaviors is different.  All four enemies are variant of this class, each with different stats and appearances.

For the combat design, this system allowed us to easily iterate and balance enemies, weapons and abilities. It also enabled us to quickly add additional enemies and weapons into the game during the later stage of development.

SPAWN SYSTEMS

BURROW SPAWNERS

In the jungle, beetles are spawned from burrow spawners, located between areas containing resources. When a settler walks into the trigger area a wave of Beetles is spawned, distributed between the spawn points.

using UnityEngine;
using System.Collections;
using System.Collections.Generic;


public class BeetleSpawner : MonoBehaviour
{
    public LocationStatus spawnLocation = LocationStatus.IN_JUNGLE;

    public float triggerRadius = 5f;
    
    public ParticleSystem spawnEffectPrefab;

    [Tooltip("Time till they spawn after trigger is triggered")]
    public float initialSpawnDelay;

    [Tooltip("Time between each beetle spawn")]
    [Range(0, 1)]
    public float spawnInterval = 0.5f;
    
    public float cooldownTime = 35;

    [Space]
    public EnemyWave[] enemyWave;

    // Private

    private List<Transform> spawnPoints;
    private Dictionary<Transform, ParticleSystem> spawnEffects;

    private bool waitingToSpawn = false;
    private bool canSpawn = true;
    private bool isWithinTrigger = false;

    private float timer = 0;
    

    private AudioSource spawnAudioSource;

    private List<Entity> settlers;


    void Awake()
    {
        spawnPoints = new List<Transform>();
        foreach (Transform child in transform)
        {
            if (child.tag == "SpawnPoint")
            {
                spawnPoints.Add(child.transform);
            }
        }
        if (spawnPoints.Count > 1)
            Debug.LogError(this.name + " has no spawn points");

        // Spawn and store spawn effects
        spawnEffects = new Dictionary<Transform, ParticleSystem>();

        foreach (Transform spawnPoint in spawnPoints)
        {
            ParticleSystem spawnEffect = Instantiate(spawnEffectPrefab, spawnPoint.position, spawnPoint.rotation) as ParticleSystem;
            spawnEffects.Add(spawnPoint, spawnEffect);
        }

        spawnAudioSource = gameObject.GetComponent<AudioSource>();
        if (spawnAudioSource == null)
            Debug.Log("spawnAudioSource has no audiosource");

    }

    private void Start()
    {
        settlers = EntityManager.instance.GetAllEntitesOfType(EntityType.Settler);
    }

    void FixedUpdate()
    {
        CheckTrigger();
    }
    

    void CheckTrigger()
    {
        if (waitingToSpawn == false)
        {
            timer += Time.fixedDeltaTime;

            if (timer < 1.0)
            {
                timer = 0.0f;
                isWithinTrigger = false;

                for(int i = 0; i< settlers.Count; i++)
                {
                    if (Vector3.Distance(settlers[i].transform.position, this.transform.position) < triggerRadius)
                    {
                        isWithinTrigger = true;
                        
                        if (((Settler)settlers[i]).currentStateName != EntityState.Incapacitated)
                        {
                            if (canSpawn)
                            {
                                StartCoroutine(WaitForSpawnTimer());
                            }
                        }
                        break;
                    }
                }

                if(isWithinTrigger == false)
                {
                    canSpawn = true;
                }

            }
        }
    }

    public IEnumerator WaitForSpawnTimer()
    {
        canSpawn = false;
        waitingToSpawn = true;

        yield return new WaitForSeconds(initialSpawnDelay);

        StartCoroutine(SpawnBeetle());
        
        yield return new WaitForSeconds(initialSpawnDelay);

        // Reset spawner
        waitingToSpawn = false;
        StopAllCoroutines();

    }

    private IEnumerator SpawnBeetle()
    {
        EnemyWave waveToSpawn = enemyWave[Random.Range(0, enemyWave.Length)];

        for (int i = 0; i < waveToSpawn.enemyGroups.Count; i++)
        {
            for (int j = 0; j < waveToSpawn.enemyGroups[i].count; j++)
            {
                Transform spawnpoint = spawnPoints[Random.Range(0, spawnPoints.Count)];

                yield return new WaitForSeconds(spawnInterval);

                spawnEffects[spawnpoint].Play();
                GameObject enemy = Instantiate(waveToSpawn.enemyGroups[i].EnemyPrefab, spawnpoint.position + spawnpoint.forward, spawnpoint.rotation) as GameObject;
                Enemy enemyScript = enemy.GetComponent<Enemy>();
                enemyScript.locationStatus = spawnLocation;
                enemyScript.state_idle.Initialize(enemyScript);
            }
        }
    }

    void OnDrawGizmos()
    {
        Color color;

        color = Color.blue; 
        color.a = 0.3f;

        Gizmos.color = color;
        Gizmos.DrawSphere(transform.position, triggerRadius);
    }

}

ISLAND SPAWNER

When the night falls, waves of beetles use their wings to fly up above the clouds to attack the settlers’ food storage. Each night the attacks get increasingly harder.

Beetles are spawned underneath the island, ascend to a random height above the island before they descend towards the nearest point on the island’s nav mesh. Unless a settler is nearby, they attack the Food Storage building, steal some food and then fly off the island.

BEAR SPAWNER

The bear’s spawn system is hidden to the player and tries to simulate a roaming bear, hearing the noise made from settler mining resources. It makes the bear encounters unpredictable and mixes up the resource gathering segment of the game in an effective way.

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

[System.Serializable]
public class SoundTypeData
{
    public ResourceType resourceType;
    public int m_soundValue;
}

/// <summary>
/// Resources generate different noise when mined.
/// When the noise threshold has been reached the bear is spawned at a beetle spawner,
/// outside of the player’s view and it moves towards the sound’s location. 
/// </summary>
[RequireComponent(typeof(AudioSource))]
public class SoundTriggeredSpawner : MonoBehaviour
{
    #region Variables

    [SerializeField]
    private int currentSoundLevel;
    public int soundThreashold;

    [Space]
    public List<SoundTypeData> m_soundTypeData;

    [Space]
    public float spawnRadius;

    [Space]
    public EnemyGroup[] enemyGroups;
    public BeetleSpawner[] beetleSpawners;

    private AudioSource spawnAudioSource;


    // Singleton
    public static SoundTriggeredSpawner Instance
    {
        get
        {
            if (theInstance == null)
            {
                theInstance = GameObject.FindObjectOfType<SoundTriggeredSpawner>();
            }
            return theInstance;
        }
    }
    private static SoundTriggeredSpawner theInstance;

    #endregion

    #region Methods

    void Start()
    {
        currentSoundLevel = 0;

        beetleSpawners = GameObject.FindObjectsOfType<BeetleSpawner>();
        if (beetleSpawners.Length < 1)
            Debug.LogError(this.name + " has no " + beetleSpawners);
        else if (beetleSpawners.Length < 5)
            Debug.LogWarning(beetleSpawners + " cound only find " + beetleSpawners.Length +
                " beetlespawners. Might not be able to find a spawnpoint not in view.");

        spawnAudioSource = gameObject.GetComponent<AudioSource>();
        if (spawnAudioSource == null)
            Debug.Log(this.name + " has no " + spawnAudioSource);
    }
    
    /// <summary>
    /// Called from a resource when it is being mined
    /// </summary>
    /// <param name="soundTransform"></param>
    /// <param name="resourceType"></param>
    public void AddSound(Transform soundTransform, ResourceType resourceType)
    {
        for (int i = 0; i < m_soundTypeData.Count; i++)
        {
            if (m_soundTypeData[i].resourceType == resourceType)
            {
                currentSoundLevel += m_soundTypeData[i].m_soundValue;
                break;
            }
        }

        if (currentSoundLevel >= soundThreashold)
        {
            SpawnEnemyAtSpawnPoint(soundTransform);
            currentSoundLevel = 0;
        }
    }

    /// <summary>
    /// Spawn a group of enemies at a beetleSpawner ouseisde of the camera view.
    /// </summary>
    /// <param name="soundTransform"></param>
    public void SpawnEnemyAtSpawnPoint(Transform soundTransform)
    {
        StopAllCoroutines();
        StartCoroutine(FindSpawnPos(soundTransform));
    }

    IEnumerator FindSpawnPos(Transform soundTransform)
    {
        Vector3 spawnPos = Vector3.zero;
        while (true)
        {
            spawnPos = beetleSpawners[Random.Range(0, beetleSpawners.Length)].transform.position;

            Vector3 screenPoint = Camera.main.WorldToViewportPoint(spawnPos);
            bool onScreen = screenPoint.z > 0 && screenPoint.x > 0 && screenPoint.x < 1 && screenPoint.y > 0 && screenPoint.y < 1;

            if (!onScreen)
            {

                SpawnEnemyGroup(enemyGroups[Random.Range(0, enemyGroups.Length)], spawnPos, soundTransform);
                StaticAudioPlayer.PlaySound(MiscSounds.BEAR_SPAWN, spawnPos, 1f, 1f);


                break;
            }

            yield return new WaitForFixedUpdate();
        }
    }


    public void SpawnEnemyGroup(EnemyGroup enemyGroup, Vector3 spawnPoint, Transform moveDestination)
    {
        for (int i = 0; i < enemyGroup.count; i++)
        {
            int count = (int)Mathf.Round(Mathf.Sqrt((float)enemyGroup.count));
            float offset = 1.2f;
            Vector3 pos = spawnPoint - (Vector3.right * count / 2 * offset) + (Vector3.right * (i % count) * offset) - (Vector3.forward * count / 2 * offset) + (Vector3.forward * (i / count) * offset);
            SpawnEnemy(enemyGroup.EnemyPrefab, pos, moveDestination);
        }
    }


    public void SpawnEnemy(GameObject prefab, Vector3 spawnPoint, Transform moveDestination)
    {
        GameObject enemy = Instantiate(prefab, spawnPoint, Quaternion.Euler(0, 180, 0)) as GameObject;
        Enemy enemyScript = enemy.GetComponent<Enemy>();

        if (enemyScript == null)
        {
            Debug.LogError(this.name + " coold not find Enemy.cs on this prefab " + prefab.name);
            return;
        }

        enemyScript.locationStatus = LocationStatus.IN_JUNGLE;

        if (moveDestination == null)
        {
            enemyScript.SwitchState(EntityState.Idle);
        }
        else
            enemyScript.state_travel.Initialize(enemyScript, moveDestination.position);

    }

    #endregion
}

DAY & NIGHT CYCLE

The day and night cycle is divided into three phases; dayevening and night. The phases have unique presents for the rotation and color of a directional light and unique post-processing settings.

Some objects, like lamps, fire flies and ambient particles are only active during certain phases of the day.

The change in mood and atmosphere is swift when a new phase has been reached. The suddenness is to draw the player’s attention to the change of day time since during the night we want the player to return to the island.

MISCELLANEOUS

MOUSE CURSOR & TOOLTIP

The mouse cursor icon changes depending on what it hovers over, to give the player feedback on what can be interacted with.

Some objects display a tooltip when hovered over. The tooltip box is always positioned in a way which makes it fully visible to the player, no matter where the cursor is on screen.

using UnityEngine;
using UnityEngine.EventSystems;

public class ToolTip : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
{
    //Public
    [TextArea(0, 10)]
    [Tooltip("Enter the tooltip that should be displayed when mouse hovers over this object. " +
                "If the object is not an UI element, it needs a collider!")]
    public string toolTipText;
    [Tooltip("The delay before a tooltip is displayed.")]
    public float delay = 0.2f;

    //Private
    private bool showToolTip = false;
    private bool hovering;
    

    public string ToolTipText
    {
        get
        {
            return toolTipText;
        }
    }
    
    //Game world space mouse collision enter
    void OnMouseEnter()
    {
        ShowToolTip();
    }
    
    void OnMouseExit()
    {
        HideToolTip();
    }


    //UI space mouse collision enter
    public void OnPointerEnter(PointerEventData eventData)
    {
        ShowToolTip();
    }
    
    public void OnPointerExit(PointerEventData eventData)
    {
        HideToolTip();
    }

    //Show/hide tooltip
    void ShowToolTip()
    {
        GUIManager.instance.SetToolTipText(delay, toolTipText);
    }

    void HideToolTip()
    {
        GUIManager.instance.SetToolTipText(0.0f, "");
    }

    //External classes can modify this tooltip.
    public void SetToolTip(string newTooltip)
    {
        toolTipText = newTooltip;
    }
    
}

  //Inside the GUIManager

    #region Tooltip
    //Tooltip


    //Called form external scripts, like ToolTip.cs
    public void SetToolTipText(float delay, string toolTip)
    {
        StopAllCoroutines();

        StartCoroutine(ToolTipDelay(delay, toolTip));
    }

    IEnumerator ToolTipDelay(float delay, string newToolTip)
    {
        yield return new WaitForSeconds(delay);
        currentToolTipText = newToolTip;
    }

    //Unity GUI method, for rendering and handling GUI events
    void OnGUI()
    {
        SetToolTipPosition();
    }

    //Update the Tooltip text box
    //Make the tooltip position beside the mouse cursor and make it fit on screen
    
    private void SetToolTipPosition()
    {
        if (currentToolTipText != "" && !string.IsNullOrEmpty(currentToolTipText))
        {
            //The guiStyle variable controls the text box's apperance, customizable in the Inspector.
            float x = Event.current.mousePosition.x + guiStyle.overflow.left;
            float y = Event.current.mousePosition.y + guiStyle.overflow.top;

            Rect labelRect = GUILayoutUtility.GetRect(new GUIContent(currentToolTipText), "label");
            float width = labelRect.width;
            float height = labelRect.height;

            //If the mouse position X is bigger than 3/4 of the screens width, 
            //then put the tooltip on the left side of the mouse cursor.
            if (x > (Screen.width / 4) * 3)
                x -= width + guiStyle.overflow.horizontal;
            else
                x += mouseRect.width;

            //Same with Y, but put it above the mouse cursor.
            if (y > (Screen.height / 4) * 3)
                y -= height + guiStyle.overflow.vertical;
            else
                y += mouseRect.height;


            GUI.TextArea(new Rect(x, y, width, height), currentToolTipText, guiStyle);
        }
    }
       
#endregion

ANIMATIONS

When an entity enters a new state, that state sets variables in the animation controller which then plays the correct animation.

The animation clips have events which call functions in code, in order to play particle- and sound effects or to execute logic on specific key frames.

Settler’s animation controller

A settler can be equipped with any of the four available weapons, which all have their unique idle-, running-, attacking- and ability animation. I divided the animations into sub-state machines, containing all the available animations for that state.

The Move state animation controller

An integer in the animation controller tells what weapon is currently equipped and plays the run animation accordingly. This also applies for the idle-, attacking- and ability animations.

Anchored

Made in 9 weeks in Unity at Uppsala University, Campus Gotland. It’s release on itch.io and have received great reception from the itch community.

What I did

  • Enemy AI
  • Spawn Systems
  • Day & Night Cycle
  •  VFX / UI / Animation implementation