Snake and Flower (Unity)

Another 48-hour Unity gamejam

This is another 48-hour gamejam, this time aiming to create a retro-arcade experience like Centipede, Burger Time, or Space Invader.

The premise is simple: You’re a snake. You love a little flower. The flower needs food or it will wilt. The more you feed the flower, the more it grows.

You can play it over at itch.io here.

For this particular project I played several roles:

  • Engineer in charge of environment, movement & UI
  • GitHub traffic cop
  • Low-key project manager

I was fortunate enough to be teamed up with folks that were really self-driven and project-focused. It really was one of the easiest groups to communicate with and push through with.

UI work

The snake loops a simple instructional animation bringing food to the flower

My first priority with this group was to build a quick UI system that allowed the game to be replayed quickly, with an main menu as an entry point, a credits page, a game scene, and a game over UI that allowed players to escape or start again.

As development went on, more UI elements were added. We also couldn’t assume what the display resolution was going to be, so everything had to scale gracefully. Going for the retro feel actually gave me a lot of leeway — the empty frame surrounding the play area itself is ‘in-theme’. Reusing elements is also a very gamejam-appropriate.

Engineering work

I told the team I wanted to play with Unity’s tilemap system as the basis for the environment and movement systems. They were gracious enough to indulge me.

This is the meat of the movement code I created for the project.

void FixedUpdate()
    {
        // Game over check. Do not carry out game actions if the game is over.
        if (gameOver)
        {
            return;
        }
        //Check to see if the snake is able to receive input
        if (!playerIsMoving && !snakeStunned)
        {
            //Receive input and pick next destination
            MovePlayer();
        }
        //Manage enemy behavior. Enemies are pooled under an empty object in unity. 
        foreach (var enemy in EnemyList)
        {
            if (!enemy.activeInHierarchy)
            {
                continue;
            }          
            //Enemy script 
            var script = enemy.GetComponent<EnemyScript>();
            script.Lifetime -= Time.fixedDeltaTime;
            if (script.Lifetime <= 0)
            {
                //Recover enemy slot
                enemySpawner.OnEnemyDespawned();

                //Deactivate enemy object
                enemy.SetActive(false);

            }
            //Check if enemy is available to be moved
            if (!script.IsMoving && Time.time - script.LastMoveTime >= script.Cadence)
            {
                //Pick next destination
                MoveEnemy(enemy);
                script.LastMoveTime = Time.time;
            }
            //If enemy is within insignificant distance from its destination, center enemy on grid and complete move. Else keep enemy moving
            if (Vector3.Distance(enemy.transform.position, script.Destination) <= 0.1f)
            {
                enemy.transform.position =
                    levelGrid.GetCellCenterWorld(Vector3Int.FloorToInt(script.Destination));
                script.IsMoving = false;
            }
            else
            {
                enemy.transform.position = Vector3.Lerp(enemy.transform.position, script.Destination, script.Speed * Time.fixedDeltaTime);
                script.IsMoving = true;
            }
        }
        //Player movement
        if (Vector3.Distance(PlayerTransform.position, PlayerDestination) <= 0.1f)
        {
            PlayerTransform.position = levelGrid.GetCellCenterWorld(Vector3Int.FloorToInt(PlayerDestination));
            playerIsMoving = false;

        }
        else
        {
            PlayerTransform.position = Vector3.Lerp(PlayerTransform.position, PlayerDestination,
                PlayerSpeed * Time.fixedDeltaTime);
        }

        ScoreDisplay.text = playerScore.ToString().PadLeft(6, '0');
        flowerHealthSlider.value = flower.SecondsRemaining;
    }

