Resource Organizing your minigame code using FSMgasm

Discussion in 'Spigot Plugin Development' started by CoKoC, Apr 25, 2017.

  1. Hi all.

    Recently I released a library named FSMgasm. I wanted to highlight here how useful it is for Minecraft minigames code.

    The library is entirely new code, but the ideas behind it are fairly mature. I'm sure similar systems have been used by other people. Personally, I first applied the idea of using state machines when working on Pixel Painters. It's been further iterated upon in games like Crazy Walls and Minerware and many incarnations of classics like Sky Wars and Survival Games. It's after this journey of iterations that I feel confident presenting the library today.

    Here are some examples showing what you get from designing your game in states:
    Minigame initialization in Minerware
    [​IMG]

    Initialization in Hypixel: PE - Sky Wars (C#)
    [​IMG]

    Hypixel: PE - Survival Games
    [​IMG]

    Installation

    You can use FSMgasm through Maven. If you've never used Maven, it's a good time to learn it!

    At the time of writing this guide, the library is available through Jitpack. Include these in your pom.xml:
    Code (Text):
    <repositories>
      <repository>
        <id>jitpack.io</id>
        <url>https://jitpack.io</url>
      </repository>
    </repositories>
    <dependency>
      <groupId>com.github.Minikloon</groupId>
      <artifactId>FSMgasm</artifactId>
      <version>-SNAPSHOT</version>
    </dependency>
    The library will need to be accessible at runtime. If you've never used an external library in your plugins, here's how (I recommend shading it with Maven).

    The Basics

    FSMgasm is a Kotlin library, for this reason its documentation is also Kotlin. But the gist of it is this: you can define your minigame as a series of states. A state is a class and it has a start, a duration, an end and sometimes it does stuff in-between.

    Here is a basic Java State:
    Code (Text):
    class PrintState extends State {
        private final String toPrint;

        public PrintState(String toPrint) {
            this.toPrint = toPrint;
        }

        @Override
        protected void onStart() {
            System.out.println(toPrint);
        }

        @Override
        public void onUpdate() {

        }

        @Override
        protected void onEnd() {

        }

        @Override
        public Duration getDuration() {
            return Duration.ofSeconds(1);
        }
    }

    You can then use it in your plugin:
    Code (Text):
    public class HeyPlugin extends JavaPlugin {
        @Override
        public void onEnable() {
            PrintState printHey = new PrintState("Hey");
            printHey.start();
            printHey.end();
        }
    }

    The typical use case for minigames is to compose all of your game's states in your plugin's onEnable within a StateSeries/ScheduledStateSeries, then just start the series.

    Shaping it for Bukkit

    FSMgasm isn't made specifically for Bukkit/Spigot plugins. That's why two of the more useful features for Bukkit plugins aren't included in the library. You can include the following classes directly in your minigame or in a common library if you're working for a network.

    GameState

    Creating a layer over the base State class lets you enjoy a cool feature: automatic listeners and tasks unregistration on top of way shorter APIs.

    Code (Text):
    import net.minikloon.fsmgasm.State;
    import org.bukkit.Bukkit;
    import org.bukkit.entity.Player;
    import org.bukkit.event.HandlerList;
    import org.bukkit.event.Listener;
    import org.bukkit.plugin.java.JavaPlugin;
    import org.bukkit.scheduler.BukkitTask;

    import java.util.Collection;
    import java.util.HashSet;
    import java.util.Set;

    public abstract class GameState extends State implements Listener {
        protected final JavaPlugin plugin; // this could be your game's "main" class

        protected final Set<Listener> listeners = new HashSet<>();
        protected final Set<BukkitTask> tasks = new HashSet<>();

        public GameState(JavaPlugin plugin) {
            this.plugin = plugin;
        }

        @Override
        public final void start() {
            super.start();
            register(this);
        }

        @Override
        public final void end() {
            super.end();
            if(! super.getEnded())
                return;
            listeners.forEach(HandlerList::unregisterAll);
            tasks.forEach(BukkitTask::cancel);
            listeners.clear();
            tasks.clear();
        }

        protected final Collection<? extends Player> getPlayers() {
            return Bukkit.getOnlinePlayers();
        }

        protected final void broadcast(String message) {
            getPlayers().forEach(p -> p.sendMessage(message));
        }

        protected void register(Listener listener) {
            listeners.add(listener);
            plugin.getServer().getPluginManager().registerEvents(listener, plugin);
        }

        protected void schedule(Runnable runnable, long delay) {
            BukkitTask task = plugin.getServer().getScheduler().runTaskLater(plugin, runnable, delay);
            tasks.add(task);
        }

        protected void scheduleRepeating(Runnable runnable, long delay, long interval) {
            BukkitTask task = plugin.getServer().getScheduler().runTaskTimer(plugin, runnable, delay, interval);
            tasks.add(task);
        }
    }
     
    Notice that GameState implements Listener and registers itself on start so your class doesn't need to.
    You can also register listeners and tasks using register(...) and schedule(...) and they won't trigger once the state is over. You can even add methods for ProtocolLib packet listeners or per-state sidebars and boss bars.

    Here's an mock usage of GameState:
    Code (Text):
    public class PregameState extends GameState {
        public PregameState(JavaPlugin plugin) {
            super(plugin);
        }

        @Override
        protected void onStart() {
            broadcast("Now accepting players!");
            register(new NoBlockBreakListener());
        }

        @EventHandler
        public void onJoin(PlayerJoinEvent e) {
     
        }

        @Override
        public void onUpdate() {
     
        }

        @Override
        protected void onEnd() {
     
        }

        @Override
        public boolean isReadyToEnd() {
            return getPlayers().size() > 12; // you can implement a countdown by creating a state!
        }

        @NotNull
        @Override
        public Duration getDuration() {
            return Duration.ZERO;
        }
    }
     

    ScheduledStateSeries

    To avoid having to write the same code in every game, you can use this class as a StateSeries which works by itself after start()ing it. It uses Bukkit's Scheduler to execute state updates.

    Code (Text):
    import org.bukkit.plugin.Plugin;
    import org.bukkit.scheduler.BukkitTask;

    import java.util.LinkedList;
    import java.util.List;

    public class ScheduledStateSeries extends StateSeries {
        private final Plugin plugin;
        private final long interval;
        protected BukkitTask scheduledTask;

        protected List<Runnable> onUpdate = new LinkedList<>();

        public ScheduledStateSeries(Plugin plugin) {
            this(plugin, 1);
        }

        public ScheduledStateSeries(Plugin plugin, long interval) {
            this.plugin = plugin;
            this.interval = interval;
        }

        @Override
        public final void onStart() {
            super.onStart();
            scheduledTask = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
                update();
                onUpdate.forEach(Runnable::run);
            }, 0L, interval);
        }

        @Override
        public final void onEnd() {
            super.onEnd();
            scheduledTask.cancel();
        }

        public final void addOnUpdate(Runnable runnable) {
            onUpdate.add(runnable);
        }
    }
     

    You can then use ScheduledStateSeries in your plugin's onEnable:
    Code (Text):
    public class TestPlugin extends JavaPlugin {
        @Override
        public void onEnable() {
            // load config
       
            // setup game rules and whatever
       
            // setup global listeners
       
            ScheduledStateSeries mainState = new ScheduledStateSeries(this);
            mainState.add(...);
            mainState.add(...);
            mainState.start();
        }
    }

    Thinking in States

    Where FSMgasm excels at is providing a framework of thinking to design minigames. When I explained how to split up Spleef at Minecon, I definitely had these state shenanigans in mind.

    Code reuse
    If you're creating a minigame network with tons of games, you can isolate the network's features from the game in a state, like PregameState. Think of Mineplex's pregame island where you pick your kit and team or Hypixel's intricate pregame countdown and sidebar design. You can just hook them in like they're part of the game.

    You can also often just straight up reuse states from an older game in your latest work.

    Minerware in particular has one layer over GameState (the Microgame class), which provides APIs for microgame implementers to interact with the game's board, dealing with players who won/lost, automatic duration adjustment and more. Here's an example of a microgame where players have to jump in the void.
    Code (Text):
    public class JumpVoid extends Microgame {
        public JumpVoid(MinerwareGame game, int microgameNumber) {
            super(game, microgameNumber);
        }

        @Override
        public String getInstructions() {
            return "Wait for it...";
        }

        @Override
        protected void onGameStart() {
            schedule(() -> {
                broadcastTitle("┬žeJUMP IN THE VOID!", "", 0, (int) (durationMs / 1000) * 20, 0);
                getPlayers().forEach(p -> p.playSound(p.getLocation(), Sound.ENCHANT_THORNS_HIT, 1.0f, 1.f));
            }, 11);
        }
     
        @Override
        protected void onUpdate() {
            getNeutralPlayers().forEach(p -> {
                if(p.getLocation().getBlockY() < board.getMinY()) {
                    win(p, true);
                    p.sendMessage("┬žaWoooo! You jumped down!");
                }
            });
        }

        @Override
        protected void onGameEnd() {
            getNeutralPlayers().forEach(p -> lose(p, false));
        }
    }
     

    Ease of development
    By using something akin to GameState, you don't have to worry about listeners unregistration or checking a ton of conditions inside the listeners themselves. Plus, the methods to register listeners and tasks are unified and shorter.

    Most minigames out there have time limits. Because duration is embedded in every state, you can easily access the remaining time for the game or current state of the game using getRemainingDuration().

    If you hook in your favorite method of authoring sidebars, boss bars and tablists, it's a lot less painful to add context-specific flavor to your game's UI.

    Maintainability
    Most larger minigames tend to become giant tentacular monstrosities. You can avoid most of the pain for your colleagues or future self by designing the game using states from the get go.

    By defining all of the states in the game's main class (typically the one extending JavaPlugin), all of the game's features can be readily available from only 2-3 method calls.

    It also facilitates developer onboarding for larger networks, as all games follow the same structure.

    Getting Help

    I realize this guide is more on the descriptive side than the nitty-gritty. If you're having a hard time understanding the code in this post, on the main documentation or you don't know how to break down a problem, feel free to contact me on Spigot or Skype. If you find a bug or you're looking for a feature, make sure to create an issue on Github. In either of those cases, you can also just reply on this thread.

    Happy coding.
     
    #1 CoKoC, Apr 25, 2017
    Last edited: May 3, 2017
    • Useful Useful x 10
    • Like Like x 6
    • Agree Agree x 1
    • Winner Winner x 1
  2. Disc

    Supporter

    Looks super useful (even though you already told me about it). Can't wait to see what people make using this. :)
     
  3. Wow! This is an interesting state machine system that really reminded of Panda3D's Finite State Machine. I think you could incorporate some of Panda3D's FSM features into this system to make it even better.
     
  4. Mas

    Mas

    Hey, awesome work.
    I've never thought of designing minigames using a state-based system before, and it really looks like a great way to go about doing so.
    I'm going to be designing a whole framework around a tweaked version of this system for people to create their own minigames with ease. I'll make sure to give credit :)
     
  5. Sounds great, but can you skip some states (if the last player mogged out i just want to skip all excepted the "map reset" state)?
     
  6. Yep! For example if you detect that all players are offline or dead, you can use StateSeries::addNext to add a state right after the current one (thus bypassing all the remaining states) and setup the game termination in some sort of GameCleanupState.

    There's also another feature I forgot to mention. StateSeries has a skip() method and "frozen" property. When I have a "main" state series, I always have commands to skip and freeze. It's really useful when debugging.

    Code (Text):
    public class SkipCommand implements CommandExecutor {
        private final StateSeries mainState;
     
        public SkipCommand(StateSeries mainState) {
            this.mainState = mainState;
        }
     
        @Override
        public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
            mainState.skip();
            Bukkit.broadcastMessage(ChatColor.BLUE + sender.getName() + " has skipped to the next state!");
            return true;
        }
    }
    You could also use the skip() method for your game's logic if that makes sense for you. Personally I think that often leads to shaky logic, because it'll if you add states in the futures in-between the one calling skip and the one skipped to.

    As a personal example, if you look in this video at 0:00, in the chat it says "Minikloon has frozen the game phases", the timer stays at 0:00 in the sidebar and the game doesn't move on. This allowed me time to record the video.
     
    • Like Like x 2