Well-Planned Space Train Heists

Try it now in the browser on your phone!

The Navigator spots a train near the current warp spot, so the Pilot warps and destroys it to take its cargo!

Well-Planned Space Train Heists is a 3D two player co-op mobile game. The goal is to work as a team to collect as much cargo as you can by destroying passing trains. The gameplay is highly asymmetrical with one player acting as the Pilot playing a first person space shooter. The other player is their Navigator who sees a map of galaxy that shows the locations of current trains and upcoming train routes, as well as a moving warp point that the Pilot can use to quickly traverse the map. Importantly, the Navigator cannot see the Pilot’s current location, but the warp function can often help with that.

I set out to make a co-op mobile game that didn’t require an Internet connection, similar to the multiplayer scheme of Keep Talking and Nobody Explodes (KTNE). However, unlike KTNE, I wanted the interaction between the players to be more two-way and real-time. This means that the game setup requires a bit of extra work from the player side, they need to point their phones in the same direction synchronize their rotations (so you can give directions like “up” “down” “towards this wall” “behind you!” etc.). Additionally, players have to start their games at the same time so that the constantly changing world is synchronized between them.

Genre: Shooter/Strategy
Timeline: Created primarily over 1 week in March 2025
Engine: Unity 6 URP
Platform: WebGL for Mobile
Team size: Solo
Itch Page: https://buildsgames.itch.io/well-planned-space-train-heists (Play on your phone! Though it’s better with two players.)
Source Code: https://github.com/WillyBMiles/Well-Planned-Space-Train-Heists

Highlights

  • The game does not require an internet connection between the two players because the world and everything that happens is deterministic based on a seed that the players share with each other.
  • Limitations in hardware meant optimization was important so the game has a generic object pooling system that works with any prefab of any type.
  • The entire world is stored in data structures called World Model and Game Plan that determine what the world looks like and which routes will be created, respectively. These structures do not themselves hold or generate any Unity GameObjects.

Challenges

  • iPhones don’t allow WebGL games in a frame to access their gyroscope information. This is a problem because the gyroscope is absolutely vital to the design of the game. This was solved with somewhat of a hack: if you open the html source of the game (linked on the Itch page) then the gyroscope works. Additionally, iPhones cannot lock in landscape mode, so I had to rotate the UI and Camera 90 degrees if the game detect your phone is in portrait mode (with a button to fix it if it detects wrong).
  • Due to the fact that phones are just big touch screens, conveying the controls clearly to players is absolutely vital. I had trouble with particularly the player movement for the Pilot. My original design
  • Given the limitations of the idea, there seemed to be no way for the Navigator to tell where the Pilot currently is. Early on I added a coordinate system, but most people have trouble thinking in 3D coordinates so it was still just as difficult. Additionally, I worried that the Navigator’s job might be too boring. I devised a feature that solved both of these problems at the same time: Warping. There’s a point on the map that only the Navigator can see than moves every 15 seconds; at any time the Pilot can press the Warp button and instantly travel to that point. Thus, at the press of a button, the Navigator would know exactly where the Pilot was and, at least for a moment, be able to tell them where to go with confidence.

System spotlight: Object Pool

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

public static class ObjectPool 
{
    public static void FlushPool()
    {
        pools.Clear();
    }

    static Dictionary<string, MonoBehaviour> prefabs = new();
    static Dictionary<string, List<MonoBehaviour>> pools = new();
    
    /// <summary>
    /// Just disable an object when you're done with it and the pool will clean it up.
    /// Make sure everything is properly initialized when spawning.
    /// </summary>
    public static T SpawnObject<T>(string resource) where T: MonoBehaviour, IPoolable
    {
        string key = resource;
        if (!pools.ContainsKey(key))
            pools[key] = new();

        foreach (var t in pools[key])
        {
            if (!t.gameObject.activeSelf)
            {
                (t as IPoolable).ResetPoolable();
                t.gameObject.SetActive(true);
                return t as T;
            }
        }

        if (!prefabs.ContainsKey(key))
        {
            Debug.Log(key);
            prefabs[key] = (Resources.Load(key) as GameObject).GetComponent<T>();
        }
        T newT = GameObject.Instantiate(prefabs[key]) as T;

        pools[key].Add(newT);
        return newT;
    }
}

public interface IPoolable
{
    public void ResetPoolable();
}

Simple item pooling system. Call with SpawnObject<Type>(“path_to_resource”) whenever you need a new instance of that prefab.

Performance is at a premium in WebGL, doubly so on mobile. I’ve crashed enough browsers to know that a bit of preemptive optimization can go a long way. I knew that throughout the game many transient objects would need to be managed, namely trains, routes, and dropped cargo. In this case I created a generic dynamic object pooling system to prevent costly instantiation calls and garbage collection.

The system relies on the Resources folder to load prefabs. In this case the caller needs to know the name of the prefab file, which is a bit finicky, but then that filename can be used as an id when getting an old object from the pool.

The pool uses whether the Game Object is inactive to determine if it’s ready to be reused. This is somewhat rigid because unintentionally setting an object inactive can lead to unexpected results but worked well on this small scale. Finally each object defines it’s own reset function through an interface IPoolable to ensure that objects with changing state get reset properly.


System Spotlight: World Model Generation

This game relies heavily on its ability to recreate itself consistently so that both players see the same game unfold before them. The information from the Navigator must be correct and up-to-date when given to the Pilot or the whole thing collapsed into confusion. To accomplish this, the entire game is pre-generated from a single seed that the players must share externally.

