Home Bean Bandits — Player Generated Board Game
Post
Cancel

Bean Bandits — Player Generated Board Game

Betrayal at the House on the Hill inspired me to recreate a player generated dungeon for the party game, Bean Bandits and the Curse of Skol. In a Mario Party style ‘overworld’, players race through the dungeon to collect 3 gems, then escape through the starting area. After each round, players face off in a minigame.

Basics of my System

This kind of level generation is mostly random and completely controlled by the players. On each turn, players can uncover new rooms. They pick from a prepared deck of cards of various types, including each of the possible passages, gems or traps. As players explore, they “pick” a card and control the rotation of the room, whenever it is not forced. When they move to an already discovered tile, they simply move there.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void HandlePlayerSelectTile(PlayerNum playerIndex) {
        //...
        PawnPlayerController currPlayer = players[(int) playerIndex];
        Tile currTile = BoardManager.Instance.currTile;
        currPlayerDir = BoardManager.GetDirectionBetweenTiles(BoardManager.Instance.GetTile(currPlayer.boardPosition), currTile);
        
        switch (currTile.state) {
            case TileState.Hidden:
                currTile.SetState(TileState.Revealing);
                break;
            case TileState.Rotating:
                if (currTile.isValidRotation) {
                    HandlePlayerPlacedTile(playerIndex);
                }
                break;
            case TileState.Revealed:
                //Move to tile
                MovePlayer(currPlayer, currTile);
                break;
        }
    }

This deck allowed us to test different ratios of these cards, to control the length of the game, in terms of rounds. We also wanted to accelerate and ensure the discovery of special tiles. So, each discovery without a special tile, increased the pull rate slightly. Winning minigames also provides a boost to finding gems. Using Machinations, were able to balance these bonuses resulting in a game that would take about 10 rounds to find every gem.

1
2
3
4
5
6
7
8
9
10
11
12
private TileData DealTileControlled(PawnPlayerController player) {
        float rand = Random.Range(0f, 1f);
        // check if rolled a special tile
        bool isSpecial = rand < baseSpecialChance + (specialChanceDelta * player.numMovesSinceSpecialTile);

        // if rolled a special tile, and its not the first round
        if (deck.Count == 0 ||(isSpecial && BoardGameManager.Instance.roundNumber >= 1)) {
            return RemoveTileFromDeck(specialDeck);
        } else {
            return RemoveTileFromDeck(deck);
        };
    }

Tiles

TileData | Scriptable Object

Tiles are not MonoBehaviors, they act as a Model in the MVC design pattern. A ScriptableObject defines each type of tile, and holds a reference to a set of Prefabs, which are selected at random when a tile is chosen:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
[CreateAssetMenu(fileName = "Tile", menuName = "Board Game Objects/Tile", order = 1)]
public class TileData : ScriptableObject {

    public TileType tileType;
    public Mesh mesh;

    public GameObject[] prefabs;

    public bool endsTurnOnReveal;
    public bool hasGem = false;


    [Tooltip("[North, East, South, West]")]
    public bool[] connections = new bool[] {true, true, true, true};


    public int GetNumConnections() {
        int res = 0;
        for (int i = 0; i < connections.Length; i++) {
            if (connections[i]) {
                res++;
            }
        }
        return res;
    }

    public GameObject GetPrefab() {
        if (prefabs != null && prefabs.Length > 0) {
            return prefabs[Random.Range(0, prefabs.Length)];
        }
        return null;
    }
}

public enum TileType {
        Corner,
        Cross,
        Tcross,
        Hall,
        Start,
        Treasure,
        Trap,
        Hidden
}

Using this data, each tile controls its effect when a player enters it based off what the tile type is

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public void OnPlayerEnterTile(PawnPlayerController _player) {
    switch (tileType) {
        case TileType.Start:
            if (debug.showDebug && debug.logState) Debug.Log("Player entered start tile");
            Vector2Int playerHomePos = BoardManager.Instance.GetStartPosition((int) _player.playerIndex);

            if (playerHomePos == position) {
                _player.StashRelics();
            }
        break;
        case TileType.Trap:
            if (debug.showDebug && debug.logState) Debug.Log("Player entered trap tile");
            _player.numMovesSinceSpecialTile = 0;
            animator.HandleTrapTile(_player, () => _player.HandleTrap());
            break;
        case TileType.Treasure:
            if (debug.showDebug && debug.logState) Debug.Log("Player entered treasure tile");
            if (!animator.hasGem) return;
            if (_player.AddRelic()) {
                animator.MoveGemToPlayer(_player);
            } else {
                if (debug.showDebug && debug.logState) Debug.Log("Player has no room for relic");
                animator.MoveGemToPlayer(_player, canPlayerHold: false);
            }
            _player.numMovesSinceSpecialTile = 0;
            // animator.SetState(TileState.Error);
            break;
        default:
            _player.numMovesSinceSpecialTile++;
            break;
    }
}

TileAnimator | Code Driven Animations

Finally, the TileAnimator class is a MonoBehavior and acts as a View class from MVC. Given a tile, it represents the visuals of a tile, and instances and animates the prefab. All animations are done using a Tweening library from Surge.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// create visuals from tile data
public void SetType(TileData tileData) {
    data = tileData;
    hasGem = data.hasGem;
    Mesh newMesh = Instantiate(data.mesh);
    meshFilter.sharedMesh = newMesh;
    if (data.tileType == TileType.Hidden && state != TileState.Highlighted) {
        HideMesh();
    }

    if (data.GetPrefab() != null && decoPrefab == null) {
        decoPrefab = Instantiate(data.GetPrefab(), transform);
    }
}

// animate rotation of tile
 public void SetRotation(Direction dir, bool animate = true) {
    rotation = dir;

    if (animate) {
        Tween.Rotation(transform, Tile.DirectionToEulerAngles(dir), 0.5f, 0, Tween.EaseInOut);
    } else {
        transform.rotation = Quaternion.Euler(Tile.DirectionToEulerAngles(dir));
    }
}

Watch out for traps! Watch out for traps!

In reflection, this was a massive undertaking. I alone wrote over 2000 lines of code in about 7 weeks, while completing other classes. Though it is by no means perfect, it was good practice in learning to effectively split and structure a large codebase.

This post is licensed under CC BY 4.0 by the author.
Trending Tags
Contents
Trending Tags