Obstacle detection relies on colliders set on the obstacle prefabs, which are also set to an “Obstacle” layer.

    // Returns true if there's an obstacle between startLocation to endLocation.
    // Obstacles are defined as gameobjects with a collider and set to the "Obstacle" layer in editor.
    // startLocation is the point where the ray for obstacle detection should start from.
    // endLocation is the end point for the ray
    private bool TestForObstacles(Vector3 startLocation, Vector3 endLocation)
    {
        LayerMask mask = LayerMask.GetMask("Obstacle");
        var hit = Physics2D.Raycast(startLocation, endLocation - startLocation, CellSize, mask);
        if (hit.collider != null)
        {

            return true;
        }
    return false;
    }

The MovePlayer and MoveEnemy methods are just meant to set destinations for either.

    // Retrieves the movement pattern that was set when the enemy was initialized (on spawn). Edge jumps.
    // <param name="enemy"/>Enemy game object to be moved</param/>
    public void MoveEnemy(GameObject enemy)
    {
        var script = enemy.GetComponent<EnemyScript>();
        var movementInput = script.MovementPattern;

        if (levelGrid.HasTile(Vector3Int.CeilToInt(enemy.transform.position + movementInput)))
        {
            script.Destination = levelGrid.WorldToCell(enemy.transform.position + movementInput);
        }
        else
        {
            if (levelGrid.HasTile(Vector3Int.CeilToInt(enemy.transform.position) +
                                  new Vector3Int(Mathf.CeilToInt(movementInput.x), 0, 0)))
            {
                enemy.transform.position = new Vector3(enemy.transform.position.x + CellSize,
                    enemy.transform.position.y * -1, 0f);
                script.Destination = enemy.transform.position;
                return;
            }
            if (levelGrid.HasTile(Vector3Int.CeilToInt(enemy.transform.position) +
                                  new Vector3Int(0, Mathf.CeilToInt(movementInput.y), 0)))
            {
                enemy.transform.position = new Vector3(enemy.transform.position.x * -1,
                    enemy.transform.position.y + CellSize, 0f);
                script.Destination = enemy.transform.position;
            }
        }
    }


    // Player movement method. Captures input axis, normalized to 1 and polls to see if there's a tile in that direction.
    // If no tile is detected, assumes edge and jumps to opposite end of the map.
    // Checks the targeted tile for obstacles, and set PlayerDestination appropriately.
    public void MovePlayer()
    {
        var axisY = new Vector3(0f, Input.GetAxisRaw("Vertical"), 0f) * CellSize;
        var axisX = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, 0f) * CellSize;
        if (Math.Abs(Input.GetAxisRaw("Vertical")) > 0.0001)
        {

            if (levelGrid.HasTile(Vector3Int.CeilToInt(PlayerTransform.position + axisY)))
            {

                //Don't move if there's an obstacle in target location
                if (TestForObstacles(PlayerTransform.position, levelGrid.GetCellCenterWorld(
                    levelGrid.WorldToCell(PlayerTransform.position + axisY))))
                {
                    return;
                }
                PlayerDestination =
                        levelGrid.GetCellCenterWorld(
                            levelGrid.WorldToCell
(PlayerTransform.position + axisY));

            }
            else if (EdgeJump)
            {
                // Check if there's an obstacle on the targeted location at the opposite edge.
                if (TestForObstacles(
                    new Vector3(PlayerTransform.position.x, PlayerTransform.position.y * -1, 0f),
                    new Vector3(PlayerTransform.position.x, PlayerTransform.position.y * -1, 0f)))
                {
                    return;
                }
                PlayerTransform.position = new Vector3(PlayerTransform.position.x, PlayerTransform.position.y * -1, 0f);
                PlayerDestination = levelGrid.GetCellCenterWorld(levelGrid.WorldToCell(PlayerTransform.position));

            }
        }
        if (Math.Abs(Input.GetAxisRaw("Horizontal")) > 0.0001)
        {

            if (levelGrid.HasTile(Vector3Int.CeilToInt(PlayerTransform.position + axisX)))
            {

                if (TestForObstacles(PlayerTransform.position, levelGrid.GetCellCenterWorld(
                    levelGrid.WorldToCell(PlayerTransform.position + axisX))))
                {
                    return;
                }
                PlayerDestination =
                    levelGrid.GetCellCenterWorld(
                        levelGrid.WorldToCell(PlayerTransform.position + axisX));

            }
            else if (EdgeJump)
            {
                if (TestForObstacles(new Vector3(PlayerTransform.position.x * -1, PlayerTransform.position.y, 0f), 
                    new Vector3(PlayerTransform.position.x * -1, PlayerTransform.position.y, 0f)))
                {
                    return;
                }
                PlayerTransform.position = new Vector3(PlayerTransform.position.x * -1, PlayerTransform.position.y, 0f);
                PlayerDestination = levelGrid.GetCellCenterWorld(levelGrid.WorldToCell(PlayerTransform.position));

            }
        }

        var direction = PlayerDestination - PlayerTransform.position;
        var angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
        PlayerTransform.rotation = Quaternion.AngleAxis(angle, Vector3.forward);
        playerIsMoving = true;
    }

