GMTK 2025 – The loop problem

GMTK 2025 – The loop problem

Last week I participated in the GMTK Game Jam 2025, where we had about 4 days to create and submit a game with the theme “Loop.” I worked on this project with my friend Marina (see more of her work here: parinamais.com). We love doing game jams together, and we usually like to create simple games that we can complete during the jam period without excessive (read: exhausting) hours of work.

So for this jam, we (mostly her) came up with the idea of a simple puzzle game where the player’s goal is to utilize scenery props to make a ball loop for an infinite amount of time. You can check out the game here: https://parinamais.itch.io/infinity-ball.

Due to my work schedule, Marina had already initiated the project with some cool custom physics algorithms she had recently learned and was eager to put into practice. So when I stepped in to work on the project, my main task was to create a code that could determine whether the ball was in an infinite loop or not.

At first I was a little bit clueless about how to detect if a ball was in such an inertial state, but fortunately, math is already pretty advanced in our time. And considering the ball in the game suffers no external influences that would stop or diverge its path, my first thought was: “If the ball returns to its initial position with the same speed and direction, it can only mean that the ball has completed a loop.”

Here is an example of the initial code I tried:

C#
    void Update()
    {
        StateSnapshot current = new StateSnapshot(transform.position, rb.velocity);

        foreach (var past in history)
        {
            if (IsSameState(past, current))
            {
                loopCount++;
                if (loopCount >= loopCheckWindow)
                {
                    Debug.Log("Loop!");
                    loopCount = 0;
                    history.Clear();
                }
                return;
            }
        }

        history.Add(current);
    }

    private bool IsSameState(StateSnapshot a, StateSnapshot b)
    {
        return Vector2.Distance(a.position, b.position) <= positionTolerance &&
               Vector2.Distance(a.velocity, b.velocity) <= velocityTolerance;
    }

    private struct StateSnapshot
    {
        public Vector2 position;
        public Vector2 velocity;

        public StateSnapshot(Vector2 pos, Vector2 vel)
        {
            position = pos;
            velocity = vel;
        }
    }
C#

This code basically does three things: it gets the ball’s current state, adds it to a list that stores all the ball’s position history, and checks whether the ball has already passed through the same spot with the same velocity.

Some tolerances were added to the state comparison because Unity’s floating point calculations aren’t always precise. I also added the possibility of requiring the loop to be detected multiple times in a row, for extra validation security.

But what happened was: this code didn’t trigger even once.

And to be sincere, I didn’t make further investigations to discover why this code didn’t work as expected. My best guess is that the physics environment wasn’t as perfect as it needed to be to keep the ball in a truly inertial state. Due to the jam’s time limit, I preferred to look for a different solution that didn’t require so much precision.

Searching through the internet, I came across an algorithm called “heatmap,” where you add values to a point, in my case a cell space, every time the desired object passes through that point. With this solution, I was able to determine the size of the cell, which gave me the ability to verify the ball’s position with less precision. I could also determine how many times the ball had to pass through the same cell to be considered a loop. Both of these variables gave me more balancing possibilities and, best of all: it worked perfectly in our game environment!

Here is the final code:

C#
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class HeatmapLoopDetection : MonoBehaviour
{
    [SerializeField] private Ball ball;
    [SerializeField] private float cellSize = 0.3f;
    [SerializeField] private int visitThreshold = 3;
    [SerializeField] private bool debugGizmos = false;
    private Dictionary<Vector2Int, int> heatmap = new Dictionary<Vector2Int, int>();

    private void Awake()
    {
        ball.OnLaunch += VerifyLoop;
    }

    private void VerifyLoop()
    {
        heatmap.Clear();
        speed = ball.velocity.magnitude;
        enabled = true;
    }

    private Vector2 pos;
    private Vector2Int cell;
    private Vector2Int lastCell;
    private float speed;
    private void Update()
    {
        if (ball.velocity.magnitude < speed)
        {
            heatmap.Clear();
        }
        
        speed = ball.velocity.magnitude;
        pos = ball.position;
        cell = new Vector2Int(Mathf.FloorToInt(pos.x / cellSize), Mathf.FloorToInt(pos.y / cellSize));
        if (cell != lastCell)
        {
            if (heatmap.ContainsKey(cell))
            {
                heatmap[cell]++;
            }
            else
                heatmap[cell] = 1;

            if (heatmap[cell] >= visitThreshold)
            {
                GameManager.instance.ChangeLevel();
                enabled = false;
            }

            lastCell = cell;
        }
    }
    
    void OnDrawGizmos()
    {
        if (heatmap == null) return;

        if (!debugGizmos) return;

        foreach (var kvp in heatmap)
        {
            Vector2Int cell = kvp.Key;
            int visits = kvp.Value;

            // 0 → green; >= visitThreshold → red
            float t = Mathf.Clamp01(visits / (float)visitThreshold);
            Gizmos.color = new Color(t, 1f - t, 0f, 0.85f);  // green → yellow → red

            Vector3 center = new Vector3(cell.x * cellSize + cellSize / 2f, cell.y * cellSize + cellSize / 2f, 0f);
            Gizmos.DrawWireCube(center, Vector3.one * cellSize);
        }
    }
}
C#

For our game, a cell size of 0.3f and a threshold of 3 visits to the same cell to consider it a loop did the trick. I also added a gizmo to get a better view of the code in action. In the GIF below, I show how the cells get colored every time the ball passes through them and how the cell turns red right after the loop is triggered.

As with most things in code, the perfect solution is the one that perfectly works for you, so I was really happy with the result I got.

Thank you for reading, and feel free to share your insights about this small piece of work!

Comments

4 responses to “GMTK 2025 – The loop problem”

  1. Gabe Avatar
    Gabe

    Cool! Interesting to know about the logic behind the game c:

    1. Mayara Avatar

      Thank you I’m glad you liked it!

  2. Matheus Avatar

    Great game! Thank you for sharing the thought process and the final loop check. That was very interesting =)

    1. Mayara Avatar

      Thank you for reading, I look forward to sharing new ideas in the future.

Leave a Reply

Your email address will not be published. Required fields are marked *

Search


Categories


Recent Posts