This seed is presented using familiar language to give the illusion of multiplayer. Generating a new seed is framed as creating a lobby, while entering your own code is framed as joining a lobby, even if the scene that follows is identical for both players. This illusion is so strong that even players that know how the game works still treat the game as if it were networked. For example, players usually expect to be able to see the Pilot when first playing as the Navigator, so perhaps the illusion was too strong.

Presented here is the basis of the code used to create the world, which is split into two parts. The first part uses a seeded System.Random variable to generate model objects, pure classes (not MonoBehaviors) that serve as data to inform the actual instantiation. When the game is ready to instantiate, it uses those model objects to deterministically place Game Objects and give their initial state. Though the view of the Navigator and Pilot are quite different, this instantiation is still shared, with any role specific differences determined individually by each object.

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

public class WorldModel 
{
    public const int SIZE_OF_GRID_CUBE = 200;
    public const int WORLD_WIDTH = 5;
    public static int HalfWidth => (WORLD_WIDTH - 1) / 2;

    Dictionary<int,Dictionary<int, Dictionary<int, GridObject>>> worldModel;

    readonly List<Vector3> warpPositions = new();
    public IReadOnlyList<Vector3> WarpPositions => warpPositions;

    public GridObject GetAtPosition(int x, int y, int z)
    {
        if (x < -HalfWidth || y < -HalfWidth || z < -HalfWidth ||
            x > HalfWidth || y > HalfWidth || z > HalfWidth)
            return null;
        return worldModel[x][y][z];
    }

    public WorldModel(System.Random random)
    {


        worldModel = new();

        for (int i = -HalfWidth; i <= HalfWidth; i++)
        {
            worldModel[i] = new();
            for (int j = -HalfWidth; j <= HalfWidth; j++)
            {
                worldModel[i][j] = new();
            }
        }


        PlaceStationNearCorner(false, false, false);
        PlaceStationNearCorner(false, false, true);
        PlaceStationNearCorner(false, true, false);
        PlaceStationNearCorner(false, true, true);
        PlaceStationNearCorner(true, false, false);
        PlaceStationNearCorner(true, false, true);
        PlaceStationNearCorner(true, true, false);
        PlaceStationNearCorner(true, true, true);


        void PlaceStationNearCorner(bool _xCorner, bool _yCorner, bool _zCorner)
        {
            int x = ConvertBoolToCorner(_xCorner);
            int y = ConvertBoolToCorner(_yCorner);
            int z = ConvertBoolToCorner(_zCorner);
            worldModel[x][y][z] = new StationGridObject(random);
        }

        int ConvertBoolToCorner(bool isPositive)
        {
            int corner = isPositive ? HalfWidth : -HalfWidth;
            return corner + (int)(random.Next(10) > 7 ? -Mathf.Sign(corner) : 0);
        }


        int numberOfAdditionalStations = random.Next(1, 3);

        List<GridObject> remainingObjects = new();
        for (int i = 0; i < numberOfAdditionalStations; i++)
        {
            remainingObjects.Add(new StationGridObject(random));
        }
        for (int i =0; i < Mathf.Pow(WORLD_WIDTH, 3); i++)
        {
            remainingObjects.Add(new PlanetGridObject(random));
        }



        for (int i = -HalfWidth; i <= HalfWidth; i++)
        {
            for (int j = -HalfWidth; j <= HalfWidth; j++)
            {
                for (int k = -HalfWidth; k <= HalfWidth; k++)
                {
                    if (!worldModel[i][j].ContainsKey(k))
                    {
                        worldModel[i][j][k] = remainingObjects.PopRandom(random);
                    }

                }
            }
        }

        for (int i = 0; i < 100; i++)
        {
            int randomX = random.Next(-HalfWidth, HalfWidth + 1);
            int randomY = random.Next(-HalfWidth, HalfWidth + 1);
            int randomZ = random.Next(-HalfWidth, HalfWidth + 1);
            warpPositions.Add(new Vector3(randomX, randomY, randomZ) * SIZE_OF_GRID_CUBE);
        }
    } 


    public void Instantiate(WorldModelInstance instance)
    {
        for (int i = -HalfWidth; i <= HalfWidth; i++)
        {
            for (int j = -HalfWidth; j <= HalfWidth; j++)
            {
                for (int k = -HalfWidth; k <= HalfWidth; k++)
                {
                    worldModel[i][j][k].Instantiate(new Vector3Int(i, j, k), instance);
                }
            }
        }
    }
}

Code for the World Model, the data structure that holds information on how to generate the world. If you input a System.Random object with the same seed, it will create the same world every time.

The Navigator’s view. The spiky star is where the Pilot will Warp to next. The green train cars are the current locations of the trains. The red and yellow paths are the current and future routes that trains will take.

The world itself is a 5x5x5 cube of grid cubes, with each cube containing either a station or a planet. Stations are the sources and sinks of all train routes and so their placement is quite important. It should be noted that there is a station near each corner. This helps routes cover all parts of the galaxy rather than bunching up in the middle. Before this change the center was a tangled mess for the Navigator to look at and the gameplay was significantly less interesting for the Pilot who mostly stayed in the same area throughout the game and didn’t need the Navigator’s directions.


A playthrough of Well-Planned Space Train Heists from both perspectives!