The enemy movement pattern is basically analogous to user input. It could be used to describe diagonals (1,1 for up-left, etc.) but this wasn’t used since it made enemy movement much harder to predict.

public class EnemyScript : MonoBehaviour
{

    public float Speed = 5;
    public Vector3 MovementPattern;
    public GameController Controller;
    public Vector3 Destination;
    public bool IsMoving = false;
    [SerializeField] AudioClip hitSnakeSFX;

    [Range(0.5f, 5f)]
    public float Cadence = 1.75f;

    public float Lifetime = 10;

    public float LastMoveTime;

    // Start is called before the first frame update
    void Start()
    {
        Controller = GameObject.Find("Game Controller").GetComponent<GameController>();
        Destination = transform.position;
        LastMoveTime = Time.time;
    }

    // These enemies are meant to traverse the map for as long as they're active. The pre-instantiated pool of enemies is set in the GameController.
    // The movement pattern should only have 1/0/-1 as values on either axis. 
    // Diagonal movement is also valid (1,1 / -1,-1 / etc..)
    public void Initialize(Vector3 startingPosition, Vector2 movementPattern, float movementCadence, float lifetime)
    {
        LastMoveTime = Time.time;
        Cadence = movementCadence;
        transform.position = startingPosition;
        Destination = startingPosition;
        MovementPattern = new Vector3(movementPattern.x, movementPattern.y, 0f);
        Lifetime = lifetime;
        gameObject.SetActive(true);
    }

    void OnTriggerEnter2D(Collider2D colliderObject)
    {
        if (colliderObject.gameObject.name == "PlayerSnake")
        {
            Debug.Log("Stun the snake");
            Controller.StunSnake();
            AudioSource.PlayClipAtPoint(hitSnakeSFX, Camera.main.transform.position);
        }
    }
}

Some lessons that I learned fairly quickly :

  • Tiles work on a Vector3int system, which means that world-to-cell (as each tile is called) requires rounding. This can lead to inconsistent results if not managed properly. This led to some “interesting” bugs later on.
  • In a top-down game where movement has to be a strict cell-to-cell affair, using any sort of physics is impractical — moving the transforms by lerping yields better, cleaner results
  • Consuming user input has to be done on a per-movement basis, since they’re choosing destination rather than creating momentum.
  • In this setup, movement cadence is just as influential as movement speed.

One of our goals as a team was to iterate as much as possible once we had an MVP. Towards that, I made sure that every relevant factor was exposed at design-time through the editor. This allowed another engineer to focus on tweaking and balancing, while the rest of us worked on polish.

One of the elements that I worked on but returned dubious value was the ability to jump from one side of the map to the other. As in, if you moved out of the game area through one edge, you’d appear in the same location but on the opposite edge.

I went down a rabbit hole with our enemy design as a result, making the birds do a “striped” movement pattern where they offset themselves every time they went across the map.

In all, I’m very satisfied with how the project panned out.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s